diff --git a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt index e24d5594e5..0cd2024584 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.SignIn import com.tangem.domain.card.analytics.IntroductionProcess +import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent class HotWalletContextInterceptor( val parent: ParamsInterceptor? = null, @@ -18,6 +19,7 @@ class HotWalletContextInterceptor( is SignIn.ButtonAddWallet, is SignIn.ButtonUnlockAllWithBiometric, is IntroductionProcess.ButtonScanCard, + is TokenScreenAnalyticsEvent.ButtonQuickTopUp, -> false is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll else -> true diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 3be7df1c1f..c1d690f533 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -216,6 +216,7 @@ internal class ChildFactory @Inject constructor( userWalletId = route.userWalletId, cryptoCurrency = route.currency, source = route.source, + initialFiatAmount = route.initialFiatAmount, ), componentFactory = onrampComponentFactory, ) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 4854b69392..30438d4e34 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -297,6 +297,7 @@ sealed class AppRoute(val path: String) : Route { val source: OnrampSource, val userWalletId: UserWalletId, val currency: CryptoCurrency, + val initialFiatAmount: SerializedBigDecimal? = null, ) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) } diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index fbadecf45f..97d5033769 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -328,6 +328,7 @@ sealed class AnalyticsParam { const val WALLET_TYPE = "Wallet Type" const val BACKUPED = "Backuped" const val MEMO = "Memo" + const val VALUE = "Value" } } diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index cf25f880f4..d465fd6778 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -114,5 +114,9 @@ { "name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED", "version": "undefined" + }, + { + "name": "AND_15258_QUICK_TOP_UP_ENABLED", + "version": "undefined" } ] diff --git a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt index 4a864c1986..e9703340e3 100644 --- a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt +++ b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt @@ -18,4 +18,5 @@ object StringsSigns { const val PASSWORD_VISUAL_CHAR = '\u2022' const val APPROXIMATE = "≈" const val WHITE_SPACE = " " + const val LIGHTNING = "⚡" } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt index 32709d5676..ba7056df00 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt @@ -5,8 +5,10 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FR import com.tangem.core.analytics.models.AnalyticsParam.Key.ACTION import com.tangem.core.analytics.models.AnalyticsParam.Key.BALANCE import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN +import com.tangem.core.analytics.models.AnalyticsParam.Key.CURRENCY import com.tangem.core.analytics.models.AnalyticsParam.Key.STATUS import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM +import com.tangem.core.analytics.models.AnalyticsParam.Key.VALUE import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason /** @@ -181,6 +183,21 @@ sealed class TokenScreenAnalyticsEvent( params = mapOf("Token" to token), ) + class ButtonQuickTopUp( + token: String, + blockchain: String, + currency: String, + value: String, + ) : TokenScreenAnalyticsEvent( + event = "Quick Top Up Button", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + CURRENCY to currency, + VALUE to value, + ), + ) + companion object { const val AVAILABLE = "Available" private const val UNAVAILABLE = "Unavailable" diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt index d77c070bac..090f282d60 100644 --- a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/OnrampComponent.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.models.wallet.UserWalletId +import java.math.BigDecimal interface OnrampComponent : ComposableContentComponent { @@ -12,6 +13,7 @@ interface OnrampComponent : ComposableContentComponent { val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, val source: OnrampSource, + val initialFiatAmount: BigDecimal? = null, ) interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt index 98df5c2a8e..6b47640fa9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/OnrampMainComponent.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampSource +import java.math.BigDecimal internal interface OnrampMainComponent : ComposableContentComponent { @@ -15,6 +16,7 @@ internal interface OnrampMainComponent : ComposableContentComponent { val source: OnrampSource, val openSettings: () -> Unit, val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, + val initialFiatAmount: BigDecimal? = null, ) interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt index 1d19b99737..46e69fe061 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt @@ -51,7 +51,7 @@ internal class OnrampStateFactory( ) } - fun getReadyState(currency: OnrampCurrency): OnrampMainComponentUM.Content { + fun getReadyState(currency: OnrampCurrency, initialFiatAmount: BigDecimal? = null): OnrampMainComponentUM.Content { val state = currentStateProvider() val endButton = when (val button = state.topBarConfig.endButtonUM) { @@ -59,7 +59,7 @@ internal class OnrampStateFactory( is TopAppBarButtonUM.Text -> button.copy(isEnabled = true) } - val initialAmountBlockState = getInitialAmountBlockState(currency) + val initialAmountBlockState = getInitialAmountBlockState(currency, initialFiatAmount) return OnrampMainComponentUM.Content( topBarConfig = state.topBarConfig.copy(endButtonUM = endButton), @@ -136,7 +136,10 @@ internal class OnrampStateFactory( ) } - private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampAmountBlockUM { + private fun getInitialAmountBlockState( + currency: OnrampCurrency, + initialFiatAmount: BigDecimal? = null, + ): OnrampAmountBlockUM { return OnrampAmountBlockUM( currencyUM = OnrampCurrencyUM( code = currency.code, @@ -146,8 +149,8 @@ internal class OnrampStateFactory( unit = currency.unit, ), amountFieldModel = AmountFieldModel( - value = "", - fiatValue = "", + value = initialFiatAmount?.toPlainString().orEmpty(), + fiatValue = initialFiatAmount?.toPlainString().orEmpty(), onValueChange = onrampIntents::onAmountValueChanged, keyboardOptions = KeyboardOptions( imeAction = ImeAction.None, @@ -156,7 +159,7 @@ internal class OnrampStateFactory( keyboardActions = KeyboardActions(), isFiatValue = true, cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency), - fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency), + fiatAmount = (initialFiatAmount ?: BigDecimal.ZERO).convertToFiatAmount(currency), isError = false, isWarning = false, error = TextReference.EMPTY, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 63d3cce362..58a8f7c652 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -274,7 +274,7 @@ internal class OnrampMainComponentModel @Inject constructor( amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency) } is OnrampMainComponentUM.InitialLoading -> { - stateFactory.getReadyState(country.defaultCurrency) + stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount) } } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt index daa379e850..5cce6b54bd 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/root/DefaultOnrampComponent.kt @@ -82,6 +82,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor( ), ) }, + initialFiatAmount = params.initialFiatAmount, ), ) is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create( diff --git a/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsFeatureToggles.kt b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsFeatureToggles.kt new file mode 100644 index 0000000000..71f92a9e18 --- /dev/null +++ b/features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.tokendetails + +interface TokenDetailsFeatureToggles { + val isQuickTopUpEnabled: Boolean +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsFeatureToggles.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsFeatureToggles.kt new file mode 100644 index 0000000000..c3d81a3a9b --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsFeatureToggles.kt @@ -0,0 +1,15 @@ +package com.tangem.feature.tokendetails + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.tokendetails.TokenDetailsFeatureToggles +import javax.inject.Inject + +internal class DefaultTokenDetailsFeatureToggles @Inject constructor( + featureTogglesManager: FeatureTogglesManager, +) : TokenDetailsFeatureToggles { + + override val isQuickTopUpEnabled: Boolean = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15258_QUICK_TOP_UP_ENABLED, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsFeatureModule.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsFeatureModule.kt new file mode 100644 index 0000000000..7797aba8a9 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsFeatureModule.kt @@ -0,0 +1,21 @@ +package com.tangem.feature.tokendetails.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.feature.tokendetails.DefaultTokenDetailsFeatureToggles +import com.tangem.features.tokendetails.TokenDetailsFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object TokenDetailsFeatureModule { + + @Provides + @Singleton + fun provideTokenDetailsFeatureToggles(featureTogglesManager: FeatureTogglesManager): TokenDetailsFeatureToggles { + return DefaultTokenDetailsFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index 2500a2bdd5..d2e32e2a48 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig +import java.math.BigDecimal @Suppress("TooManyFunctions") interface TokenDetailsClickIntents { @@ -71,6 +72,8 @@ interface TokenDetailsClickIntents { fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) + fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) + fun onYieldInfoClick() // region Clore migration @@ -168,6 +171,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents { override fun onYieldInfoClick() { /* no op */ } + override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { /* no op */ } + override fun onCopyAddress(): TextReference? { /* no op */ return null diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 1ee997f1dc..fb46837cc7 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -64,6 +64,7 @@ import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.offramp.GetOfframpUrlUseCase +import com.tangem.domain.onramp.CheckOnrampAvailabilityUseCase import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetStakingEntryInfoUseCase @@ -84,6 +85,7 @@ import com.tangem.domain.transaction.error.OpenTrustlineError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase import com.tangem.domain.wallets.usecase.GetWalletIconUseCase import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase @@ -103,6 +105,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.QuickTopUpBlockFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer @@ -132,6 +135,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import java.math.BigDecimal import javax.inject.Inject @Suppress("LongParameterList", "LargeClass", "TooManyFunctions", "PropertyUsedBeforeDeclaration") @@ -192,6 +196,9 @@ internal class TokenDetailsModel @Inject constructor( private val redesignStateController: TokenDetailsStateController, private val swapFeedbackUseCase: SwapFeedbackUseCase, private val swapFeatureToggles: SwapFeatureToggles, + private val quickTopUpBlockFactory: QuickTopUpBlockFactory, + private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase, ) : Model(), TokenDetailsClickIntents, YieldSupplyDepositedWarningComponent.ModelCallback { @@ -304,6 +311,7 @@ internal class TokenDetailsModel @Inject constructor( handleBalanceHiding() checkForActionUpdates() handleNavigationParam() + observeQuickTopUpBlock() } private fun initButtons() { @@ -584,6 +592,43 @@ internal class TokenDetailsModel @Inject constructor( } } + override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { + analyticsEventsHandler.send( + TokenScreenAnalyticsEvent.ButtonQuickTopUp( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + currency = currencyCode, + value = amount.toInt().toString(), + ), + ) + appRouter.push( + AppRoute.Onramp( + source = OnrampSource.TOKEN_DETAILS, + userWalletId = userWalletId, + currency = cryptoCurrency, + initialFiatAmount = amount, + ), + ) + } + + private fun onQuickTopUpOtherClick() { + analyticsEventsHandler.send( + TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + status = ScenarioUnavailabilityReason.None.toReasonAnalyticsText(), + derivationIndex = getAccountIndexOrNull(), + ), + ) + appRouter.push( + AppRoute.Onramp( + source = OnrampSource.TOKEN_DETAILS, + userWalletId = userWalletId, + currency = cryptoCurrency, + ), + ) + } + override fun onBuyCoinClick(cryptoCurrency: CryptoCurrency) { analyticsEventsHandler.send( TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy( @@ -1363,6 +1408,38 @@ internal class TokenDetailsModel @Inject constructor( observeRedesignStakingNotification() } + private fun observeQuickTopUpBlock() { + getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .map { it.status } + .distinctUntilChanged() + .flatMapLatest { status -> + flow { + val amount = status.value.amount + if (amount == null || !amount.isZero()) { + emit(null) + return@flow + } + val txCount = getTxHistoryItemsCountUseCase(userWalletId, cryptoCurrency) + val availability = checkOnrampAvailabilityUseCase(userWallet) + emit( + quickTopUpBlockFactory.build( + currencyStatus = status, + isTxHistoryEmpty = txCount, + onrampAvailability = availability, + onPresetClick = ::onQuickTopUpClick, + onOtherClick = ::onQuickTopUpOtherClick, + ), + ) + } + } + .onEach { block -> + uiState.value = uiState.value.copy(quickTopUpBlock = block) + redesignStateController.update { state -> state.copy(quickTopUpBlock = block) } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + private fun observeRedesignStakingNotification() { val statusFlow = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) .map { it.status } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/QuickTopUpBlockUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/QuickTopUpBlockUM.kt new file mode 100644 index 0000000000..b887360947 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/QuickTopUpBlockUM.kt @@ -0,0 +1,17 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class QuickTopUpBlockUM( + val amounts: ImmutableList, +) { + @Immutable + data class QuickTopUpAmountUM( + val displayValue: TextReference, + val onClick: () -> Unit, + val isOther: Boolean = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt index a4b8ad05d5..72ff8dd5db 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt @@ -15,4 +15,5 @@ internal data class TokenDetailsState( val pullToRefreshConfig: PullToRefreshConfig, val isBalanceHidden: Boolean, val isMarketPriceAvailable: Boolean, + val quickTopUpBlock: QuickTopUpBlockUM? = null, ) \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt index 55220af5a7..512109a189 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt @@ -24,6 +24,7 @@ internal data class TokenDetailsUM( val addFundsUM: AddFundsUM, val transferUM: TransferUM, val zeroBalanceActionsUM: ZeroBalanceActionsUM, + val quickTopUpBlock: QuickTopUpBlockUM? = null, ) @Immutable diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactory.kt new file mode 100644 index 0000000000..c09bdcf582 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactory.kt @@ -0,0 +1,79 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import arrow.core.Either +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.onramp.model.OnrampAvailability +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM +import com.tangem.features.tokendetails.TokenDetailsFeatureToggles +import com.tangem.utils.extensions.isZero +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import javax.inject.Inject + +internal class QuickTopUpBlockFactory @Inject constructor( + private val featureToggles: TokenDetailsFeatureToggles, +) { + + fun build( + currencyStatus: CryptoCurrencyStatus, + isTxHistoryEmpty: Either, + onrampAvailability: Either, + onPresetClick: (BigDecimal, String) -> Unit, + onOtherClick: () -> Unit, + ): QuickTopUpBlockUM? { + if (!featureToggles.isQuickTopUpEnabled) return null + + val amount = currencyStatus.value.amount + if (amount == null || !amount.isZero()) return null + + val isHistoryEmpty = isTxHistoryEmpty.fold( + ifLeft = { it is TxHistoryStateError.EmptyTxHistories }, + ifRight = { it == 0 }, + ) + if (!isHistoryEmpty) return null + + val currency = when (val availability = onrampAvailability.getOrNull()) { + is OnrampAvailability.Available -> availability.currency + is OnrampAvailability.ConfirmResidency -> { + if (!availability.country.onrampAvailable) return null + availability.country.defaultCurrency + } + else -> return null + } + + val presets = when (currency.code) { + USD_CODE -> USD_PRESETS + EUR_CODE -> EUR_PRESETS + else -> return null + } + + val presetAmounts = presets.map { value -> + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = stringReference("${currency.unit}$value"), + onClick = { onPresetClick(BigDecimal(value), currency.code) }, + ) + } + val otherAmount = QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = resourceReference(R.string.quick_top_up_chip_other), + onClick = onOtherClick, + isOther = true, + ) + + return QuickTopUpBlockUM( + amounts = (presetAmounts + otherAmount).toImmutableList(), + ) + } + + private companion object { + const val USD_CODE = "USD" + const val EUR_CODE = "EUR" + + val USD_PRESETS = listOf(50, 200, 700) + val EUR_PRESETS = listOf(50, 200, 650) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt new file mode 100644 index 0000000000..cd2765fa44 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt @@ -0,0 +1,135 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.util.fastForEach +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.utils.StringsSigns +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM +import kotlinx.collections.immutable.persistentListOf + +private val quickTopUpGradientBrush = Brush.linearGradient( + colors = listOf(Color(0xFFEDE5F3), Color(0xFFD7EDD9)), + start = Offset(0f, 0f), + end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY), +) + +private val quickTopUpBorderBrush = Brush.sweepGradient( + listOf( + Color(0x0D000000), + Color(0x26000000), + Color(0x0D000000), + Color(0x26000000), + ), +) + +@Composable +internal fun QuickTopUpBlock(state: QuickTopUpBlockUM, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(TangemTheme.dimens.radius20) + + Box( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(brush = quickTopUpGradientBrush) + .border(width = 1.dp, brush = quickTopUpBorderBrush, shape = shape), + ) { + Column( + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + val textColor = TangemTheme.colors.text.primary1 + Text( + text = combinedReference( + stringReference("${StringsSigns.LIGHTNING} "), + resourceReference(R.string.quick_top_up_title), + ).resolveReference(), + style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.graphicsLayer { + colorFilter = ColorFilter.tint(textColor, BlendMode.SrcIn) + }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6), + ) { + state.amounts.fastForEach { amountUM -> + Button( + onClick = amountUM.onClick, + shape = CircleShape, + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing0, + ), + colors = ButtonDefaults.buttonColors( + containerColor = TangemTheme.colors.background.primary, + contentColor = TangemTheme.colors.text.primary1, + ), + modifier = Modifier.heightIn(TangemTheme.dimens.size36), + elevation = null, + ) { + Text( + text = amountUM.displayValue.resolveReference(), + style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold), + ) + } + } + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Composable +private fun QuickTopUpBlock_Preview() { + TangemThemePreviewRedesign { + QuickTopUpBlock( + state = QuickTopUpBlockUM( + amounts = persistentListOf( + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = stringReference("\$50"), + onClick = {}, + ), + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = stringReference("\$200"), + onClick = {}, + ), + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = stringReference("\$700"), + onClick = {}, + ), + QuickTopUpBlockUM.QuickTopUpAmountUM( + displayValue = resourceReference(R.string.quick_top_up_chip_other), + onClick = {}, + isOther = true, + ), + ), + ), + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 31eaefbf5e..724f3b1e2e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -226,6 +226,14 @@ private fun TokenDetailsBody( modifier = expressTransactionModifier, ) } + tokenDetailsUM.quickTopUpBlock?.let { quickTopUpBlock -> + item(key = "quick_top_up_block") { + QuickTopUpBlock( + state = quickTopUpBlock, + modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0), + ) + } + } with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index d77790fea8..ea6976fc1b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -160,6 +160,15 @@ internal fun TokenDetailsScreenLegacy( ) } + state.quickTopUpBlock?.let { quickTopUpBlock -> + item(key = "quick_top_up_block") { + QuickTopUpBlock( + state = quickTopUpBlock, + modifier = itemModifier, + ) + } + } + with(txHistoryComponent) { txHistoryContentLegacy(listState = listState, state = txHistoryComponentState) } diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactoryTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactoryTest.kt new file mode 100644 index 0000000000..aa46e392f0 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactoryTest.kt @@ -0,0 +1,347 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.onramp.model.OnrampAvailability +import com.tangem.domain.onramp.model.OnrampCountry +import com.tangem.domain.onramp.model.OnrampCurrency +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.features.tokendetails.TokenDetailsFeatureToggles +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class QuickTopUpBlockFactoryTest { + + private val featureToggles: TokenDetailsFeatureToggles = mockk { + every { isQuickTopUpEnabled } returns true + } + private val factory = QuickTopUpBlockFactory(featureToggles) + + private val zeroBalanceStatus: CryptoCurrencyStatus = mockk { + every { value } returns mockk { + every { amount } returns BigDecimal.ZERO + } + } + + private val nonZeroBalanceStatus: CryptoCurrencyStatus = mockk { + every { value } returns mockk { + every { amount } returns BigDecimal.TEN + } + } + + private val usdCurrency = OnrampCurrency( + name = "US Dollar", + code = "USD", + image = null, + precision = 2, + unit = "$", + ) + + private val eurCurrency = OnrampCurrency( + name = "Euro", + code = "EUR", + image = null, + precision = 2, + unit = "€", + ) + + private val gbpCurrency = OnrampCurrency( + name = "British Pound", + code = "GBP", + image = null, + precision = 2, + unit = "£", + ) + + private val countryMock: OnrampCountry = mockk(relaxed = true) + + private val availableUsd: OnrampAvailability = OnrampAvailability.Available( + country = countryMock, + currency = usdCurrency, + ) + + private val notSupported: OnrampAvailability = OnrampAvailability.NotSupported(country = countryMock) + + private val emptyHistory = TxHistoryStateError.EmptyTxHistories.left() + private val histWithItems = 5.right() + private val histRightZero = 0.right() + + @Test + fun `returns null when feature toggle is disabled`() { + val disabledToggles: TokenDetailsFeatureToggles = mockk { + every { isQuickTopUpEnabled } returns false + } + val disabledFactory = QuickTopUpBlockFactory(disabledToggles) + + val result = disabledFactory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when balance is non-zero`() { + val result = factory.build( + currencyStatus = nonZeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when history has transactions`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = histWithItems, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when onramp is not available`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = notSupported.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when currency is not USD or EUR`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = OnrampAvailability.Available( + country = countryMock, + currency = gbpCurrency, + ).right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns block with USD presets when all conditions met`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNotNull() + val amounts = result!!.amounts + assertThat(amounts.map { it.displayValue }).containsExactly( + stringReference("$50"), + stringReference("$200"), + stringReference("$700"), + resourceReference(R.string.quick_top_up_chip_other), + ).inOrder() + assertThat(amounts.last().isOther).isTrue() + assertThat(amounts.take(3).all { !it.isOther }).isTrue() + } + + @Test + fun `returns block with EUR presets`() { + val availableEur = OnrampAvailability.Available( + country = countryMock, + currency = eurCurrency, + ) + + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableEur.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNotNull() + val amounts = result!!.amounts + assertThat(amounts.map { it.displayValue }).containsExactly( + stringReference("€50"), + stringReference("€200"), + stringReference("€650"), + resourceReference(R.string.quick_top_up_chip_other), + ).inOrder() + assertThat(amounts.last().isOther).isTrue() + } + + @Test + fun `returns block when history count is right zero (boundary case)`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = histRightZero, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNotNull() + } + + @Test + fun `returns block when ConfirmResidency and country supports onramp with USD`() { + val usdCountry = OnrampCountry( + id = "us", + name = "United States", + code = "US", + image = "", + alpha3 = "USA", + continent = "America", + defaultCurrency = usdCurrency, + onrampAvailable = true, + ) + val confirmResidency = OnrampAvailability.ConfirmResidency(country = usdCountry) + + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = confirmResidency.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNotNull() + val amounts = result!!.amounts + assertThat(amounts.map { it.displayValue }).containsExactly( + stringReference("$50"), + stringReference("$200"), + stringReference("$700"), + resourceReference(R.string.quick_top_up_chip_other), + ).inOrder() + } + + @Test + fun `returns null when onramp availability is error`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = OnrampError.DataError(code = "error", description = null).left(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when balance is loading (amount is null)`() { + val loadingStatus: CryptoCurrencyStatus = mockk { + every { value } returns CryptoCurrencyStatus.Loading + } + + val result = factory.build( + currencyStatus = loadingStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when tx history is not implemented`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = TxHistoryStateError.TxHistoryNotImplemented.left(), + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when tx history fetch fails with data error`() { + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = TxHistoryStateError.DataError(RuntimeException("network error")).left(), + onrampAvailability = availableUsd.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when ConfirmResidency with non-USD or EUR default currency`() { + val gbpCountry = OnrampCountry( + id = "gb", + name = "United Kingdom", + code = "GB", + image = "", + alpha3 = "GBR", + continent = "Europe", + defaultCurrency = gbpCurrency, + onrampAvailable = true, + ) + + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = OnrampAvailability.ConfirmResidency(country = gbpCountry).right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } + + @Test + fun `returns null when ConfirmResidency but country does not support onramp`() { + val restrictedCountry = OnrampCountry( + id = "kp", + name = "North Korea", + code = "KP", + image = "", + alpha3 = "PRK", + continent = "Asia", + defaultCurrency = usdCurrency, + onrampAvailable = false, + ) + val confirmResidency = OnrampAvailability.ConfirmResidency(country = restrictedCountry) + + val result = factory.build( + currencyStatus = zeroBalanceStatus, + isTxHistoryEmpty = emptyHistory, + onrampAvailability = confirmResidency.right(), + onPresetClick = { _, _ -> }, + onOtherClick = {}, + ) + + assertThat(result).isNull() + } +} \ No newline at end of file