Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-02 15:51:42 +02:00
parent d34a83ced5
commit 498e62329a
21 changed files with 1700 additions and 11 deletions

View file

@ -0,0 +1,79 @@
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.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.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,
) : OnrampV2MainComponent, AppComponentContext by appComponentContext {
private val model: OnrampV2MainComponentModel = getOrCreateModel(params)
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,
onDismiss = { model.bottomSheetNavigation.dismiss() },
),
)
is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create(
context = childByContext(componentContext),
params = SelectCurrencyComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory
interface Factory : OnrampV2MainComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnrampV2MainComponent.Params,
): DefaultOnrampV2MainComponent
}
}

View file

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

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.newmain package com.tangem.features.onramp.mainv2
import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
@ -7,7 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.onramp.model.OnrampSource
internal interface OnrampNewMainComponent : ComposableContentComponent { internal interface OnrampV2MainComponent : ComposableContentComponent {
data class Params( data class Params(
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
@ -17,5 +17,5 @@ internal interface OnrampNewMainComponent : ComposableContentComponent {
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
) )
interface Factory : ComponentFactory<Params, OnrampNewMainComponent> interface Factory : ComponentFactory<Params, OnrampV2MainComponent>
} }

View file

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

View file

@ -0,0 +1,20 @@
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 bindOnrampSelectCountryModel(model: OnrampV2MainComponentModel): Model
}

View file

@ -0,0 +1,33 @@
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

@ -0,0 +1,41 @@
package com.tangem.features.onramp.mainv2.entity
import androidx.compose.runtime.Immutable
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed interface OnrampOffersBlockUM {
val isBlockVisible: Boolean
data object Empty : OnrampOffersBlockUM {
override val isBlockVisible: Boolean
get() = false
}
data class Loading(
override val isBlockVisible: Boolean,
) : OnrampOffersBlockUM
data class Content(
override val isBlockVisible: Boolean,
val offers: ImmutableList<OnrampOfferUM>,
) : OnrampOffersBlockUM
}
internal data class OnrampOfferUM(
val category: OnrampOfferCategory,
val advantages: OnrampOfferAdvantages,
val paymentMethod: OnrampPaymentMethod,
val providerId: String,
val providerName: String,
)
internal enum class OnrampOfferCategory {
RecentlyUsed, Recommended
}
internal enum class OnrampOfferAdvantages {
Default, BestRate, Fastest
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.onramp.mainv2.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,
val amountFieldModel: AmountFieldModel,
val secondaryFieldModel: OnrampNewAmountSecondaryFieldUM,
)
internal data class OnrampNewCurrencyUM(
val unit: String,
val code: String,
val iconUrl: String?,
val precision: Int,
val onClick: () -> Unit,
)
@Immutable
internal sealed interface OnrampNewAmountSecondaryFieldUM {
data object Loading : OnrampNewAmountSecondaryFieldUM
data class Content(val amount: TextReference) : OnrampNewAmountSecondaryFieldUM
data class Error(val error: TextReference) : OnrampNewAmountSecondaryFieldUM
}
internal sealed interface OnrampV2AmountButtonUMState {
data class Loaded(val amountButtons: ImmutableList<OnrampAmountButtonUM>) : OnrampV2AmountButtonUMState
data object None : OnrampV2AmountButtonUMState
}
internal data class OnrampAmountButtonUM(
val value: Int,
val currency: String,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,13 @@
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
}

View file

@ -0,0 +1,43 @@
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 continueButtonConfig: ContinueButtonUM
val errorNotification: NotificationUM?
data class InitialLoading(
override val topBarConfig: OnrampV2MainTopBarUM,
override val continueButtonConfig: ContinueButtonUM,
override val errorNotification: NotificationUM?,
) : OnrampV2MainComponentUM
data class Content(
override val topBarConfig: OnrampV2MainTopBarUM,
override val continueButtonConfig: ContinueButtonUM,
override val errorNotification: NotificationUM?,
val amountBlockState: OnrampNewAmountBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampV2AmountButtonUMState,
val onrampProviderState: OnrampV2ProvidersUM,
) : OnrampV2MainComponentUM
}
internal data class ContinueButtonUM(
val text: TextReference,
val onClick: () -> Unit,
val enabled: Boolean,
val showProgress: Boolean = false,
)
internal data class OnrampV2MainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,
)

View file

@ -0,0 +1,15 @@
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

