From c3da52ff308fb8d044a43e01c1d6aca662fc243a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 16:44:36 +0100 Subject: [PATCH 01/10] Updated on 2026-08-14 --- .../com/tangem/feature/swap/DefaultSwapComponent.kt | 7 +++++-- .../swap/component/SwapFeeSelectorBlockComponent.kt | 13 +++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 16cb6b2bcb..cb68e69748 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -123,6 +123,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( analyticsCategoryName = CommonSendAnalyticEvents.SWAP_CATEGORY, analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap, ), + isTransferMode = config.isTransferMode, ), ) } @@ -143,6 +144,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( data class FeeSelectorConfig( val sendingCurrencyStatus: CryptoCurrencyStatus, val feeCurrencyStatus: CryptoCurrencyStatus, + val isTransferMode: Boolean, ) @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -151,6 +153,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle() val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } } val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } } + val isInTransferMode by remember { derivedStateOf { dataState.currentTransferState != null } } val shouldHideBlock by remember { derivedStateOf { val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero() @@ -158,7 +161,6 @@ internal class DefaultSwapComponent @AssistedInject constructor( val isProviderMissing = dataState.selectedProvider == null val loadedState = dataState.getCurrentLoadedSwapState() val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty - val isInTransferMode = dataState.currentTransferState != null val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady) val isTangemPayWithdrawal = model.isTangemPayWithdrawal() @@ -166,7 +168,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( } } - LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) { + LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock, isInTransferMode) { if (shouldHideBlock) { TangemLogger.e( messageString = "Dismissing fee selector: " + @@ -194,6 +196,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( FeeSelectorConfig( sendingCurrencyStatus = sendingCryptoCurrencyStatus, feeCurrencyStatus = feeCurrencyStatus, + isTransferMode = isInTransferMode, ), ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt index 96a5d81ee3..7607eafad4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt @@ -19,11 +19,7 @@ import com.tangem.features.send.v2.api.params.FeeSelectorParams import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* class SwapFeeSelectorBlockComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @@ -44,7 +40,11 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor( null }, feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen, - feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow, + feeStateConfiguration = if (params.isTransferMode) { + FeeSelectorParams.FeeStateConfiguration.None + } else { + FeeSelectorParams.FeeStateConfiguration.ExcludeLow + }, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus, analyticsCategoryName = params.analyticsParams.analyticsCategoryName, @@ -100,6 +100,7 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor( val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val analyticsParams: AnalyticsParams, val repository: ModelRepository, + val isTransferMode: Boolean, ) @AssistedFactory From 9e3a352ff658298504409c4216da70c004be1cae Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 18:21:06 +0200 Subject: [PATCH 02/10] Updated on 2026-08-14 --- .../com/tangem/core/ui/res/TangemThemeRedesign.kt | 2 +- .../java/com/tangem/core/ui/res/TangemTypography.kt | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index f7840693f7..a83c29e34b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -32,7 +32,7 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) { val tangemDimens3 = remember { TangemDimens3() } val tangemTypography3 = remember { TangemTypography3(InterFamily) } val tangemTypography2 = remember { TangemTypography2(InterFamily) } - val tangemTypography = remember { TangemTypography(InterFamily) } + val tangemTypography = remember { TangemTypography(InterFamily, useMediumForRegular = true) } MaterialTheme( colorScheme = tangemColorScheme(colors = rememberedColors), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt index a86e82124c..21321045bc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTypography.kt @@ -19,7 +19,10 @@ internal val RobotoFamily = FontFamily( @Immutable class TangemTypography internal constructor( fontFamily: FontFamily, + useMediumForRegular: Boolean = false, ) { + private val regularWeight: FontWeight = if (useMediumForRegular) FontWeight.Medium else FontWeight.Normal + val head: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 34.sp, @@ -34,7 +37,7 @@ class TangemTypography internal constructor( val h1: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 34.sp, - fontWeight = FontWeight.Normal, + fontWeight = regularWeight, letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp), lineHeight = TextUnit(value = 44f, type = TextUnitType.Sp), lineHeightStyle = LineHeightStyle( @@ -89,7 +92,7 @@ class TangemTypography internal constructor( val body1: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 16.sp, - fontWeight = FontWeight.Normal, + fontWeight = regularWeight, letterSpacing = TextUnit(value = 0.5f, type = TextUnitType.Sp), lineHeight = TextUnit(value = 24f, type = TextUnitType.Sp), lineHeightStyle = LineHeightStyle( @@ -100,7 +103,7 @@ class TangemTypography internal constructor( val body2: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 14.sp, - fontWeight = FontWeight.Normal, + fontWeight = regularWeight, letterSpacing = TextUnit(value = 0.25f, type = TextUnitType.Sp), lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp), lineHeightStyle = LineHeightStyle( @@ -133,7 +136,7 @@ class TangemTypography internal constructor( val caption2: TextStyle = TextStyle( fontFamily = fontFamily, fontSize = 12.sp, - fontWeight = FontWeight.Normal, + fontWeight = regularWeight, letterSpacing = TextUnit(value = 0.4f, type = TextUnitType.Sp), lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp), lineHeightStyle = LineHeightStyle( From 8b69fb70dc0c1be2ed53f2440e72fb8d2c989e2a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 21:24:41 +0500 Subject: [PATCH 03/10] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 2 +- .../FeatureTogglesNamingConventionTest.kt | 1 - .../domain/transfer/SwapTransferInteractor.kt | 4 +- .../transfer/SwapTransferInteractorImpl.kt | 10 ++--- .../SwapTransferInteractorImplTest.kt | 38 ++++++++++++++++--- .../feature/swap/DefaultSwapFeatureToggles.kt | 2 +- .../tangem/feature/swap/model/SwapModel.kt | 14 +++---- gradle/tangem_dependencies.toml | 2 +- .../WalletManagerFactoryCreator.kt | 3 ++ .../di/BlockchainSDKFactoryModule.kt | 5 ++- 10 files changed, 56 insertions(+), 25 deletions(-) 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 8eed9a1b0a..cf25f880f4 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 @@ -68,7 +68,7 @@ "version": "undefined" }, { - "name": "SWAP_INTEGRATED_APPROVE", + "name": "AND_15120_SWAP_INTEGRATED_APPROVE", "version": "undefined" }, { diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt index fa6ba51dee..b4d72d4288 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt @@ -50,7 +50,6 @@ internal class FeatureTogglesNamingConventionTest { "SOLANA_TX_HISTORY_ENABLED", "STAKING_ETH_ENABLED", "SWAP_AB_ENABLED", - "SWAP_INTEGRATED_APPROVE", "USEDESK_ENABLED", "VIRTUAL_ACCOUNTS_ENABLED", "VISA_ONBOARDING_ENABLED", diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index 6731dc4b61..976de31b40 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -30,13 +30,13 @@ interface SwapTransferInteractor { suspend fun loadFee( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + fromTokenAmount: BigDecimal, ): Either suspend fun loadFeeExtended( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + fromTokenAmount: BigDecimal, ): Either suspend fun sendTransfer( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 2263812fe8..ef795d8ae8 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -205,15 +205,14 @@ class SwapTransferInteractorImpl @Inject constructor( override suspend fun loadFee( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + fromTokenAmount: BigDecimal, ): Either { - val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError( message = "Destination address is null", ) return getFeeUseCase( - amount = amount, + amount = fromTokenAmount, destination = destination, userWallet = fromSwapCurrencyStatus.userWallet, cryptoCurrency = fromSwapCurrencyStatus.currency, @@ -223,9 +222,8 @@ class SwapTransferInteractorImpl @Inject constructor( override suspend fun loadFeeExtended( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, - fromTokenAmount: String, + fromTokenAmount: BigDecimal, ): Either { - val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError( message = "Destination address is null", ) @@ -233,7 +231,7 @@ class SwapTransferInteractorImpl @Inject constructor( val currency = fromSwapCurrencyStatus.currency val transactionData = createTransferTransactionUseCase( - amount = amount.convertToSdkAmount( + amount = fromTokenAmount.convertToSdkAmount( cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ), memo = null, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index 864296e66f..3116e627ff 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -133,7 +133,17 @@ internal class SwapTransferInteractorImplTest { every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true val currencyCheck = buildCurrencyCheck() - coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns currencyCheck coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() @@ -193,7 +203,17 @@ internal class SwapTransferInteractorImplTest { every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true val currencyCheck = buildCurrencyCheck() - coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns currencyCheck coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() @@ -259,7 +279,15 @@ internal class SwapTransferInteractorImplTest { every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false coEvery { - getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) } returns buildCurrencyCheck() coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) @@ -309,7 +337,7 @@ internal class SwapTransferInteractorImplTest { val result = sut.loadFee( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "1.5", + fromTokenAmount = BigDecimal("1.5"), ) assertThat(result).isEqualTo(transactionFee.right()) @@ -365,7 +393,7 @@ internal class SwapTransferInteractorImplTest { val result = sut.loadFeeExtended( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, - fromTokenAmount = "2.0", + fromTokenAmount = BigDecimal("2.0"), ) assertThat(result).isEqualTo(feeExtended.right()) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index a98c00d50b..fc49fe82dc 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -18,7 +18,7 @@ internal class DefaultSwapFeatureToggles @Inject constructor( ) override val isSwapIntegratedApproveEnabled: Boolean = featureTogglesManager.isFeatureEnabled( - toggle = FeatureToggles.SWAP_INTEGRATED_APPROVE, + toggle = FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE, ) override val isSwapAbEnabled: Boolean = featureTogglesManager.isFeatureEnabled( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 7ca2eef0a1..59973dc978 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -2180,6 +2180,7 @@ internal class SwapModel @Inject constructor( dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) + val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap( fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency, @@ -2188,7 +2189,7 @@ internal class SwapModel @Inject constructor( return swapTransferInteractor.loadFee( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, - fromTokenAmount = lastAmount.value, + fromTokenAmount = amount, ).onLeft { TangemLogger.e("loadFee[transfer]: Failed to load fee with error $it") }.onRight { @@ -2202,9 +2203,7 @@ internal class SwapModel @Inject constructor( return Either.Left(GetFeeError.UnknownError) } - val amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull() - ?: return Either.Left(GetFeeError.UnknownError) - val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals) val swapDataForCall = when (quoteState.swapProvider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { quoteState.swapDataModel ?: return Either.Left(GetFeeError.UnknownError) @@ -2235,6 +2234,8 @@ internal class SwapModel @Inject constructor( dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) + val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) + val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap( fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency, @@ -2243,7 +2244,7 @@ internal class SwapModel @Inject constructor( return swapTransferInteractor.loadFeeExtended( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, - fromTokenAmount = lastAmount.value, + fromTokenAmount = amount, ) } val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError) @@ -2252,8 +2253,7 @@ internal class SwapModel @Inject constructor( return Either.Left(GetFeeError.UnknownError) } - val amountDecimal = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) - val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals) // DEX path requires a SwapDataModel. val swapDataForCall = when (quoteState.swapProvider.type) { diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index ad6ebb8cb7..9323268e46 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1532" +tangemBlockchainSdk = "develop-1535" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-620" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 58af77dd60..1e2e3552c5 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -20,6 +20,7 @@ import javax.inject.Inject * [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") internal class WalletManagerFactoryCreator @Inject constructor( private val accountCreator: AccountCreator, private val blockchainDataStorage: BlockchainDataStorage, @@ -41,6 +42,7 @@ internal class WalletManagerFactoryCreator @Inject constructor( isSolanaTxHistoryEnabled = featureToggleValues.isSolanaTxHistoryEnabled, isSolanaScaledUiAmountEnabled = featureToggleValues.isSolanaScaledUiAmountEnabled, isHederaErc20Enabled = featureToggleValues.isHederaErc20Enabled, + isStateOverrideGasEstimateEnabled = featureToggleValues.isStateOverrideGasEstimateEnabled, ), blockchainDataStorage = blockchainDataStorage, loggers = listOf(blockchainSDKLogger), @@ -52,5 +54,6 @@ internal class WalletManagerFactoryCreator @Inject constructor( val isSolanaScaledUiAmountEnabled: Boolean, val isYieldModeSwapEnabled: Boolean, val isHederaErc20Enabled: Boolean, + val isStateOverrideGasEstimateEnabled: Boolean, ) } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt index 3ff97082fb..ef8a7025e7 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt @@ -23,8 +23,8 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.libs.blockchain_sdk.BuildConfig +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -110,6 +110,9 @@ internal object BlockchainSDKFactoryModule { isHederaErc20Enabled = featureTogglesManager.isFeatureEnabled( FeatureToggles.HEDERA_ERC20_ENABLED, ), + isStateOverrideGasEstimateEnabled = featureTogglesManager.isFeatureEnabled( + FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE, + ), ), ) } From 6a664f4a526ff53cfa299613a7205e04973d6997 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 18:25:31 +0200 Subject: [PATCH 04/10] Updated on 2026-08-14 --- .../HotWalletContextInterceptor.kt | 2 + .../tangem/tap/routing/utils/ChildFactory.kt | 1 + .../com/tangem/common/routing/AppRoute.kt | 1 + .../core/analytics/models/AnalyticsParam.kt | 1 + .../configs/feature_toggles_config.json | 4 + .../java/com/tangem/utils/StringsSigns.kt | 1 + .../analytics/TokenScreenAnalyticsEvent.kt | 17 + .../onramp/component/OnrampComponent.kt | 2 + .../onramp/main/OnrampMainComponent.kt | 2 + .../main/entity/factory/OnrampStateFactory.kt | 15 +- .../main/model/OnrampMainComponentModel.kt | 2 +- .../onramp/root/DefaultOnrampComponent.kt | 1 + .../TokenDetailsFeatureToggles.kt | 5 + .../DefaultTokenDetailsFeatureToggles.kt | 15 + .../di/TokenDetailsFeatureModule.kt | 21 ++ .../model/TokenDetailsClickIntents.kt | 5 + .../tokendetails/model/TokenDetailsModel.kt | 77 ++++ .../tokendetails/state/QuickTopUpBlockUM.kt | 17 + .../tokendetails/state/TokenDetailsState.kt | 1 + .../tokendetails/state/TokenDetailsUM.kt | 1 + .../state/factory/QuickTopUpBlockFactory.kt | 79 ++++ .../tokendetails/ui/QuickTopUpBlock.kt | 135 +++++++ .../tokendetails/ui/TokenDetailsScreen.kt | 8 + .../ui/TokenDetailsScreenLegacy.kt | 9 + .../factory/QuickTopUpBlockFactoryTest.kt | 347 ++++++++++++++++++ 25 files changed, 762 insertions(+), 7 deletions(-) create mode 100644 features/tokendetails/api/src/main/kotlin/com/tangem/features/tokendetails/TokenDetailsFeatureToggles.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsFeatureToggles.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/di/TokenDetailsFeatureModule.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/QuickTopUpBlockUM.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactory.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/QuickTopUpBlockFactoryTest.kt 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 From e2d0973392bc88aa3588d6a236851be839745981 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 21:26:06 +0500 Subject: [PATCH 05/10] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 1 + .../tangem/common/constants/TestConstants.kt | 7 + .../tangem/screens/MainScreenPageObject.kt | 34 ++- .../tests/hotWallet/AssetsDiscoveryTest.kt | 251 ++++++++++++++++++ .../utils/WireMockRedirectInterceptor.kt | 32 ++- .../tangem/core/ui/test/MainScreenTestTags.kt | 1 + .../ui/test/WalletNotificationTestTags.kt | 5 + .../wallet/ui/components/common/WalletCard.kt | 5 +- .../components/common/WalletNotifications.kt | 42 +-- 9 files changed, 353 insertions(+), 25 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/WalletNotificationTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 15e6bfcafc..455df5348b 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -183,6 +183,7 @@ abstract class BaseTestCase : TestCase( "GASLESS_APPROVAL_ENABLED" to true, "MAIN_SCREEN_QR_SCANNING_ENABLED" to true, "ADD_AND_MANAGE_TOKENS_ENABLED" to true, + "ASSETS_DISCOVERY_ENABLED" to true, "VISA_ONBOARDING_ENABLED" to true, "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true, "AND_15310_ADD_FUNDS_STAGE1" to true, diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index fc4d42796b..e9cf254d43 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -48,6 +48,10 @@ object TestConstants { const val USER_TOKENS_API_SCENARIO = "user_tokens_api" const val REFERRAL_API_SCENARIO = "referral_api" const val QUOTES_API_SCENARIO = "quotes_api" + const val CREATE_USER_WALLET_API_SCENARIO = "create_user_wallet_api" + const val WALLET_TOKENS_API_SCENARIO = "wallet_tokens_api" + const val MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO = "moralis_evm_token_balances_api" + const val PROVIDERS_API_SCENARIO = "networks_providers" const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk" const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " + @@ -60,6 +64,9 @@ object TestConstants { "bread much nature basic fun iron benefit egg error prosper" const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash" + const val SEED_PHRASE_HAPPY_PATH = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val TANGEM_PAY_ELIGIBILITY_SCENARIO = "tangem_pay_eligibility" const val TANGEM_PAY_ACCESS_CODE = "517384" } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index ce7201883e..82bc1fb24d 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.swipeUp import com.tangem.common.BaseTestCase @@ -23,7 +24,7 @@ import androidx.compose.ui.test.hasText as withText import com.tangem.core.res.R as CoreResR import com.tangem.core.ui.R as CoreUiR -class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : +class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { private val lazyList = KLazyListNode( @@ -100,6 +101,22 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + val restoringProgressText: KNode = child { + hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT) + useUnmergedTree = true + } + + val walletImportedBanner: KNode = child { + hasTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER) + useUnmergedTree = true + } + + val walletImportedBannerCheckHereButton: KNode = child { + hasAnyAncestor(withTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER)) + hasText(getResourceString(CoreResR.string.main_manage_tokens)) + useUnmergedTree = true + } + @OptIn(ExperimentalTestApi::class) fun marketPriceBlock(): LazyListItemNode { collapseHeader() @@ -253,6 +270,15 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + @OptIn(ExperimentalTestApi::class) + fun tokenRowWithTitle(tokenTitle: String): LazyListItemNode { + return lazyList.childWith { + hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) + hasText(tokenTitle) + useUnmergedTree = true + } + } + /** * Find token list item with title and address */ @@ -350,6 +376,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } } + + fun assertTokensCount(expectedCount: Int) { + semanticsProvider + .onAllNodes(withTestTag(TokenElementsTestTags.TOKEN_PRICE)) + .assertCountEquals(expectedCount) + } } internal fun BaseTestCase.onMainScreen(function: MainScreenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt new file mode 100644 index 0000000000..c96032df3a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt @@ -0,0 +1,251 @@ +package com.tangem.tests.hotWallet + +import androidx.test.InstrumentationRegistry.getTargetContext +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.CREATE_USER_WALLET_API_SCENARIO +import com.tangem.common.constants.TestConstants.MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO +import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO +import com.tangem.common.constants.TestConstants.SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_HAPPY_PATH +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WALLET_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.restartApp +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreenWithExistingHotWallet +import com.tangem.screens.* +import com.tangem.screens.accounts.onAccountDetailsScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test +import com.tangem.core.ui.R as CoreUiR + +@HiltAndroidTest +class AssetsDiscoveryTest : BaseTestCase() { + + private companion object { + const val DISCOVERY_TIMEOUT_MILLIS = 120_000L + + const val SCENARIO_STATE_STARTED = "Started" + const val SCENARIO_STATE_EMPTY = "Empty" + const val SCENARIO_STATE_ALREADY_EXISTS = "AlreadyExists" + const val SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT = "AssetsDiscoveryRedirect" + const val SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH = "AssetsDiscoveryHappyPath" + const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES = "NonZeroEvmBalances" + const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW = "NonZeroEvmBalancesSlow" + + val EXPECTED_DISCOVERED_TOKENS = listOf( + "Ethereum", + "Polygon", + "Tether", + ) + + val TOKENS_THAT_MUST_NOT_APPEAR = listOf( + "Solana", + "USDC", + ) + + val BACKEND_PRE_POPULATED_TOKENS = listOf( + "Bitcoin", + "Ethereum", + "Polygon", + ) + } + + @AllureId("9280") + @DisplayName("Hot wallet: new import — Discovery → Sync → Banner → Check here happy path") + @Test + fun newHotWalletImportHappyPathTest() { + val packageName = getTargetContext().packageName + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT) + setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH) + setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES) + }, + additionalAfterSection = { + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO) + resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO) + }, + ).run { + step("Import a new hot wallet from seed phrase") { + openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH) + } + step("Assert 'Restoring' progress loader is shown (discovery is in flight)") { + onMainScreen { restoringProgressText.assertIsDisplayed() } + } + step("Wait for 'Wallet successfully imported' banner (discovery completes)") { + flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) { + onMainScreen { walletImportedBanner.assertIsDisplayed() } + } + } + step("Assert expected discovered tokens are visible in the assets list") { + onMainScreen { + EXPECTED_DISCOVERED_TOKENS.forEach { token -> + tokenRowWithTitle(token).assertIsDisplayed() + } + } + } + step("Tap 'Check here' (Manage tokens) on the banner") { + onMainScreen { walletImportedBannerCheckHereButton.clickWithAssertion() } + } + step("Assert 'Manage Tokens' screen is opened") { + onManageTokensScreen { searchField.assertIsDisplayed() } + } + step("Return to main screen") { + device.uiDevice.pressBack() + waitForIdle() + } + step("Assert banner is hidden after navigating into Manage Tokens") { + onMainScreen { walletImportedBanner.assertIsNotDisplayed() } + } + step("Force-close and re-launch the app") { + restartApp(packageName) + } + step("Assert banner is NOT shown again after relaunch") { + onMainScreen { walletImportedBanner.assertIsNotDisplayed() } + } + step("Assert previously discovered tokens still appear in the assets list") { + onMainScreen { + EXPECTED_DISCOVERED_TOKENS.forEach { token -> + tokenRowWithTitle(token).assertIsDisplayed() + } + } + } + step("Assert zero-balance and spam tokens are NOT shown in the assets list") { + onMainScreen { + TOKENS_THAT_MUST_NOT_APPEAR.forEach { token -> + assertTokenDoesNotExist(token) + } + } + } + } + } + + @AllureId("9284") + @DisplayName("Hot wallet: token added manually during Discovery — no duplicate created") + @Test + fun manualTokenAddDuringDiscoveryNoDuplicateTest() { + val tetherTitle = "Tether" + val ethereumNetworkTitle = "ETHEREUM" + val accountName = getResourceString(CoreUiR.string.account_main_account_title) + val expectedTokensCount = 4 + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT) + setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH) + setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState( + MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, + state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW, + ) + }, + additionalAfterSection = { + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO) + resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO) + }, + ).run { + step("Import a new hot wallet from seed phrase") { + openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH) + } + step("Assert 'Restoring' progress loader is shown (discovery is in flight)") { + onMainScreen { restoringProgressText.assertIsDisplayed() } + } + step("Open wallet details from top bar") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + step("Open 'Wallet settings'") { + onDetailsScreen { walletNameButton.performClick() } + } + step("Open account: '$accountName'") { + onWalletSettingsScreen { accountItem(accountName).performClick() } + } + step("Open 'Manage Tokens' from account details") { + onAccountDetailsScreen { manageTokensButton.performClick() } + } + step("Search for '$tetherTitle' in Manage Tokens") { + onManageTokensScreen { + searchField.performClick() + searchField.performTextInput(tetherTitle) + } + device.uiDevice.pressBack() + waitForIdle() + } + step("Expand '$tetherTitle'") { + onManageTokensScreen { tokenItem(tetherTitle).clickWithAssertion() } + waitForIdle() + } + step("Enable the $ethereumNetworkTitle network") { + onManageTokensScreen { networkSwitch(ethereumNetworkTitle).clickWithAssertion() } + } + step("Save Manage Tokens changes") { + onManageTokensScreen { saveButton.clickWithAssertion() } + waitForIdle() + } + step("Navigate back to main screen") { + repeat(times = 3) { + device.uiDevice.pressBack() + waitForIdle() + } + } + step("Wait for 'Wallet successfully imported' banner (discovery completes after delay)") { + flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) { + onMainScreen { walletImportedBanner.assertIsDisplayed() } + } + } + step("Assert '$tetherTitle' is in the assets list (manual add + discovery merged)") { + onMainScreen { tokenRowWithTitle(tetherTitle).assertIsDisplayed() } + } + step("Assert assets list contains exactly $expectedTokensCount tokens (no duplicate after merge)") { + onMainScreen { assertTokensCount(expectedTokensCount) } + } + } + } + + @AllureId("9282") + @DisplayName("Hot wallet: re-import existing wallet — 200 OK, no Discovery, tokens from backend") + @Test + fun reimportExistingHotWalletTest() { + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_ALREADY_EXISTS) + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_EMPTY) + }, + additionalAfterSection = { + resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO) + }, + ).run { + step("Import an existing hot wallet from seed phrase") { + openMainScreenWithExistingHotWallet(SEED_PHRASE_12) + } + step("Assert tokens from backend are displayed immediately") { + BACKEND_PRE_POPULATED_TOKENS.forEach { token -> + onMainScreen { tokenRowWithTitle(token).assertIsDisplayed() } + } + } + step("Assert 'Restoring' loader is NOT displayed (discovery did not start)") { + onMainScreen { restoringProgressText.assertIsNotDisplayed() } + } + step("Assert 'Wallet successfully imported' banner is NOT displayed") { + onMainScreen { walletImportedBanner.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt index 4451945383..430e41140a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt @@ -14,21 +14,39 @@ class WireMockRedirectInterceptor : Interceptor { val request = chain.request() val url = request.url.toString() + val host = request.url.host + val sanitizedOverride = override.trimEnd('/') - if (url.contains(WIREMOCK_REMOTE_URL)) { - val newUrl = url.replace(WIREMOCK_REMOTE_URL, override.trimEnd('/')) + if (host == WIREMOCK_REMOTE_HOST) { + val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride) TangemLogger.d("WireMockRedirect: $url -> $newUrl") - val newRequest = request.newBuilder() - .url(newUrl) - .build() - return chain.proceed(newRequest) + return chain.proceed(request.newBuilder().url(newUrl).build()) + } + + if (host in REDIRECTABLE_THIRD_PARTY_HOSTS) { + val newUrl = url.replace("https://$host", "$sanitizedOverride/$host") + TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl") + return chain.proceed(request.newBuilder().url(newUrl).build()) } return chain.proceed(request) } companion object { - private const val WIREMOCK_REMOTE_URL = "[REDACTED_ENV_URL]" + private const val WIREMOCK_REMOTE_HOST = "wiremock.tests-d.com" + private const val WIREMOCK_REMOTE_URL = "https://$WIREMOCK_REMOTE_HOST" + + /** + * Upstream hosts that have no other override knob and should be funnelled into WireMock + * when [overriddenBaseUrl] is set. Each matched URL becomes `//`, + * so mock mappings should live under that host-prefixed path in tangem-api-mocks. Matching + * is done against the request's parsed host (exact equality) — substring matching would + * incorrectly redirect look-alikes such as `deep-index.moralis.io.evil.example`. + */ + private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf( + "deep-index.moralis.io", + "solana-gateway.moralis.io", + ) /** * Override base URL for WireMock requests. diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt index b8d5867f2e..c12417b238 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt @@ -11,6 +11,7 @@ object MainScreenTestTags { const val CARD_TITLE = "MAIN_SCREEN_CARD_TITLE" const val CARD_IMAGE = "MAIN_SCREEN_CARD_IMAGE" const val DEVICES_COUNT = "MAIN_SCREEN_DEVICES_COUNT" + const val SYNC_PROGRESS_TEXT = "MAIN_SCREEN_SYNC_PROGRESS_TEXT" const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE" const val TOTAL_BALANCE_MENU_ITEM = "MAIN_SCREEN_TOTAL_BALANCE_MENU_ITEM" diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletNotificationTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletNotificationTestTags.kt new file mode 100644 index 0000000000..a2ea41d594 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletNotificationTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object WalletNotificationTestTags { + const val ASSETS_DISCOVERY_BANNER = "WALLET_NOTIFICATION_ASSETS_DISCOVERY_BANNER" +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt index 9f7899e8e9..2da96f8bd2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletCard.kt @@ -337,6 +337,7 @@ private fun AdditionalInfo( id = R.string.initial_wallet_sync_restore_progress, formatArgs = wrappedList(animatedContent.progressPercent), ), + testTag = MainScreenTestTags.SYNC_PROGRESS_TEXT, ) CircularProgressIndicator( modifier = Modifier.size(TangemTheme.dimens.size16), @@ -353,14 +354,14 @@ private fun AdditionalInfo( } @Composable -private fun AdditionalInfoText(text: TextReference) { +private fun AdditionalInfoText(text: TextReference, testTag: String = MainScreenTestTags.DEVICES_COUNT) { Text( text = text.resolveReference(), color = TangemTheme.colors.text.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TangemTheme.typography.caption2, - modifier = Modifier.testTag(MainScreenTestTags.DEVICES_COUNT), + modifier = Modifier.testTag(testTag), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index 4459d2403c..765a96d132 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -1,9 +1,11 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -16,6 +18,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.ForceDarkTheme import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.WalletNotificationTestTags import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import kotlinx.collections.immutable.ImmutableList @@ -73,26 +76,35 @@ internal fun LazyListScope.notifications(configs: ImmutableList { - Notification( - config = item.config, - modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), - iconTint = when (item) { - is WalletNotification.Critical -> TangemTheme.colors.icon.warning - is WalletNotification.Informational -> TangemTheme.colors.icon.accent - is WalletNotification.RateApp -> TangemTheme.colors.icon.attention - is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 - is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention - else -> null - }, - subtitleColor = TangemTheme.colors.text.secondary, - ) - } + else -> DefaultWalletNotification(item = item, modifier = modifier) } }, ) } +@Composable +private fun LazyItemScope.DefaultWalletNotification(item: WalletNotification, modifier: Modifier = Modifier) { + val itemModifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + val taggedModifier = when (item) { + is WalletNotification.AssetsDiscoveryCompleted -> + itemModifier.testTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER) + else -> itemModifier + } + Notification( + config = item.config, + modifier = taggedModifier, + iconTint = when (item) { + is WalletNotification.Critical -> TangemTheme.colors.icon.warning + is WalletNotification.Informational -> TangemTheme.colors.icon.accent + is WalletNotification.RateApp -> TangemTheme.colors.icon.attention + is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 + is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention + else -> null + }, + subtitleColor = TangemTheme.colors.text.secondary, + ) +} + @Composable private fun yieldBoostPromoTitle(): AnnotatedString { val accent = TangemTheme.colors.text.accent From bb5d6848d929b635e48b96b8a436aff49b145339 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 19:47:19 +0100 Subject: [PATCH 06/10] Updated on 2026-08-14 --- .../swap/domain/models/ui/SwapState.kt | 2 + .../domain/transfer/SwapTransferInteractor.kt | 1 + .../transfer/SwapTransferInteractorImpl.kt | 59 ++++++++++-- .../SwapTransferInteractorImplTest.kt | 95 +++++++++++++++++-- .../feature/swap/analytics/SwapEvents.kt | 45 ++++++++- .../tangem/feature/swap/model/SwapModel.kt | 55 +++++++++-- .../SwapTransferNotificationsFactory.kt | 22 +++++ .../ui/transfer/SwapTransferStateBuilder.kt | 35 +++++-- .../SwapTransferNotificationsFactoryTest.kt | 32 +++++++ .../transfer/SwapTransferStateBuilderTest.kt | 36 +++++-- 10 files changed, 339 insertions(+), 43 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 861066f760..8e147f1458 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -6,6 +6,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState @@ -36,6 +37,7 @@ sealed interface SwapState { val userWallet: UserWallet, val fromTokenInfo: TokenSwapInfo, val toTokenInfo: TokenSwapInfo, + val cryptoCurrencyWarning: CryptoCurrencyWarning?, val isInsufficientBalance: Boolean, val appCurrency: AppCurrency, val isBalanceHidden: Boolean, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index 976de31b40..fc42b91521 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -37,6 +37,7 @@ interface SwapTransferInteractor { fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: BigDecimal, + selectedToken: CryptoCurrencyStatus?, ): Either suspend fun sendTransfer( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index ef795d8ae8..d9e91edffc 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -22,9 +22,11 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -46,7 +48,7 @@ import kotlinx.coroutines.flow.first import java.math.BigDecimal import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") class SwapTransferInteractorImpl @Inject constructor( private val swapFeatureToggles: SwapFeatureToggles, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, @@ -60,6 +62,7 @@ class SwapTransferInteractorImpl @Inject constructor( private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, ) : SwapTransferInteractor { override suspend fun updateTransfer( @@ -110,10 +113,19 @@ class SwapTransferInteractorImpl @Inject constructor( fee = fee, currencyCheck = currencyCheck, ) + val cryptoCurrencyWarning = feePaidCurrencyStatus?.let { feeStatus -> + getCryptoCurrencyWarning( + feeValue = fee?.amount?.value.orZero(), + userWallet = userWallet, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + feeStatus = feeStatus, + ) + } return SwapState.Transfer( userWallet = userWallet, fromTokenInfo = fromTokenInfo, toTokenInfo = toTokenInfo, + cryptoCurrencyWarning = cryptoCurrencyWarning, isInsufficientBalance = fromTokenAmountValue > fromTokenBalance, appCurrency = appCurrency, isBalanceHidden = isBalanceHidden, @@ -124,6 +136,20 @@ class SwapTransferInteractorImpl @Inject constructor( ) } + private suspend fun getCryptoCurrencyWarning( + feeValue: BigDecimal, + userWallet: UserWallet, + fromSwapCurrencyStatus: SwapCurrencyStatus, + feeStatus: CryptoCurrencyStatus, + ): CryptoCurrencyWarning? { + return getBalanceNotEnoughForFeeWarningUseCase( + fee = feeValue, + userWalletId = userWallet.walletId, + tokenStatus = fromSwapCurrencyStatus.status, + feeStatus = feeStatus, + ).getOrNull() + } + private suspend fun getCoverageState( fromTokenInfo: TokenSwapInfo, userWallet: UserWallet, @@ -210,12 +236,22 @@ class SwapTransferInteractorImpl @Inject constructor( val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError( message = "Destination address is null", ) + val userWallet = fromSwapCurrencyStatus.userWallet + val currency = fromSwapCurrencyStatus.currency + val transactionData = createTransferTransactionUseCase( + amount = fromTokenAmount.convertToSdkAmount( + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, + ), + memo = null, + destination = destination, + userWalletId = userWallet.walletId, + network = currency.network, + ).getOrNull() ?: return feeDataError("Failed to build transfer transaction") return getFeeUseCase( - amount = fromTokenAmount, - destination = destination, userWallet = fromSwapCurrencyStatus.userWallet, - cryptoCurrency = fromSwapCurrencyStatus.currency, + network = fromSwapCurrencyStatus.currency.network, + transactionData = transactionData, ) } @@ -223,6 +259,7 @@ class SwapTransferInteractorImpl @Inject constructor( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: BigDecimal, + selectedToken: CryptoCurrencyStatus?, ): Either { val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError( message = "Destination address is null", @@ -244,7 +281,13 @@ class SwapTransferInteractorImpl @Inject constructor( userWallet = userWallet, network = currency.network, transactionData = transactionData, - ) + ).map { transactionFeeExtended -> + selectedToken ?: return@map transactionFeeExtended + val selectedTokenId = selectedToken.currency.id + transactionFeeExtended.copy( + feeTokenId = selectedTokenId, + ) + } } override suspend fun sendTransfer( @@ -310,9 +353,9 @@ class SwapTransferInteractorImpl @Inject constructor( transactionFeeResult: TransactionFeeResult, txData: TransactionData, ): Either { - val isToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token - val isGaslessToken = isToken && transactionFeeResult is TransactionFeeResult.LoadedExtended - return if (isGaslessToken) { + val isFeeInTokenCurrency = transactionFeeResult is TransactionFeeResult.LoadedExtended && + transactionFeeResult.fee.transactionFee.normal is Fee.Ethereum.TokenCurrency + return if (isFeeInTokenCurrency) { createAndSendGaslessTransactionUseCase( transactionData = txData, userWallet = userWallet, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index 3116e627ff..fc31a2c1ef 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -19,6 +19,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck @@ -59,6 +60,7 @@ internal class SwapTransferInteractorImplTest { private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase = mockk() + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true) private val sut = SwapTransferInteractorImpl( swapFeatureToggles = swapFeatureToggles, @@ -73,6 +75,7 @@ internal class SwapTransferInteractorImplTest { getCurrencyCheckUseCase = getCurrencyCheckUseCase, isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, tangemPayWithdrawUseCase = tangemPayWithdrawUseCase, + getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, ) @AfterEach @@ -170,6 +173,7 @@ internal class SwapTransferInteractorImplTest { swapCurrencyStatus = toCurrencyStatus, amountFiat = expectedFiat, ), + cryptoCurrencyWarning = null, isInsufficientBalance = false, appCurrency = appCurrency, isBalanceHidden = true, @@ -240,6 +244,7 @@ internal class SwapTransferInteractorImplTest { swapCurrencyStatus = toCurrencyStatus, amountFiat = expectedFiat, ), + cryptoCurrencyWarning = null, isInsufficientBalance = true, appCurrency = appCurrency, isBalanceHidden = true, @@ -313,24 +318,36 @@ internal class SwapTransferInteractorImplTest { @Test fun `GIVEN valid amount and destination WHEN loadFee THEN return TransactionFee from use case`() = runTest { - val userWallet: UserWallet = mockk() + val userWalletId: UserWalletId = mockk() + val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + val network: Network = mockk() val fromCurrencyStatus = buildCurrencyStatus( rawCurrencyId = FROM_RAW_CURRENCY_ID, decimals = FROM_DECIMALS, userWallet = userWallet, + network = network, ) val toCurrencyStatus = buildCurrencyStatus( rawCurrencyId = TO_RAW_CURRENCY_ID, decimals = TO_DECIMALS, destinationAddress = DESTINATION_ADDRESS, ) + val transactionData: TransactionData.Uncompiled = mockk() val transactionFee: TransactionFee = mockk() coEvery { - getFeeUseCase( - amount = BigDecimal("1.5"), + createTransferTransactionUseCase( + amount = any(), + memo = null, destination = DESTINATION_ADDRESS, + userWalletId = userWalletId, + network = network, + ) + } returns transactionData.right() + coEvery { + getFeeUseCase( userWallet = userWallet, - cryptoCurrency = fromCurrencyStatus.currency, + network = network, + transactionData = transactionData, ) } returns transactionFee.right() @@ -343,10 +360,9 @@ internal class SwapTransferInteractorImplTest { assertThat(result).isEqualTo(transactionFee.right()) coVerify { getFeeUseCase( - amount = BigDecimal("1.5"), - destination = DESTINATION_ADDRESS, userWallet = userWallet, - cryptoCurrency = fromCurrencyStatus.currency, + network = network, + transactionData = transactionData, ) } } @@ -394,6 +410,7 @@ internal class SwapTransferInteractorImplTest { fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, fromTokenAmount = BigDecimal("2.0"), + selectedToken = null, ) assertThat(result).isEqualTo(feeExtended.right()) @@ -493,7 +510,7 @@ internal class SwapTransferInteractorImplTest { } @Test - fun `GIVEN token and LoadedExtended fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() = + fun `GIVEN LoadedExtended fee with TokenCurrency normal fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() = runTest { val userWalletId: UserWalletId = mockk() val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } @@ -511,7 +528,11 @@ internal class SwapTransferInteractorImplTest { ) val fee: Fee = mockk() val txData: TransactionData.Uncompiled = mockk() - val transactionFeeExtended: TransactionFeeExtended = mockk() + val transactionFeeExtended: TransactionFeeExtended = mockk { + every { transactionFee } returns mockk { + every { normal } returns mockk() + } + } val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended) coEvery { createTransferTransactionUseCase( @@ -602,6 +623,62 @@ internal class SwapTransferInteractorImplTest { } } + @Test + fun `GIVEN LoadedExtended fee with non-TokenCurrency normal fee WHEN sendTransfer THEN fall back to sendTransactionUseCase`() = + runTest { + val userWalletId: UserWalletId = mockk() + val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + val network: Network = mockk() + val fromCurrencyStatus = buildTokenCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + userWallet = userWallet, + network = network, + ) + val toCurrencyStatus = buildTokenCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + destinationAddress = DESTINATION_ADDRESS, + ) + val fee: Fee = mockk() + val txData: TransactionData.Uncompiled = mockk() + val transactionFeeExtended: TransactionFeeExtended = mockk { + every { transactionFee } returns mockk { + every { normal } returns mockk() + } + } + val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended) + coEvery { + createTransferTransactionUseCase( + amount = any(), + fee = fee, + memo = null, + destination = DESTINATION_ADDRESS, + userWalletId = userWalletId, + network = network, + ) + } returns txData.right() + coEvery { + sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network) + } returns TX_HASH.right() + + val result = sut.sendTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + sendingAmount = BigDecimal("1.0"), + fee = fee, + transactionFeeResult = transactionFeeResult, + ) + + assertThat(result).isEqualTo(TX_HASH.right()) + coVerify { + sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network) + } + coVerify(exactly = 0) { + createAndSendGaslessTransactionUseCase(any(), any(), any()) + } + } + @Test fun `GIVEN createTransferTransactionUseCase fails WHEN sendTransfer THEN return DataError`() = runTest { val userWalletId: UserWalletId = mockk() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 9a5ad696ea..0af80a1a91 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -1,11 +1,11 @@ package com.tangem.feature.swap.analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE -import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN @@ -13,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN import com.tangem.core.analytics.models.AppsFlyerIncludedEvent import com.tangem.core.analytics.models.getReferralParams import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.FeeBucket @@ -247,4 +248,46 @@ sealed class SwapEvents( "Provider" to provider.name, ), ) + + class TransferModeSwitched( + fromCurrency: CryptoCurrency?, + toCurrency: CryptoCurrency?, + ) : SwapEvents( + event = "Transfer Mode Switched", + params = mapOf( + SEND_TOKEN to fromCurrency?.symbol.orEmpty(), + "Send Blockchain" to fromCurrency?.network?.name.orEmpty(), + RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(), + "Receive Blockchain" to toCurrency?.network?.name.orEmpty(), + ), + ) + + class ButtonTransferClicked( + fromCurrency: CryptoCurrency?, + toCurrency: CryptoCurrency?, + ) : SwapEvents( + event = "Button - Transfer", + params = mapOf( + SEND_TOKEN to fromCurrency?.symbol.orEmpty(), + "Send Blockchain" to fromCurrency?.network?.name.orEmpty(), + RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(), + "Receive Blockchain" to toCurrency?.network?.name.orEmpty(), + ), + ) + + @Suppress("NullableToStringCall", "LongParameterList") + class TransferInProgressScreen( + fromCurrency: CryptoCurrency?, + toCurrency: CryptoCurrency?, + feeNetwork: Network, + ) : SwapEvents( + event = "Transfer in Progress Screen Opened", + params = mapOf( + SEND_TOKEN to fromCurrency?.symbol.orEmpty(), + "Send Blockchain" to fromCurrency?.network?.name.orEmpty(), + RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(), + "Receive Blockchain" to toCurrency?.network?.name.orEmpty(), + "Network fee" to feeNetwork.name, + ), + ), AppsFlyerIncludedEvent } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 59973dc978..3e0eb1ed9f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -601,7 +601,15 @@ internal class SwapModel @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = lastAmount.value, ) - if (isUpdatedToTransferMode) return + if (isUpdatedToTransferMode) { + analyticsEventHandler.send( + event = SwapEvents.TransferModeSwitched( + fromCurrency = fromSwapCurrencyStatus.currency, + toCurrency = toSwapCurrencyStatus.currency, + ), + ) + return + } dataState = dataState.copy(currentTransferState = null) modelScope.launch { uiState = stateBuilder.createInitialLoadingState( @@ -770,7 +778,10 @@ internal class SwapModel @Inject constructor( feePaidCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, ) as? SwapState.Transfer ?: currentTransferState - dataState = dataState.copy(currentTransferState = refreshed) + dataState = dataState.copy( + currentTransferState = refreshed, + feePaidCryptoCurrency = feePaidCryptoCurrencyStatus ?: dataState.feePaidCryptoCurrency, + ) uiState = swapTransferStateBuilder.updateTransferButtonEnableState( dataState = dataState, transferState = refreshed, @@ -1291,6 +1302,12 @@ internal class SwapModel @Inject constructor( private fun onTransferClick() { val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + analyticsEventHandler.send( + event = SwapEvents.ButtonTransferClicked( + fromCurrency = fromSwapCurrencyStatus?.currency, + toCurrency = toSwapCurrencyStatus?.currency, + ), + ) val fee = (feeSelectorRepository.state.value as? FeeSelectorUM.Content)?.selectedFeeItem?.fee if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null) { TangemLogger.e("onTransferClick: missing currency status, aborting") @@ -1332,6 +1349,7 @@ internal class SwapModel @Inject constructor( TangemLogger.e( messageString = "onTransferClick: withdrawTangemPay failed: ${error.getAnalyticsDescription()}", ) + startLoadingQuotesFromLastState() showAlert() } .onRight { result -> @@ -1343,9 +1361,11 @@ internal class SwapModel @Inject constructor( } private fun updateTransferModeTangemPayState() { + sendTransferInProgressEvent() uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState( uiState = uiState, dataState = dataState, + fee = getSelectedSwapFee()?.fee, onExploreClick = { val txUrl = uiState.successState?.txUrl.orEmpty() if (txUrl.isNotEmpty()) { @@ -1373,6 +1393,7 @@ internal class SwapModel @Inject constructor( ).fold( ifLeft = { error -> TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}") + startLoadingQuotesFromLastState() showAlert() }, ifRight = { txHash -> @@ -1384,14 +1405,13 @@ internal class SwapModel @Inject constructor( "" } updateWalletBalance() + sendTransferInProgressEvent() uiState = swapTransferStateBuilder.createSuccessState( uiState = uiState, dataState = dataState, - appCurrency = selectedAppCurrencyFlow.value, - isAccountsMode = isAccountsMode, txUrl = txUrl, timestamp = System.currentTimeMillis(), - fee = null, + fee = getSelectedSwapFee()?.fee, onExplorerClick = { if (txUrl.isNotEmpty()) { urlOpener.openUrl(txUrl) @@ -1403,6 +1423,18 @@ internal class SwapModel @Inject constructor( ) } + private fun sendTransferInProgressEvent() { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + analyticsEventHandler.send( + event = SwapEvents.TransferInProgressScreen( + fromCurrency = fromSwapCurrencyStatus?.currency, + toCurrency = toSwapCurrencyStatus?.currency, + feeNetwork = getFeeToken().network, + ), + ) + } + private suspend fun processTangemPayWithdrawal( fromSwapCurrencyStatus: SwapCurrencyStatus, swapTransactionState: SwapTransactionState.TangemPayWithdrawalData, @@ -2245,6 +2277,7 @@ internal class SwapModel @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = amount, + selectedToken = selectedToken, ) } val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError) @@ -2300,10 +2333,6 @@ internal class SwapModel @Inject constructor( modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) } return } - refreshTransferUIStateIfNeeded( - feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, - fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee, - ) val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus @@ -2312,7 +2341,13 @@ internal class SwapModel @Inject constructor( fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency, ) - if (shouldTransferInsteadOfSwap) return + if (shouldTransferInsteadOfSwap) { + refreshTransferUIStateIfNeeded( + feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken, + fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee, + ) + return + } val quoteState = dataState.getCurrentLoadedSwapState() ?: return val swapFee = getSelectedSwapFee() ?: return diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt index 7468e1af03..5ae7859709 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -3,12 +3,14 @@ package com.tangem.feature.swap.ui.transfer import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.ui.SwapState @@ -24,12 +26,14 @@ import javax.inject.Inject internal class SwapTransferNotificationsFactory @Inject constructor() { + @Suppress("LongParameterList") fun getNotifications( transferState: SwapState.Transfer, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, fee: Fee?, onReduceByAmount: (SwapAmount, BigDecimal) -> Unit, onReduceToAmount: (SwapAmount) -> Unit, + onBuyClick: (CryptoCurrency) -> Unit, ): ImmutableList { return buildList { maybeAddRentExemptionError(transferState) @@ -41,6 +45,7 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { onReduceToAmount = onReduceToAmount, ) maybeAddNeedReserveToCreateAccountWarning(transferState) + maybeAddExceedsBalanceNotification(transferState, onBuyClick) }.toPersistentList() } @@ -172,4 +177,21 @@ internal class SwapTransferNotificationsFactory @Inject constructor() { ) } } + + private fun MutableList.maybeAddExceedsBalanceNotification( + transferState: SwapState.Transfer, + onBuyClick: (CryptoCurrency) -> Unit, + ) { + val cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status + addExceedsBalanceNotification( + cryptoCurrencyWarning = transferState.cryptoCurrencyWarning, + cryptoCurrencyStatus = cryptoCurrencyStatus, + shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum( + networkId = cryptoCurrencyStatus.currency.network.rawId, + ), + onClick = onBuyClick, + onAnalyticsEvent = {}, + onResetAnalyticsEvent = {}, + ) + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 97e06f6f2b..9f2b1065b0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -59,6 +59,7 @@ internal class SwapTransferStateBuilder @Inject constructor( transferState = transferState, feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, + onBuyClick = actions.openTokenDetailsScreen, onReduceByAmount = actions.onReduceByAmount, onReduceToAmount = actions.onReduceToAmount, ) @@ -227,6 +228,7 @@ internal class SwapTransferStateBuilder @Inject constructor( transferState = transferState, feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, + onBuyClick = actions.openTokenDetailsScreen, onReduceByAmount = actions.onReduceByAmount, onReduceToAmount = actions.onReduceToAmount, ) @@ -325,13 +327,12 @@ internal class SwapTransferStateBuilder @Inject constructor( fun createSuccessState( uiState: SwapStateHolder, dataState: SwapProcessDataState, - appCurrency: AppCurrency, - isAccountsMode: Boolean, + fee: Fee?, txUrl: String, timestamp: Long, - fee: TextReference?, onExplorerClick: () -> Unit, ): SwapStateHolder { + val transferState = requireNotNull(dataState.currentTransferState) val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val amount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO @@ -341,11 +342,11 @@ internal class SwapTransferStateBuilder @Inject constructor( val fromAmountText = amount.format { crypto(fromCurrency.symbol, fromCurrency.decimals) } val toAmountText = amount.format { crypto(toCurrency.symbol, toCurrency.decimals) } val fromFiatAmount = getFormattedFiatAmount( - appCurrency = appCurrency, + appCurrency = transferState.appCurrency, amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amount), ) val toFiatAmount = getFormattedFiatAmount( - appCurrency = appCurrency, + appCurrency = transferState.appCurrency, amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(amount), ) @@ -359,15 +360,15 @@ internal class SwapTransferStateBuilder @Inject constructor( isTransferMode = true, providerIcon = "", rate = TextReference.EMPTY, - fee = fee, + fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) }, fromTitle = getCardAccountTitle( account = fromSwapCurrencyStatus.account, - isAccountsMode = isAccountsMode, + isAccountsMode = transferState.isAccountsMode, isFromCard = true, ), toTitle = getCardAccountTitle( account = toSwapCurrencyStatus.account, - isAccountsMode = isAccountsMode, + isAccountsMode = transferState.isAccountsMode, isFromCard = false, ), fromTokenAmount = stringReference(fromAmountText), @@ -385,6 +386,7 @@ internal class SwapTransferStateBuilder @Inject constructor( fun createTangemPayWithdrawalSuccessState( uiState: SwapStateHolder, dataState: SwapProcessDataState, + fee: Fee?, onExploreClick: () -> Unit, ): SwapStateHolder { val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) @@ -407,7 +409,7 @@ internal class SwapTransferStateBuilder @Inject constructor( isTransferMode = true, providerIcon = "", rate = TextReference.EMPTY, - fee = null, + fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) }, fromTitle = getCardAccountTitle( account = fromSwapCurrencyStatus.account, isAccountsMode = transferState.isAccountsMode, @@ -429,4 +431,19 @@ internal class SwapTransferStateBuilder @Inject constructor( ), ) } + + private fun formatFeeForSuccess(transferState: SwapState.Transfer, fee: Fee): TextReference { + val feeAmount = fee.amount + val totalFeeValue = feeAmount.value ?: BigDecimal.ZERO + val cryptoFormatted = totalFeeValue.format { + crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals) + } + val appCurrency = transferState.appCurrency + val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus + val fiatRate = swapCurrencyStatus.status.value.fiatRate + val fiatFormatted = fiatRate?.multiply(totalFeeValue).format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + return stringReference("$cryptoFormatted ($fiatFormatted)") + } } \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt index ab218123aa..f541df7896 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt @@ -43,6 +43,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result).isEmpty() @@ -65,6 +66,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -91,6 +93,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = fee, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -113,6 +116,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -131,6 +135,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -154,6 +159,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -176,6 +182,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) val reserve = result.filterIsInstance() @@ -199,15 +206,39 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) } + @Test + fun `GIVEN BalanceNotEnoughForFee warning WHEN getNotifications THEN TokenExceedsBalance is added`() = runTest { + val warning = CryptoCurrencyWarning.BalanceNotEnoughForFee( + tokenCurrency = buildCoin(), + coinCurrency = buildCoin(), + ) + val transferState = buildTransferState( + cryptoCurrencyWarning = warning, + ) + + val result = sut.getNotifications( + transferState = transferState, + feeCryptoCurrencyStatus = null, + fee = null, + onReduceByAmount = { _, _ -> }, + onReduceToAmount = {}, + onBuyClick = {}, + ) + + assertThat(result.filterIsInstance()).hasSize(1) + } + @Suppress("LongParameterList") private fun buildTransferState( fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()), toTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()), + cryptoCurrencyWarning: CryptoCurrencyWarning? = null, currencyCheck: CryptoCurrencyCheck? = null, validationResult: Throwable? = null, minAdaValue: BigDecimal? = null, @@ -217,6 +248,7 @@ internal class SwapTransferNotificationsFactoryTest { userWallet = coldWallet, fromTokenInfo = fromTokenInfo, toTokenInfo = toTokenInfo, + cryptoCurrencyWarning = cryptoCurrencyWarning, isInsufficientBalance = false, appCurrency = AppCurrency.Default, isBalanceHidden = false, diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index b33d90d5e3..16bddb47cc 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -56,6 +56,7 @@ internal class SwapTransferStateBuilderTest { fee = any(), onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } returns persistentListOf() } @@ -129,6 +130,7 @@ internal class SwapTransferStateBuilderTest { fee = null, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -170,6 +172,7 @@ internal class SwapTransferStateBuilderTest { fee = null, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -212,6 +215,7 @@ internal class SwapTransferStateBuilderTest { fee = null, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -260,6 +264,7 @@ internal class SwapTransferStateBuilderTest { fee = null, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -307,6 +312,7 @@ internal class SwapTransferStateBuilderTest { fee = fee, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } returns persistentListOf() @@ -330,6 +336,7 @@ internal class SwapTransferStateBuilderTest { fee = fee, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -468,25 +475,40 @@ internal class SwapTransferStateBuilderTest { @Test fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() { - val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") val amount = BigDecimal("1.5") + val transferState = buildTransferState( + fromAmount = amount, + toAmount = amount, + isAccountsMode = true, + ) val dataState = SwapProcessDataState( fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, amount = amount.toPlainString(), + currentTransferState = transferState, + ) + val feeValue = BigDecimal("0.001") + val fee = Fee.Common( + amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18), + ) + val appCurrency = transferState.appCurrency + val expectedFee = stringReference( + "${feeValue.format { crypto(symbol = "ETH", decimals = 18) }} " + + "(${ + fromCurrencyStatus.status.value.fiatRate!!.multiply(feeValue).format { + fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) + } + })", ) - val fee: TextReference = stringReference("0.001 ETH") val txUrl = "https://explorer.example/tx/0xabc" val timestamp = 1_700_000_000_000L val result = sut.createSuccessState( uiState = baseStateHolder(), dataState = dataState, - appCurrency = appCurrency, - isAccountsMode = true, + fee = fee, txUrl = txUrl, timestamp = timestamp, - fee = fee, onExplorerClick = {}, ) @@ -495,7 +517,7 @@ internal class SwapTransferStateBuilderTest { assertThat(success.shouldShowStatusButton).isFalse() assertThat(success.timestamp).isEqualTo(timestamp) assertThat(success.txUrl).isEqualTo(txUrl) - assertThat(success.fee).isEqualTo(fee) + assertThat(success.fee).isEqualTo(expectedFee) assertThat(success.providerName).isEqualTo(TextReference.EMPTY) assertThat(success.providerType).isEqualTo(TextReference.EMPTY) assertThat(success.providerIcon).isEmpty() @@ -613,6 +635,7 @@ internal class SwapTransferStateBuilderTest { val result = sut.createTangemPayWithdrawalSuccessState( uiState = baseStateHolder(), dataState = dataState, + fee = null, onExploreClick = onExploreClick, ) val after = System.currentTimeMillis() @@ -707,6 +730,7 @@ internal class SwapTransferStateBuilderTest { userWallet = coldWallet, fromTokenInfo = fromInfo, toTokenInfo = toInfo, + cryptoCurrencyWarning = null, isInsufficientBalance = isInsufficientBalance, appCurrency = AppCurrency.Default, isBalanceHidden = false, From 0ee2f48898d72ce6295ca962fe6e980ccaa83fa9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 21:49:35 +0300 Subject: [PATCH 07/10] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 11 ++ .../deeplink/DefaultDeeplinkLauncher.kt | 29 ++++- .../java/com/tangem/tap/di/UtilsModule.kt | 8 +- .../tangem/tap/routing/utils/ChildFactory.kt | 9 ++ .../tap/routing/utils/DeepLinkFactory.kt | 3 + .../tap/routing/utils/DeepLinkFactoryTest.kt | 6 + .../com/tangem/common/routing/AppRoute.kt | 3 + .../tangem/common/routing/DeepLinkRoute.kt | 4 + .../configs/feature_toggles_config.json | 4 + .../impl/model/PromoBannersBlockModel.kt | 20 +++- features/survey/api/build.gradle.kts | 14 +++ .../tangem/features/survey/SurveyComponent.kt | 11 ++ .../features/survey/SurveyFeatureToggles.kt | 6 + .../features/survey/SurveySparrowLauncher.kt | 14 +++ .../survey/deeplink/SurveyDeepLinkHandler.kt | 8 ++ features/survey/impl/build.gradle.kts | 61 ++++++++++ .../survey/impl/DefaultSurveyComponent.kt | 73 ++++++++++++ .../impl/DefaultSurveyFeatureToggles.kt | 14 +++ .../impl/DefaultSurveySparrowLauncher.kt | 38 +++++++ .../deeplink/DefaultSurveyDeepLinkHandler.kt | 47 ++++++++ .../features/survey/impl/di/SurveyModule.kt | 36 ++++++ .../impl/service/SurveyCustomParamsBuilder.kt | 48 ++++++++ .../DefaultSurveyDeepLinkHandlerTest.kt | 81 ++++++++++++++ .../service/SurveyCustomParamsBuilderTest.kt | 104 ++++++++++++++++++ features/tester/impl/build.gradle.kts | 2 +- .../tester/presentation/TesterActivity.kt | 28 ++--- .../surveysparrow/SurveySparrowManager.kt | 55 --------- settings.gradle.kts | 3 + 29 files changed, 660 insertions(+), 82 deletions(-) create mode 100644 features/survey/api/build.gradle.kts create mode 100644 features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt create mode 100644 features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt create mode 100644 features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt create mode 100644 features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt create mode 100644 features/survey/impl/build.gradle.kts create mode 100644 features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt create mode 100644 features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt create mode 100644 features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt create mode 100644 features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt create mode 100644 features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt create mode 100644 features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt create mode 100644 features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt create mode 100644 features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt delete mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22f93e0530..9d754f3add 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -283,6 +283,8 @@ dependencies { implementation(projects.features.onboardingV2.impl) implementation(projects.features.stories.api) implementation(projects.features.stories.impl) + implementation(projects.features.survey.api) + implementation(projects.features.survey.impl) implementation(projects.features.txhistory.api) implementation(projects.features.txhistory.impl) implementation(projects.features.biometry.api) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6139be58a..36cca67394 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -210,6 +210,17 @@ android:scheme="tangem" /> + + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt index 0b6f9c503e..61ac194a7f 100644 --- a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt +++ b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt @@ -6,6 +6,8 @@ import android.net.Uri import androidx.core.net.toUri import com.tangem.common.routing.DeepLinkScheme import com.tangem.common.uri.ExternalUrlValidator +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.url.UrlOpener import com.tangem.utils.logging.TangemLogger @@ -17,6 +19,7 @@ import com.tangem.utils.logging.TangemLogger internal class DefaultDeeplinkLauncher( private val context: Context, private val urlOpener: UrlOpener, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, ) : DeeplinkLauncher { override fun launch(link: String) { @@ -58,11 +61,33 @@ internal class DefaultDeeplinkLauncher( } private fun launchDeepLink(uri: Uri) { - context.startActivity(createDeepLinkIntent(uri)) + val intent = createDeepLinkIntent(uri) + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + TangemLogger.i( + """ + No match found for deep link + |- Received URI: $uri + """.trimIndent(), + ) + analyticsExceptionHandler.sendException( + ExceptionAnalyticsEvent( + exception = UnresolvedDeeplinkException(uri), + params = mapOf( + "uri_scheme" to uri.scheme.orEmpty(), + "uri_host" to uri.host.orEmpty(), + ), + ), + ) + } } private fun createDeepLinkIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply { setPackage(context.packageName) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } -} \ No newline at end of file +} + +internal class UnresolvedDeeplinkException(uri: Uri) : + RuntimeException("Deeplink has no matching activity: scheme=${uri.scheme}, host=${uri.host}") \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt index 492904d18a..d8cb34aece 100644 --- a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di import android.content.Context +import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.tap.common.deeplink.DefaultDeeplinkLauncher import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.finisher.AppFinisher @@ -55,7 +56,10 @@ internal interface UtilsModule { @Provides @Singleton - fun provideDeeplinkLauncher(@ApplicationContext context: Context, urlOpener: UrlOpener): DeeplinkLauncher = - DefaultDeeplinkLauncher(context, urlOpener) + fun provideDeeplinkLauncher( + @ApplicationContext context: Context, + urlOpener: UrlOpener, + analyticsExceptionHandler: AnalyticsExceptionHandler, + ): DeeplinkLauncher = DefaultDeeplinkLauncher(context, urlOpener, analyticsExceptionHandler) } } \ No newline at end of file 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 c1d690f533..41205cfc6e 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 @@ -21,6 +21,7 @@ import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.home.api.HomeComponent import com.tangem.features.hotwallet.* import com.tangem.features.kyc.KycComponent +import com.tangem.features.survey.SurveyComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensMode @@ -112,6 +113,7 @@ internal class ChildFactory @Inject constructor( private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory, private val kycComponentFactory: KycComponent.Factory, + private val surveyComponentFactory: SurveyComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val feedEntryComponentFactory: FeedEntryComponent.Factory, private val addFundsComponentFactory: AddFundsComponent.Factory, @@ -702,6 +704,13 @@ internal class ChildFactory @Inject constructor( componentFactory = kycComponentFactory, ) } + is AppRoute.Survey -> { + createComponentChild( + context = context, + params = SurveyComponent.Params(token = route.token, displayId = route.displayId), + componentFactory = surveyComponentFactory, + ) + } is AppRoute.YieldSupplyEntry -> { createComponentChild( context = context, diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 72fdba54ab..e886e10e14 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -19,6 +19,7 @@ import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler +import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler @@ -62,6 +63,7 @@ internal class DeepLinkFactory @Inject constructor( private val newsDeepLink: NewsDeepLinkHandler.Factory, private val earnDeepLink: EarnDeepLinkHandler.Factory, private val yieldDeepLink: YieldDeepLinkHandler.Factory, + private val surveyDeepLink: SurveyDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -175,6 +177,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams) DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams) else -> { TangemLogger.i( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index b256302118..1650ffedea 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -11,6 +11,7 @@ import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHand import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler +import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler @@ -99,6 +100,10 @@ class DeepLinkFactoryTest { every { create(any()) } returns mockk() } + private val surveyDeepLinkFactory = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + private val earnDeepLinkFactory = mockk(relaxed = true) { every { create(any()) } returns mockk() } @@ -140,6 +145,7 @@ class DeepLinkFactoryTest { newsDeepLink = newsDeepLinkFactory, earnDeepLink = earnDeepLinkFactory, yieldDeepLink = yieldDeepLinkFactory, + surveyDeepLink = surveyDeepLinkFactory, ) @OptIn(ExperimentalCoroutinesApi::class) 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 30438d4e34..02d03d772d 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 @@ -498,6 +498,9 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc") + @Serializable + data class Survey(val token: String, val displayId: String? = null) : AppRoute(path = "/survey") + @Serializable data class YieldSupplyEntry( val userWalletId: UserWalletId, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index ee03ee94b4..e2e31a626c 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -87,6 +87,10 @@ sealed class DeepLinkRoute { data object PayAppMain : DeepLinkRoute() { override val host: String = "pay-app-main" } + + data object Survey : DeepLinkRoute() { + override val host: String = "survey" + } } enum class DeepLinkScheme(val scheme: String) { 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 d465fd6778..2fe3cc2043 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 @@ -115,6 +115,10 @@ "name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED", "version": "undefined" }, + { + "name": "AND_15482_SURVEYSPARROW_ENABLED", + "version": "undefined" + }, { "name": "AND_15258_QUICK_TOP_UP_ENABLED", "version": "undefined" diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt index 7abc5a48bd..108b6fe3e5 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt @@ -1,5 +1,6 @@ package com.tangem.features.promobanners.impl.model +import androidx.core.net.toUri import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -118,7 +119,18 @@ internal class PromoBannersBlockModel @Inject constructor( private fun onButtonClick(displayId: Int, deeplink: String?) { analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName)) - deeplink?.let { deeplinkLauncher.launch(it) } + deeplink?.let { deeplinkLauncher.launch(appendSurveyDisplayId(it, displayId)) } + } + + private fun appendSurveyDisplayId(deeplink: String, displayId: Int): String { + val uri = deeplink.toUri() + val isSurveyDeeplink = uri.scheme == DEEPLINK_SCHEME_TANGEM && uri.host == DEEPLINK_HOST_SURVEY + if (!isSurveyDeeplink || uri.getQueryParameter(QUERY_DISPLAY_ID) != null) return deeplink + + return uri.buildUpon() + .appendQueryParameter(QUERY_DISPLAY_ID, displayId.toString()) + .build() + .toString() } private fun getInitialState() = PromoBannersBlockUM( @@ -152,4 +164,10 @@ internal class PromoBannersBlockModel @Inject constructor( } } } + + private companion object { + const val DEEPLINK_SCHEME_TANGEM = "tangem" + const val DEEPLINK_HOST_SURVEY = "survey" + const val QUERY_DISPLAY_ID = "display_id" + } } \ No newline at end of file diff --git a/features/survey/api/build.gradle.kts b/features/survey/api/build.gradle.kts new file mode 100644 index 0000000000..a0db7dc04e --- /dev/null +++ b/features/survey/api/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.survey.api" +} + +dependencies { + implementation(projects.core.decompose) + implementation(projects.core.ui) +} \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt new file mode 100644 index 0000000000..4fe9360d56 --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt @@ -0,0 +1,11 @@ +package com.tangem.features.survey + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface SurveyComponent : ComposableContentComponent { + + data class Params(val token: String, val displayId: String?) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt new file mode 100644 index 0000000000..c42ffbf905 --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.survey + +interface SurveyFeatureToggles { + + val areSurveysEnabled: Boolean +} \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt new file mode 100644 index 0000000000..84d518d39e --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt @@ -0,0 +1,14 @@ +package com.tangem.features.survey + +import android.app.Activity + +interface SurveySparrowLauncher { + + fun present(activity: Activity, data: SurveyLaunchData) +} + +data class SurveyLaunchData( + val domain: String, + val token: String, + val customParams: Map, +) \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt new file mode 100644 index 0000000000..a1d6abc36d --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.survey.deeplink + +interface SurveyDeepLinkHandler { + + interface Factory { + fun create(queryParams: Map): SurveyDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/survey/impl/build.gradle.kts b/features/survey/impl/build.gradle.kts new file mode 100644 index 0000000000..e512a05413 --- /dev/null +++ b/features/survey/impl/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.survey.impl" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + /* Project - API */ + implementation(projects.features.survey.api) + + /* Domain */ + implementation(projects.domain.common) + implementation(projects.domain.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) + + /* Core */ + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.configToggles) + implementation(projects.core.datasource) + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.core.utils) + + /* Common */ + implementation(projects.common.routing) + + /* DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /* Compose */ + implementation(deps.compose.runtime) + implementation(deps.compose.ui) + + /* Other */ + implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + + /** Tangem libraries */ + implementation(tangemDeps.card.core) + implementation(deps.surveysparrow) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt new file mode 100644 index 0000000000..bc06782acc --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt @@ -0,0 +1,73 @@ +package com.tangem.features.survey.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.features.survey.SurveyComponent +import com.tangem.features.survey.SurveyLaunchData +import com.tangem.features.survey.SurveySparrowLauncher +import com.tangem.features.survey.impl.service.SurveyCustomParamsBuilder +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class DefaultSurveyComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: SurveyComponent.Params, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val customParamsBuilder: SurveyCustomParamsBuilder, + private val surveySparrowLauncher: SurveySparrowLauncher, + @Suppress("UnusedPrivateProperty") // TODO([REDACTED_TASK_KEY]): emit [Survey] analytics events + private val analyticsEventHandler: AnalyticsEventHandler, +) : SurveyComponent, AppComponentContext by appComponentContext { + + init { + // componentScope runs on mainImmediate, so presenting the SDK is already on the main thread. + componentScope.launch { + val launchData = buildLaunchData() + if (launchData != null) { + surveySparrowLauncher.present(activity, launchData) + // TODO([REDACTED_TASK_KEY]): analyticsEventHandler.send(SurveyAnalyticsEvent.Shown(...)) + } + router.pop() + } + } + + private suspend fun buildLaunchData(): SurveyLaunchData? { + return getSelectedWalletSyncUseCase().fold( + ifLeft = { error -> + TangemLogger.e("$TAG: survey skipped, no available wallet ($error)") + null + }, + ifRight = { userWallet -> + SurveyLaunchData( + domain = SURVEY_DOMAIN, + token = params.token, + customParams = customParamsBuilder.build( + userWallet = userWallet, + token = params.token, + displayId = params.displayId, + ), + ) + }, + ) + } + + @Composable + override fun Content(modifier: Modifier) = Unit + + @AssistedFactory + interface Factory : SurveyComponent.Factory { + override fun create(context: AppComponentContext, params: SurveyComponent.Params): DefaultSurveyComponent + } + + private companion object { + const val TAG = "SurveyComponent" + const val SURVEY_DOMAIN = "tangem.surveysparrow.com" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt new file mode 100644 index 0000000000..6959c5bc9e --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt @@ -0,0 +1,14 @@ +package com.tangem.features.survey.impl + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.survey.SurveyFeatureToggles +import javax.inject.Inject + +internal class DefaultSurveyFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : SurveyFeatureToggles { + + override val areSurveysEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15482_SURVEYSPARROW_ENABLED) +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt new file mode 100644 index 0000000000..eb648e78b8 --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt @@ -0,0 +1,38 @@ +package com.tangem.features.survey.impl + +import android.app.Activity +import com.surveysparrow.ss_android_sdk.SsSurvey +import com.surveysparrow.ss_android_sdk.SurveySparrow +import com.tangem.features.survey.SurveyLaunchData +import com.tangem.features.survey.SurveySparrowLauncher +import com.tangem.utils.logging.TangemLogger +import javax.inject.Inject + +internal class DefaultSurveySparrowLauncher @Inject constructor() : SurveySparrowLauncher { + + override fun present(activity: Activity, data: SurveyLaunchData) { + if (activity.isFinishing || activity.isDestroyed) { + TangemLogger.e("$TAG: cannot present survey, activity is finishing/destroyed") + return + } + + val survey = try { + SsSurvey(data.domain, data.token).apply { + setSurveyType(SurveySparrow.CLASSIC) + data.customParams.forEach { (key, value) -> addCustomParam(key, value) } + } + } catch (e: Exception) { + TangemLogger.e("$TAG: failed to create SurveySparrow survey", e) + return + } + + // Result handling (onActivityResult -> [Survey] Completed/Dismissed) is planned in [REDACTED_TASK_KEY] + SurveySparrow(activity, survey).startSurveyForResult(SURVEY_REQUEST_CODE) + TangemLogger.d("$TAG: survey started (requestCode=$SURVEY_REQUEST_CODE)") + } + + private companion object { + const val TAG = "SurveySparrowPresenter" + const val SURVEY_REQUEST_CODE = 1001 + } +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt new file mode 100644 index 0000000000..013015eb8a --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt @@ -0,0 +1,47 @@ +package com.tangem.features.survey.impl.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.features.survey.SurveyFeatureToggles +import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultSurveyDeepLinkHandler @AssistedInject constructor( + @Assisted private val queryParams: Map, + private val surveyFeatureToggles: SurveyFeatureToggles, + private val appRouter: AppRouter, +) : SurveyDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + if (!surveyFeatureToggles.areSurveysEnabled) { + TangemLogger.i("$TAG: survey deeplink ignored, feature is disabled") + return + } + + val token = queryParams[QUERY_TOKEN]?.takeIf { it.isNotBlank() } + if (token == null) { + TangemLogger.e("$TAG: survey deeplink ignored, missing 'token' query param") + return + } + + appRouter.push(AppRoute.Survey(token = token, displayId = queryParams[QUERY_DISPLAY_ID])) + } + + @AssistedFactory + interface Factory : SurveyDeepLinkHandler.Factory { + override fun create(queryParams: Map): DefaultSurveyDeepLinkHandler + } + + private companion object { + const val TAG = "SurveyDeepLink" + const val QUERY_TOKEN = "token" + const val QUERY_DISPLAY_ID = "display_id" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt new file mode 100644 index 0000000000..2af50ede8b --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt @@ -0,0 +1,36 @@ +package com.tangem.features.survey.impl.di + +import com.tangem.features.survey.SurveyComponent +import com.tangem.features.survey.SurveyFeatureToggles +import com.tangem.features.survey.SurveySparrowLauncher +import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler +import com.tangem.features.survey.impl.DefaultSurveyComponent +import com.tangem.features.survey.impl.DefaultSurveyFeatureToggles +import com.tangem.features.survey.impl.DefaultSurveySparrowLauncher +import com.tangem.features.survey.impl.deeplink.DefaultSurveyDeepLinkHandler +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface SurveyModule { + + @Binds + @Singleton + fun bindSurveyFeatureToggles(impl: DefaultSurveyFeatureToggles): SurveyFeatureToggles + + @Binds + @Singleton + fun bindSurveySparrowLauncher(impl: DefaultSurveySparrowLauncher): SurveySparrowLauncher + + @Binds + @Singleton + fun bindSurveyComponentFactory(impl: DefaultSurveyComponent.Factory): SurveyComponent.Factory + + @Binds + @Singleton + fun bindSurveyDeepLinkHandlerFactory(impl: DefaultSurveyDeepLinkHandler.Factory): SurveyDeepLinkHandler.Factory +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt new file mode 100644 index 0000000000..d5d905d0b4 --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt @@ -0,0 +1,48 @@ +package com.tangem.features.survey.impl.service + +import com.tangem.common.extensions.calculateSha256 +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.AppInstanceIdProvider +import com.tangem.datasource.api.tangemTech.models.WalletType +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.SupportedLanguages +import com.tangem.utils.info.AppInfoProvider +import javax.inject.Inject + +internal class SurveyCustomParamsBuilder @Inject constructor( + private val appInstanceIdProvider: AppInstanceIdProvider, + private val appInfoProvider: AppInfoProvider, +) { + + suspend fun build(userWallet: UserWallet, token: String, displayId: String?): Map { + return buildMap { + put(KEY_SURVEY_KEY, token) + put(KEY_WALLET_ID, hashWalletId(userWallet)) + WalletType.from(userWallet)?.let { put(KEY_WALLET_TYPE, it.name.lowercase()) } + displayId?.takeIf { it.isNotBlank() }?.let { put(KEY_DISPLAY_ID, it) } + appInstanceIdProvider.getAppInstanceId()?.let { put(KEY_DEVICE_ID, it) } + put(KEY_PLATFORM, appInfoProvider.platform.lowercase()) + put(KEY_APP_VERSION, appInfoProvider.appVersion) + put(KEY_LANGUAGE, SupportedLanguages.getCurrentSupportedLanguageCode()) + } + } + + private fun hashWalletId(userWallet: UserWallet): String { + return userWallet.walletId.stringValue + .hexToBytes() + .calculateSha256() + .toHexString() + } + + private companion object { + const val KEY_SURVEY_KEY = "survey_key" + const val KEY_WALLET_ID = "wallet_id" + const val KEY_WALLET_TYPE = "wallet_type" + const val KEY_DISPLAY_ID = "display_id" + const val KEY_DEVICE_ID = "device_id" + const val KEY_PLATFORM = "platform" + const val KEY_APP_VERSION = "app_version" + const val KEY_LANGUAGE = "language" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..e3c1345e03 --- /dev/null +++ b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt @@ -0,0 +1,81 @@ +package com.tangem.features.survey.impl.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.features.survey.SurveyFeatureToggles +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import com.tangem.utils.logging.TangemLogger +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +internal class DefaultSurveyDeepLinkHandlerTest { + + private val featureToggles = mockk() + private val appRouter = mockk(relaxed = true) + + @BeforeEach + fun setup() { + mockkObject(TangemLogger) + every { TangemLogger.i(any()) } just Runs + every { TangemLogger.e(any()) } just Runs + } + + @AfterEach + fun tearDown() { + unmockkObject(TangemLogger) + } + + @Test + fun `does not navigate when feature is disabled`() { + every { featureToggles.areSurveysEnabled } returns false + + createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID)) + + verify(exactly = 0) { appRouter.push(any(), any()) } + } + + @Test + fun `does not navigate when token is missing`() { + every { featureToggles.areSurveysEnabled } returns true + + createHandler(emptyMap()) + + verify(exactly = 0) { appRouter.push(any(), any()) } + } + + @Test + fun `pushes survey route with token and display id on happy path`() { + every { featureToggles.areSurveysEnabled } returns true + + createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID)) + + verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = DISPLAY_ID), onComplete = any()) } + } + + @Test + fun `pushes survey route with null display id when absent`() { + every { featureToggles.areSurveysEnabled } returns true + + createHandler(mapOf("token" to TOKEN)) + + verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = null), onComplete = any()) } + } + + private fun createHandler(queryParams: Map) = DefaultSurveyDeepLinkHandler( + queryParams = queryParams, + surveyFeatureToggles = featureToggles, + appRouter = appRouter, + ) + + private companion object { + const val TOKEN = "ntt-84iF22PDajmervYneMW4kv" + const val DISPLAY_ID = "42" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt new file mode 100644 index 0000000000..290c70adca --- /dev/null +++ b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt @@ -0,0 +1,104 @@ +package com.tangem.features.survey.impl.service + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.extensions.calculateSha256 +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.AppInstanceIdProvider +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.info.AppInfoProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.Locale + +internal class SurveyCustomParamsBuilderTest { + + private val appInstanceIdProvider = mockk() + private val appInfoProvider = mockk() + + private val builder = SurveyCustomParamsBuilder( + appInstanceIdProvider = appInstanceIdProvider, + appInfoProvider = appInfoProvider, + ) + + @BeforeEach + fun setup() { + Locale.setDefault(Locale.ENGLISH) + every { appInfoProvider.platform } returns "Android" + every { appInfoProvider.appVersion } returns "5.40" + coEvery { appInstanceIdProvider.getAppInstanceId() } returns "device-123" + } + + @Test + fun `builds all params for a cold wallet`() = runTest { + val wallet = coldWallet(WALLET_ID_HEX) + + val params = builder.build(userWallet = wallet, token = TOKEN, displayId = "42") + + assertThat(params).containsExactlyEntriesIn( + mapOf( + "survey_key" to TOKEN, + "wallet_id" to expectedWalletIdHash(WALLET_ID_HEX), + "wallet_type" to "cold", + "display_id" to "42", + "device_id" to "device-123", + "platform" to "android", + "app_version" to "5.40", + "language" to "en", + ), + ) + } + + @Test + fun `wallet_id hash is uppercase hex`() = runTest { + val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null) + + val walletId = params.getValue("wallet_id") + assertThat(walletId).isEqualTo(walletId.uppercase()) + assertThat(walletId).matches("[0-9A-F]+") + } + + @Test + fun `wallet_type is hot for a hot wallet`() = runTest { + val wallet = mockk { every { walletId } returns UserWalletId(WALLET_ID_HEX) } + + val params = builder.build(userWallet = wallet, token = TOKEN, displayId = null) + + assertThat(params["wallet_type"]).isEqualTo("hot") + } + + @Test + fun `device_id is omitted when app instance id is null`() = runTest { + coEvery { appInstanceIdProvider.getAppInstanceId() } returns null + + val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = "42") + + assertThat(params).doesNotContainKey("device_id") + } + + @Test + fun `display_id is omitted when null or blank`() = runTest { + val nullCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null) + val blankCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = " ") + + assertThat(nullCase).doesNotContainKey("display_id") + assertThat(blankCase).doesNotContainKey("display_id") + } + + private fun coldWallet(walletIdHex: String): UserWallet.Cold = mockk { + every { walletId } returns UserWalletId(walletIdHex) + } + + private fun expectedWalletIdHash(walletIdHex: String): String = + walletIdHex.hexToBytes().calculateSha256().toHexString() + + private companion object { + const val TOKEN = "ntt-84iF22PDajmervYneMW4kv" + const val WALLET_ID_HEX = "0123456789ABCDEF" + } +} \ No newline at end of file diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index f96c63f4b4..b3e1f1c4b4 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -47,7 +47,6 @@ dependencies { /** Other libraries */ implementation(deps.arrow.core) implementation(deps.kotlin.immutable.collections) - implementation(deps.surveysparrow) /** Core modules */ implementation(projects.core.datasource) @@ -60,6 +59,7 @@ dependencies { /** Feature Apis */ implementation(projects.features.tester.api) implementation(projects.features.pushNotifications.api) + implementation(projects.features.survey.api) /* SDK */ implementation(tangemDeps.blockchain) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 3829e306b7..a1ede089f5 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -1,6 +1,5 @@ package com.tangem.feature.tester.presentation -import android.widget.Toast import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,7 +17,6 @@ import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeActivity -import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen import com.tangem.feature.tester.presentation.accounts.viewmodel.TesterAccountsViewModel import com.tangem.feature.tester.presentation.actions.TesterActionsScreen @@ -40,9 +38,10 @@ import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersSc import com.tangem.feature.tester.presentation.providers.viewmodel.BlockchainProvidersViewModel import com.tangem.feature.tester.presentation.storybook.ui.StoryBookScreen import com.tangem.feature.tester.presentation.storybook.viewmodel.StoryBookViewModel -import com.tangem.feature.tester.presentation.surveysparrow.SurveySparrowManager import com.tangem.feature.tester.presentation.testpush.ui.TestPushScreen import com.tangem.feature.tester.presentation.testpush.viewmodel.TestPushViewModel +import com.tangem.features.survey.SurveyLaunchData +import com.tangem.features.survey.SurveySparrowLauncher import dagger.hilt.android.AndroidEntryPoint import kotlinx.collections.immutable.persistentSetOf import javax.inject.Inject @@ -64,7 +63,7 @@ internal class TesterActivity : ComposeActivity() { lateinit var appRouter: AppRouter @Inject - lateinit var environmentConfig: EnvironmentConfig + lateinit var surveySparrowLauncher: SurveySparrowLauncher @Composable override fun ScreenContent(modifier: Modifier) { @@ -217,29 +216,16 @@ internal class TesterActivity : ComposeActivity() { } private fun startSurveySparrow(): Boolean { - val token = environmentConfig.surveySparrowToken - - if (token.isNullOrEmpty()) { - val toast = Toast.makeText( - this, - "Survey Sparrow is not configured. Token is missing.", - Toast.LENGTH_LONG, - ) - - toast.show() - return false - } - - SurveySparrowManager(domain = DOMAIN, token = token).startSurveyForResult( + surveySparrowLauncher.present( activity = this, - requestCode = SURVEY_SPARROW_REQUEST_CODE, + data = SurveyLaunchData(domain = DOMAIN, token = TEST_SHARE_TOKEN, customParams = emptyMap()), ) return true } private companion object { - const val DOMAIN = "tangem.com" - const val SURVEY_SPARROW_REQUEST_CODE = 1001 + const val DOMAIN = "tangem.surveysparrow.com" + const val TEST_SHARE_TOKEN = "ntt-84iF22PDajmervYneMW4kv" } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt deleted file mode 100644 index 3b2385900c..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.tangem.feature.tester.presentation.surveysparrow - -import android.app.Activity -import com.surveysparrow.ss_android_sdk.SsSurvey -import com.surveysparrow.ss_android_sdk.SurveySparrow -import com.tangem.utils.logging.TangemLogger - -/** - * Manager for Survey Sparrow SDK. - * - * @param domain Survey Sparrow domain (e.g., "yourcompany") - * @param token Survey Sparrow SDK token - */ -class SurveySparrowManager( - private val domain: String, - private val token: String, -) { - - /** - * Create a SurveySparrow instance to start a survey. - * - * @param activity The activity context - * @param customVariables Optional custom variables to pass to the survey - * @return SurveySparrow instance ready to start - */ - fun createSurvey(activity: Activity, customVariables: Map? = null): SurveySparrow? { - return try { - val survey = SsSurvey(domain, token).apply { - customVariables?.forEach { (key, value) -> - addCustomParam(key, value) - } - } - - SurveySparrow(activity, survey) - } catch (e: Exception) { - TangemLogger.e("Failed to create SurveySparrow survey", e) - null - } - } - - /** - * Start a survey for result. - * - * @param activity The activity context - * @param requestCode The request code for onActivityResult - * @param customVariables Optional custom variables to pass to the survey - */ - fun startSurveyForResult(activity: Activity, requestCode: Int, customVariables: Map? = null) { - val surveySparrow = createSurvey(activity, customVariables) - if (surveySparrow != null) { - surveySparrow.startSurveyForResult(requestCode) - TangemLogger.d("SurveySparrow survey started with requestCode: $requestCode") - } - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 4c1a0fb251..590257dd05 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -259,6 +259,9 @@ include(":features:rating:impl") include(":features:stories:api") include(":features:stories:impl") +include(":features:survey:api") +include(":features:survey:impl") + include(":features:txhistory:api") include(":features:txhistory:impl") From 5ed229f6ed57a731115cfdb22c0b7b43f6e285f4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 15:36:20 +0400 Subject: [PATCH 08/10] Updated on 2026-08-14 --- libs/auth/build.gradle.kts | 8 + .../java/com/tangem/lib/auth/di/AuthModule.kt | 63 +++++++ .../tangem/lib/auth/dpop/DpopProofFactory.kt | 25 +++ .../dpop/internal/DefaultDpopProofFactory.kt | 101 +++++++++++ .../dpop/internal/DisabledDpopProofFactory.kt | 10 ++ .../auth/http/DpopAuthorizationInterceptor.kt | 70 ++++++++ .../tangem/lib/auth/session/SessionTokens.kt | 31 ++++ .../lib/auth/session/SessionTokensStore.kt | 20 +++ .../internal/DefaultSessionTokensStore.kt | 62 +++++++ .../internal/DisabledSessionTokensStore.kt | 19 ++ .../internal/SessionTokensConverter.kt | 30 ++++ .../internal/DefaultDpopProofFactoryTest.kt | 170 ++++++++++++++++++ .../http/DpopAuthorizationInterceptorTest.kt | 142 +++++++++++++++ .../internal/DefaultSessionTokensStoreTest.kt | 97 ++++++++++ .../internal/SessionTokensConverterTest.kt | 62 +++++++ 15 files changed, 910 insertions(+) create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt create mode 100644 libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt create mode 100644 libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt create mode 100644 libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt create mode 100644 libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index 9cdc3747e2..7187124c7a 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) alias(deps.plugins.hilt.android) id("configuration") } @@ -17,10 +18,12 @@ tasks.withType().configureEach { dependencies { /** Core */ implementation(projects.core.configToggles) + implementation(projects.core.datasource) implementation(projects.core.utils) /** Tangem libraries */ implementation(tangemDeps.card.core) + implementation(tangemDeps.card.android) /** Firebase */ implementation(platform(deps.firebase.bom)) @@ -28,6 +31,11 @@ dependencies { /** Other */ implementation(deps.arrow.core) + implementation(deps.kotlin.datetime) + implementation(deps.kotlin.serialization) + implementation(deps.moshi) + implementation(deps.okHttp) + implementation(deps.retrofit) /** DI */ implementation(deps.hilt.android) diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt index 716b126163..ba1d0742c7 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt @@ -1,19 +1,34 @@ package com.tangem.lib.auth.di +import android.content.Context import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.squareup.moshi.Moshi +import com.tangem.common.services.secure.SecureStorage +import com.tangem.datasource.di.NetworkMoshi import com.tangem.lib.auth.AuthFeatureToggles import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager import com.tangem.lib.auth.devicekey.internal.DisabledDeviceKeyManager +import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.lib.auth.dpop.internal.DefaultDpopProofFactory +import com.tangem.lib.auth.dpop.internal.DisabledDpopProofFactory +import com.tangem.lib.auth.http.DpopAuthorizationInterceptor import com.tangem.lib.auth.nonce.AuthNonceDecryptor import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.lib.auth.session.internal.DefaultSessionTokensStore +import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore +import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json import java.security.KeyStore import javax.inject.Named import javax.inject.Singleton @@ -58,4 +73,52 @@ internal object AuthModule { DisabledAuthNonceDecryptor } } + + @Provides + @Singleton + fun provideSessionTokensStore( + authFeatureToggles: AuthFeatureToggles, + @ApplicationContext context: Context, + @NetworkMoshi moshi: Moshi, + dispatchers: CoroutineDispatcherProvider, + ): SessionTokensStore { + if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledSessionTokensStore + + return runCatching { + val storage: SecureStorage = AndroidSecureStorageV2( + appContext = context, + useStrongBox = false, + name = "tangem_session_tokens", + ) + DefaultSessionTokensStore(storage, moshi, dispatchers) + }.getOrElse { e -> + TangemLogger.e("Failed to init DefaultSessionTokensStore, falling back to disabled store", e) + FirebaseCrashlytics.getInstance().recordException(e) + DisabledSessionTokensStore + } + } + + @Provides + @Singleton + fun provideDpopProofFactory( + authFeatureToggles: AuthFeatureToggles, + deviceKeyManager: DeviceKeyManager, + dispatchers: CoroutineDispatcherProvider, + ): DpopProofFactory { + if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDpopProofFactory + + return DefaultDpopProofFactory( + deviceKeyManager = deviceKeyManager, + json = Json.Default, + clock = Clock.System, + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideDpopAuthorizationInterceptor( + store: SessionTokensStore, + proofFactory: DpopProofFactory, + ): DpopAuthorizationInterceptor = DpopAuthorizationInterceptor(store, proofFactory) } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt new file mode 100644 index 0000000000..92d9ba6154 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt @@ -0,0 +1,25 @@ +package com.tangem.lib.auth.dpop + +import arrow.core.Option + +/** + * Builds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP proofs (JWS) for outgoing + * HTTP requests. Each proof is bound to a single request — `htm` / `htu` / `ath` claims must + * not be reused, and `jti` is a fresh UUID per invocation. + */ +interface DpopProofFactory { + + /** + * Builds a DPoP-proof for the given request. + * + * @param httpMethod uppercase HTTP method (e.g. `"POST"`). + * @param httpUri target URI **without** query and fragment (RFC 9449 §4.2). + * @param accessToken access token bound to this proof; when present, the SHA-256 hash + * is included as `ath` claim (RFC 9449 §4.3). Pass `null` for unauthenticated + * requests (initial registration, `/authenticate`) or `/refresh` where the access + * token has already expired (RFC 9449 §5). + * @return compact-serialised JWS suitable for the `DPoP:` header, or [arrow.core.None] + * when the device key is unavailable or signing fails. + */ + suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt new file mode 100644 index 0000000000..d46fe19fd4 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt @@ -0,0 +1,101 @@ +package com.tangem.lib.auth.dpop.internal + +import android.util.Base64 +import arrow.core.None +import arrow.core.Option +import arrow.core.Some +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.security.MessageDigest +import java.util.UUID + +internal class DefaultDpopProofFactory( + private val deviceKeyManager: DeviceKeyManager, + private val json: Json, + private val clock: Clock, + private val dispatchers: CoroutineDispatcherProvider, +) : DpopProofFactory { + + override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option = + withContext(dispatchers.default) { + val publicKey = deviceKeyManager.getPublicKey().getOrNull() + + if (publicKey == null) { + TangemLogger.e("DPoP proof skipped: device key unavailable") + return@withContext None + } + + // DeviceKeyManager.getPublicKey() guarantees an uncompressed P-256 point + // (0x04 || X(32) || Y(32)) — see DefaultDeviceKeyManager.getPublicKeyBytes. + val x = publicKey.copyOfRange(fromIndex = 1, toIndex = 1 + COORDINATE_SIZE) + val y = publicKey.copyOfRange(fromIndex = 1 + COORDINATE_SIZE, toIndex = 1 + 2 * COORDINATE_SIZE) + + val header: JsonObject = buildJsonObject { + put("alg", ES256_ALG) + put("typ", DPOP_TYP) + putJsonObject("jwk") { + put("kty", EC_KTY) + put("crv", P256_CRV) + put("x", x.base64UrlNoPad()) + put("y", y.base64UrlNoPad()) + } + } + + val claims: JsonObject = buildJsonObject { + put("jti", UUID.randomUUID().toString()) + put("iat", clock.now().epochSeconds) + put("htm", httpMethod.uppercase()) + put("htu", stripQueryAndFragment(httpUri)) + if (accessToken != null) { + put("ath", sha256(accessToken.toByteArray(Charsets.US_ASCII)).base64UrlNoPad()) + } + } + + val signingInput = json.encodeToString(JsonObject.serializer(), header) + .toByteArray(Charsets.UTF_8).base64UrlNoPad() + + "." + + json.encodeToString(JsonObject.serializer(), claims) + .toByteArray(Charsets.UTF_8).base64UrlNoPad() + + val signature = try { + deviceKeyManager.sign(signingInput.toByteArray(Charsets.US_ASCII)) + } catch (e: Exception) { + TangemLogger.e("Failed to sign DPoP proof", e) + return@withContext None + } + + Some("$signingInput.${signature.base64UrlNoPad()}") + } + + private fun sha256(bytes: ByteArray): ByteArray { + return MessageDigest.getInstance(SHA_256).digest(bytes) + } + + private fun ByteArray.base64UrlNoPad(): String = + Base64.encodeToString(this, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + + /** + * Removes query and fragment without touching scheme/authority/path encoding. + * `java.net.URI.path` would decode percent-encoded bytes (e.g. `%2F` → `/`), which would + * make the `htu` claim diverge from the wire URI and fail DPoP verification. + */ + private fun stripQueryAndFragment(uri: String): String = uri.substringBefore('#').substringBefore('?') + + private companion object { + const val ES256_ALG = "ES256" + const val DPOP_TYP = "dpop+jwt" + const val EC_KTY = "EC" + const val P256_CRV = "P-256" + const val SHA_256 = "SHA-256" + const val COORDINATE_SIZE = 32 + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt new file mode 100644 index 0000000000..eb2242eaa5 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt @@ -0,0 +1,10 @@ +package com.tangem.lib.auth.dpop.internal + +import arrow.core.None +import arrow.core.Option +import com.tangem.lib.auth.dpop.DpopProofFactory + +internal object DisabledDpopProofFactory : DpopProofFactory { + + override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option = None +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt new file mode 100644 index 0000000000..9db1b87cd1 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt @@ -0,0 +1,70 @@ +package com.tangem.lib.auth.http + +import com.tangem.datasource.api.auth.RequiresSessionAuth +import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import retrofit2.Invocation + +/** + * Adds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP headers to requests whose + * Retrofit method is marked with [RequiresSessionAuth]: + * - `Authorization: DPoP ` — present if [SessionTokensStore] holds an access token. + * - `DPoP: ` — freshly generated for every annotated request; `ath` claim is set if + * the access token is present. + * + * Methods **without** the annotation pass through unchanged — keeps public endpoints + * (e.g. `/auth/nonce/auth`, `/auth/authenticate`) free of unnecessary proof generation. + * + * On unrecoverable proof-generation failures (e.g. device key unavailable) the request is passed + * through unmodified — the upstream HTTP layer will surface the resulting 401/403 and the + * `SessionAuthenticator` (if installed) will attempt recovery. + */ +class DpopAuthorizationInterceptor( + private val store: SessionTokensStore, + private val proofFactory: DpopProofFactory, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + + if (!original.requiresSessionAuth()) return chain.proceed(original) + + val accessToken = runBlocking { store.get().getOrNull()?.accessToken } + if (accessToken == null) { + // Annotated endpoint reached without a session — let the upstream HTTP layer surface + // the resulting 401 so `SessionAuthenticator` can drive recovery. + TangemLogger.e("Skipping DPoP headers: no access token in store") + return chain.proceed(original) + } + + val proof = runBlocking { + proofFactory.create(original.method, original.url.toString(), accessToken) + }.getOrNull() + + if (proof == null) { + TangemLogger.e("DPoP proof generation failed; sending request without DPoP headers") + return chain.proceed(original) + } + + return chain.proceed( + original.newBuilder() + .header(HEADER_AUTHORIZATION, "$DPOP_SCHEME $accessToken") + .header(HEADER_DPOP, proof) + .build(), + ) + } + + private fun Request.requiresSessionAuth(): Boolean = + tag(Invocation::class.java)?.method()?.isAnnotationPresent(RequiresSessionAuth::class.java) == true + + private companion object { + const val HEADER_AUTHORIZATION = "Authorization" + const val HEADER_DPOP = "DPoP" + const val DPOP_SCHEME = "DPoP" + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt new file mode 100644 index 0000000000..1eb627ee59 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt @@ -0,0 +1,31 @@ +package com.tangem.lib.auth.session + +import kotlinx.datetime.Instant + +/** + * JWT session tokens issued by the Tangem Auth Service — pure domain model. + * + * Persisted on the device via [SessionTokensStore]. The default implementation serialises a + * storage DTO mirroring the wire format (`TokenApiResponse`) into AES-256-GCM-encrypted local + * storage (`SecureStorage` / `AndroidSecureStorageV2`) with the master key residing in + * AndroidKeystore. Survives app process death but not user data wipe / app uninstall. + * + * The domain class itself carries no serialization annotations: it can grow with business + * helpers (`isAccessTokenExpired`, computed properties, etc.) without touching the on-disk + * format. + * + * @property accessToken short-lived signed JWT (verified via JWKS at API Gateway). Sent as + * `Authorization: DPoP ` on every authenticated request. + * @property refreshToken opaque rotation token. `null` for ORANGE-tier sessions (require full + * re-authentication for every new access token — see SR-8 / token policy by trust tier). + * @property refreshTokenExpiresAt `null` if [refreshToken] is `null`. + * @property walletIds wallet ids bound to the device by the backend, mirrored from token claims + * to avoid parsing the JWT on the client. + */ +data class SessionTokens( + val accessToken: String, + val accessTokenExpiresAt: Instant, + val refreshToken: String?, + val refreshTokenExpiresAt: Instant?, + val walletIds: List, +) \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt new file mode 100644 index 0000000000..6aa8fea604 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt @@ -0,0 +1,20 @@ +package com.tangem.lib.auth.session + +import arrow.core.Option + +/** + * Persistent, hardware-backed storage of [SessionTokens]. + * + * Implementations are expected to survive app process death but not user data wipe / app uninstall. + */ +interface SessionTokensStore { + + /** Returns the currently stored tokens, or [arrow.core.None] if the device is not authenticated. */ + suspend fun get(): Option + + /** Atomically replaces the stored tokens. */ + suspend fun save(tokens: SessionTokens) + + /** Removes stored tokens. */ + suspend fun clear() +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt new file mode 100644 index 0000000000..cb2a7c8940 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt @@ -0,0 +1,62 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.None +import arrow.core.Option +import arrow.core.Some +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.tangem.common.services.secure.SecureStorage +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext + +/** + * Stores tokens in [SecureStorage] using the wire-format [TokenApiResponse] as the on-disk + * DTO — keeps the storage layout in lockstep with the Auth Service contract while + * isolating the [SessionTokens] domain model from serialization concerns. + * + * The underlying `AndroidSecureStorageV2` wraps `SharedPreferences` with an AES-256-GCM + * cipher whose key lives in AndroidKeystore, so token blobs are encrypted at rest and only + * decryptable on this device. + */ +internal class DefaultSessionTokensStore( + private val storage: SecureStorage, + private val moshi: Moshi, + private val dispatchers: CoroutineDispatcherProvider, +) : SessionTokensStore { + + private val adapter: JsonAdapter by lazy { + moshi.adapter(TokenApiResponse::class.java) + } + + override suspend fun get(): Option = withContext(dispatchers.io) { + val payload = storage.getAsString(KEY) ?: return@withContext None + try { + val dto = adapter.fromJson(payload) ?: return@withContext None + Some(SessionTokensConverter.convertBack(dto)) + } catch (e: Exception) { + TangemLogger.e("Failed to decode session tokens; clearing storage", e) + storage.delete(KEY) + None + } + } + + override suspend fun save(tokens: SessionTokens) { + withContext(dispatchers.io) { + storage.store(KEY, adapter.toJson(SessionTokensConverter.convert(tokens))) + } + } + + override suspend fun clear() { + withContext(dispatchers.io) { + storage.delete(KEY) + } + } + + private companion object { + const val KEY = "session_tokens" + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt new file mode 100644 index 0000000000..f5b83d9231 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt @@ -0,0 +1,19 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.None +import arrow.core.Option +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.lib.auth.session.SessionTokensStore + +/** + * No-op fallback used when the backend-authentication feature toggle is off + * or the encrypted storage failed to initialise. + */ +internal object DisabledSessionTokensStore : SessionTokensStore { + + override suspend fun get(): Option = None + + override suspend fun save(tokens: SessionTokens) = Unit + + override suspend fun clear() = Unit +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt new file mode 100644 index 0000000000..7dc85dd12d --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt @@ -0,0 +1,30 @@ +package com.tangem.lib.auth.session.internal + +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.utils.converter.TwoWayConverter +import kotlinx.datetime.Instant + +/** + * Maps between the [SessionTokens] domain model and the [TokenApiResponse] wire/storage DTO. + * `convert` produces the on-disk / on-wire shape; `convertBack` parses ISO-8601 timestamps + * into [kotlinx.datetime.Instant]. + */ +internal object SessionTokensConverter : TwoWayConverter { + + override fun convert(value: SessionTokens): TokenApiResponse = TokenApiResponse( + accessToken = value.accessToken, + accessTokenExpiresAt = value.accessTokenExpiresAt.toString(), + refreshToken = value.refreshToken, + refreshTokenExpiresAt = value.refreshTokenExpiresAt?.toString(), + walletIds = value.walletIds, + ) + + override fun convertBack(value: TokenApiResponse): SessionTokens = SessionTokens( + accessToken = value.accessToken, + accessTokenExpiresAt = Instant.parse(value.accessTokenExpiresAt), + refreshToken = value.refreshToken, + refreshTokenExpiresAt = value.refreshTokenExpiresAt?.let(Instant::parse), + walletIds = value.walletIds, + ) +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt new file mode 100644 index 0000000000..8dee6ba5f9 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt @@ -0,0 +1,170 @@ +package com.tangem.lib.auth.dpop.internal + +import arrow.core.None +import arrow.core.Some +import com.google.common.truth.Truth.assertThat +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.security.MessageDigest +import java.util.Base64 +import java.util.UUID + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultDpopProofFactoryTest { + + private val dispatchers = TestingCoroutineDispatcherProvider() + private val deviceKeyManager: DeviceKeyManager = mockk() + private val json = Json.Default + + // Fixed P-256 public key (uncompressed): 0x04 || X(32) || Y(32). Values are arbitrary but + // span both halves so any off-by-one slice mistake is caught. + private val devicePublicKey: ByteArray = byteArrayOf(0x04) + + ByteArray(COORDINATE_SIZE) { it.toByte() } + + ByteArray(COORDINATE_SIZE) { (it + COORDINATE_SIZE).toByte() } + + private val signatureBytes: ByteArray = ByteArray(SIGNATURE_SIZE) { (it + 1).toByte() } + + private val fixedInstant = Instant.fromEpochSeconds(1_700_000_000) + private val fixedJti = UUID.fromString("11111111-2222-3333-4444-555555555555") + + private lateinit var factory: DefaultDpopProofFactory + + @BeforeEach + fun setup() { + // android.util.Base64 → java.util.Base64 + mockkStatic(android.util.Base64::class) + every { android.util.Base64.encodeToString(any(), any()) } answers { + val bytes = firstArg() + val flags = secondArg() + val padded = flags and android.util.Base64.NO_PADDING == 0 + val encoder = if (flags and android.util.Base64.URL_SAFE != 0) { + if (padded) Base64.getUrlEncoder() else Base64.getUrlEncoder().withoutPadding() + } else { + Base64.getEncoder() + } + encoder.encodeToString(bytes) + } + + mockkStatic(UUID::class) + every { UUID.randomUUID() } returns fixedJti + + coEvery { deviceKeyManager.getPublicKey() } returns Some(devicePublicKey) + coEvery { deviceKeyManager.sign(any()) } returns signatureBytes + + factory = DefaultDpopProofFactory( + deviceKeyManager = deviceKeyManager, + json = json, + clock = object : Clock { override fun now(): Instant = fixedInstant }, + dispatchers = dispatchers, + ) + } + + @AfterEach + fun teardown() = unmockkAll() + + @Test + fun `create produces JWS with ath when access token is provided`() = runTest { + val token = "header.payload.signature" + val proof = factory.create("post", "https://example.com/api/v1/auth/refresh?ignored=1#frag", token) + .getOrNull()!! + + val parts = proof.split('.') + assertThat(parts).hasSize(3) + + val header = decodeJsonObject(parts[0]) + assertThat(header["alg"]?.jsonPrimitive?.contentOrNull).isEqualTo("ES256") + assertThat(header["typ"]?.jsonPrimitive?.contentOrNull).isEqualTo("dpop+jwt") + val jwk = header["jwk"]!!.jsonObject + assertThat(jwk["kty"]?.jsonPrimitive?.contentOrNull).isEqualTo("EC") + assertThat(jwk["crv"]?.jsonPrimitive?.contentOrNull).isEqualTo("P-256") + assertThat(jwk["x"]?.jsonPrimitive?.contentOrNull) + .isEqualTo(base64UrlNoPad(devicePublicKey.sliceArray(1..COORDINATE_SIZE))) + assertThat(jwk["y"]?.jsonPrimitive?.contentOrNull) + .isEqualTo(base64UrlNoPad(devicePublicKey.sliceArray(COORDINATE_SIZE + 1..2 * COORDINATE_SIZE))) + + val claims = decodeJsonObject(parts[1]) + assertThat(claims["jti"]?.jsonPrimitive?.contentOrNull).isEqualTo(fixedJti.toString()) + assertThat(claims["iat"]?.jsonPrimitive?.longOrNull).isEqualTo(fixedInstant.epochSeconds) + assertThat(claims["htm"]?.jsonPrimitive?.contentOrNull).isEqualTo("POST") + assertThat(claims["htu"]?.jsonPrimitive?.contentOrNull).isEqualTo("https://example.com/api/v1/auth/refresh") + assertThat(claims["ath"]?.jsonPrimitive?.contentOrNull) + .isEqualTo(base64UrlNoPad(sha256(token.toByteArray(Charsets.US_ASCII)))) + + assertThat(parts[2]).isEqualTo(base64UrlNoPad(signatureBytes)) + } + + @Test + fun `create omits ath when access token is null`() = runTest { + val proof = factory.create("POST", "https://example.com/refresh", null).getOrNull()!! + + val claims = decodeJsonObject(proof.split('.')[1]) + assertThat(claims.containsKey("ath")).isFalse() + assertThat(claims["htm"]?.jsonPrimitive?.contentOrNull).isEqualTo("POST") + } + + @Test + fun `htu preserves percent-encoded characters in path`() = runTest { + // DPoP verification is byte-sensitive: %2F must NOT be decoded to / in htu. + val proof = factory.create("GET", "https://api.example.com/wallet%2F123/sub?x=1", null).getOrNull()!! + + val claims = decodeJsonObject(proof.split('.')[1]) + assertThat(claims["htu"]?.jsonPrimitive?.contentOrNull) + .isEqualTo("https://api.example.com/wallet%2F123/sub") + } + + @Test + fun `create returns None when device key unavailable`() = runTest { + coEvery { deviceKeyManager.getPublicKey() } returns None + + val result = factory.create("POST", "https://example.com", null) + + assertThat(result).isEqualTo(None) + } + + @Test + fun `create signs the b64u-encoded header dot payload`() = runTest { + factory.create("GET", "https://example.com", null) + + // The signing input is `.` — verify it has a dot separator and + // a non-empty header section. + coVerify { + deviceKeyManager.sign(match { bytes -> + val text = String(bytes, Charsets.US_ASCII) + text.contains('.') && text.substringBefore('.').isNotEmpty() + }) + } + } + + private fun decodeJsonObject(b64: String): JsonObject = + json.parseToJsonElement(String(Base64.getUrlDecoder().decode(b64), Charsets.UTF_8)).jsonObject + + private fun base64UrlNoPad(bytes: ByteArray): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + + private fun sha256(bytes: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(bytes) + + private companion object { + const val COORDINATE_SIZE = 32 + const val SIGNATURE_SIZE = 64 + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt new file mode 100644 index 0000000000..d96da75296 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt @@ -0,0 +1,142 @@ +package com.tangem.lib.auth.http + +import arrow.core.None +import arrow.core.Some +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.auth.RequiresSessionAuth +import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.lib.auth.session.SessionTokensStore +import io.mockk.CapturingSlot +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.datetime.Instant +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import retrofit2.Invocation +import java.lang.reflect.Method + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DpopAuthorizationInterceptorTest { + + private val store: SessionTokensStore = mockk() + private val proofFactory: DpopProofFactory = mockk() + + private val interceptor = DpopAuthorizationInterceptor(store, proofFactory) + + @BeforeEach + fun setup() { + clearMocks(store, proofFactory) + } + + private val storedTokens = SessionTokens( + accessToken = "old-access", + accessTokenExpiresAt = Instant.fromEpochSeconds(1_700_000_000), + refreshToken = "rt", + refreshTokenExpiresAt = Instant.fromEpochSeconds(1_700_003_600), + walletIds = emptyList(), + ) + + @Test + fun `annotated request gets Authorization and DPoP headers`() { + coEvery { store.get() } returns Some(storedTokens) + coEvery { proofFactory.create(any(), any(), "old-access") } returns Some("proof-jwt") + + val proceeded = slot() + val chain = chain(request(annotated = true), proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("Authorization")).isEqualTo("DPoP old-access") + assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt") + } + + @Test + fun `annotated request without access token passes through unmodified`() { + coEvery { store.get() } returns None + + val proceeded = slot() + val chain = chain(request(annotated = true), proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("Authorization")).isNull() + assertThat(proceeded.captured.header("DPoP")).isNull() + coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) } + } + + @Test + fun `unannotated request passes through unchanged — proof factory never invoked`() { + val original = request(annotated = false) + val proceeded = slot() + val chain = chain(original, proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("Authorization")).isNull() + assertThat(proceeded.captured.header("DPoP")).isNull() + coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) } + } + + @Test + fun `request without Invocation tag (not via Retrofit) is treated as unannotated`() { + val original = Request.Builder().url("https://example.com/api/v1/foo").build() + val proceeded = slot() + val chain = chain(original, proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("DPoP")).isNull() + coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) } + } + + @Test + fun `proof generation failure on annotated request passes through without headers`() { + coEvery { store.get() } returns Some(storedTokens) + coEvery { proofFactory.create(any(), any(), any()) } returns None + + val proceeded = slot() + val chain = chain(request(annotated = true), proceeded) + + interceptor.intercept(chain) + + assertThat(proceeded.captured.header("Authorization")).isNull() + assertThat(proceeded.captured.header("DPoP")).isNull() + } + + private fun request(annotated: Boolean): Request { + val builder = Request.Builder().url("https://example.com/api/v1/foo") + builder.tag(Invocation::class.java, invocationWithAnnotation(annotated)) + return builder.build() + } + + private fun invocationWithAnnotation(annotated: Boolean): Invocation { + val method = mockk() + every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns annotated + val invocation = mockk() + every { invocation.method() } returns method + return invocation + } + + private fun chain(request: Request, captureSlot: CapturingSlot): Interceptor.Chain { + val response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("ok") + .build() + val chain = mockk() + every { chain.request() } returns request + every { chain.proceed(capture(captureSlot)) } returns response + return chain + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt new file mode 100644 index 0000000000..e603fa8d27 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt @@ -0,0 +1,97 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.None +import arrow.core.Some +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.common.services.secure.SecureStorage +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultSessionTokensStoreTest { + + private val dispatchers = TestingCoroutineDispatcherProvider() + private val moshi = Moshi.Builder().build() + private val adapter = moshi.adapter(TokenApiResponse::class.java) + + private val sampleDto = TokenApiResponse( + accessToken = "acc", + accessTokenExpiresAt = "2023-11-14T22:13:20Z", + refreshToken = "rt", + refreshTokenExpiresAt = "2023-11-14T23:13:20Z", + walletIds = listOf("w1", "w2"), + ) + + private val sampleDomain = SessionTokens( + accessToken = "acc", + accessTokenExpiresAt = Instant.parse("2023-11-14T22:13:20Z"), + refreshToken = "rt", + refreshTokenExpiresAt = Instant.parse("2023-11-14T23:13:20Z"), + walletIds = listOf("w1", "w2"), + ) + + @Test + fun `get returns None when nothing stored`() = runTest { + val storage = mockk(relaxed = true) + every { storage.getAsString("session_tokens") } returns null + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + + assertThat(store.get()).isEqualTo(None) + } + + @Test + fun `save round-trips through TokenApiResponse adapter`() = runTest { + val storage = mockk(relaxed = true) + val captured = slot() + every { storage.store(eq("session_tokens"), capture(captured)) } returns Unit + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + store.save(sampleDomain) + + verify { storage.store("session_tokens", any()) } + val decoded = adapter.fromJson(captured.captured) + assertThat(decoded).isEqualTo(sampleDto) + } + + @Test + fun `get decodes TokenApiResponse and maps to domain`() = runTest { + val storage = mockk(relaxed = true) + every { storage.getAsString("session_tokens") } returns adapter.toJson(sampleDto) + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + + assertThat(store.get()).isEqualTo(Some(sampleDomain)) + } + + @Test + fun `get returns None and clears corrupted entry`() = runTest { + val storage = mockk(relaxed = true) + every { storage.getAsString("session_tokens") } returns "{not json" + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + + assertThat(store.get()).isEqualTo(None) + verify { storage.delete("session_tokens") } + } + + @Test + fun `clear removes the entry`() = runTest { + val storage = mockk(relaxed = true) + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + store.clear() + + verify { storage.delete("session_tokens") } + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt new file mode 100644 index 0000000000..a7adc7b1dd --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt @@ -0,0 +1,62 @@ +package com.tangem.lib.auth.session.internal + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.lib.auth.session.SessionTokens +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SessionTokensConverterTest { + + private val domain = SessionTokens( + accessToken = "acc", + accessTokenExpiresAt = Instant.parse("2023-11-14T22:13:20Z"), + refreshToken = "rt", + refreshTokenExpiresAt = Instant.parse("2023-11-14T23:13:20Z"), + walletIds = listOf("w1", "w2"), + ) + + private val dto = TokenApiResponse( + accessToken = "acc", + accessTokenExpiresAt = "2023-11-14T22:13:20Z", + refreshToken = "rt", + refreshTokenExpiresAt = "2023-11-14T23:13:20Z", + walletIds = listOf("w1", "w2"), + ) + + @Test + fun `convert maps domain to DTO with ISO-8601 timestamps`() { + assertThat(SessionTokensConverter.convert(domain)).isEqualTo(dto) + } + + @Test + fun `convertBack maps DTO to domain with parsed Instant timestamps`() { + assertThat(SessionTokensConverter.convertBack(dto)).isEqualTo(domain) + } + + @Test + fun `convert round-trip preserves domain value`() { + val roundTripped = SessionTokensConverter.convertBack(SessionTokensConverter.convert(domain)) + assertThat(roundTripped).isEqualTo(domain) + } + + @Test + fun `null refresh token survives both directions`() { + val orangeTier = domain.copy(refreshToken = null, refreshTokenExpiresAt = null) + val orangeDto = dto.copy(refreshToken = null, refreshTokenExpiresAt = null) + + assertThat(SessionTokensConverter.convert(orangeTier)).isEqualTo(orangeDto) + assertThat(SessionTokensConverter.convertBack(orangeDto)).isEqualTo(orangeTier) + } + + @Test + fun `empty walletIds list survives both directions`() { + val noWallets = domain.copy(walletIds = emptyList()) + val noWalletsDto = dto.copy(walletIds = emptyList()) + + assertThat(SessionTokensConverter.convert(noWallets)).isEqualTo(noWalletsDto) + assertThat(SessionTokensConverter.convertBack(noWalletsDto)).isEqualTo(noWallets) + } +} \ No newline at end of file From 9c1d2f2cf29c8c866441bae524bb906813aa8a58 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 31 May 2026 10:12:47 +0200 Subject: [PATCH 09/10] Updated on 2026-08-14 --- .../feed/components/DefaultFeedEntryComponent.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index fabfb0afa0..0ea374a12d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -190,11 +190,18 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( bottomSheetState = bottomSheetState, stackState = stackStack, onHeaderSizeChange = onHeaderSizeChange, - onExpandSheet = onExpandSheet, + onExpandSheet = { onCollapsedSheetClick(onExpandSheet) }, isOpenedInBottomSheet = true, ) } + private fun onCollapsedSheetClick(onExpandSheet: () -> Unit) { + if (stack.value.active.configuration is FeedEntryChildFactory.Child.Feed) { + clickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value) + } + onExpandSheet() + } + @Composable override fun Content(modifier: Modifier) { val bottomSheetState = remember { From a1a82d3ece8b41d24b03050da87f0194b36af96f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Jun 2026 07:59:48 +0200 Subject: [PATCH 10/10] Updated on 2026-08-14 --- .../src/main/res/drawable/ic_replace_20.xml | 27 ++ .../ui/src/main/res/drawable/ic_visa_logo.xml | 29 ++ .../components/TangemPayDetailsComponent.kt | 23 +- .../tangempay/entity/TangemPayCardPageUM.kt | 2 + .../entity/TangemPayDetailsStateFactory.kt | 31 ++ .../entity/TangemPayDetailsTopBarConfig.kt | 1 + .../tangempay/entity/TangemPayDetailsUM.kt | 2 +- .../entity/TangemPayDropDownItemUM.kt | 10 + .../tangempay/model/TangemPayCardPageModel.kt | 20 +- .../transformers/DetailsBalanceTransformer.kt | 14 +- .../tangempay/ui/TangemPayCardPageScreen.kt | 56 ++- .../TangemPayCardPageSettingsButtonsBlock.kt | 45 +- .../tangempay/ui/TangemPayDetailsScreen.kt | 34 +- .../tangempay/ui/TangemPayDetailsScreenV2.kt | 404 ++++++++++++++++++ .../ui/components/PayContextMenuBlock.kt | 57 +++ .../ui/components/TangemPayActionButton.kt | 66 +++ .../ui/components/TangemPayCardView.kt | 215 ++++++++++ .../img_bg_pay_details.webp | Bin 0 -> 2136 bytes .../img_bg_pay_details.webp | Bin 0 -> 3052 bytes .../img_bg_pay_details.webp | Bin 0 -> 5644 bytes .../img_bg_pay_details.webp | Bin 0 -> 10094 bytes .../img_bg_pay_details.webp | Bin 0 -> 2678 bytes .../img_bg_pay_details.webp | Bin 0 -> 3956 bytes .../img_bg_pay_details.webp | Bin 0 -> 7808 bytes .../img_bg_pay_details.webp | Bin 0 -> 12528 bytes 25 files changed, 976 insertions(+), 60 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_replace_20.xml create mode 100644 core/ui/src/main/res/drawable/ic_visa_logo.xml create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayActionButton.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayCardView.kt create mode 100644 features/tangempay/details/impl/src/main/res/drawable-night-hdpi/img_bg_pay_details.webp create mode 100644 features/tangempay/details/impl/src/main/res/drawable-night-xhdpi/img_bg_pay_details.webp create mode 100644 features/tangempay/details/impl/src/main/res/drawable-night-xxhdpi/img_bg_pay_details.webp create mode 100644 features/tangempay/details/impl/src/main/res/drawable-night-xxxhdpi/img_bg_pay_details.webp create mode 100644 features/tangempay/details/impl/src/main/res/drawable-notnight-hdpi/img_bg_pay_details.webp create mode 100644 features/tangempay/details/impl/src/main/res/drawable-notnight-xhdpi/img_bg_pay_details.webp create mode 100644 features/tangempay/details/impl/src/main/res/drawable-notnight-xxhdpi/img_bg_pay_details.webp create mode 100644 features/tangempay/details/impl/src/main/res/drawable-notnight-xxxhdpi/img_bg_pay_details.webp diff --git a/core/ui/src/main/res/drawable/ic_replace_20.xml b/core/ui/src/main/res/drawable/ic_replace_20.xml new file mode 100644 index 0000000000..3fdf7b9e3d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_replace_20.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_visa_logo.xml b/core/ui/src/main/res/drawable/ic_visa_logo.xml new file mode 100644 index 0000000000..a03fb04772 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_visa_logo.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index a669eab012..abff694265 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -16,11 +16,13 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.NavigationBar3ButtonsScrim import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.model.TangemPayDetailsModel import com.tangem.features.tangempay.ui.TangemPayDetailsScreen +import com.tangem.features.tangempay.ui.TangemPayDetailsScreenV2 import com.tangem.features.tangempay.utils.requireLoaded import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokendetails.ExpressTransactionsComponent @@ -72,12 +74,21 @@ internal class TangemPayDetailsComponent( val bottomSheet by bottomSheetSlot.subscribeAsState() NavigationBar3ButtonsScrim() - TangemPayDetailsScreen( - state = state, - txHistoryComponent = txHistoryComponent, - expressTransactionsComponent = expressTransactionsComponent, - modifier = modifier, - ) + if (LocalRedesignEnabled.current) { + TangemPayDetailsScreenV2( + state = state, + txHistoryComponent = txHistoryComponent, + expressTransactionsComponent = expressTransactionsComponent, + modifier = modifier, + ) + } else { + TangemPayDetailsScreen( + state = state, + txHistoryComponent = txHistoryComponent, + expressTransactionsComponent = expressTransactionsComponent, + modifier = modifier, + ) + } bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt index b4d70fd998..cb24f4b42d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt @@ -17,6 +17,7 @@ internal data class TangemPayCardPageUM( val dailyLimitState: TangemPayDailyLimitBlockState, val addToWalletBlockState: AddToWalletBlockState? = null, val isReissueInProgress: Boolean = false, + val menuItems: ImmutableList, ) { companion object { fun stub( @@ -40,6 +41,7 @@ internal data class TangemPayCardPageUM( onBackClick = {}, isReissueInProgress = isReissueInProgress, dailyLimitState = dailyLimitState, + menuItems = persistentListOf(), ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index efabb31b08..8de4b0c86d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -4,9 +4,12 @@ import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_document_20 import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.utils.TangemPayDetailIntents @@ -31,6 +34,7 @@ internal class TangemPayDetailsStateFactory( onBackClick = onBack, onOpenMenu = onOpenMenu, items = getTopBarMenuItems(isTangemPayDeactivated), + itemsV2 = getTopBarMenuItemsV2(isTangemPayDeactivated), ), pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, @@ -89,4 +93,31 @@ internal class TangemPayDetailsStateFactory( ), ) } + + private fun getTopBarMenuItemsV2(isTangemPayDeactivated: Boolean): ImmutableList { + if (isTangemPayDeactivated) return persistentListOf() + + return persistentListOf( + TangemPayDropDownItemUM( + title = resourceReference(R.string.tangem_pay_terms_limits), + onClick = intents::onClickTermsAndLimits, + icon = TangemIconUM.Icon( + imageVector = Icons.ic_document_20, + tintReference = { + TangemTheme.colors3.icon.primary + }, + ), + ), + TangemPayDropDownItemUM( + title = resourceReference(R.string.tangempay_pay_support), + onClick = intents::onContactSupportClicked, + icon = TangemIconUM.Icon( + imageVector = Icons.ic_document_20, + tintReference = { + TangemTheme.colors3.icon.primary + }, + ), // TODO change when the icon will be ready in design + ), + ) + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt index 9675f1b31d..f25f792ca5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -7,4 +7,5 @@ internal data class TangemPayDetailsTopBarConfig( val onBackClick: () -> Unit, val onOpenMenu: () -> Unit, val items: ImmutableList, + val itemsV2: ImmutableList, ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index e51533434b..706d875d26 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -77,7 +77,7 @@ internal sealed class TangemPayDetailsBalanceBlockState { data class Content( override val actionButtons: ImmutableList, override val cardsBlockState: CardsBlockState?, - val fiatBalance: String, + val fiatBalance: TextReference, val isBalanceFlickering: Boolean, ) : TangemPayDetailsBalanceBlockState() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt new file mode 100644 index 0000000000..71d30e3f2e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference + +internal data class TangemPayDropDownItemUM( + val onClick: () -> Unit, + val title: TextReference, + val icon: TangemIconUM, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 0694b3694b..9db19e4817 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.format.bigdecimal.fiat @@ -20,6 +21,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.format.bigdecimal.optionalDecimals import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig @@ -53,7 +55,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject import com.tangem.core.ui.R as CoreUiR -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class TangemPayCardPageModel @Inject constructor( @@ -89,6 +91,7 @@ internal class TangemPayCardPageModel @Inject constructor( dailyLimitState = TangemPayDailyLimitBlockState.Loading, settings = persistentListOf(), settingsV2 = persistentListOf(), + menuItems = buildMenuItems(), ), ) @@ -210,6 +213,21 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + private fun buildMenuItems(): ImmutableList { + return persistentListOf( + TangemPayDropDownItemUM( + title = TextReference.Res(R.string.tangempay_card_details_reissue_card), + onClick = ::onClickReissueCard, + icon = TangemIconUM.Icon( + iconRes = CoreUiR.drawable.ic_replace_20, + tintReference = { + TangemTheme.colors3.icon.primary + }, + ), + ), + ) + } + private fun onClickViewDetails() { modelScope.launch(dispatchers.default) { cardDetailsEventListener.send(CardDetailsEvent.Show) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index d2a9482577..eca90fdbb4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -2,8 +2,10 @@ package com.tangem.features.tangempay.model.transformers import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState import com.tangem.features.tangempay.entity.TangemPayDetailsUM @@ -35,10 +37,14 @@ internal class DetailsBalanceTransformer( return prevState.copy(balanceBlockState = balance) } - private fun getFiatBalanceText(balance: TangemPayCardBalance): String { + private fun getFiatBalanceText(balance: TangemPayCardBalance): TextReference { val currency = Currency.getInstance(balance.currencyCode) - return balance.fiatBalance.format { - fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol) + return balance.fiatBalance.formatStyled { + fiat( + fiatCurrencyCode = currency.currencyCode, + fiatCurrencySymbol = currency.symbol, + spanStyleReference = { TangemTheme.typography3.heading.medium.toSpanStyle() }, + ) } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 6b2374624b..6793c51dd5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -15,9 +15,8 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity @@ -25,6 +24,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalRedesignEnabled @@ -36,7 +38,9 @@ import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCard import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.* +import com.tangem.features.tangempay.ui.components.PayContextMenuBlock import kotlinx.collections.immutable.ImmutableList +import com.tangem.core.ui.R as CoreUiR private const val CONTENT_FADE_DURATION_MS = 300 @@ -51,8 +55,8 @@ internal fun TangemPayCardPageScreen( Scaffold( modifier = modifier, topBar = { - AppBarWithBackButton( - modifier = Modifier.statusBarsPadding(), + CardPageTopBar( + items = state.menuItems, onBackClick = state.onBackClick, ) }, @@ -170,6 +174,48 @@ private fun TangemPayCardPageSettingRow( } } +@Composable +private fun CardPageTopBar( + onBackClick: () -> Unit, + items: ImmutableList, + modifier: Modifier = Modifier, +) { + if (LocalRedesignEnabled.current) { + var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) } + TangemTopBar( + modifier = modifier.statusBarsPadding(), + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_back_28), + onClick = onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + endContent = { + Box { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_more_default_24), + onClick = { isDropdownMenuShown = true }, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + PayContextMenuBlock( + items = items, + onMenuDismiss = { isDropdownMenuShown = false }, + isDropdownMenuShown = isDropdownMenuShown, + ) + } + }, + ) + } else { + AppBarWithBackButton( + modifier = modifier, + onBackClick = onBackClick, + ) + } +} + private fun LazyListScope.cardPageItem( key: Any? = null, contentType: Any? = null, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageSettingsButtonsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageSettingsButtonsBlock.kt index ce36ac6ad5..ca0e75ed12 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageSettingsButtonsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageSettingsButtonsBlock.kt @@ -2,20 +2,20 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach -import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.ds2.button.TangemButton -import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.tangempay.entity.TangemPayCardPageSettingV2 +import com.tangem.features.tangempay.ui.components.TangemPayActionButton import kotlinx.collections.immutable.ImmutableList @Composable @@ -30,41 +30,18 @@ internal fun TangemPayCardPageSettingsButtonsBlock( horizontalArrangement = Arrangement.Center, ) { settings.fastForEach { setting -> - TangemPaySettingButton( - setting = setting, + TangemPayActionButton( modifier = Modifier.then(if (setting.testTag != null) Modifier.testTag(setting.testTag) else Modifier), + title = setting.title, + iconRes = setting.iconRes, + onClick = setting.onClick, + isEnabled = setting.isEnabled, + isLoading = setting.isLoading, ) } } } -@Composable -private fun TangemPaySettingButton(setting: TangemPayCardPageSettingV2, modifier: Modifier = Modifier) { - Column( - modifier = modifier.padding(horizontal = TangemTheme.dimens2.x6), - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - TangemButton( - variant = TangemButton.Variant.Material, - size = TangemButton.Size.X14, - onClick = setting.onClick, - iconStart = TangemIconUM.Icon( - iconRes = setting.iconRes, - tintReference = { TangemTheme.colors3.icon.primary }, - ), - isLoading = setting.isLoading, - isEnabled = setting.isEnabled, - ) - - Text( - text = setting.title.resolveAnnotatedReference(), - style = TangemTheme.typography3.subheading.medium, - color = TangemTheme.colors3.text.primary, - ) - } -} - @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 55d6159978..3371d0dcdb 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -45,9 +46,7 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.extensions.orMaskWithStars -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TangemPayTestTags @@ -343,7 +342,7 @@ private fun FiatBalance( ) is TangemPayDetailsBalanceBlockState.Content -> Text( modifier = modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), - text = state.fiatBalance.orMaskWithStars(isBalanceHidden), + text = state.fiatBalance.orMaskWithStars(isBalanceHidden).resolveReference(), style = TangemTheme.typography.h2.applyBladeBrush( isEnabled = state.isBalanceFlickering, textColor = TangemTheme.colors.text.primary1, @@ -432,10 +431,15 @@ private fun TangemPayDetailsScreenPreview( } } -private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( +internal class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( collection = listOf( TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = persistentListOf()), + topBarConfig = TangemPayDetailsTopBarConfig( + onBackClick = {}, + onOpenMenu = {}, + items = persistentListOf(), + itemsV2 = persistentListOf(), + ), pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), balanceBlockState = TangemPayDetailsBalanceBlockState.Content( actionButtons = persistentListOf( @@ -445,7 +449,14 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( +internal class TangemPayDetailsTxHistoryProvider : CollectionPreviewParameterProvider( collection = listOf( PreviewTangemPayTxHistoryComponent.loadingUM, PreviewTangemPayTxHistoryComponent.contentUM, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt new file mode 100644 index 0000000000..8be30b01d3 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt @@ -0,0 +1,404 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.components.topFade +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.message.TangemMessage +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.shimmers.TextShimmer +import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.TangemPayTestTags +import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTransactionsComponent +import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent +import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM +import com.tangem.features.tangempay.ui.components.PayContextMenuBlock +import com.tangem.features.tangempay.ui.components.TangemPayActionButton +import com.tangem.features.tangempay.ui.components.TangemPayAddCardView +import com.tangem.features.tangempay.ui.components.TangemPayCardView +import com.tangem.features.tokendetails.ExpressTransactionsComponent +import com.tangem.utils.StringsSigns.DASH_SIGN +import kotlinx.collections.immutable.ImmutableList +import com.tangem.core.ui.R as CoreUiR + +private val InitialTopBarHeight: Dp = 64.dp +private val BgImageFadeDistance: Dp = 96.dp +private const val TOP_FADE_MID_STOP = 0.8f +private const val TOP_FADE_MID_ALPHA = 0.8f + +@Suppress("LongMethod") +@Composable +internal fun TangemPayDetailsScreenV2( + state: TangemPayDetailsUM, + txHistoryComponent: TangemPayTxHistoryComponent, + expressTransactionsComponent: ExpressTransactionsComponent, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val density = LocalDensity.current + val statusBarHeight = with(density) { WindowInsets.systemBars.getTop(this).toDp() } + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + var topBarTotalHeight by remember { mutableStateOf(InitialTopBarHeight + statusBarHeight) } + val rootBackground = TangemTheme.colors3.bg.primary + val fadeDistancePx = with(LocalDensity.current) { BgImageFadeDistance.toPx() } + val bgImageAlpha by remember(fadeDistancePx) { + derivedStateOf { + val scrollOffsetPx = when (listState.firstVisibleItemIndex) { + 0 -> listState.firstVisibleItemScrollOffset.toFloat() + else -> fadeDistancePx + }.coerceAtLeast(0f) + (1f - scrollOffsetPx / fadeDistancePx).coerceIn(0f, 1f) + } + } + + val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle() + val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() + val expressTransactionsBottomSheetState = expressState.bottomSheetSlot + + Box( + modifier = modifier + .fillMaxSize() + .background(rootBackground), + ) { + Image( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(R.drawable.img_bg_pay_details), + contentDescription = null, + contentScale = ContentScale.FillWidth, + alpha = bgImageAlpha, + ) + + TangemPullToRefreshSlidingContainer( + config = state.pullToRefreshConfig, + indicatorOffset = topBarTotalHeight, + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .topFade( + height = topBarTotalHeight, + 0f to rootBackground.copy(alpha = 1f - bgImageAlpha), + TOP_FADE_MID_STOP to rootBackground.copy(alpha = TOP_FADE_MID_ALPHA * (1f - bgImageAlpha)), + 1f to Color.Transparent, + ), + horizontalAlignment = Alignment.CenterHorizontally, + state = listState, + contentPadding = PaddingValues( + top = topBarTotalHeight, + bottom = TangemTheme.dimens2.x4 + bottomBarHeight, + ), + ) { + payDetailsBody(state) + with(expressTransactionsComponent) { + expressTransactionsContent( + state = expressState.transactionsToDisplay, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp) + .fillMaxWidth(), + ) + } + with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) } + } + } + + PayDetailsTopBar( + config = state.topBarConfig, + onHeightChange = { measuredHeight -> + if (topBarTotalHeight != measuredHeight) topBarTotalHeight = measuredHeight + }, + ) + } + expressTransactionsBottomSheetState?.content(null) +} + +private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) { + item("balanceBlock") { + BalanceBlock( + state = state.balanceBlockState, + isBalanceHidden = state.isBalanceHidden, + ) + } + state.balanceBlockState.cardsBlockState?.let { cardsState -> + item("cardsBlock") { + CardsBlock(cardsBlockState = cardsState) + } + } + if (state.balanceBlockState.actionButtons.isNotEmpty()) { + item("actionButtonsBlock") { + ActionBlock(actionButtons = state.balanceBlockState.actionButtons) + } + } + when { + state.balanceBlockState.cardsBlockState?.cards?.fastAny { it.isReissuing } == true -> { + item("reissuingBannerBlock") { + TangemMessage( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), + title = resourceReference(R.string.tangempay_reissue_card_in_progress), + subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description), + ) + } + } + else -> { + if (state.addToWalletBlockState != null) { + item("addToWalletBannerBlock") { + TangemPayAddToWalletBlock( + state = state.addToWalletBlockState, + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), + ) + } + } + if (state.accountDeactivatedNotificationConfig != null) { + item("deactivationBannerBlock") { + TangemMessage( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens2.x4) + .clickableSingle( + onClick = { state.accountDeactivatedNotificationConfig.onClick?.invoke() }, + ), + title = state.accountDeactivatedNotificationConfig.title, + subtitle = state.accountDeactivatedNotificationConfig.subtitle, + messageEffect = TangemMessageEffect.Warning, + ) + } + } + } + } +} + +@Composable +private fun PayDetailsTopBar( + config: TangemPayDetailsTopBarConfig, + onHeightChange: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + TangemTopBar( + modifier = modifier + .onSizeChanged { size -> + onHeightChange(with(density) { size.height.toDp() }) + } + .statusBarsPadding(), + title = resourceReference(R.string.tangempay_payment_account), + subtitle = resourceReference(R.string.tangempay_usdc_on_polygon_network), + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_arrow_back_28), + onClick = config.onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + endContent = if (config.itemsV2.isNotEmpty()) { + { + var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) } + Box { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_more_default_24), + onClick = { + config.onOpenMenu() + isDropdownMenuShown = true + }, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + PayContextMenuBlock( + items = config.itemsV2, + onMenuDismiss = { isDropdownMenuShown = false }, + isDropdownMenuShown = isDropdownMenuShown, + ) + } + } + } else { + null + }, + ) +} + +@Composable +private fun BalanceBlock( + state: TangemPayDetailsBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(top = TangemTheme.dimens2.x12), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContent( + targetState = state, + label = "Updating the balance", + transitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 90)) + }, + ) { animatedState -> + when (animatedState) { + is TangemPayDetailsBalanceBlockState.Loading -> TextShimmer( + modifier = Modifier.size(width = 160.dp, height = 56.dp), + text = "1234.00", + style = TextShimmerStyle.HEADING_MEDIUM, + radius = TangemTheme.dimens2.x25, + ) + is TangemPayDetailsBalanceBlockState.Content -> Text( + modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), + text = animatedState.fiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + style = TangemTheme.typography3.display.medium.applyBladeBrush( + isEnabled = animatedState.isBalanceFlickering, + textColor = TangemTheme.colors3.text.primary, + ), + color = TangemTheme.colors3.text.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography3.heading.medium.fontSize, + maxFontSize = TangemTheme.typography3.display.medium.fontSize, + ), + ) + is TangemPayDetailsBalanceBlockState.Error -> Text( + modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), + text = DASH_SIGN.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography3.display.medium, + color = TangemTheme.colors3.text.primary, + ) + } + } + } +} + +@Composable +private fun CardsBlock( + cardsBlockState: TangemPayDetailsBalanceBlockState.CardsBlockState, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier = modifier.fillMaxWidth(), + state = rememberLazyListState(), + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens2.x4, + vertical = TangemTheme.dimens2.x6, + ), + horizontalArrangement = Arrangement.Center, + ) { + items(items = cardsBlockState.cards) { item -> + TangemPayCardView( + isReissuing = item.isReissuing, + lastDigits = item.lastDigits, + onClick = item.onClick, + ) + SpacerW(TangemTheme.dimens2.x2) + } + item { + TangemPayAddCardView(onClick = cardsBlockState.onAddCardClick) + } + } +} + +@Composable +private fun LazyItemScope.ActionBlock( + actionButtons: ImmutableList, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x6), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + actionButtons.fastForEach { actionConfig -> + TangemPayActionButton( + iconRes = actionConfig.iconResId, + onClick = actionConfig.onClick, + isEnabled = actionConfig.isEnabled, + isLoading = actionConfig.isInProgress, + title = actionConfig.text, + ) + } + } +} + +// region preview + +@Preview(device = Devices.PIXEL_7_PRO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) +@Composable +private fun TangemPayDetailsScreenPreview( + @PreviewParameter(TangemPayDetailsUMProvider::class) state: TangemPayDetailsUM, +) { + TangemThemePreviewRedesign { + TangemPayDetailsScreenV2( + state = state, + txHistoryComponent = PreviewTangemPayTxHistoryComponent( + txHistoryUM = PreviewTangemPayTxHistoryComponent.contentUM, + ), + expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), + ) + } +} + +@Preview(device = Devices.PIXEL_7_PRO) +@Composable +private fun TangemPayDetailsTxHistoryScreenPreview( + @PreviewParameter(TangemPayDetailsTxHistoryProvider::class) state: TangemPayTxHistoryUM, +) { + TangemThemePreviewRedesign { + TangemPayDetailsScreenV2( + state = TangemPayDetailsUMProvider().values.first(), + txHistoryComponent = PreviewTangemPayTxHistoryComponent(txHistoryUM = state), + expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), + ) + } +} + +// end region preview \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt new file mode 100644 index 0000000000..88dd7ca342 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt @@ -0,0 +1,57 @@ +package com.tangem.features.tangempay.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.ds.contextmenu.TangemContextMenu +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.entity.TangemPayDropDownItemUM +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun PayContextMenuBlock( + items: ImmutableList, + isDropdownMenuShown: Boolean, + onMenuDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + TangemContextMenu( + expanded = isDropdownMenuShown, + onDismissRequest = onMenuDismiss, + offset = DpOffset.Zero, + modifier = modifier, + ) { + items.fastForEach { item -> + Column { + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + modifier = Modifier + .clickableSingle( + onClick = { + item.onClick() + onMenuDismiss() + }, + ) + .padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4), + ) { + TangemIcon( + modifier = Modifier.size(TangemTheme.dimens2.x5), + tangemIconUM = item.icon, + ) + Text( + text = item.title.resolveReference(), + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + maxLines = 1, + ) + } + } + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayActionButton.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayActionButton.kt new file mode 100644 index 0000000000..924d154d90 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayActionButton.kt @@ -0,0 +1,66 @@ +package com.tangem.features.tangempay.ui.components + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TangemPayActionButton( + title: TextReference, + @DrawableRes iconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + isLoading: Boolean = false, +) { + Column( + modifier = modifier.padding(horizontal = TangemTheme.dimens2.x4), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TangemButton( + variant = TangemButton.Variant.Material, + size = TangemButton.Size.X14, + onClick = onClick, + iconStart = TangemIconUM.Icon( + iconRes = iconRes, + tintReference = { TangemTheme.colors3.icon.primary }, + ), + isLoading = isLoading, + isEnabled = isEnabled, + ) + + Text( + text = title.resolveAnnotatedReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayActionButtonPreview() { + TangemThemePreviewRedesign { + TangemPayActionButton( + title = stringReference("Action"), + iconRes = R.drawable.ic_arrow_down_24, + onClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayCardView.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayCardView.kt new file mode 100644 index 0000000000..0663d4bd6d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPayCardView.kt @@ -0,0 +1,215 @@ +package com.tangem.features.tangempay.ui.components + +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.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_clock_12 +import com.tangem.core.ui.res.generated.icons.ic_cloud_12 +import com.tangem.core.ui.test.TangemPayTestTags + +private const val DEFAULT_CARD_BG = 0xFF1C1F29 +private const val REISSUING_CARD_BG = 0xFF1E1E1E + +@Composable +internal fun TangemPayCardView( + isReissuing: Boolean, + lastDigits: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + CardBackground( + modifier = modifier + .size( + height = TangemTheme.dimens2.x10, + width = TangemTheme.dimens2.x14, + ) + .testTag(TangemPayTestTags.PAYMENT_ACCOUNT_CARD_BUTTON), + isReissuing = isReissuing, + onClick = onClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x1) + .padding(top = TangemTheme.dimens2.x1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x3), + imageVector = if (isReissuing) { + Icons.ic_clock_12 + } else { + Icons.ic_cloud_12 + }, + tint = TangemTheme.colors3.icon.staticDark, + contentDescription = null, + ) + + Icon( + modifier = Modifier.size(height = TangemTheme.dimens2.x2, width = 22.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_visa_logo), + tint = TangemTheme.colors3.icon.staticDark, + contentDescription = null, + ) + } + + Text( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = TangemTheme.dimens2.x1, bottom = TangemTheme.dimens2.x1), + text = lastDigits, + style = TangemTheme.typography3.caption.medium.copy(fontSize = 10.sp), + color = TangemTheme.colors3.text.staticDark.primary, + ) + } +} + +@Composable +internal fun TangemPayAddCardView(onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size( + height = TangemTheme.dimens2.x10, + width = TangemTheme.dimens2.x14, + ) + .clip(RoundedCornerShape(TangemTheme.dimens3.borderRadius.b075)) + .background(TangemTheme.colors3.bg.opaque.primary) + .clickableSingle(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_plus_default_24), + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun CardBackground( + isReissuing: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + val bgColor = remember(isReissuing) { + if (isReissuing) { + Color(REISSUING_CARD_BG) + } else { + Color(DEFAULT_CARD_BG) + } + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(TangemTheme.dimens3.borderRadius.b075)) + .drawBehind { + drawRect(bgColor) + + val w = size.width + val h = size.height + val radiusScaleRightCorner = h / 2f + val radiusScaleLeftCorner = h / 1.27f + + drawRect( + brush = Brush.radialGradient( + colors = listOf( + Color( + if (isReissuing) 0xFFB0B4BC else 0xFF38587F, + ).copy( + if (isReissuing) .1f else .41f, + ), + Color.Transparent, + ), + center = Offset(w - 20f, h * .05f), + radius = radiusScaleRightCorner, + tileMode = TileMode.Clamp, + ), + ) + + if (!isReissuing) { + drawRect( + brush = Brush.radialGradient( + colors = listOf( + Color(0xFF2881FF).copy(.25f), + Color.Transparent, + ), + center = Offset(0f, h + h * .1f), + radius = radiusScaleLeftCorner, + ), + ) + } + } + .border( + width = 1.dp, + color = TangemTheme.colors3.border.primary, + shape = RoundedCornerShape(TangemTheme.dimens3.borderRadius.b075), + ) + .clickableSingle(onClick = onClick), + content = content, + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) +@Composable +private fun CardBackgroundPreview() { + TangemThemePreviewRedesign { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center) { + CardBackground( + modifier = Modifier.size( + height = TangemTheme.dimens2.x10, + width = TangemTheme.dimens2.x14, + ), + isReissuing = false, + onClick = {}, + content = {}, + ) + SpacerH(TangemTheme.dimens2.x4) + CardBackground( + modifier = Modifier.size( + height = TangemTheme.dimens2.x10, + width = TangemTheme.dimens2.x14, + ), + isReissuing = true, + onClick = {}, + content = {}, + ) + SpacerH(TangemTheme.dimens2.x4) + TangemPayCardView(isReissuing = false, onClick = {}, lastDigits = "1234") + SpacerH(TangemTheme.dimens2.x4) + TangemPayCardView(isReissuing = true, onClick = {}, lastDigits = "") + SpacerH(TangemTheme.dimens2.x4) + TangemPayAddCardView(onClick = {}) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/res/drawable-night-hdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-night-hdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000000000000000000000000000000000..190f51ba02e0e40450717de578cde948a94b427b GIT binary patch literal 2136 zcmV-e2&eZ_Nk&Fc2mk|KUIUhyU>3-wyoO@6vr+XK~Z$#i`Nn++Fa4@7#8}V7i)sn9*)HGrF845V^lCzZ5jR zs~o`uaQPVOlr}qb)aGehk&duzc3v*liDXO(1qethv;CG8IF6Gk_C^%QD1*^(>sSEK zGN=TAkao}l3<8>(fB{G~z|%z&K$z!LY(IMurR3A@uw%G1YsFS0s#PSPJ}1<(4f~>bhJF!%L$|t^n(7EiV*&g z?=P*#ah!;s8dt4I&lD$UsxrRw3bhsOx#N6%j3f>q0s!BDnr6g~#oF9i$a1IOo}zU{ zY`BZCTV+h{tw|O6B;C=Ac;BoN$3Iy%A@OJpxGFmH2FsJj=fRg+`WFGs92}fhTKOcfo&^^suVsOn+reJ_9*2#?kZkAO}e+tiDa?Zc_-+3z&f|}km4`Q%CUuqk6+$NQ;qh=OQ#}FhSGR^rh*H6a7 zMP8&3D#GphGl;li!M)9!B7hv6d6+)u)G}^6)@HNZ@vFJ$%T5<>Icu=@9tfGW729#z z_5du1XGx4pjANuyV-&%GPAWI)3Xy?I;})IpMZZasgzr7rTw+3WqC4@mYPxE7$s=iu z*Yp9Z?YN_K!GDZ>_&$BUPC-b#z7}1!{9eFlecWvsI}xNH$y2Ojj=9SSB&cL_v3v9= z@OD7R!m{}<^`WaWnEGqAQmVAc}arSw_50{^`|@+KG34XBVY6I2?b`Uhs7hwJO{|Ywzss>nb*?FUv8!5*k#ev(V-2NC3znzD)z%*$wf}64Y-(Vm-+uCr$f-Q_H#wkAvLg z_mA94{yEy;>A&2rM?M~*-m5Y9nxbV5uwEiU{K>;jhe{941XSiN+P@)>ecL4*L76cX zd5_e~=-0BTspJWjD=2-S(QNJ_^9B;M(dY+*+(My~EPsuUE_^Z5yVa?_#469rPg9Ai zUPx6j!D^3u<3z1*V0Az-2!5XiX`A7+CNm(<__o<9(w?7 zZ(sD<3ivr$xpD&3H8W+3$78~t0zC{}{&=l3DHyo`6SqQ7lxLH!I)x*~ua|H(K;(9F zNFf+8N*igdyk4tC$gJRgo9*!*s7`7$6W3ADWx&aCrepPo)onfvF(@s}r8j zE7w9{9?5{?KuUWOl`9Ue~uwA z03CSS#eI5QP`G;Y*E>_2sr=i->D0F1j)3zjk!Lvk)aG8(*&RMSNiHy248%Tf@niW+ zO7cGc*V3RMIesV>6x;wf;?TDQ;iI5Uz$8?|MF=qB+r3fX5@Evm-%&IG005{!#Q@2G OKJVq9$1%Z#0001D3lCQS literal 0 HcmV?d00001 diff --git a/features/tangempay/details/impl/src/main/res/drawable-night-xhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-night-xhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000000000000000000000000000000000..c9b40847346af7604027df0efdcc0af8368e7338 GIT binary patch literal 3052 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 literal 0 HcmV?d00001 diff --git a/features/tangempay/details/impl/src/main/res/drawable-night-xxhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-night-xxhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000000000000000000000000000000000..d8aedcf855464580f4ee797e80ab5efe796b802e GIT binary patch literal 5644 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 G;r{}1gzE7C literal 0 HcmV?d00001 diff --git a/features/tangempay/details/impl/src/main/res/drawable-night-xxxhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-night-xxxhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000000000000000000000000000000000..7671f73787cf681b4eb8a2a2541151b26fbde4f8 GIT binary patch literal 10094 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 TpwR+aXlqQ_FrO~@=ktF6Z}CY@ literal 0 HcmV?d00001 diff --git a/features/tangempay/details/impl/src/main/res/drawable-notnight-hdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-notnight-hdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000000000000000000000000000000000..dcc819b5672a61f6a7a68082f2ba0b0792997759 GIT binary patch literal 2678 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 literal 0 HcmV?d00001 diff --git a/features/tangempay/details/impl/src/main/res/drawable-notnight-xhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-notnight-xhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000000000000000000000000000000000..8f47020f267ec48b9cd9e8a25ebb443e61a837e3 GIT binary patch literal 3956 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 Oir@se001N|0002Qdw+uf literal 0 HcmV?d00001 diff --git a/features/tangempay/details/impl/src/main/res/drawable-notnight-xxhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-notnight-xxhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000000000000000000000000000000000..1a91ca2907900fc9e5c9e8ee32cf9bdac38bd36c GIT binary patch literal 7808 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 literal 0 HcmV?d00001 diff --git a/features/tangempay/details/impl/src/main/res/drawable-notnight-xxxhdpi/img_bg_pay_details.webp b/features/tangempay/details/impl/src/main/res/drawable-notnight-xxxhdpi/img_bg_pay_details.webp new file mode 100644 index 0000000000000000000000000000000000000000..0122654158ceddf4d34f1911cb9535dac4236b3d GIT binary patch literal 12528 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