diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt new file mode 100644 index 0000000000..a651416d7f --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt similarity index 69% rename from features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt rename to features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt index e73a8570bd..256bead28d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/DefaultOnrampV2MainFeatureToggle.kt @@ -1,10 +1,10 @@ -package com.tangem.features.onramp.newmain +package com.tangem.features.onramp.mainv2 import com.tangem.core.configtoggle.feature.FeatureTogglesManager -class DefaultOnrampNewMainFeatureToggle( +class DefaultOnrampV2MainFeatureToggle( private val featureTogglesManager: FeatureTogglesManager, -) : OnrampNewMainFeatureToggle { +) : OnrampV2MainFeatureToggle { override val isOnrampNewMainEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_RECEIVE_ENABLED") } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainComponent.kt similarity index 76% rename from features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt rename to features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainComponent.kt index d30d134f81..9767cf4496 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainComponent.kt @@ -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.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.OnrampSource -internal interface OnrampNewMainComponent : ComposableContentComponent { +internal interface OnrampV2MainComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, @@ -17,5 +17,5 @@ internal interface OnrampNewMainComponent : ComposableContentComponent { val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, ) - interface Factory : ComponentFactory + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt new file mode 100644 index 0000000000..54595ff8d7 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/OnrampV2MainFeatureToggle.kt @@ -0,0 +1,5 @@ +package com.tangem.features.onramp.mainv2 + +internal interface OnrampV2MainFeatureToggle { + val isOnrampNewMainEnabled: Boolean +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt new file mode 100644 index 0000000000..6d20f87cf7 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampMainV2ComponentModelModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt new file mode 100644 index 0000000000..08817d31ac --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/di/OnrampNewV2ComponentModule.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt new file mode 100644 index 0000000000..583cc962bb --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampOfferBlockUM.kt @@ -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, + ) : 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 +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt new file mode 100644 index 0000000000..b151b7818c --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2AmountBlockUM.kt @@ -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) : OnrampV2AmountButtonUMState + data object None : OnrampV2AmountButtonUMState +} + +internal data class OnrampAmountButtonUM( + val value: Int, + val currency: String, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt new file mode 100644 index 0000000000..dcb21bbf8a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainBottomSheetConfig.kt @@ -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 +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt new file mode 100644 index 0000000000..0b6560e380 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2MainComponentUM.kt @@ -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, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt new file mode 100644 index 0000000000..750e9ba8f3 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/OnrampV2ProvidersUM.kt @@ -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 +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt new file mode 100644 index 0000000000..2f8f0f4186 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/converter/OnrampV2AmountFieldChangeConverter.kt @@ -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, + private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory, + private val onrampIntents: OnrampIntents, + private val cryptoCurrency: CryptoCurrency, +) : Converter { + + 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, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt new file mode 100644 index 0000000000..4bf1a7285a --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampAmountButtonUMStateFactory.kt @@ -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" + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt new file mode 100644 index 0000000000..3c7a477fd1 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2AmountStateFactory.kt @@ -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, + 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), + ), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt new file mode 100644 index 0000000000..8e37235b44 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/entity/factory/OnrampV2StateFactory.kt @@ -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, + 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), + ) +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt new file mode 100644 index 0000000000..8d6615a675 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/model/OnrampV2MainComponentModel.kt @@ -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() + + private var loadQuotesJob: Job? = null + + private val lastUpdateState = mutableStateOf(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 = MutableStateFlow( + value = stateFactory.getInitialState( + currency = params.cryptoCurrency.name, + onClose = ::onCloseClick, + openSettings = ::openSettings, + ), + ) + val state: StateFlow get() = _state.asStateFlow() + val bottomSheetNavigation: SlotNavigation = 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) { + 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? { + 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) { + 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, + ), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt new file mode 100644 index 0000000000..5668fac482 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampFooterContent.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt new file mode 100644 index 0000000000..cf0d2ca599 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampNewMainComponentContent.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt new file mode 100644 index 0000000000..6b35933d9e --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampOffersContent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt new file mode 100644 index 0000000000..284e2e04cc --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/mainv2/ui/OnrampV2AmountContent.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt deleted file mode 100644 index 5eab7b94c5..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.onramp.newmain - -internal interface OnrampNewMainFeatureToggle { - val isOnrampNewMainEnabled: Boolean -} \ No newline at end of file