@ -0,0 +1,84 @@
package com.tangem.features.onramp.mainv2.entity.converter
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.onramp.main.entity.OnrampIntents
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.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>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val onrampIntents: OnrampIntents,
private val cryptoCurrency: CryptoCurrency,
) : Converter<String, OnrampV2MainComponentUM> {
override fun convert(value: String): OnrampV2MainComponentUM {
val state = currentStateProvider()
if (state !is OnrampV2MainComponentUM.Content) return state
if (value.isEmpty()) return state.emptyState()
val amountState = state.amountBlockState
val amountTextField = amountState.amountFieldModel
val fiatDecimal = value.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val amountFieldModel = amountState.amountFieldModel.copy(
fiatValue = value,
fiatAmount = amountTextField.fiatAmount.copy(value = fiatDecimal),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
return state.copy(
amountBlockState = amountState.copy(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading,
),
continueButtonConfig = state.continueButtonConfig.copy(enabled = false),
onrampProviderState = OnrampV2ProvidersUM.Loading,
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
)
}
private fun OnrampV2MainComponentUM.Content.emptyState(): OnrampV2MainComponentUM.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 = OnrampNewAmountSecondaryFieldUM.Content(
stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
),
continueButtonConfig = continueButtonConfig.copy(enabled = false),
offersBlockState = OnrampOffersBlockUM.Empty,
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencySymbol = amountBlockState.currencyUM.unit,
currencyCode = amountBlockState.currencyUM.code,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
onrampProviderState = OnrampV2ProvidersUM.Empty,
)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.onramp.mainv2.entity.factory
import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState
import kotlinx.collections.immutable.toPersistentList
internal class OnrampAmountButtonUMStateFactory {
private val defaultPreselectedAmount = listOf(50, 100, 200, 300, 500)
fun createOnrampAmountActionButton(
currencyCode: String,
currencySymbol: String,
onAmountValueChanged: (String) -> Unit,
): OnrampV2AmountButtonUMState {
return when (currencyCode) {
USD_CODE, EUR_CODE -> {
val buttons = defaultPreselectedAmount.map { value ->
OnrampAmountButtonUM(
value = value,
currency = currencySymbol,
onClick = { onAmountValueChanged(value.toString()) },
)
}.toPersistentList()
OnrampV2AmountButtonUMState.Loaded(buttons)
}
else -> OnrampV2AmountButtonUMState.None
}
}
companion object {
private const val USD_CODE = "USD"
private const val EUR_CODE = "EUR"
}
}

View file

@ -0,0 +1,222 @@
package com.tangem.features.onramp.mainv2.entity.factory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampQuote
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.OnrampIntents
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.converter.OnrampV2AmountFieldChangeConverter
import com.tangem.utils.Provider
import java.math.BigDecimal
internal class OnrampV2AmountStateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onrampIntents: OnrampIntents,
private val cryptoCurrency: CryptoCurrency,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
) {
private val onrampAmountFieldChangeConverter: OnrampV2AmountFieldChangeConverter by lazy(
mode = LazyThreadSafetyMode.NONE,
) {
OnrampV2AmountFieldChangeConverter(
currentStateProvider = currentStateProvider,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
onrampIntents = onrampIntents,
cryptoCurrency = cryptoCurrency,
)
}
fun getOnAmountValueChange(value: String): OnrampV2MainComponentUM {
return onrampAmountFieldChangeConverter.convert(value)
}
fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
return currentState.copy(
amountBlockState = amountState.copy(
currencyUM = amountState.currencyUM.copy(
unit = currency.unit,
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),
),
),
),
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencyCode = currency.code,
currencySymbol = currency.unit,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
)
}
fun getAmountSecondaryLoadingState(): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading,
),
offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false),
continueButtonConfig = currentState.continueButtonConfig.copy(enabled = false),
errorNotification = null,
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampProviderState = OnrampV2ProvidersUM.Loading,
)
}
fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.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,
),
continueButtonConfig = currentState.continueButtonConfig.copy(
enabled = quote is OnrampQuote.Data,
onClick = onrampIntents::onContinueClick,
),
errorNotification = null,
)
}
fun getUpdatedProviderState(selectedQuote: OnrampQuote): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
analyticsEventHandler.send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = selectedQuote.provider.info.name,
tokenSymbol = cryptoCurrency.symbol,
paymentMethod = selectedQuote.paymentMethod.name,
),
)
return currentState.copy(
onrampProviderState = selectedQuote.toProviderBlockState(),
)
}
fun getAmountSecondaryResetState(): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.secondaryFieldModel is OnrampNewAmountSecondaryFieldUM.Content) return currentState
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
amount = stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
errorNotification = null,
)
}
fun getShowProvidersState(): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
return when (currentState.offersBlockState) {
is OnrampOffersBlockUM.Content -> {
currentState.copy(
offersBlockState = currentState.offersBlockState.copy(isBlockVisible = true),
)
}
OnrampOffersBlockUM.Empty,
is OnrampOffersBlockUM.Loading,
-> currentState
}
}
private fun OnrampQuote.toProviderBlockState(): OnrampV2ProvidersUM {
return OnrampV2ProvidersUM.Content(
paymentMethod = paymentMethod,
providerId = provider.id,
)
}
private fun OnrampQuote.toSecondaryFieldUiModel(
amountState: OnrampNewAmountBlockUM,
): OnrampNewAmountSecondaryFieldUM? {
return when (this) {
is OnrampQuote.Error -> null
is OnrampQuote.Data -> {
val amount = toAmount.value.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
}
val contentAmount = combinedReference(stringReference("\u007E"), stringReference(amount))
OnrampNewAmountSecondaryFieldUM.Content(contentAmount)
}
is OnrampQuote.AmountError -> this.toSecondaryFieldUiModel(amountState)
}
}
private fun OnrampQuote.AmountError.toSecondaryFieldUiModel(
amountState: OnrampNewAmountBlockUM,
): OnrampNewAmountSecondaryFieldUM.Error {
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 OnrampNewAmountSecondaryFieldUM.Error(
resourceReference(
errorTextRes,
wrappedList(amount),
),
)
}
}

