Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-17 14:25:13 +04:00
parent 52c39047d6
commit 2be3a915ea
51 changed files with 597 additions and 2448 deletions

View file

@ -146,7 +146,6 @@ abstract class BaseTestCase : TestCase(
return ApplicationInjectionExecutionRule(
toggleStates = mapOf(
"SWAP_REDESIGN_ENABLED" to false,
"NEW_ONRAMP_MAIN_ENABLED" to true,
"HOT_WALLET_ENABLED" to true,
"ACCOUNTS_FEATURE_ENABLED" to true,
"FEED_ENABLED" to true,

View file

@ -24,10 +24,6 @@
"name": "HOT_WALLET_CREATION_RESTRICTION_ENABLED",
"version": "5.32.0"
},
{
"name": "NEW_ONRAMP_MAIN_ENABLED",
"version": "5.31.0"
},
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "5.33.0"

View file

@ -1,7 +1,7 @@
package com.tangem.features.onramp.alloffers.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
internal interface AllOffersIntents {

View file

@ -10,9 +10,9 @@ import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.*
import com.tangem.domain.onramp.model.error.OnrampError
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.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.MINUS
import kotlinx.collections.immutable.toImmutableList

View file

@ -4,7 +4,7 @@ import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import kotlinx.collections.immutable.ImmutableList
internal sealed interface AllOffersStateUM {

View file

@ -12,7 +12,7 @@ import com.tangem.features.onramp.alloffers.entity.AllOffersIntents
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateFactory
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.Job

View file

@ -32,10 +32,10 @@ import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
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.ui.Offer
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.features.onramp.main.ui.Offer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList

View file

@ -33,10 +33,10 @@ import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
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.ui.TimingBlock
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.features.onramp.main.ui.TimingBlock
import com.tangem.features.onramp.paymentmethod.ui.PaymentMethodIcon
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -8,15 +8,16 @@ import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
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.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.main.ui.OnrampMainScreen
import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -27,10 +28,15 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
@Assisted private val params: OnrampMainComponent.Params,
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val selectProviderComponentFactory: SelectProviderComponent.Factory,
private val allOffersComponentFactory: AllOffersComponent.Factory,
) : OnrampMainComponent, AppComponentContext by appComponentContext {
private val model: OnrampMainComponentModel = getOrCreateModel(params)
init {
lifecycle.subscribe(onStop = model::onStop)
}
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
@ -43,7 +49,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
val state by model.state.collectAsState()
val bottomSheet by bottomSheetSlot.subscribeAsState()
OnrampMainComponentContent(modifier = modifier, state = state)
OnrampMainScreen(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
@ -57,7 +63,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
country = config.country,
isLaunchSepa = params.isLaunchSepa,
isLaunchSepa = false,
onDismiss = {
model.bottomSheetNavigation.dismiss()
model.handleOnrampAvailable()
@ -72,14 +78,14 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
is OnrampMainBottomSheetConfig.ProvidersList -> selectProviderComponentFactory.create(
is OnrampMainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create(
context = childByContext(componentContext),
params = SelectProviderComponent.Params(
onProviderClick = model::onProviderSelected,
onDismiss = model.bottomSheetNavigation::dismiss,
selectedProviderId = config.selectedProviderId,
selectedPaymentMethod = config.selectedPaymentMethod,
params = AllOffersComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
openRedirectPage = params.openRedirectPage,
amountCurrencyCode = config.amountCurrencyCode,
),
)
}

View file

@ -15,7 +15,6 @@ internal interface OnrampMainComponent : ComposableContentComponent {
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val isLaunchSepa: Boolean,
)
interface Factory : ComponentFactory<Params, OnrampMainComponent>

View file

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

View file

@ -1,11 +1,15 @@
package com.tangem.features.onramp.main.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.main.DefaultOnrampMainComponent
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.model.OnrampMainComponentModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@ -15,4 +19,9 @@ internal interface OnrampMainComponentModule {
@Binds
@Singleton
fun bindOnrampMainComponentFactory(factory: DefaultOnrampMainComponent.Factory): OnrampMainComponent.Factory
@Binds
@IntoMap
@ClassKey(OnrampMainComponentModel::class)
fun bindOnrampMainComponentModel(model: OnrampMainComponentModel): Model
}

View file

@ -1,25 +0,0 @@
package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.extensions.TextReference
internal data class OnrampAmountBlockUM(
val currencyUM: OnrampCurrencyUM,
val amountFieldModel: AmountFieldModel,
val secondaryFieldModel: OnrampAmountSecondaryFieldUM,
)
internal data class OnrampCurrencyUM(
val code: String,
val iconUrl: String?,
val precision: Int,
val onClick: () -> Unit,
)
@Immutable
internal sealed interface OnrampAmountSecondaryFieldUM {
data object Loading : OnrampAmountSecondaryFieldUM
data class Content(val amount: TextReference) : OnrampAmountSecondaryFieldUM
data class Error(val error: TextReference) : OnrampAmountSecondaryFieldUM
}

View file

@ -1,17 +1,17 @@
package com.tangem.features.onramp.mainv2.entity
package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class OnrampNewAmountBlockUM(
val currencyUM: OnrampNewCurrencyUM,
internal data class OnrampAmountBlockUM(
val currencyUM: OnrampCurrencyUM,
val amountFieldModel: AmountFieldModel,
val secondaryFieldModel: OnrampSecondaryFieldErrorUM,
)
internal data class OnrampNewCurrencyUM(
internal data class OnrampCurrencyUM(
val unit: String,
val code: String,
val iconUrl: String?,
@ -25,9 +25,9 @@ internal sealed interface OnrampSecondaryFieldErrorUM {
data class Error(val error: TextReference) : OnrampSecondaryFieldErrorUM
}
internal sealed interface OnrampV2AmountButtonUMState {
data class Loaded(val amountButtons: ImmutableList<OnrampAmountButtonUM>) : OnrampV2AmountButtonUMState
data object None : OnrampV2AmountButtonUMState
internal sealed interface OnrampAmountButtonUMState {
data class Loaded(val amountButtons: ImmutableList<OnrampAmountButtonUM>) : OnrampAmountButtonUMState
data object None : OnrampAmountButtonUMState
}
internal data class OnrampAmountButtonUM(

View file

@ -2,12 +2,15 @@ package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
interface OnrampIntents {
fun onAmountValueChanged(value: String, isValuePasted: Boolean)
internal interface OnrampIntents {
fun onAmountValueChanged(value: String)
fun openSettings()
fun openCurrenciesList()
fun onBuyClick(quote: OnrampProviderWithQuote.Data)
fun onBuyClick(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
)
fun openProviders()
fun onRefresh()
fun onLinkClick(link: String)
}

View file

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

View file

@ -1,11 +1,10 @@
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
internal sealed interface OnrampMainBottomSheetConfig {
sealed interface OnrampMainBottomSheetConfig {
@Serializable
data class ConfirmResidency(val country: OnrampCountry) : OnrampMainBottomSheetConfig
@ -13,8 +12,5 @@ internal sealed interface OnrampMainBottomSheetConfig {
data object CurrenciesList : OnrampMainBottomSheetConfig
@Serializable
data class ProvidersList(
val selectedProviderId: String,
val selectedPaymentMethod: OnrampPaymentMethod,
) : OnrampMainBottomSheetConfig
data class AllOffers(val amountCurrencyCode: String) : OnrampMainBottomSheetConfig
}

View file

@ -4,55 +4,29 @@ import androidx.compose.runtime.Immutable
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.onramp.impl.R
@Immutable
internal sealed interface OnrampMainComponentUM {
val topBarConfig: OnrampMainTopBarUM
val buyButtonConfig: BuyButtonConfig
val errorNotification: NotificationUM?
data class InitialLoading(
val currency: String,
val onClose: () -> Unit,
val openSettings: () -> Unit,
override val errorNotification: NotificationUM? = null,
) : OnrampMainComponentUM {
override val topBarConfig: OnrampMainTopBarUM = OnrampMainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
startButtonUM = TopAppBarButtonUM.Back(
onBackClicked = onClose,
enabled = true,
),
endButtonUM = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_more_vertical_24,
onClicked = openSettings,
isEnabled = false,
),
)
override val buyButtonConfig: BuyButtonConfig = BuyButtonConfig(
text = resourceReference(R.string.common_buy),
onClick = {},
isEnabled = false,
)
}
override val topBarConfig: OnrampMainTopBarUM,
override val errorNotification: NotificationUM?,
) : OnrampMainComponentUM
data class Content(
override val topBarConfig: OnrampMainTopBarUM,
override val buyButtonConfig: BuyButtonConfig,
override val errorNotification: NotificationUM?,
val amountBlockState: OnrampAmountBlockUM,
val providerBlockState: OnrampProviderBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampAmountButtonUMState,
) : OnrampMainComponentUM
}
internal data class BuyButtonConfig(
val text: TextReference,
val onClick: () -> Unit,
val isEnabled: Boolean,
internal data class OnrampMainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,
)

View file

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

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.mainv2.entity
package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference

View file

@ -1,18 +0,0 @@
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 providerId: String,
val paymentMethod: OnrampPaymentMethod,
val providerName: String,
val termsOfUseLink: String?,
val privacyPolicyLink: String?,
val isBestRate: Boolean,
val onLinkClick: (String) -> Unit,
val onClick: () -> Unit,
) : OnrampProviderBlockUM()
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
sealed interface OnrampProvidersUM {
data object Empty : OnrampProvidersUM
data object Loading : OnrampProvidersUM
data class Content(
val providerId: String,
val paymentMethod: OnrampPaymentMethod,
) : OnrampProvidersUM
}

View file

@ -1,24 +1,24 @@
package com.tangem.features.onramp.mainv2.entity.converter
package com.tangem.features.onramp.main.entity.converter
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class OnrampV2AmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
internal class OnrampAmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val onrampIntents: OnrampV2Intents,
) : Converter<String, OnrampV2MainComponentUM> {
private val onrampIntents: OnrampIntents,
) : Converter<String, OnrampMainComponentUM> {
override fun convert(value: String): OnrampV2MainComponentUM {
override fun convert(value: String): OnrampMainComponentUM {
val state = currentStateProvider()
if (state !is OnrampV2MainComponentUM.Content) return state
if (state !is OnrampMainComponentUM.Content) return state
if (value.isEmpty()) return state.emptyState()
@ -36,13 +36,13 @@ internal class OnrampV2AmountFieldChangeConverter(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Loading,
errorNotification = null,
)
}
private fun OnrampV2MainComponentUM.Content.emptyState(): OnrampV2MainComponentUM.Content {
private fun OnrampMainComponentUM.Content.emptyState(): OnrampMainComponentUM.Content {
val amountFieldModel = amountBlockState.amountFieldModel.copy(
value = "",
fiatValue = "",

View file

@ -1,7 +1,7 @@
package com.tangem.features.onramp.mainv2.entity.factory
package com.tangem.features.onramp.main.entity.factory
import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUMState
import kotlinx.collections.immutable.toPersistentList
internal class OnrampAmountButtonUMStateFactory {
@ -12,7 +12,7 @@ internal class OnrampAmountButtonUMStateFactory {
currencyCode: String,
currencySymbol: String,
onAmountValueChanged: (String) -> Unit,
): OnrampV2AmountButtonUMState {
): OnrampAmountButtonUMState {
return when (currencyCode) {
USD_CODE, EUR_CODE -> {
val buttons = defaultPreselectedAmount.map { value ->
@ -22,9 +22,9 @@ internal class OnrampAmountButtonUMStateFactory {
onClick = { onAmountValueChanged(value.toString()) },
)
}.toPersistentList()
OnrampV2AmountButtonUMState.Loaded(buttons)
OnrampAmountButtonUMState.Loaded(buttons)
}
else -> OnrampV2AmountButtonUMState.None
else -> OnrampAmountButtonUMState.None
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.mainv2.entity.factory
package com.tangem.features.onramp.main.entity.factory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.resourceReference
@ -11,34 +11,34 @@ import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.AmountType
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.converter.OnrampV2AmountFieldChangeConverter
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.converter.OnrampAmountFieldChangeConverter
import com.tangem.utils.Provider
internal class OnrampV2AmountStateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
internal class OnrampAmountStateFactory(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onrampIntents: OnrampV2Intents,
private val onrampIntents: OnrampIntents,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
) {
private val onrampAmountFieldChangeConverter: OnrampV2AmountFieldChangeConverter by lazy(
private val onrampAmountFieldChangeConverter: OnrampAmountFieldChangeConverter by lazy(
mode = LazyThreadSafetyMode.NONE,
) {
OnrampV2AmountFieldChangeConverter(
OnrampAmountFieldChangeConverter(
currentStateProvider = currentStateProvider,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
onrampIntents = onrampIntents,
)
}
fun getOnAmountValueChange(value: String): OnrampV2MainComponentUM {
fun getOnAmountValueChange(value: String): OnrampMainComponentUM {
return onrampAmountFieldChangeConverter.convert(value)
}
fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampV2MainComponentUM {
fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
@ -72,9 +72,9 @@ internal class OnrampV2AmountStateFactory(
)
}
fun getSecondaryFieldAmountErrorState(quotes: List<OnrampQuote>): OnrampV2MainComponentUM {
fun getSecondaryFieldAmountErrorState(quotes: List<OnrampQuote>): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.amountFieldModel.fiatValue.isEmpty()) return currentState
@ -91,23 +91,23 @@ internal class OnrampV2AmountStateFactory(
)
}
fun getAmountSecondaryFieldResetState(): OnrampV2MainComponentUM {
fun getAmountSecondaryFieldResetState(): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Empty) return currentState
return currentState.copy(
amountBlockState = amountState.copy(secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
errorNotification = null,
offersBlockState = currentState.offersBlockState,
)
}
private fun OnrampQuote.AmountError.toSecondaryFieldUiModel(
amountState: OnrampNewAmountBlockUM,
amountState: OnrampAmountBlockUM,
): OnrampSecondaryFieldErrorUM.Error {
val amount = error.requiredAmount.format {
fiat(

View file

@ -1,24 +1,23 @@
package com.tangem.features.onramp.mainv2.entity.factory
package com.tangem.features.onramp.main.entity.factory
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.onramp.model.*
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.main.entity.*
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toPersistentList
internal class OnrampOffersStateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val onrampIntents: OnrampV2Intents,
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val onrampIntents: OnrampIntents,
) {
fun getOffersState(offers: List<OnrampOffersBlock>): OnrampV2MainComponentUM {
val currentState = currentStateProvider.invoke()
return when (currentState) {
is OnrampV2MainComponentUM.InitialLoading -> currentState
is OnrampV2MainComponentUM.Content -> {
fun getOffersState(offers: List<OnrampOffersBlock>): OnrampMainComponentUM {
return when (val currentState = currentStateProvider.invoke()) {
is OnrampMainComponentUM.InitialLoading -> currentState
is OnrampMainComponentUM.Content -> {
if (currentState.offersBlockState is OnrampOffersBlockUM.Loading) {
return currentState
}

View file

@ -8,7 +8,9 @@ import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
@ -22,16 +24,30 @@ import java.math.BigDecimal
internal class OnrampStateFactory(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val cryptoCurrency: CryptoCurrency,
private val onrampIntents: OnrampIntents,
) {
fun getInitialState(currency: String, onClose: () -> Unit): OnrampMainComponentUM.InitialLoading {
fun getInitialState(
currency: String,
onClose: () -> Unit,
openSettings: () -> Unit,
): OnrampMainComponentUM.InitialLoading {
return OnrampMainComponentUM.InitialLoading(
currency = currency,
onClose = onClose,
openSettings = onrampIntents::openSettings,
errorNotification = null,
topBarConfig = OnrampMainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
startButtonUM = TopAppBarButtonUM.Close(
onCloseClick = onClose,
enabled = true,
),
endButtonUM = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_more_vertical_24,
onClicked = openSettings,
isEnabled = false,
),
),
)
}
@ -42,12 +58,19 @@ internal class OnrampStateFactory(
is TopAppBarButtonUM.Icon -> button.copy(isEnabled = true)
is TopAppBarButtonUM.Text -> button.copy(isEnabled = true)
}
val initialAmountBlockState = getInitialAmountBlockState(currency)
return OnrampMainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
buyButtonConfig = state.buyButtonConfig,
amountBlockState = getInitialAmountBlockState(currency),
providerBlockState = OnrampProviderBlockUM.Empty,
amountBlockState = initialAmountBlockState,
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = null,
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencyCode = currency.code,
currencySymbol = currency.unit,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
)
}
@ -68,21 +91,6 @@ internal class OnrampStateFactory(
}
}
private fun getNoPairsErrorState(): OnrampMainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampMainComponentUM.Content ?: return state
return contentState.copy(
buyButtonConfig = contentState.buyButtonConfig.copy(isEnabled = false),
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
)
}
fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampMainComponentUM {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
@ -93,15 +101,15 @@ internal class OnrampStateFactory(
return when (state) {
is OnrampMainComponentUM.Content -> state.copy(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
buyButtonConfig = state.buyButtonConfig.copy(isEnabled = false),
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(TextReference.EMPTY),
),
providerBlockState = OnrampProviderBlockUM.Empty,
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
)
is OnrampMainComponentUM.InitialLoading -> state.copy(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
@ -112,6 +120,22 @@ internal class OnrampStateFactory(
}
}
private fun getNoPairsErrorState(): OnrampMainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampMainComponentUM.Content ?: return state
return contentState.copy(
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}
private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampAmountBlockUM {
return OnrampAmountBlockUM(
currencyUM = OnrampCurrencyUM(
@ -119,11 +143,12 @@ internal class OnrampStateFactory(
iconUrl = currency.image,
precision = currency.precision,
onClick = onrampIntents::openCurrenciesList,
unit = currency.unit,
),
amountFieldModel = AmountFieldModel(
value = "",
fiatValue = "",
onValueChange = { onrampIntents.onAmountValueChanged(value = it, isValuePasted = false) },
onValueChange = onrampIntents::onAmountValueChanged,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,
@ -139,7 +164,7 @@ internal class OnrampStateFactory(
isValuePasted = false,
onValuePastedTriggerDismiss = {},
),
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(TextReference.EMPTY),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
)
}
@ -149,8 +174,4 @@ internal class OnrampStateFactory(
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
)
companion object {
const val PREDEFINED_SEPA_AMOUNT = "100"
}
}

View file

@ -1,75 +0,0 @@
package com.tangem.features.onramp.main.entity.factory.amount
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.features.onramp.main.entity.OnrampAmountSecondaryFieldUM
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampProviderBlockUM
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import java.math.BigDecimal
internal class OnrampAmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
) : Converter<OnrampAmountFieldChangeConverter.Input, OnrampMainComponentUM> {
override fun convert(input: Input): OnrampMainComponentUM {
val value = input.value
val isValuePasted = input.isValuePasted
val state = currentStateProvider()
if (state !is OnrampMainComponentUM.Content) return state
if (value.isEmpty()) return state.emptyState()
val amountState = state.amountBlockState
val amountTextField = amountState.amountFieldModel
val fiatDecimal = value.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val isDoneActionEnabled = !fiatDecimal.isNullOrZero()
val amountFieldModel = amountState.amountFieldModel.copy(
fiatValue = value,
fiatAmount = amountTextField.fiatAmount.copy(value = fiatDecimal),
keyboardOptions = KeyboardOptions(
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
isValuePasted = isValuePasted,
)
return state.copy(
amountBlockState = amountState.copy(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading,
),
providerBlockState = OnrampProviderBlockUM.Loading,
)
}
private fun OnrampMainComponentUM.Content.emptyState(): OnrampMainComponentUM.Content {
val amountFieldModel = amountBlockState.amountFieldModel.copy(
value = "",
fiatValue = "",
cryptoAmount = amountBlockState.amountFieldModel.cryptoAmount.copy(value = BigDecimal.ZERO),
fiatAmount = amountBlockState.amountFieldModel.fiatAmount.copy(value = BigDecimal.ZERO),
isError = false,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,
),
)
return copy(
amountBlockState = amountBlockState.copy(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(TextReference.EMPTY),
),
buyButtonConfig = buyButtonConfig.copy(isEnabled = false),
providerBlockState = OnrampProviderBlockUM.Empty,
)
}
data class Input(val value: String, val isValuePasted: Boolean)
}

View file

@ -1,259 +0,0 @@
package com.tangem.features.onramp.main.entity.factory.amount
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.AmountType
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.providers.entity.SelectProviderResult
import com.tangem.utils.Provider
import com.tangem.utils.extensions.isSingleItem
internal class OnrampAmountStateFactory(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onrampIntents: OnrampIntents,
private val cryptoCurrency: CryptoCurrency,
private val needApplyFCARestrictions: Provider<Boolean>,
) {
private val onrampAmountFieldChangeConverter = OnrampAmountFieldChangeConverter(
currentStateProvider = currentStateProvider,
)
fun getOnAmountValueChange(value: String, isValuePasted: Boolean) =
onrampAmountFieldChangeConverter.convert(OnrampAmountFieldChangeConverter.Input(value, isValuePasted))
fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
return currentState.copy(
amountBlockState = amountState.copy(
currencyUM = amountState.currencyUM.copy(
code = currency.code,
iconUrl = currency.image,
precision = currency.precision,
),
amountFieldModel = amountState.amountFieldModel.copy(
isError = false,
fiatAmount = amountState.amountFieldModel.fiatAmount.copy(
currencySymbol = currency.unit,
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
),
),
),
)
}
fun getAmountSecondaryLoadingState(): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
return currentState.copy(
amountBlockState = amountState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading),
providerBlockState = OnrampProviderBlockUM.Loading,
buyButtonConfig = currentState.buyButtonConfig.copy(isEnabled = false),
errorNotification = null,
)
}
fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.amountFieldModel.fiatValue.isEmpty()) return currentState
return currentState.copy(
amountBlockState = amountState.copy(
amountFieldModel = amountState.amountFieldModel.copy(isError = false),
secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel,
),
buyButtonConfig = currentState.buyButtonConfig.copy(
isEnabled = 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,
),
)
}
},
),
errorNotification = null,
)
}
fun getUpdatedProviderState(selectedQuote: OnrampQuote, quotes: List<OnrampQuote>): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
analyticsEventHandler.send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = selectedQuote.provider.info.name,
tokenSymbol = cryptoCurrency.symbol,
paymentMethod = selectedQuote.paymentMethod.name,
),
)
val bestProvider = quotes.firstOrNull()
val isMultipleQuotes = !quotes.isSingleItem()
val isOtherQuotesHasData = quotes
.filter { it.paymentMethod == selectedQuote.paymentMethod }
.filterNot { it == bestProvider }
.any { it is OnrampQuote.Data }
val isBestProvider = selectedQuote == bestProvider &&
isMultipleQuotes &&
isOtherQuotesHasData &&
!needApplyFCARestrictions()
return currentState.copy(
providerBlockState = selectedQuote.toProviderBlockState(isBestProvider),
)
}
fun getAmountSecondaryUpdatedState(
providerResult: SelectProviderResult,
isBestRate: Boolean,
): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
val secondaryField = when (providerResult) {
is SelectProviderResult.ProviderWithError -> {
providerResult.quoteError.toSecondaryFieldUiModel(amountState)
}
is SelectProviderResult.ProviderWithQuote -> {
val amount = providerResult.toAmount.value.format {
crypto(symbol = providerResult.toAmount.symbol, decimals = providerResult.toAmount.decimals)
}
OnrampAmountSecondaryFieldUM.Content(stringReference(amount))
}
}
return currentState.copy(
amountBlockState = amountState.copy(secondaryFieldModel = secondaryField),
providerBlockState = OnrampProviderBlockUM.Content(
paymentMethod = providerResult.paymentMethod,
providerId = providerResult.provider.id,
providerName = providerResult.provider.info.name,
isBestRate = isBestRate && !needApplyFCARestrictions(),
onClick = onrampIntents::openProviders,
termsOfUseLink = providerResult.provider.info.termsOfUseLink,
privacyPolicyLink = providerResult.provider.info.privacyPolicyLink,
onLinkClick = onrampIntents::onLinkClick,
),
buyButtonConfig = currentState.buyButtonConfig.copy(
isEnabled = providerResult is SelectProviderResult.ProviderWithQuote,
onClick = {
if (providerResult is SelectProviderResult.ProviderWithQuote) {
onrampIntents.onBuyClick(
OnrampProviderWithQuote.Data(
provider = providerResult.provider,
paymentMethod = providerResult.paymentMethod,
toAmount = providerResult.toAmount,
fromAmount = providerResult.fromAmount,
),
)
}
},
),
errorNotification = null,
)
}
fun getAmountSecondaryResetState(): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.secondaryFieldModel is OnrampAmountSecondaryFieldUM.Content) return currentState
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(
amount = TextReference.EMPTY,
),
),
errorNotification = null,
)
}
private fun OnrampQuote.toProviderBlockState(isBestRate: Boolean): OnrampProviderBlockUM {
return OnrampProviderBlockUM.Content(
paymentMethod = paymentMethod,
providerId = provider.id,
providerName = provider.info.name,
isBestRate = isBestRate,
onClick = onrampIntents::openProviders,
termsOfUseLink = provider.info.termsOfUseLink,
privacyPolicyLink = provider.info.privacyPolicyLink,
onLinkClick = onrampIntents::onLinkClick,
)
}
private fun OnrampQuote.toSecondaryFieldUiModel(amountState: OnrampAmountBlockUM): OnrampAmountSecondaryFieldUM? {
return when (this) {
is OnrampQuote.Error -> null
is OnrampQuote.Data -> {
val amount = toAmount.value.format {
crypto(symbol = toAmount.symbol, decimals = toAmount.decimals)
}
OnrampAmountSecondaryFieldUM.Content(stringReference(amount))
}
is OnrampQuote.AmountError -> this.toSecondaryFieldUiModel(amountState)
}
}
private fun OnrampQuote.AmountError.toSecondaryFieldUiModel(
amountState: OnrampAmountBlockUM,
): OnrampAmountSecondaryFieldUM.Error {
val amount = error.requiredAmount.format {
fiat(
fiatCurrencyCode = amountState.amountFieldModel.fiatAmount.currencySymbol,
fiatCurrencySymbol = amountState.amountFieldModel.fiatAmount.currencySymbol,
)
}
val errorTextRes = when (error) {
is OnrampError.AmountError.TooBigError -> {
analyticsEventHandler.send(OnrampAnalyticsEvent.MaxAmountError())
R.string.onramp_max_amount_restriction
}
is OnrampError.AmountError.TooSmallError -> {
analyticsEventHandler.send(OnrampAnalyticsEvent.MinAmountError())
R.string.onramp_min_amount_restriction
}
}
return OnrampAmountSecondaryFieldUM.Error(
resourceReference(
errorTextRes,
wrappedList(amount),
),
)
}
}

View file

@ -1,38 +1,25 @@
package com.tangem.features.onramp.main.model
import androidx.compose.runtime.mutableStateOf
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampAvailability
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.onramp.model.error.OnrampError
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampAmountStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampOffersStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory.Companion.PREDEFINED_SEPA_AMOUNT
import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory
import com.tangem.features.onramp.providers.entity.SelectProviderResult
import com.tangem.features.onramp.utils.model.EUR_CURRENCY
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -43,7 +30,6 @@ import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -51,85 +37,141 @@ internal class OnrampMainComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val router: Router,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase,
private val getOnrampCountryUseCase: GetOnrampCountryUseCase,
private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase,
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
private val onrampSaveDefaultCurrencyUseCase: OnrampSaveDefaultCurrencyUseCase,
private val onrampGetDefaultCurrencyUseCase: OnrampGetDefaultCurrencyUseCase,
private val amountInputManager: InputManager,
private val messageSender: UiMessageSender,
private val urlOpener: UrlOpener,
getWalletsUseCase: GetWalletsUseCase,
getUserCountryUseCase: GetUserCountryUseCase,
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampIntents {
private val params: OnrampMainComponent.Params = paramsContainer.require()
val params = paramsContainer.require<OnrampMainComponent.Params>()
private var shouldForceChooseSepa = params.isLaunchSepa
private var currencyToRestore: OnrampCurrency? = null
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
val bottomSheetNavigation: SlotNavigation<OnrampMainBottomSheetConfig> = SlotNavigation()
private val lastUpdateState = mutableStateOf<OnrampLastUpdate?>(null)
private var userCountry: UserCountry? = null
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountButtonUMStateFactory()
}
@Suppress("PropertyUsedBeforeDeclaration")
private val stateFactory = OnrampStateFactory(
currentStateProvider = Provider { state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
)
private val stateFactory: OnrampStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampStateFactory(
currentStateProvider = Provider { state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
val state: StateFlow<OnrampMainComponentUM>
field = MutableStateFlow<OnrampMainComponentUM>(
value = stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = ::onCloseClick,
openSettings = ::openSettings,
),
)
private val amountStateFactory = OnrampAmountStateFactory(
currentStateProvider = Provider { state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
cryptoCurrency = params.cryptoCurrency,
needApplyFCARestrictions = Provider { userCountry.needApplyFCARestrictions() },
)
private val amountStateFactory: OnrampAmountStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountStateFactory(
currentStateProvider = Provider { state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
private val onrampOffersStateFactory: OnrampOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampOffersStateFactory(
currentStateProvider = Provider { state.value },
onrampIntents = this,
)
}
private val quotesTaskScheduler = SingleTaskScheduler<Unit>()
init {
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
val bottomSheetNavigation: SlotNavigation<OnrampMainBottomSheetConfig> = SlotNavigation()
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
init {
modelScope.launch {
clearOnrampCacheUseCase()
if (params.isLaunchSepa) {
currencyToRestore = onrampGetDefaultCurrencyUseCase.invoke().getOrNull()
onrampSaveDefaultCurrencyUseCase.invoke(EUR_CURRENCY)
}
}
startLoadingQuotes()
sendScreenOpenAnalytics()
checkResidenceCountry()
subscribeToAmountChanges()
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
subscribeOnOffers()
}
private fun sendScreenOpenAnalytics() {
override fun onDestroy() {
modelScope.launch { clearOnrampCacheUseCase.invoke() }
quotesTaskScheduler.cancelTask()
super.onDestroy()
}
override fun onAmountValueChanged(value: String) {
state.update { amountStateFactory.getOnAmountValueChange(value) }
modelScope.launch { amountInputManager.update(value) }
}
override fun openSettings() {
params.openSettings.invoke()
}
override fun openCurrenciesList() {
analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened())
bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.CurrenciesList)
}
override fun onBuyClick(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
) {
val currentContentState = state.value as? OnrampMainComponentUM.Content ?: return
analyticsEventHandler.send(
OnrampAnalyticsEvent.ScreenOpened(
source = params.source,
OnrampAnalyticsEvent.OnBuyClick(
providerName = quote.provider.info.name,
currency = currentContentState.amountBlockState.currencyUM.code,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
sendOfferClickEvent(
quote = quote,
onrampOfferAdvantagesUM = onrampOfferAdvantagesUM,
categoryUM = categoryUM,
)
params.openRedirectPage(quote)
}
override fun openProviders() {
val currentContentState = state.value as? OnrampMainComponentUM.Content ?: return
val amountCurrentCode = currentContentState.amountBlockState.currencyUM.code
bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.AllOffers(amountCurrentCode))
}
override fun onRefresh() {
state.update {
stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = router::pop,
openSettings = ::openSettings,
)
}
modelScope.launch {
clearOnrampCacheUseCase.invoke()
checkResidenceCountry()
handleOnrampAvailable()
}
}
fun onStop() {
quotesTaskScheduler.cancelTask()
}
fun handleOnrampAvailable() {
@ -137,92 +179,6 @@ internal class OnrampMainComponentModel @Inject constructor(
subscribeToQuotesUpdate()
}
fun onProviderSelected(result: SelectProviderResult, isBestRate: Boolean) {
state.update { amountStateFactory.getAmountSecondaryUpdatedState(result, isBestRate) }
if (result.paymentMethod.id != SEPA_METHOD_ID) {
shouldForceChooseSepa = false
}
}
private fun checkResidenceCountry() {
modelScope.launch {
checkOnrampAvailabilityUseCase(userWallet)
.onRight(::handleOnrampAvailability)
.onLeft(::handleOnrampError)
}
}
private fun handleOnrampAvailability(availability: OnrampAvailability) {
when (availability) {
is OnrampAvailability.Available -> handleOnrampAvailable()
is OnrampAvailability.ConfirmResidency,
is OnrampAvailability.NotSupported,
-> bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.ConfirmResidency(availability.country))
}
}
private fun subscribeToCountryAndCurrencyUpdates() {
getOnrampCountryUseCase.invoke()
.onEach { maybeCountry ->
maybeCountry.fold(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
val wasInitialLoading = state.value is OnrampMainComponentUM.InitialLoading
state.update { prevState ->
if (prevState is OnrampMainComponentUM.InitialLoading) {
stateFactory.getReadyState(country.defaultCurrency)
} else {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
}
updatePairsAndQuotes()
if (wasInitialLoading && params.isLaunchSepa) {
onAmountValueChanged(value = PREDEFINED_SEPA_AMOUNT, isValuePasted = true)
}
},
)
}
.launchIn(modelScope)
}
private fun subscribeToAmountChanges() = modelScope.launch {
amountInputManager.query
.filter(String::isNotEmpty)
.collectLatest { _ ->
state.update { amountStateFactory.getAmountSecondaryLoadingState() }
startLoadingQuotes()
}
}
private suspend fun updatePairsAndQuotes() {
state.update { prevState ->
val contentState = state.value as? OnrampMainComponentUM.Content ?: return@update prevState
if (contentState.amountBlockState.amountFieldModel.fiatValue.isNotEmpty()) {
amountStateFactory.getAmountSecondaryLoadingState()
} else {
prevState
}
}
fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError,
ifRight = { state.update { amountStateFactory.getAmountSecondaryResetState() } },
)
startLoadingQuotes()
}
private fun handleOnrampError(onrampError: OnrampError) {
Timber.e(onrampError.toString())
sendOnrampErrorAnalytic(onrampError)
state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun startLoadingQuotes() {
quotesTaskScheduler.cancelTask()
quotesTaskScheduler.scheduleTask(scope = modelScope, task = loadQuotesTask())
@ -233,11 +189,12 @@ internal class OnrampMainComponentModel @Inject constructor(
delay = UPDATE_DELAY,
task = {
runSuspendCatching {
val content = state.value as? OnrampMainComponentUM.Content ?: return@runSuspendCatching
val amountBlockState = content.amountBlockState
if (amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero()) {
return@runSuspendCatching
}
val amountBlockState = (state.value as? OnrampMainComponentUM.Content)?.amountBlockState
?: return@runSuspendCatching
val fiatAmount = amountBlockState.amountFieldModel.fiatAmount
if (fiatAmount.value.isNullOrZero()) return@runSuspendCatching
fetchQuotesUseCase.invoke(
userWallet = userWallet,
amount = amountBlockState.amountFieldModel.fiatAmount,
@ -250,6 +207,84 @@ internal class OnrampMainComponentModel @Inject constructor(
)
}
private fun checkResidenceCountry() {
modelScope.launch {
checkOnrampAvailabilityUseCase(userWallet)
.onRight(::handleOnrampAvailability)
.onLeft(::handleOnrampError)
}
}
private fun handleOnrampAvailability(availability: OnrampAvailability) {
when (availability) {
is OnrampAvailability.Available -> Unit
is OnrampAvailability.ConfirmResidency,
is OnrampAvailability.NotSupported,
-> bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.ConfirmResidency(availability.country))
}
}
private fun onCloseClick() {
analyticsEventHandler.send(OnrampAnalyticsEvent.CloseOnramp())
router.pop()
}
private fun subscribeOnOffers() = modelScope.launch {
getOnrampOffersUseCase
.invoke()
.collectLatest { maybeOffers ->
maybeOffers.fold(
ifLeft = ::handleOnrampError,
ifRight = { offers ->
val currentState = state.value
if (currentState is OnrampMainComponentUM.Content) {
if (currentState.amountBlockState.amountFieldModel.fiatValue.isEmpty()) {
state.update {
currentState.copy(offersBlockState = OnrampOffersBlockUM.Empty)
}
return@fold
}
state.update {
onrampOffersStateFactory.getOffersState(offers)
}
}
},
)
}
}
private fun subscribeToAmountChanges() = modelScope.launch {
amountInputManager.query
.filter(String::isNotEmpty)
.collectLatest { _ ->
startLoadingQuotes()
}
}
private fun subscribeToCountryAndCurrencyUpdates() {
getOnrampCountryUseCase.invoke()
.onEach { maybeCountry ->
maybeCountry.fold(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
state.update { prevState ->
when (prevState) {
is OnrampMainComponentUM.Content -> {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampMainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency)
}
}
}
updatePairsAndQuotes()
},
)
}
.launchIn(modelScope)
}
private fun subscribeToQuotesUpdate() {
getOnrampQuotesUseCase.invoke()
.conflate()
@ -262,152 +297,31 @@ internal class OnrampMainComponentModel @Inject constructor(
.launchIn(modelScope)
}
override fun onAmountValueChanged(value: String, isValuePasted: Boolean) {
state.update { amountStateFactory.getOnAmountValueChange(value, isValuePasted) }
modelScope.launch { amountInputManager.update(value) }
}
override fun openSettings() {
params.openSettings()
}
override fun onBuyClick(quote: OnrampProviderWithQuote.Data) {
if (userWallet is UserWallet.Cold && isDemoCardUseCase.invoke(userWallet.cardId)) {
showDemoWarning()
} else {
val currentContentState = state.value as? OnrampMainComponentUM.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 openCurrenciesList() {
analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened())
bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.CurrenciesList)
}
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,
selectedProviderId = providerContentState.providerId,
),
)
}
override fun onRefresh() {
state.update {
stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = router::pop,
)
}
quotesTaskScheduler.cancelTask()
modelScope.launch {
clearOnrampCacheUseCase.invoke()
checkResidenceCountry()
}
}
override fun onLinkClick(link: String) = urlOpener.openUrl(link)
override fun onDestroy() {
modelScope.launch { clearOnrampCacheUseCase.invoke() }
quotesTaskScheduler.cancelTask()
modelScope.launch {
if (params.isLaunchSepa) {
currencyToRestore?.let { onrampSaveDefaultCurrencyUseCase.invoke(it) }
}
}
super.onDestroy()
}
private fun onCloseClick() {
analyticsEventHandler.send(OnrampAnalyticsEvent.CloseOnramp())
router.pop()
}
private fun handleQuoteResult(quotes: List<OnrampQuote>) {
sendOnrampQuotesErrorAnalytic(quotes)
val quote = selectOrUpdateQuote(quotes)
if (quote == null) {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
lastUpdateState.value = null
return
}
state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) }
}
/**
* !!! Important quote selection logic !!!
* Selects or updated quote based on input data (amount, country, currency).
* If input data has changed select new best quote, otherwise last selected quote.
* If last selected quote on same input data is in an error state, select next best quote
* If new best quote or next best quote does not exist (i.e. Error state) select nothing.
*/
private fun selectOrUpdateQuote(quotes: List<OnrampQuote>): OnrampQuote? {
val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error }
val bestSepaQuote = if (params.isLaunchSepa && shouldForceChooseSepa) {
quotes.filterIsInstance<OnrampQuote.Data>()
.filter { it.paymentMethod.id == SEPA_METHOD_ID }
.maxByOrNull { it.toAmount.value }
} else {
null
}
// Check if amount, country or currency has changed
val newQuote = bestSepaQuote ?: if (isAmountOrCountryChanged(quoteToCheck)) {
quoteToCheck
} else {
val state = state.value as? OnrampMainComponentUM.Content
val providerState = state?.providerBlockState as? OnrampProviderBlockUM.Content
// Get current selected quote to update
val lastSelectedQuote = quotes.firstOrNull { quote ->
quote.provider.id == providerState?.providerId &&
quote.paymentMethod.id == providerState.paymentMethod.id
when {
quotes.isEmpty() -> {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
// Check if selected updated quote is not error
if (lastSelectedQuote is OnrampQuote.Error) {
quoteToCheck
} else {
lastSelectedQuote
quotes.all { it is OnrampQuote.AmountError } -> {
state.update { amountStateFactory.getSecondaryFieldAmountErrorState(quotes) }
}
quotes.none { it is OnrampQuote.Data } -> {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
else -> {
state.update { prevState ->
val resetState = amountStateFactory.getAmountSecondaryFieldResetState()
if (prevState is OnrampMainComponentUM.Content &&
resetState is OnrampMainComponentUM.Content &&
prevState.offersBlockState is OnrampOffersBlockUM.Loading
) {
resetState.copy(offersBlockState = OnrampOffersBlockUM.Empty)
} else {
resetState
}
}
}
}
if (newQuote != null) {
updateProvider(newQuote, quotes)
}
return newQuote
}
private fun updateProvider(quote: OnrampQuote, quotes: List<OnrampQuote>) {
lastUpdateState.value = OnrampLastUpdate(
quote.fromAmount,
quote.countryCode,
quote.paymentMethod,
)
if (quote.paymentMethod.id != SEPA_METHOD_ID) {
shouldForceChooseSepa = false
}
state.update {
amountStateFactory.getUpdatedProviderState(selectedQuote = quote, quotes = quotes)
}
}
@ -415,41 +329,30 @@ internal class OnrampMainComponentModel @Inject constructor(
state.update { prevState ->
(prevState as? OnrampMainComponentUM.Content)?.copy(
errorNotification = null,
providerBlockState = OnrampProviderBlockUM.Loading,
offersBlockState = OnrampOffersBlockUM.Loading,
amountBlockState = prevState.amountBlockState.copy(
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading,
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
) ?: prevState
}
startLoadingQuotes()
}
private fun showDemoWarning() {
val alertUM = AlertDemoModeUM(onConfirmClick = {})
val message = DialogMessage(
title = alertUM.title,
message = alertUM.message,
firstActionBuilder = {
EventMessageAction(
title = alertUM.confirmButtonText,
onClick = alertUM.onConfirmClick,
)
private suspend fun updatePairsAndQuotes() {
fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError,
ifRight = {
state.update {
amountStateFactory.getAmountSecondaryFieldResetState()
}
startLoadingQuotes()
},
secondActionBuilder = { cancelAction() },
)
messageSender.send(message)
}
private fun sendOnrampErrorAnalytic(error: OnrampError) {
val content = state.value as? OnrampMainComponentUM.Content
val providerContent = content?.providerBlockState as? OnrampProviderBlockUM.Content
analyticsEventHandler.sendOnrampErrorEvent(
error = error,
tokenSymbol = params.cryptoCurrency.symbol,
providerName = providerContent?.providerName,
paymentMethod = providerContent?.paymentMethod?.name,
)
private fun handleOnrampError(onrampError: OnrampError) {
Timber.e(onrampError.toString())
state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
@ -467,20 +370,48 @@ internal class OnrampMainComponentModel @Inject constructor(
providerName = errorState.provider.info.name,
paymentMethod = errorState.paymentMethod.name,
)
else -> { /* no-op */
}
else -> Unit
}
}
}
private fun isAmountOrCountryChanged(quote: OnrampQuote?): Boolean {
return lastUpdateState.value?.fromAmount != quote?.fromAmount ||
lastUpdateState.value?.countryCode != quote?.countryCode
private fun sendScreenOpenAnalytics() {
analyticsEventHandler.send(
OnrampAnalyticsEvent.ScreenOpened(
source = params.source,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
}
private fun sendOfferClickEvent(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
) {
val event = when (categoryUM) {
OnrampOfferCategoryUM.RecentlyUsed -> {
OnrampAnalyticsEvent.RecentlyBuyClicked(
tokenSymbol = params.cryptoCurrency.symbol,
providerName = quote.provider.info.name,
paymentMethod = quote.paymentMethod.name,
)
}
OnrampOfferCategoryUM.Recommended -> {
onrampOfferAdvantagesUM.toAnalyticsEvent(
cryptoCurrencySymbol = params.cryptoCurrency.symbol,
providerName = quote.provider.info.name,
paymentMethodName = quote.paymentMethod.name,
)
}
}
if (event != null) {
analyticsEventHandler.send(event)
}
}
private companion object {
const val UPDATE_DELAY = 10_000L
const val SEPA_METHOD_ID = "sepa"
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.features.onramp.main.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -17,61 +19,89 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.BuyTokenDetailsScreenTestTags
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampAmountBlockUM
import com.tangem.features.onramp.main.entity.OnrampAmountSecondaryFieldUM
import com.tangem.features.onramp.main.entity.OnrampCurrencyUM
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampSecondaryFieldErrorUM
@Composable
internal fun OnrampAmountContent(state: OnrampAmountBlockUM, modifier: Modifier = Modifier) {
internal fun OnrampAmountContent(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.padding(vertical = TangemTheme.dimens.spacing28),
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(size = TangemTheme.dimens.radius16),
)
.padding(vertical = 24.dp, horizontal = 16.dp)
.animateContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OnrampCurrencyIcon(currencyUM = state.currencyUM)
OnrampAmountField(amountField = state.amountFieldModel)
OnrampAmountSecondary(state = state.secondaryFieldModel)
OnrampHeaderTitle()
OnrampAmountField(
amountField = state.amountBlockState.amountFieldModel,
currencyCode = state.amountBlockState.currencyUM.code,
)
AnimatedVisibility(
visible = state.amountBlockState.secondaryFieldModel !is OnrampSecondaryFieldErrorUM.Empty,
) {
if (state.amountBlockState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Error) {
OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel)
}
}
SpacerH(20.dp)
OnrampCurrencyIcon(currencyUM = state.amountBlockState.currencyUM)
}
}
@Composable
private fun OnrampAmountField(amountField: AmountFieldModel) {
private fun OnrampHeaderTitle() {
Text(
text = stringResourceSafe(R.string.onramp_you_will_pay_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: String) {
val decimalFormat = rememberDecimalFormat()
val requester = remember { FocusRequester() }
val symbolColor = if (amountField.fiatValue.isBlank()) {
TangemTheme.colors.text.disabled
} else {
TangemTheme.colors.text.primary1
}
AmountTextField(
value = amountField.fiatValue,
decimals = amountField.fiatAmount.decimals,
visualTransformation = AmountVisualTransformation(
decimals = amountField.fiatAmount.decimals,
symbol = amountField.fiatAmount.currencySymbol,
currencyCode = amountField.fiatAmount.currencySymbol,
symbol = currencyCode,
currencyCode = currencyCode,
decimalFormat = decimalFormat,
symbolColor = symbolColor,
symbolColor = if (amountField.fiatValue.isBlank()) {
TangemTheme.colors.text.disabled
} else {
TangemTheme.colors.text.primary1
},
),
onValueChange = amountField.onValueChange,
keyboardOptions = amountField.keyboardOptions,
keyboardActions = amountField.keyboardActions,
textStyle = TangemTheme.typography.h2.copy(
textStyle = TangemTheme.typography.head.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
@ -82,7 +112,8 @@ private fun OnrampAmountField(amountField: AmountFieldModel) {
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,
)
@ -96,7 +127,7 @@ private fun OnrampAmountField(amountField: AmountFieldModel) {
}
@Composable
private fun OnrampAmountSecondary(state: OnrampAmountSecondaryFieldUM) {
private fun OnrampAmountSecondary(state: OnrampSecondaryFieldErrorUM.Error) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -107,24 +138,12 @@ private fun OnrampAmountSecondary(state: OnrampAmountSecondaryFieldUM) {
),
contentAlignment = Alignment.Center,
) {
when (state) {
is OnrampAmountSecondaryFieldUM.Content -> Text(
text = state.amount.resolveReference(),
style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
is OnrampAmountSecondaryFieldUM.Error -> Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
is OnrampAmountSecondaryFieldUM.Loading -> TextShimmer(
style = TangemTheme.typography.caption2,
modifier = Modifier.width(TangemTheme.dimens.size62),
)
}
Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
}
}
@ -132,20 +151,27 @@ private fun OnrampAmountSecondary(state: OnrampAmountSecondaryFieldUM) {
private fun OnrampCurrencyIcon(currencyUM: OnrampCurrencyUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(14.dp))
.background(TangemTheme.colors.button.secondary)
.clickable(onClick = currencyUM.onClick)
.padding(start = TangemTheme.dimens.spacing24),
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AsyncImage(
modifier = Modifier
.size(TangemTheme.dimens.size40)
.size(20.dp)
.clip(CircleShape)
.testTag(BuyTokenDetailsScreenTestTags.FIAT_CURRENCY_ICON),
model = currencyUM.iconUrl,
contentDescription = null,
)
Text(
text = currencyUM.code,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
)
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size16)

View file

@ -1,104 +0,0 @@
package com.tangem.features.onramp.main.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.extensions.appendColored
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampProviderBlockUM
private const val TERMS_OF_USE_KEY = "termsOfUse"
private const val PRIVACY_POLICY_KEY = "privacyPolicy"
@Composable
internal fun OnrampButtonComponent(state: OnrampMainComponentUM) {
val content = state as? OnrampMainComponentUM.Content
val providerState = content?.providerBlockState as? OnrampProviderBlockUM.Content
Column(
modifier = Modifier
.navigationBarsPadding()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OnrampTosText(providerState)
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(id = R.string.common_buy),
onClick = state.buyButtonConfig.onClick,
enabled = state.buyButtonConfig.isEnabled,
)
}
}
@Composable
private fun OnrampTosText(provider: OnrampProviderBlockUM.Content?) {
val termsOfUse = stringResourceSafe(R.string.common_terms_of_use)
val privacyPolicy = stringResourceSafe(R.string.common_privacy_policy)
val tosText = stringResourceSafe(R.string.onramp_legal, termsOfUse, privacyPolicy)
val clickableAnnotation = buildAnnotatedString {
append(tosText.substringBefore(termsOfUse))
pushStringAnnotation(TERMS_OF_USE_KEY, "")
appendColored(termsOfUse, TangemTheme.colors.text.accent)
pop()
append(tosText.substringAfter(termsOfUse).substringBefore(privacyPolicy))
pushStringAnnotation(PRIVACY_POLICY_KEY, "")
appendColored(privacyPolicy, TangemTheme.colors.text.accent)
pop()
}
AnimatedContent(
targetState = provider,
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
label = "Onramp Legal Info Animation",
) { state ->
val termsOfUseLink = provider?.termsOfUseLink
val privacyPolicyLink = provider?.privacyPolicyLink
if (state != null && termsOfUseLink != null && privacyPolicyLink != null) {
ClickableText(
text = clickableAnnotation,
style = TangemTheme.typography.caption2.copy(
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
),
onClick = { offset ->
val tosAnnotations = clickableAnnotation.getStringAnnotations(
tag = TERMS_OF_USE_KEY,
start = offset,
end = offset,
)
if (tosAnnotations.any()) {
state.onLinkClick(termsOfUseLink)
}
val privacyPolicyAnnotations = clickableAnnotation.getStringAnnotations(
tag = PRIVACY_POLICY_KEY,
start = offset,
end = offset,
)
if (privacyPolicyAnnotations.any()) {
state.onLinkClick(privacyPolicyLink)
}
},
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.mainv2.ui
package com.tangem.features.onramp.main.ui
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
@ -20,13 +20,13 @@ import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUMState
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampOffersBlockUM
@Composable
internal fun BoxScope.OnrampFooterContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
internal fun BoxScope.OnrampFooterContent(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) {
AnimatedVisibility(
modifier = modifier
.imePadding()
@ -53,16 +53,16 @@ internal fun BoxScope.OnrampFooterContent(state: OnrampV2MainComponentUM.Content
}
@Composable
private fun OnrampAmountButtons(state: OnrampV2AmountButtonUMState) {
private fun OnrampAmountButtons(state: OnrampAmountButtonUMState) {
val keyboard by keyboardAsState()
AnimatedVisibility(
visible = state is OnrampV2AmountButtonUMState.Loaded,
visible = state is OnrampAmountButtonUMState.Loaded,
enter = fadeIn(),
exit = fadeOut(),
) {
when (state) {
is OnrampV2AmountButtonUMState.Loaded -> {
is OnrampAmountButtonUMState.Loaded -> {
if (keyboard is Keyboard.Opened) {
LazyRow(
modifier = Modifier.background(color = TangemTheme.colors.button.secondary),
@ -82,7 +82,7 @@ private fun OnrampAmountButtons(state: OnrampV2AmountButtonUMState) {
}
}
}
OnrampV2AmountButtonUMState.None -> Unit
OnrampAmountButtonUMState.None -> Unit
}
}
}

View file

@ -5,13 +5,12 @@ 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.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import com.tangem.core.ui.components.CircleShimmer
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.notifications.Notification
@ -21,41 +20,58 @@ import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
@Composable
internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.imePadding(),
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
modifier = modifier.systemBarsPadding(),
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
startButton = state.topBarConfig.startButtonUM,
endButton = state.topBarConfig.endButtonUM,
title = state.topBarConfig.title.resolveReference(),
)
},
content = { innerPadding ->
val contentModifier = Modifier
.padding(innerPadding)
.padding(horizontal = TangemTheme.dimens.spacing16)
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
) { scaffoldPaddings ->
OnrampMainComponentContent(
state = state,
modifier = Modifier.padding(scaffoldPaddings),
)
}
}
@Composable
internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is OnrampMainComponentUM.InitialLoading -> InitialLoading(modifier = contentModifier, state = state)
is OnrampMainComponentUM.Content -> Content(modifier = contentModifier, state = state)
is OnrampMainComponentUM.InitialLoading -> InitialLoading(state = state)
is OnrampMainComponentUM.Content -> Content(state = state)
}
},
floatingActionButton = {
OnrampButtonComponent(state)
},
floatingActionButtonPosition = FabPosition.Center,
)
}
if (state is OnrampMainComponentUM.Content) {
OnrampFooterContent(state = state)
}
}
}
@Composable
private fun InitialLoading(state: OnrampMainComponentUM.InitialLoading, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContentLoading()
@ -64,27 +80,38 @@ private fun InitialLoading(state: OnrampMainComponentUM.InitialLoading, modifier
}
@Composable
private fun OnrampAmountContentLoading(modifier: Modifier = Modifier) {
private fun OnrampAmountContentLoading() {
Column(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.padding(vertical = TangemTheme.dimens.spacing28),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircleShimmer(modifier = Modifier.size(TangemTheme.dimens.size40))
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.size(width = TangemTheme.dimens.size96, height = TangemTheme.dimens.size24),
radius = TangemTheme.dimens.radius3,
.size(width = 76.dp, height = 20.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.size(width = TangemTheme.dimens.size72, height = TangemTheme.dimens.size12),
radius = TangemTheme.dimens.radius3,
.padding(top = TangemTheme.dimens.spacing12)
.size(width = 136.dp, height = 44.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8)
.size(width = 52.dp, height = 16.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing20)
.size(width = 84.dp, height = 28.dp),
radius = TangemTheme.dimens.radius14,
)
}
}
@ -93,13 +120,20 @@ private fun OnrampAmountContentLoading(modifier: Modifier = Modifier) {
private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.wrapContentHeight()
.navigationBarsPadding()
.padding(bottom = TangemTheme.dimens.spacing76),
.padding(
bottom = 76.dp,
start = 16.dp,
end = 16.dp,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContent(state = state.amountBlockState)
OnrampProviderContent(state = state.providerBlockState, modifier = Modifier.fillMaxWidth())
OnrampAmountContent(state = state)
OnrampOffersContent(state = state.offersBlockState)
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.mainv2.ui
package com.tangem.features.onramp.main.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
@ -33,7 +33,7 @@ import com.tangem.core.ui.test.OnrampOffersBlockTestTags
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.*
import com.tangem.features.onramp.main.entity.*
import kotlinx.collections.immutable.persistentListOf
@Composable

View file

@ -1,128 +0,0 @@
package com.tangem.features.onramp.main.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
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.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.tangem.core.ui.extensions.appendSpace
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampProviderBlockUM
import com.tangem.features.onramp.paymentmethod.ui.PaymentMethodIcon
@Composable
internal fun OnrampProviderContent(state: OnrampProviderBlockUM, modifier: Modifier = Modifier) {
when (state) {
is OnrampProviderBlockUM.Empty -> Unit
is OnrampProviderBlockUM.Loading -> OnrampProviderLoading(modifier)
is OnrampProviderBlockUM.Content -> OnrampProviderBlock(modifier = modifier, state = state)
}
}
@Composable
private fun OnrampProviderBlock(state: OnrampProviderBlockUM.Content, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.clickable(onClick = state.onClick)
.padding(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
PaymentMethodIcon(imageUrl = state.paymentMethod.imageUrl)
Column(modifier = Modifier.weight(1F)) {
Text(
text = buildAnnotatedString {
append(stringResourceSafe(id = R.string.onramp_pay_with))
appendSpace()
withStyle(
style = SpanStyle(
fontWeight = TangemTheme.typography.subtitle2.fontWeight,
color = TangemTheme.colors.text.primary1,
),
) {
append(state.paymentMethod.name)
}
},
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
)
Text(
text = buildAnnotatedString {
append(stringResourceSafe(id = R.string.onramp_via))
appendSpace()
append(state.providerName)
},
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
AnimatedVisibility(
visible = state.isBestRate,
enter = fadeIn(),
exit = fadeOut(),
label = "Best Rate visibility animation",
) {
Text(
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent,
shape = RoundedCornerShape(TangemTheme.dimens.radius4),
)
.padding(
horizontal = TangemTheme.dimens.spacing6,
vertical = TangemTheme.dimens.spacing1,
),
text = stringResourceSafe(id = R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary2,
)
}
}
}
@Composable
private fun OnrampProviderLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
Text(
text = stringResourceSafe(id = R.string.express_provider),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
CircularProgressIndicator(
color = TangemTheme.colors.icon.informative,
strokeWidth = TangemTheme.dimens.size2,
modifier = Modifier.size(TangemTheme.dimens.size16),
)
Text(
text = stringResourceSafe(id = R.string.express_fetch_best_rates),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}

View file

@ -1,100 +0,0 @@
package com.tangem.features.onramp.mainv2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
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
import com.tangem.features.onramp.mainv2.ui.OnrampNewMainScreen
import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnrampV2MainComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@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)
init {
lifecycle.subscribe(onStop = model::onStop)
}
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsState()
val bottomSheet by bottomSheetSlot.subscribeAsState()
OnrampNewMainScreen(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: OnrampV2MainBottomSheetConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is OnrampV2MainBottomSheetConfig.ConfirmResidency -> confirmResidencyComponentFactory.create(
context = childByContext(componentContext),
params = ConfirmResidencyComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
country = config.country,
isLaunchSepa = false,
onDismiss = {
model.bottomSheetNavigation.dismiss()
model.handleOnrampAvailable()
},
),
)
is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create(
context = childByContext(componentContext),
params = SelectCurrencyComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
is OnrampV2MainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create(
context = childByContext(componentContext),
params = AllOffersComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
openRedirectPage = params.openRedirectPage,
amountCurrencyCode = config.amountCurrencyCode,
),
)
}
@AssistedFactory
interface Factory : OnrampV2MainComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnrampV2MainComponent.Params,
): DefaultOnrampV2MainComponent
}
}

View file

@ -1,10 +0,0 @@
package com.tangem.features.onramp.mainv2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
class DefaultOnrampV2MainFeatureToggle(
private val featureTogglesManager: FeatureTogglesManager,
) : OnrampV2MainFeatureToggle {
override val isOnrampNewMainEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_MAIN_ENABLED")
}

View file

@ -1,21 +0,0 @@
package com.tangem.features.onramp.mainv2
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampSource
internal interface OnrampV2MainComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
)
interface Factory : ComponentFactory<Params, OnrampV2MainComponent>
}

View file

@ -1,5 +0,0 @@
package com.tangem.features.onramp.mainv2
internal interface OnrampV2MainFeatureToggle {
val isOnrampNewMainEnabled: Boolean
}

View file

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

View file

@ -1,33 +0,0 @@
package com.tangem.features.onramp.mainv2.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainComponent
import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainFeatureToggle
import com.tangem.features.onramp.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.OnrampV2MainFeatureToggle
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface OnrampNewMainComponentModule {
@Binds
@Singleton
fun bindOnrampV2MainComponentFactory(factory: DefaultOnrampV2MainComponent.Factory): OnrampV2MainComponent.Factory
}
@Module
@InstallIn(SingletonComponent::class)
internal object FeatureToggleModule {
@Provides
@Singleton
fun provideOnrampV2MainFeatureToggle(featureTogglesManager: FeatureTogglesManager): OnrampV2MainFeatureToggle {
return DefaultOnrampV2MainFeatureToggle(featureTogglesManager = featureTogglesManager)
}
}

View file

@ -1,16 +0,0 @@
package com.tangem.features.onramp.mainv2.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
internal interface OnrampV2Intents {
fun onAmountValueChanged(value: String)
fun openSettings()
fun openCurrenciesList()
fun onBuyClick(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
)
fun openProviders()
fun onRefresh()
}

View file

@ -1,16 +0,0 @@
package com.tangem.features.onramp.mainv2.entity
import com.tangem.domain.onramp.model.OnrampCountry
import kotlinx.serialization.Serializable
@Serializable
sealed interface OnrampV2MainBottomSheetConfig {
@Serializable
data class ConfirmResidency(val country: OnrampCountry) : OnrampV2MainBottomSheetConfig
@Serializable
data object CurrenciesList : OnrampV2MainBottomSheetConfig
@Serializable
data class AllOffers(val amountCurrencyCode: String) : OnrampV2MainBottomSheetConfig
}

View file

@ -1,32 +0,0 @@
package com.tangem.features.onramp.mainv2.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed interface OnrampV2MainComponentUM {
val topBarConfig: OnrampV2MainTopBarUM
val errorNotification: NotificationUM?
data class InitialLoading(
override val topBarConfig: OnrampV2MainTopBarUM,
override val errorNotification: NotificationUM?,
) : OnrampV2MainComponentUM
data class Content(
override val topBarConfig: OnrampV2MainTopBarUM,
override val errorNotification: NotificationUM?,
val amountBlockState: OnrampNewAmountBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampV2AmountButtonUMState,
) : OnrampV2MainComponentUM
}
internal data class OnrampV2MainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,
)

View file

@ -1,15 +0,0 @@
package com.tangem.features.onramp.mainv2.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
sealed interface OnrampV2ProvidersUM {
data object Empty : OnrampV2ProvidersUM
data object Loading : OnrampV2ProvidersUM
data class Content(
val providerId: String,
val paymentMethod: OnrampPaymentMethod,
) : OnrampV2ProvidersUM
}

View file

@ -1,177 +0,0 @@
package com.tangem.features.onramp.mainv2.entity.factory
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.tokens.model.convertToAmount
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.utils.Provider
import java.math.BigDecimal
internal class OnrampV2StateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val cryptoCurrency: CryptoCurrency,
private val onrampIntents: OnrampV2Intents,
) {
fun getInitialState(
currency: String,
onClose: () -> Unit,
openSettings: () -> Unit,
): OnrampV2MainComponentUM.InitialLoading {
return OnrampV2MainComponentUM.InitialLoading(
errorNotification = null,
topBarConfig = OnrampV2MainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
startButtonUM = TopAppBarButtonUM.Close(
onCloseClick = onClose,
enabled = true,
),
endButtonUM = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_more_vertical_24,
onClicked = openSettings,
isEnabled = false,
),
),
)
}
fun getReadyState(currency: OnrampCurrency): OnrampV2MainComponentUM.Content {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
is TopAppBarButtonUM.Icon -> button.copy(isEnabled = true)
is TopAppBarButtonUM.Text -> button.copy(isEnabled = true)
}
val initialAmountBlockState = getInitialAmountBlockState(currency)
return OnrampV2MainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
amountBlockState = initialAmountBlockState,
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = null,
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencyCode = currency.code,
currencySymbol = currency.unit,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
)
}
fun getOnrampErrorState(onrampError: OnrampError): OnrampV2MainComponentUM {
return when (onrampError) {
OnrampError.PairsNotFound -> getNoPairsErrorState()
is OnrampError.DataError -> getErrorState(
errorCode = onrampError.code,
onRefresh = onrampIntents::onRefresh,
)
is OnrampError.DomainError -> getErrorState(onRefresh = onrampIntents::onRefresh)
is OnrampError.AmountError.TooBigError,
is OnrampError.AmountError.TooSmallError,
OnrampError.RedirectError.VerificationFailed,
OnrampError.RedirectError.WrongRequestId,
OnrampError.AlreadyHandledTransaction,
-> currentStateProvider() // ignore error state
}
}
fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampV2MainComponentUM {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
is TopAppBarButtonUM.Icon -> button.copy(isEnabled = true)
is TopAppBarButtonUM.Text -> button.copy(isEnabled = true)
}
return when (state) {
is OnrampV2MainComponentUM.Content -> state.copy(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
)
is OnrampV2MainComponentUM.InitialLoading -> state.copy(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
)
}
}
private fun getNoPairsErrorState(): OnrampV2MainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampV2MainComponentUM.Content ?: return state
return contentState.copy(
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}
private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampNewAmountBlockUM {
return OnrampNewAmountBlockUM(
currencyUM = OnrampNewCurrencyUM(
code = currency.code,
iconUrl = currency.image,
precision = currency.precision,
onClick = onrampIntents::openCurrenciesList,
unit = currency.unit,
),
amountFieldModel = AmountFieldModel(
value = "",
fiatValue = "",
onValueChange = onrampIntents::onAmountValueChanged,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
isFiatValue = true,
cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency),
fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency),
isError = false,
isWarning = false,
error = TextReference.EMPTY,
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
)
}
private fun BigDecimal.convertToFiatAmount(currency: OnrampCurrency): Amount = Amount(
currencySymbol = currency.unit,
value = this,
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
)
}

View file

@ -1,417 +0,0 @@
package com.tangem.features.onramp.mainv2.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.ui.components.fields.InputManager
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.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
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.PeriodicTask
import com.tangem.utils.coroutines.SingleTaskScheduler
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
internal class OnrampV2MainComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val router: Router,
private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase,
private val getOnrampCountryUseCase: GetOnrampCountryUseCase,
private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase,
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
private val amountInputManager: InputManager,
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampV2Intents {
val params = paramsContainer.require<OnrampV2MainComponent.Params>()
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountButtonUMStateFactory()
}
@Suppress("PropertyUsedBeforeDeclaration")
private val stateFactory: OnrampV2StateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampV2StateFactory(
currentStateProvider = Provider { state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
val state: StateFlow<OnrampV2MainComponentUM>
field = MutableStateFlow<OnrampV2MainComponentUM>(
value = stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = ::onCloseClick,
openSettings = ::openSettings,
),
)
private val amountStateFactory: OnrampV2AmountStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampV2AmountStateFactory(
currentStateProvider = Provider { state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
private val onrampOffersStateFactory: OnrampOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampOffersStateFactory(
currentStateProvider = Provider { state.value },
onrampIntents = this,
)
}
private val quotesTaskScheduler = SingleTaskScheduler<Unit>()
val bottomSheetNavigation: SlotNavigation<OnrampV2MainBottomSheetConfig> = SlotNavigation()
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
init {
modelScope.launch {
clearOnrampCacheUseCase()
}
startLoadingQuotes()
sendScreenOpenAnalytics()
checkResidenceCountry()
subscribeToAmountChanges()
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
subscribeOnOffers()
}
override fun onDestroy() {
modelScope.launch { clearOnrampCacheUseCase.invoke() }
quotesTaskScheduler.cancelTask()
super.onDestroy()
}
override fun onAmountValueChanged(value: String) {
state.update { amountStateFactory.getOnAmountValueChange(value) }
modelScope.launch { amountInputManager.update(value) }
}
override fun openSettings() {
params.openSettings.invoke()
}
override fun openCurrenciesList() {
analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened())
bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.CurrenciesList)
}
override fun onBuyClick(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
) {
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,
),
)
sendOfferClickEvent(
quote = quote,
onrampOfferAdvantagesUM = onrampOfferAdvantagesUM,
categoryUM = categoryUM,
)
params.openRedirectPage(quote)
}
override fun openProviders() {
val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return
val amountCurrentCode = currentContentState.amountBlockState.currencyUM.code
bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.AllOffers(amountCurrentCode))
}
override fun onRefresh() {
state.update {
stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = router::pop,
openSettings = ::openSettings,
)
}
modelScope.launch {
clearOnrampCacheUseCase.invoke()
checkResidenceCountry()
handleOnrampAvailable()
}
}
fun onStop() {
quotesTaskScheduler.cancelTask()
}
fun handleOnrampAvailable() {
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
}
private fun startLoadingQuotes() {
quotesTaskScheduler.cancelTask()
quotesTaskScheduler.scheduleTask(scope = modelScope, task = loadQuotesTask())
}
private fun loadQuotesTask(): PeriodicTask<Unit> {
return PeriodicTask(
delay = UPDATE_DELAY,
task = {
runSuspendCatching {
val amountBlockState = (state.value as? OnrampV2MainComponentUM.Content)?.amountBlockState
?: return@runSuspendCatching
val fiatAmount = amountBlockState.amountFieldModel.fiatAmount
if (fiatAmount.value.isNullOrZero()) return@runSuspendCatching
fetchQuotesUseCase.invoke(
userWallet = userWallet,
amount = amountBlockState.amountFieldModel.fiatAmount,
cryptoCurrency = params.cryptoCurrency,
).onLeft(::handleOnrampError)
}
},
onSuccess = {},
onError = {},
)
}
private fun checkResidenceCountry() {
modelScope.launch {
checkOnrampAvailabilityUseCase(userWallet)
.onRight(::handleOnrampAvailability)
.onLeft(::handleOnrampError)
}
}
private fun handleOnrampAvailability(availability: OnrampAvailability) {
when (availability) {
is OnrampAvailability.Available -> Unit
is OnrampAvailability.ConfirmResidency,
is OnrampAvailability.NotSupported,
-> bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.ConfirmResidency(availability.country))
}
}
private fun onCloseClick() {
analyticsEventHandler.send(OnrampAnalyticsEvent.CloseOnramp())
router.pop()
}
private fun subscribeOnOffers() = modelScope.launch {
getOnrampOffersUseCase
.invoke()
.collectLatest { maybeOffers ->
maybeOffers.fold(
ifLeft = ::handleOnrampError,
ifRight = { offers ->
val currentState = state.value
if (currentState is OnrampV2MainComponentUM.Content) {
if (currentState.amountBlockState.amountFieldModel.fiatValue.isEmpty()) {
state.update {
currentState.copy(offersBlockState = OnrampOffersBlockUM.Empty)
}
return@fold
}
state.update {
onrampOffersStateFactory.getOffersState(offers)
}
}
},
)
}
}
private fun subscribeToAmountChanges() = modelScope.launch {
amountInputManager.query
.filter(String::isNotEmpty)
.collectLatest { _ ->
startLoadingQuotes()
}
}
private fun subscribeToCountryAndCurrencyUpdates() {
getOnrampCountryUseCase.invoke()
.onEach { maybeCountry ->
maybeCountry.fold(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
state.update { prevState ->
when (prevState) {
is OnrampV2MainComponentUM.Content -> {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampV2MainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency)
}
}
}
updatePairsAndQuotes()
},
)
}
.launchIn(modelScope)
}
private fun subscribeToQuotesUpdate() {
getOnrampQuotesUseCase.invoke()
.conflate()
.onEach { maybeQuotes ->
maybeQuotes.fold(
ifLeft = ::handleOnrampError,
ifRight = ::handleQuoteResult,
)
}
.launchIn(modelScope)
}
private fun handleQuoteResult(quotes: List<OnrampQuote>) {
sendOnrampQuotesErrorAnalytic(quotes)
when {
quotes.isEmpty() -> {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
quotes.all { it is OnrampQuote.AmountError } -> {
state.update { amountStateFactory.getSecondaryFieldAmountErrorState(quotes) }
}
quotes.none { it is OnrampQuote.Data } -> {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
else -> {
state.update { prevState ->
val resetState = amountStateFactory.getAmountSecondaryFieldResetState()
if (prevState is OnrampV2MainComponentUM.Content &&
resetState is OnrampV2MainComponentUM.Content &&
prevState.offersBlockState is OnrampOffersBlockUM.Loading
) {
resetState.copy(offersBlockState = OnrampOffersBlockUM.Empty)
} else {
resetState
}
}
}
}
}
private fun onRetryQuotes() {
state.update { prevState ->
(prevState as? OnrampV2MainComponentUM.Content)?.copy(
errorNotification = null,
offersBlockState = OnrampOffersBlockUM.Loading,
amountBlockState = prevState.amountBlockState.copy(
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
) ?: prevState
}
startLoadingQuotes()
}
private suspend fun updatePairsAndQuotes() {
fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError,
ifRight = {
state.update {
amountStateFactory.getAmountSecondaryFieldResetState()
}
startLoadingQuotes()
},
)
}
private fun handleOnrampError(onrampError: OnrampError) {
Timber.e(onrampError.toString())
state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
quotes.forEach { errorState ->
when (errorState) {
is OnrampQuote.Error -> analyticsEventHandler.sendOnrampErrorEvent(
error = errorState.error,
tokenSymbol = params.cryptoCurrency.symbol,
providerName = errorState.provider.info.name,
paymentMethod = errorState.paymentMethod.name,
)
is OnrampQuote.AmountError -> analyticsEventHandler.sendOnrampErrorEvent(
error = errorState.error,
tokenSymbol = params.cryptoCurrency.symbol,
providerName = errorState.provider.info.name,
paymentMethod = errorState.paymentMethod.name,
)
else -> Unit
}
}
}
private fun sendScreenOpenAnalytics() {
analyticsEventHandler.send(
OnrampAnalyticsEvent.ScreenOpened(
source = params.source,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
}
private fun sendOfferClickEvent(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
) {
val event = when (categoryUM) {
OnrampOfferCategoryUM.RecentlyUsed -> {
OnrampAnalyticsEvent.RecentlyBuyClicked(
tokenSymbol = params.cryptoCurrency.symbol,
providerName = quote.provider.info.name,
paymentMethod = quote.paymentMethod.name,
)
}
OnrampOfferCategoryUM.Recommended -> {
onrampOfferAdvantagesUM.toAnalyticsEvent(
cryptoCurrencySymbol = params.cryptoCurrency.symbol,
providerName = quote.provider.info.name,
paymentMethodName = quote.paymentMethod.name,
)
}
}
if (event != null) {
analyticsEventHandler.send(event)
}
}
private companion object {
const val UPDATE_DELAY = 10_000L
}
}

View file

@ -1,139 +0,0 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.foundation.background
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.Scaffold
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.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.notifications.Notification
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.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampNewMainScreen(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.systemBarsPadding(),
topBar = {
TangemTopAppBar(
startButton = state.topBarConfig.startButtonUM,
endButton = state.topBarConfig.endButtonUM,
title = state.topBarConfig.title.resolveReference(),
)
},
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
) { scaffoldPaddings ->
OnrampNewMainComponentContent(
state = state,
modifier = Modifier.padding(scaffoldPaddings),
)
}
}
@Composable
internal fun OnrampNewMainComponentContent(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is OnrampV2MainComponentUM.InitialLoading -> InitialLoading(state = state)
is OnrampV2MainComponentUM.Content -> Content(state = state)
}
}
if (state is OnrampV2MainComponentUM.Content) {
OnrampFooterContent(state = state)
}
}
}
@Composable
private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContentLoading()
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
}
}
@Composable
private fun OnrampAmountContentLoading() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.padding(vertical = TangemTheme.dimens.spacing28),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.size(width = 76.dp, height = 20.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12)
.size(width = 136.dp, height = 44.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8)
.size(width = 52.dp, height = 16.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing20)
.size(width = 84.dp, height = 28.dp),
radius = TangemTheme.dimens.radius14,
)
}
}
@Composable
private fun Content(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.navigationBarsPadding()
.padding(
bottom = 76.dp,
start = 16.dp,
end = 16.dp,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampV2AmountContent(state = state)
OnrampOffersContent(state = state.offersBlockState)
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
}
}

View file

@ -1,184 +0,0 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.BuyTokenDetailsScreenTestTags
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampNewCurrencyUM
import com.tangem.features.onramp.mainv2.entity.OnrampSecondaryFieldErrorUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(size = TangemTheme.dimens.radius16),
)
.padding(vertical = 24.dp, horizontal = 16.dp)
.animateContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OnrampHeaderTitle()
OnrampAmountField(
amountField = state.amountBlockState.amountFieldModel,
currencyCode = state.amountBlockState.currencyUM.code,
)
AnimatedVisibility(
visible = state.amountBlockState.secondaryFieldModel !is OnrampSecondaryFieldErrorUM.Empty,
) {
if (state.amountBlockState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Error) {
OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel)
}
}
SpacerH(20.dp)
OnrampCurrencyIcon(currencyUM = state.amountBlockState.currencyUM)
}
}
@Composable
private fun OnrampHeaderTitle() {
Text(
text = stringResourceSafe(R.string.onramp_you_will_pay_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: String) {
val decimalFormat = rememberDecimalFormat()
val requester = remember { FocusRequester() }
AmountTextField(
value = amountField.fiatValue,
decimals = amountField.fiatAmount.decimals,
visualTransformation = AmountVisualTransformation(
decimals = amountField.fiatAmount.decimals,
symbol = currencyCode,
currencyCode = currencyCode,
decimalFormat = decimalFormat,
symbolColor = if (amountField.fiatValue.isBlank()) {
TangemTheme.colors.text.disabled
} else {
TangemTheme.colors.text.primary1
},
),
onValueChange = amountField.onValueChange,
keyboardOptions = amountField.keyboardOptions,
keyboardActions = amountField.keyboardActions,
textStyle = TangemTheme.typography.head.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
isEnabled = !amountField.isError,
isAutoResize = true,
isValuePasted = amountField.isValuePasted,
onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss,
modifier = Modifier
.focusRequester(requester)
.padding(
top = TangemTheme.dimens.spacing8,
bottom = TangemTheme.dimens.spacing4,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)
.requiredHeightIn(min = TangemTheme.dimens.size32)
.testTag(BuyTokenDetailsScreenTestTags.FIAT_AMOUNT_TEXT_FIELD),
)
LaunchedEffect(key1 = Unit) {
requester.requestFocus()
}
}
@Composable
private fun OnrampAmountSecondary(state: OnrampSecondaryFieldErrorUM.Error) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
contentAlignment = Alignment.Center,
) {
Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun OnrampCurrencyIcon(currencyUM: OnrampNewCurrencyUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(14.dp))
.background(TangemTheme.colors.button.secondary)
.clickable(onClick = currencyUM.onClick)
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AsyncImage(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.testTag(BuyTokenDetailsScreenTestTags.FIAT_CURRENCY_ICON),
model = currencyUM.iconUrl,
contentDescription = null,
)
Text(
text = currencyUM.code,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
)
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.testTag(BuyTokenDetailsScreenTestTags.EXPAND_FIAT_LIST_BUTTON),
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}

View file

@ -17,8 +17,6 @@ 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
@ -32,9 +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 onrampMainV2ComponentFactory: OnrampV2MainComponent.Factory,
private val onrampRedirectComponentFactory: OnrampRedirectComponent.Factory,
private val onrampV2MainFeatureToggle: OnrampV2MainFeatureToggle,
) : OnrampComponent, AppComponentContext by context {
private val navigation = StackNavigation<OnrampChild>()
@ -71,44 +67,23 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
onBack = navigation::pop,
),
)
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 = { quote ->
navigation.push(
OnrampChild.RedirectPage(
quote = quote,
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 = { onrampProviderWithQuoteData ->
navigation.push(
OnrampChild.RedirectPage(
quote = onrampProviderWithQuoteData,
cryptoCurrency = params.cryptoCurrency,
),
)
},
isLaunchSepa = params.shouldLaunchSepa,
),
)
}
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 = { quote ->
navigation.push(
OnrampChild.RedirectPage(
quote = quote,
cryptoCurrency = params.cryptoCurrency,
),
)
},
),
)
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(
context = childByContext(componentContext),
params = OnrampRedirectComponent.Params(