View file

@ -0,0 +1,205 @@
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.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
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.main.entity.OnrampIntents
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: OnrampIntents,
) {
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,
enabled = false,
),
),
continueButtonConfig = ContinueButtonUM(
text = resourceReference(R.string.common_continue),
onClick = {},
enabled = false,
),
)
}
fun getReadyState(currency: OnrampCurrency): OnrampV2MainComponentUM.Content {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
is TopAppBarButtonUM.Icon -> button.copy(enabled = true)
is TopAppBarButtonUM.Text -> button.copy(enabled = true)
}
val initialAmountBlockState = getInitialAmountBlockState(currency)
return OnrampV2MainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
continueButtonConfig = ContinueButtonUM(
text = resourceReference(R.string.common_continue),
onClick = onrampIntents::onContinueClick,
enabled = false,
),
amountBlockState = initialAmountBlockState,
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = null,
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencyCode = currency.code,
currencySymbol = currency.unit,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
onrampProviderState = OnrampV2ProvidersUM.Empty,
)
}
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,
-> currentStateProvider() // ignore error state
}
}
private fun getNoPairsErrorState(): OnrampV2MainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampV2MainComponentUM.Content ?: return state
return contentState.copy(
continueButtonConfig = contentState.continueButtonConfig.copy(enabled = false),
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}
fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampV2MainComponentUM {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
is TopAppBarButtonUM.Icon -> button.copy(enabled = true)
is TopAppBarButtonUM.Text -> button.copy(enabled = true)
}
return when (state) {
is OnrampV2MainComponentUM.Content -> state.copy(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
continueButtonConfig = state.continueButtonConfig.copy(enabled = false),
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
),
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampProviderState = OnrampV2ProvidersUM.Empty,
)
is OnrampV2MainComponentUM.InitialLoading -> state.copy(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
)
}
}
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 = OnrampNewAmountSecondaryFieldUM.Content(
stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
)
}
private fun BigDecimal.convertToFiatAmount(currency: OnrampCurrency): Amount = Amount(
currencySymbol = currency.unit,
value = this,
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
)
}

View file

@ -0,0 +1,359 @@
package com.tangem.features.onramp.mainv2.model
import androidx.compose.runtime.mutableStateOf
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.main.entity.OnrampIntents
import com.tangem.features.onramp.main.entity.OnrampLastUpdate
import com.tangem.features.onramp.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.features.onramp.mainv2.entity.factory.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.isNullOrZero
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
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,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampIntents {
val params = paramsContainer.require<OnrampV2MainComponent.Params>()
private var loadQuotesJob: Job? = null
private val lastUpdateState = mutableStateOf<OnrampLastUpdate?>(null)
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountButtonUMStateFactory()
}
private val stateFactory = OnrampV2StateFactory(
currentStateProvider = Provider { _state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
private val amountStateFactory = OnrampV2AmountStateFactory(
currentStateProvider = Provider { _state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
cryptoCurrency = params.cryptoCurrency,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
private val _state: MutableStateFlow<OnrampV2MainComponentUM> = MutableStateFlow(
value = stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = ::onCloseClick,
openSettings = ::openSettings,
),
)
val state: StateFlow<OnrampV2MainComponentUM> get() = _state.asStateFlow()
val bottomSheetNavigation: SlotNavigation<OnrampV2MainBottomSheetConfig> = SlotNavigation()
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
init {
modelScope.launch {
clearOnrampCacheUseCase()
}
sendScreenOpenAnalytics()
checkResidenceCountry()
subscribeToAmountChanges()
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
}
override fun onDestroy() {
modelScope.launch { clearOnrampCacheUseCase.invoke() }
loadQuotesJob?.cancel()
super.onDestroy()
}
override fun onAmountValueChanged(value: String) {
_state.update { amountStateFactory.getOnAmountValueChange(value) }
modelScope.launch { amountInputManager.update(value) }
}
override fun openSettings() {
params.openSettings
}
override fun openCurrenciesList() {
analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened)
bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.CurrenciesList)
}
override fun onBuyClick(quote: OnrampProviderWithQuote.Data) {
// TODO in [REDACTED_TASK_KEY] to be continued
}
override fun openProviders() {
// TODO in [REDACTED_TASK_KEY] to be continued
}
override fun onRefresh() {
_state.update {
stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = router::pop,
openSettings = ::openSettings,
)
}
loadQuotesJob?.cancel()
modelScope.launch {
clearOnrampCacheUseCase.invoke()
checkResidenceCountry()
}
}
override fun onLinkClick(link: String) {
// TODO in [REDACTED_TASK_KEY] will be deleted
}
override fun onContinueClick() {
val currentState = _state.value
if (currentState is OnrampV2MainComponentUM.Content) {
_state.update { amountStateFactory.getShowProvidersState() }
}
}
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 subscribeToAmountChanges() = modelScope.launch {
amountInputManager.query
.filter(String::isNotEmpty)
.collectLatest { _ ->
_state.update { amountStateFactory.getAmountSecondaryLoadingState() }
loadQuotes()
}
}
private fun subscribeToCountryAndCurrencyUpdates() {
getOnrampCountryUseCase.invoke()
.onEach { maybeCountry ->
maybeCountry.fold(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
_state.update {
when (it) {
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)
val quote = selectOrUpdateQuote(quotes)
if (quote == null) {
_state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
lastUpdateState.value = null
return
}
_state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) }
}
private fun selectOrUpdateQuote(quotes: List<OnrampQuote>): OnrampQuote? {
val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error }
// Check if amount, country or currency has changed
val newQuote = if (checkLastInputState(quoteToCheck)) {
quoteToCheck
} else {
val state = state.value as? OnrampV2MainComponentUM.Content
val providerState = state?.onrampProviderState as? OnrampV2ProvidersUM.Content
// Get current selected quote to update
val lastSelectedQuote = quotes.firstOrNull {
it.provider.id == providerState?.providerId &&
it.paymentMethod.id == providerState.paymentMethod.id
}
if (lastSelectedQuote is OnrampQuote.Error) {
quoteToCheck
} else {
lastSelectedQuote
}
}
newQuote?.let { updateProvider(newQuote) }
return newQuote
}
private fun onRetryQuotes() {
_state.update {
(it as? OnrampV2MainComponentUM.Content)?.copy(
errorNotification = null,
onrampProviderState = OnrampV2ProvidersUM.Loading,
offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false),
amountBlockState = it.amountBlockState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading,
),
) ?: it
}
loadQuotes()
}
private suspend fun updatePairsAndQuotes() {
val state = state.value as? OnrampV2MainComponentUM.Content
if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) {
_state.update { amountStateFactory.getAmountSecondaryLoadingState() }
}
fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError,
ifRight = {
_state.update {
if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) {
return@fold
} else {
amountStateFactory.getAmountSecondaryResetState()
}
}
},
)
loadQuotes()
}
private fun loadQuotes() {
loadQuotesJob?.cancel()
loadQuotesJob = modelScope.launch {
runCatching {
val content = state.value as? OnrampV2MainComponentUM.Content ?: return@runCatching
if (content.amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero()) return@runCatching
fetchQuotesUseCase.invoke(
userWallet = userWallet,
amount = content.amountBlockState.amountFieldModel.fiatAmount,
cryptoCurrency = params.cryptoCurrency,
).onLeft(::handleOnrampError)
}
}
}
private fun handleOnrampError(onrampError: OnrampError) {
Timber.e(onrampError.toString())
_state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun updateProvider(quote: OnrampQuote) {
lastUpdateState.value = OnrampLastUpdate(
quote.fromAmount,
quote.countryCode,
)
_state.update {
amountStateFactory.getUpdatedProviderState(selectedQuote = quote)
}
}
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 checkLastInputState(quote: OnrampQuote?): Boolean {
return lastUpdateState.value?.lastAmount != quote?.fromAmount ||
lastUpdateState.value?.lastCountryString != quote?.countryCode
}
private fun sendScreenOpenAnalytics() {
analyticsEventHandler.send(
OnrampAnalyticsEvent.ScreenOpened(
source = params.source,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
}
}

View file

@ -0,0 +1,130 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampFooterContent(
state: OnrampV2MainComponentUM.Content,
boxScope: BoxScope,
modifier: Modifier = Modifier,
) {
val keyboardController = LocalSoftwareKeyboardController.current
boxScope.apply {
AnimatedVisibility(
modifier = Modifier
.imePadding()
.align(Alignment.BottomCenter),
visible = state.offersBlockState.isBlockVisible.not(),
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
label = "Footer block animation",
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = stringResourceSafe(id = R.string.common_continue),
onClick = {
state.continueButtonConfig.onClick()
keyboardController?.hide()
},
enabled = state.continueButtonConfig.enabled,
)
SpacerH(16.dp)
OnrampAmountButtons(state = state.onrampAmountButtonUMState)
}
}
}
}
@Composable
private fun OnrampAmountButtons(state: OnrampV2AmountButtonUMState) {
val keyboard by keyboardAsState()
AnimatedVisibility(
visible = state is OnrampV2AmountButtonUMState.Loaded,
enter = fadeIn(),
exit = fadeOut(),
) {
when (state) {
is OnrampV2AmountButtonUMState.Loaded -> {
if (keyboard is Keyboard.Opened) {
LazyRow(
modifier = Modifier.background(color = TangemTheme.colors.button.secondary),
contentPadding = PaddingValues(
vertical = 10.dp,
horizontal = 8.dp,
),
state = rememberLazyListState(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = state.amountButtons,
key = OnrampAmountButtonUM::value,
) {
AmountButton(button = it)
}
}
}
}
OnrampV2AmountButtonUMState.None -> Unit
}
}
}
@Composable
private fun AmountButton(button: OnrampAmountButtonUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.sizeIn(minHeight = 24.dp, minWidth = 62.dp)
.background(
color = TangemTheme.colors.field.primary,
shape = RoundedCornerShape(16.dp),
)
.clickable(onClick = button.onClick)
.padding(vertical = 4.dp, horizontal = 20.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "${button.value}${button.currency}",
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}

View file

@ -0,0 +1,141 @@
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,
boxScope = this,
)
}
}
}
@Composable
private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
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

@ -0,0 +1,32 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM
@Composable
internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
AnimatedVisibility(
visible = state.isBlockVisible,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
label = "Offers block animation",
) {
Text(
text = "Some offers",
style = TangemTheme.typography.head,
)
// TODO in [REDACTED_TASK_KEY] to be continued
}
}

View file

@ -0,0 +1,199 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
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.text.style.TextDirection
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.resolveReference
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.OnrampNewAmountSecondaryFieldUM
import com.tangem.features.onramp.mainv2.entity.OnrampNewCurrencyUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
val padding = remember(state.offersBlockState.isBlockVisible) {
if (state.offersBlockState.isBlockVisible) {
22.dp
} else {
46.dp
}
}
Column(
modifier = modifier
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(size = TangemTheme.dimens.radius16),
)
.padding(vertical = padding)
.animateContentSize(animationSpec = tween(durationMillis = 300)),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OnrampHeaderTitle()
SpacerH(12.dp)
OnrampAmountField(
amountField = state.amountBlockState.amountFieldModel,
currencyCode = state.amountBlockState.currencyUM.code,
)
SpacerH(8.dp)
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 = TangemTheme.colors.text.disabled,
),
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.spacing24,
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: OnrampNewAmountSecondaryFieldUM) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)
.testTag(BuyTokenDetailsScreenTestTags.TOKEN_AMOUNT),
contentAlignment = Alignment.Center,
) {
when (state) {
is OnrampNewAmountSecondaryFieldUM.Content -> Text(
text = state.amount.resolveReference(),
style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
is OnrampNewAmountSecondaryFieldUM.Error -> Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
is OnrampNewAmountSecondaryFieldUM.Loading -> TextShimmer(
style = TangemTheme.typography.caption2,
modifier = Modifier.width(TangemTheme.dimens.size62),
)
}
}
}
@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

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