diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c48609883..17ee83e2e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -243,6 +243,8 @@ dependencies { implementation(projects.features.tangempay.details.impl) implementation(projects.features.tangempay.main.api) implementation(projects.features.tangempay.main.impl) + implementation(projects.features.tokenRecieve.api) + implementation(projects.features.tokenRecieve.impl) /** AndroidX libraries */ implementation(deps.androidx.core.ktx) diff --git a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt index dcedc27851..4d82335720 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt @@ -154,4 +154,10 @@ internal object NFTDomainModule { ): ObserveAndClearNFTCacheIfNeedUseCase { return ObserveAndClearNFTCacheIfNeedUseCase(nftRepository, currenciesRepository) } + + @Provides + @Singleton + fun provideGetNftCurrencyUseCase(nftRepository: NFTRepository): GetNFTCurrencyUseCase { + return GetNFTCurrencyUseCase(nftRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index 4a0a3c5922..bdbe9037cc 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -23,6 +23,7 @@ import com.tangem.domain.tokens.operations.CachedCurrenciesStatusesOperations import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository +import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles @@ -469,4 +470,20 @@ internal object TokensDomainModule { fun provideGetAssetRequirementsUseCase(walletManagersFacade: WalletManagersFacade): GetAssetRequirementsUseCase { return GetAssetRequirementsUseCase(walletManagersFacade) } + + @Provides + @Singleton + fun provideGetViewedTokenReceiveWarningUseCase( + tokenReceiveWarningsViewedRepository: TokenReceiveWarningsViewedRepository, + ): GetViewedTokenReceiveWarningUseCase { + return GetViewedTokenReceiveWarningUseCase(tokenReceiveWarningsViewedRepository) + } + + @Provides + @Singleton + fun provideSaveViewedTokenReceiveWarningUseCase( + tokenReceiveWarningsViewedRepository: TokenReceiveWarningsViewedRepository, + ): SaveViewedTokenReceiveWarningUseCase { + return SaveViewedTokenReceiveWarningUseCase(tokenReceiveWarningsViewedRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index d7c8721bff..0b8802198c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -10,6 +10,7 @@ import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.usecase.* import com.tangem.domain.walletmanager.WalletManagersFacade import dagger.Module @@ -220,4 +221,24 @@ internal object TransactionDomainModule { ): CreateNFTTransferTransactionUseCase { return CreateNFTTransferTransactionUseCase(transactionRepository) } + + @Provides + @Singleton + fun provideGetEnsNameUseCase( + walletManagersFacade: WalletManagersFacade, + walletAddressServiceRepository: WalletAddressServiceRepository, + ): GetEnsNameUseCase { + return GetEnsNameUseCase( + walletManagersFacade = walletManagersFacade, + walletAddressServiceRepository = walletAddressServiceRepository, + ) + } + + @Provides + @Singleton + fun provideGetReverseResolvedEnsAddressUseCase( + walletAddressServiceRepository: WalletAddressServiceRepository, + ): GetReverseResolvedEnsAddressUseCase { + return GetReverseResolvedEnsAddressUseCase(walletAddressServiceRepository) + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt index ff58fd1c80..1d4ff127d6 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt @@ -273,6 +273,7 @@ sealed class NotificationUM(val config: NotificationConfig) { iconResId: Int = R.drawable.ic_alert_circle_24, buttonsState: NotificationConfig.ButtonsState? = null, onCloseClick: (() -> Unit)? = null, + iconTint: NotificationConfig.IconTint = NotificationConfig.IconTint.Unspecified, ) : NotificationUM( config = NotificationConfig( title = title, @@ -280,6 +281,7 @@ sealed class NotificationUM(val config: NotificationConfig) { iconResId = iconResId, buttonsState = buttonsState, onCloseClick = onCloseClick, + iconTint = iconTint, ), ) 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 d04509676c..5879b1920e 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 @@ -192,6 +192,10 @@ sealed class AnalyticsParam { Pending(value = "Pending"), } + enum class EnsStatus(val value: String) { + EMPTY("Empty"), FULL("Full") + } + companion object Key { const val BLOCKCHAIN = "Blockchain" const val TOKEN_PARAM = "Token" @@ -240,5 +244,7 @@ sealed class AnalyticsParam { const val SEND_BLOCKCHAIN = "Send Blockchain" const val RECEIVE_BLOCKCHAIN = "Receive Blockchain" const val CHOSEN_TOKEN = "Token Chosen" + const val ENS = "ENS" + const val ENS_ADDRESS = "ENS Address" } } \ No newline at end of file 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 0d8eea380b..bd1f7e6e67 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 @@ -9,7 +9,7 @@ }, { "name": "STAKING_TON_ENABLED", - "version": "undefined" + "version": "5.28.0" }, { "name": "NFT_MEDIA_CONTENT_ENABLED", @@ -17,7 +17,7 @@ }, { "name": "STAKING_CARDANO_ENABLED", - "version": "undefined" + "version": "5.28.0" }, { "name": "WALLET_CONNECT_REDESIGN_ENABLED", @@ -33,7 +33,7 @@ }, { "name": "SEND_VIA_SWAP_ENABLED", - "version": "undefined" + "version": "5.28.0" }, { "name": "SWAP_REDESIGN_ENABLED", @@ -41,7 +41,7 @@ }, { "name": "SEND_REDESIGN_ENABLED", - "version": "undefined" + "version": "5.28.0" }, { "name": "WALLET_BALANCE_FETCHER_ENABLED", @@ -53,10 +53,14 @@ }, { "name": "NFT_SEND_REDESIGN_ENABLED", - "version": "undefined" + "version": "5.28.0" }, { "name": "TANGEM_PAY_ENABLED", "version": "undefined" + }, + { + "name": "NEW_TOKEN_RECEIVE_ENABLED", + "version": "5.28.0" } ] diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TokenReceiveWarningModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TokenReceiveWarningModule.kt new file mode 100644 index 0000000000..c0aa4159dd --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TokenReceiveWarningModule.kt @@ -0,0 +1,44 @@ +package com.tangem.datasource.di + +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi +import com.tangem.datasource.local.token.DefaultTokenReceiveWarningActionStore +import com.tangem.datasource.local.token.TokenReceiveWarningActionStore +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.setTypes +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object TokenReceiveWarningModule { + + @Provides + @Singleton + fun provideTokenReceiveWarningStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + dispatchers: CoroutineDispatcherProvider, + ): TokenReceiveWarningActionStore { + return DefaultTokenReceiveWarningActionStore( + persistenceStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = setTypes(), + defaultValue = emptySet(), + ), + produceFile = { context.dataStoreFile(fileName = "token_receive_warnings_viewed") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ), + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultTokenReceiveWarningActionStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultTokenReceiveWarningActionStore.kt new file mode 100644 index 0000000000..8be4bb214a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultTokenReceiveWarningActionStore.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.local.token + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.firstOrNull + +internal class DefaultTokenReceiveWarningActionStore( + private val persistenceStore: DataStore>, +) : TokenReceiveWarningActionStore { + + override suspend fun getSync(): Set { + return persistenceStore.data.firstOrNull() ?: emptySet() + } + + override suspend fun store(symbol: String) { + persistenceStore.updateData { data -> data.plus(symbol) } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/TokenReceiveWarningActionStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/TokenReceiveWarningActionStore.kt new file mode 100644 index 0000000000..d7ef18221d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/TokenReceiveWarningActionStore.kt @@ -0,0 +1,8 @@ +package com.tangem.datasource.local.token + +interface TokenReceiveWarningActionStore { + + suspend fun getSync(): Set + + suspend fun store(symbol: String) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt index 4727ba9759..ff97530009 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.res.TangemTheme @@ -28,11 +29,16 @@ import com.tangem.core.ui.utils.getGreyScaleColorFilter * @param shouldDisplayNetwork specifies whether to display network badge */ @Composable -fun CurrencyIcon(state: CurrencyIconState, modifier: Modifier = Modifier, shouldDisplayNetwork: Boolean = true) { +fun CurrencyIcon( + state: CurrencyIconState, + modifier: Modifier = Modifier, + shouldDisplayNetwork: Boolean = true, + iconSize: Dp = 36.dp, +) { BaseContainer(modifier = modifier) { val iconModifier = Modifier .align(Alignment.Center) - .size(TangemTheme.dimens.size36) + .size(iconSize) when (state) { is CurrencyIconState.Loading -> LoadingIcon(modifier = iconModifier) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconStateBuilder.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconStateBuilder.kt new file mode 100644 index 0000000000..1aaed64244 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconStateBuilder.kt @@ -0,0 +1,103 @@ +package com.tangem.core.ui.components.currency.icon + +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.Color +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.getTintForTokenIcon +import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon +import com.tangem.domain.models.currency.CryptoCurrency + +object CurrencyIconStateBuilder { + + fun build( + cryptoCurrency: CryptoCurrency, + isGrayscale: Boolean = false, + showCustomBadge: Boolean = true, + ): CurrencyIconState = when (cryptoCurrency) { + is CryptoCurrency.Coin -> fromCoin(cryptoCurrency, isGrayscale, showCustomBadge) + is CryptoCurrency.Token -> fromToken(cryptoCurrency, isGrayscale, showCustomBadge) + } + + private fun createCoinIcon( + url: String? = null, + @DrawableRes fallbackResId: Int = R.drawable.ic_empty_64, + isGrayscale: Boolean = false, + showCustomBadge: Boolean = false, + ): CurrencyIconState.CoinIcon = CurrencyIconState.CoinIcon( + url = url, + fallbackResId = fallbackResId, + isGrayscale = isGrayscale, + showCustomBadge = showCustomBadge, + ) + + private fun createTokenIcon( + url: String? = null, + @DrawableRes topBadgeIconResId: Int? = null, + isGrayscale: Boolean = false, + showCustomBadge: Boolean = false, + fallbackTint: Color = Color.Black, + fallbackBackground: Color = Color.White, + ): CurrencyIconState.TokenIcon = CurrencyIconState.TokenIcon( + url = url, + topBadgeIconResId = topBadgeIconResId, + isGrayscale = isGrayscale, + fallbackTint = fallbackTint, + fallbackBackground = fallbackBackground, + showCustomBadge = showCustomBadge, + ) + + private fun createCustomTokenIcon( + tint: Color, + background: Color, + @DrawableRes topBadgeIconResId: Int, + isGrayscale: Boolean = false, + showCustomBadge: Boolean = true, + ): CurrencyIconState.CustomTokenIcon = CurrencyIconState.CustomTokenIcon( + tint = tint, + background = background, + topBadgeIconResId = topBadgeIconResId, + isGrayscale = isGrayscale, + showCustomBadge = showCustomBadge, + ) + + private fun fromCoin( + coin: CryptoCurrency.Coin, + isGrayscale: Boolean = false, + showCustomBadge: Boolean = true, + ): CurrencyIconState.CoinIcon = createCoinIcon( + url = coin.iconUrl, + fallbackResId = coin.networkIconResId, + isGrayscale = isGrayscale || coin.network.isTestnet, + showCustomBadge = coin.isCustom && showCustomBadge, + ) + + private fun fromToken( + token: CryptoCurrency.Token, + isGrayscale: Boolean = false, + showCustomBadge: Boolean = true, + ): CurrencyIconState { + val grayScale = isGrayscale || token.network.isTestnet + val background = token.tryGetBackgroundForTokenIcon(grayScale) + val tint = getTintForTokenIcon(background) + + return if (token.isCustom && token.iconUrl == null) { + createCustomTokenIcon( + tint = tint, + background = background, + topBadgeIconResId = token.networkIconResId, + isGrayscale = grayScale, + showCustomBadge = showCustomBadge, + ) + } else { + createTokenIcon( + url = token.iconUrl, + topBadgeIconResId = token.networkIconResId, + isGrayscale = grayScale, + fallbackTint = tint, + fallbackBackground = background, + showCustomBadge = token.isCustom && showCustomBadge, + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_copy_new_24.xml b/core/ui/src/main/res/drawable/ic_copy_new_24.xml new file mode 100644 index 0000000000..a073a2b686 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_copy_new_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_ens_36.xml b/core/ui/src/main/res/drawable/ic_ens_36.xml new file mode 100644 index 0000000000..29b19ea2c7 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_ens_36.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_qrcode_new_24.xml b/core/ui/src/main/res/drawable/ic_qrcode_new_24.xml new file mode 100644 index 0000000000..173a8c1979 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_qrcode_new_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index 2cba8e6ef9..fe5dd5cc34 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -8,14 +8,17 @@ import com.tangem.data.common.currency.UserTokensSaver import com.tangem.data.tokens.repository.DefaultCurrenciesRepository import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository import com.tangem.data.tokens.repository.DefaultPolkadotAccountHealthCheckRepository +import com.tangem.data.tokens.repository.DefaultTokenReceiveWarningsViewedRepository import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.token.TokenReceiveWarningActionStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository +import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -83,4 +86,14 @@ internal object TokensDataModule { dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideTokenReceiveWarningsViewedRepository( + tokenReceiveWarningActionStore: TokenReceiveWarningActionStore, + ): TokenReceiveWarningsViewedRepository { + return DefaultTokenReceiveWarningsViewedRepository( + tokenReceiveWarningActionStore = tokenReceiveWarningActionStore, + ) + } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultTokenReceiveWarningsViewedRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultTokenReceiveWarningsViewedRepository.kt new file mode 100644 index 0000000000..0d31e13a55 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultTokenReceiveWarningsViewedRepository.kt @@ -0,0 +1,17 @@ +package com.tangem.data.tokens.repository + +import com.tangem.datasource.local.token.TokenReceiveWarningActionStore +import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository + +internal class DefaultTokenReceiveWarningsViewedRepository( + private val tokenReceiveWarningActionStore: TokenReceiveWarningActionStore, +) : TokenReceiveWarningsViewedRepository { + + override suspend fun getViewedWarnings(): Set { + return tokenReceiveWarningActionStore.getSync() + } + + override suspend fun view(symbol: String) { + tokenReceiveWarningActionStore.store(symbol) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultWalletAddressServiceRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultWalletAddressServiceRepository.kt index 48cef0138b..e22b05cb02 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultWalletAddressServiceRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultWalletAddressServiceRepository.kt @@ -6,6 +6,7 @@ import com.tangem.blockchain.blockchains.near.NearWalletManager import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.NameResolver import com.tangem.blockchain.common.ResolveAddressResult +import com.tangem.blockchain.common.ReverseResolveAddressResult import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -22,6 +23,40 @@ class DefaultWalletAddressServiceRepository( private val dispatchers: CoroutineDispatcherProvider, ) : WalletAddressServiceRepository { + override suspend fun getEns(userWalletId: UserWalletId, network: Network, address: String): String? { + return withContext(dispatchers.io) { + val blockchain = network.toBlockchain() + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) + walletManager?.wallet?.ens + } + } + + override suspend fun reverseResolveAddress( + userWalletId: UserWalletId, + network: Network, + address: String, + ): ReverseResolveAddressResult { + return withContext(dispatchers.io) { + val blockchain = network.toBlockchain() + + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) + + if (walletManager is NameResolver) { + walletManager.reverseResolve(address.toByteArray()) + } else { + ReverseResolveAddressResult.NotSupported + } + } + } + override suspend fun resolveAddress( userWalletId: UserWalletId, network: Network, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt new file mode 100644 index 0000000000..728496ef05 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt @@ -0,0 +1,37 @@ +package com.tangem.domain.models + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +@Serializable +data class TokenReceiveConfig( + val shouldShowWarning: Boolean, + val cryptoCurrency: CryptoCurrency, + val userWalletId: UserWalletId, + val showMemoDisclaimer: Boolean, + val receiveAddress: List, + val tokenReceiveNotification: List = emptyList(), + val asset: Asset = Asset.Currency, +) + +@Serializable +data class ReceiveAddressModel( + val nameService: NameService, + val value: String, + val displayName: String, +) { + enum class NameService { + Default, Ens + } +} + +@Serializable +data class TokenReceiveNotification( + val title: Int, + val subtitle: Int, +) + +enum class Asset { + Currency, NFT +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/ens/EnsAddress.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/ens/EnsAddress.kt new file mode 100644 index 0000000000..d1ab13dded --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/ens/EnsAddress.kt @@ -0,0 +1,7 @@ +package com.tangem.domain.models.ens + +sealed interface EnsAddress { + data class Address(val name: String) : EnsAddress + data class Error(val error: Exception) : EnsAddress + data object NotSupported : EnsAddress +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCurrencyUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCurrencyUseCase.kt new file mode 100644 index 0000000000..85f77d810a --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCurrencyUseCase.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.nft + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.nft.repository.NFTRepository + +class GetNFTCurrencyUseCase(private val nftRepository: NFTRepository) { + + operator fun invoke(network: Network): CryptoCurrency = nftRepository.getNFTCurrency(network) +} \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt new file mode 100644 index 0000000000..424ac7f642 --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt @@ -0,0 +1,59 @@ +package com.tangem.domain.tokens.model.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN +import com.tangem.core.analytics.models.AnalyticsParam.Key.ENS +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM + +sealed class TokenReceiveNewAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Token / Receive", event, params) { + + class ReceiveScreenOpened( + token: String, + blockchainName: String, + ensStatus: AnalyticsParam.EnsStatus, + ) : TokenReceiveNewAnalyticsEvent( + event = "Receive Screen Opened", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchainName, + ENS to ensStatus.value, + ), + ) + + class ButtonCopyAddress( + token: String, + blockchainName: String, + ) : TokenReceiveNewAnalyticsEvent( + event = "Button - Copy Address", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchainName, + ), + ) + + class ButtonCopyEns( + token: String, + blockchainName: String, + ) : TokenReceiveNewAnalyticsEvent( + event = "Button - ENS", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchainName, + ), + ) + + class QrScreenOpened( + token: String, + blockchainName: String, + ) : TokenReceiveNewAnalyticsEvent( + event = "QR Screen Opened", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchainName, + ), + ) +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetViewedTokenReceiveWarningUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetViewedTokenReceiveWarningUseCase.kt new file mode 100644 index 0000000000..7665d02abc --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetViewedTokenReceiveWarningUseCase.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.tokens + +import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository + +class GetViewedTokenReceiveWarningUseCase( + private val tokenReceiveWarningsViewedRepository: TokenReceiveWarningsViewedRepository, +) { + suspend operator fun invoke(): Set { + return tokenReceiveWarningsViewedRepository.getViewedWarnings() + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/SaveViewedTokenReceiveWarningUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/SaveViewedTokenReceiveWarningUseCase.kt new file mode 100644 index 0000000000..8827166743 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/SaveViewedTokenReceiveWarningUseCase.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.tokens + +import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository + +class SaveViewedTokenReceiveWarningUseCase( + private val tokenReceiveWarningsViewedRepository: TokenReceiveWarningsViewedRepository, +) { + suspend operator fun invoke(symbol: String) { + tokenReceiveWarningsViewedRepository.view(symbol) + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokenReceiveWarningsViewedRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokenReceiveWarningsViewedRepository.kt new file mode 100644 index 0000000000..02765acfe4 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokenReceiveWarningsViewedRepository.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.tokens.repository + +interface TokenReceiveWarningsViewedRepository { + + suspend fun getViewedWarnings(): Set + + suspend fun view(symbol: String) +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/WalletAddressServiceRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/WalletAddressServiceRepository.kt index 2dfc380480..f566a5a93b 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/WalletAddressServiceRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/WalletAddressServiceRepository.kt @@ -1,15 +1,24 @@ package com.tangem.domain.transaction import com.tangem.blockchain.common.ResolveAddressResult +import com.tangem.blockchain.common.ReverseResolveAddressResult import com.tangem.domain.models.network.Network -import com.tangem.domain.wallets.models.ParsedQrCode import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.ParsedQrCode /** * Wallet address service repository. */ interface WalletAddressServiceRepository { + suspend fun getEns(userWalletId: UserWalletId, network: Network, address: String): String? + + suspend fun reverseResolveAddress( + userWalletId: UserWalletId, + network: Network, + address: String, + ): ReverseResolveAddressResult + suspend fun resolveAddress(userWalletId: UserWalletId, network: Network, address: String): ResolveAddressResult suspend fun validateAddress(userWalletId: UserWalletId, network: Network, address: String): Boolean diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEnsNameUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEnsNameUseCase.kt new file mode 100644 index 0000000000..27a9fd1bfc --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEnsNameUseCase.kt @@ -0,0 +1,31 @@ +package com.tangem.domain.transaction.usecase + +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.WalletAddressServiceRepository +import com.tangem.domain.walletmanager.WalletManagersFacade + +class GetEnsNameUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val walletAddressServiceRepository: WalletAddressServiceRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network, address: String): String? { + if (network.nameResolvingType != Network.NameResolvingType.ENS) { + return null + } + + val addresses = walletManagersFacade.getAddresses(userWalletId, network) + + val isOwnAddress = addresses.any { it.value == address } + if (!isOwnAddress) { + return null + } + + return walletAddressServiceRepository.getEns( + userWalletId = userWalletId, + network = network, + address = address, + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetReverseResolvedEnsAddressUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetReverseResolvedEnsAddressUseCase.kt new file mode 100644 index 0000000000..93f053818e --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetReverseResolvedEnsAddressUseCase.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.transaction.usecase + +import com.tangem.blockchain.common.ReverseResolveAddressResult +import com.tangem.domain.models.ens.EnsAddress +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.WalletAddressServiceRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.supervisorScope + +class GetReverseResolvedEnsAddressUseCase(private val walletAddressServiceRepository: WalletAddressServiceRepository) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + addresses: List, + ): List { + return supervisorScope { + val addressesDeferred = addresses.map { address -> + async { + val result = walletAddressServiceRepository.reverseResolveAddress( + userWalletId = userWalletId, + network = network, + address = address, + ) + mapReverseResolveResult(result) + } + } + addressesDeferred.awaitAll() + } + } + + private fun mapReverseResolveResult(reverseResolveAddressResult: ReverseResolveAddressResult): EnsAddress { + return when (reverseResolveAddressResult) { + is ReverseResolveAddressResult.Error -> EnsAddress.Error(reverseResolveAddressResult.error) + ReverseResolveAddressResult.NotSupported -> EnsAddress.NotSupported + is ReverseResolveAddressResult.Resolved -> EnsAddress.Address(reverseResolveAddressResult.name) + } + } +} \ No newline at end of file diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 4f3b8566e0..8f41e0f6c1 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.features.disclaimer.api) implementation(projects.features.tester.api) implementation(projects.features.createWalletSelection.api) + implementation(projects.features.hotWallet.api) /* Project - Core */ implementation(projects.core.decompose) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index 01b1ebb3b1..bfdc9b284b 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -17,6 +17,8 @@ import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase import com.tangem.features.details.entity.UserWalletListUM import com.tangem.features.details.impl.R +import com.tangem.features.details.utils.UserWalletSaver +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList @@ -38,6 +40,8 @@ internal class UserWalletListModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, + private val userWalletSaver: UserWalletSaver, + private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { private val isWalletSavingInProgress: MutableStateFlow = MutableStateFlow(value = false) @@ -53,7 +57,7 @@ internal class UserWalletListModel @Inject constructor( userWallets = persistentListOf(), isWalletSavingInProgress = false, addNewWalletText = TextReference.EMPTY, - onAddNewWalletClick = ::showAddWalletBottomSheet, + onAddNewWalletClick = ::onAddNewWalletClick, addWalletBottomSheet = TangemBottomSheetConfig.Empty, ), ) @@ -76,7 +80,7 @@ internal class UserWalletListModel @Inject constructor( value.copy( userWallets = userWallets, isWalletSavingInProgress = isWalletSavingInProgress, - addNewWalletText = if (shouldSaveUserWallets) { + addNewWalletText = if (shouldSaveUserWallets || hotWalletFeatureToggles.isHotWalletEnabled) { resourceReference(R.string.user_wallet_list_add_button) } else { resourceReference(R.string.scan_card_settings_button) @@ -84,15 +88,21 @@ internal class UserWalletListModel @Inject constructor( ) } - private fun showAddWalletBottomSheet() { - state.update { currentState -> - currentState.copy( - addWalletBottomSheet = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = ::dismissAddWalletBottomSheet, - content = createAddWalletBottomSheetContent(), - ), - ) + private fun onAddNewWalletClick() { + if (hotWalletFeatureToggles.isHotWalletEnabled) { + state.update { currentState -> + currentState.copy( + addWalletBottomSheet = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismissAddWalletBottomSheet, + content = createAddWalletBottomSheetContent(), + ), + ) + } + } else { + withProgress(isWalletSavingInProgress) { + userWalletSaver.scanAndSaveUserWallet(modelScope) + } } } diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index df2755faef..1b9562b290 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { api(projects.features.markets.api) api(projects.features.onramp.api) api(projects.features.sendV2.api) + api(projects.features.tokenRecieve.api) /* Data */ implementation(projects.data.common) @@ -39,6 +40,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.settings) implementation(projects.domain.notifications.models) + implementation(projects.domain.transaction) // FIXME [REDACTED_TASK_KEY] // Remove the "Buy" and "Sell" actions from the redux middleware. diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt index 73841ec05d..8f3e1d6da7 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -5,12 +5,20 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.TokenReceiveConfig import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioModel import com.tangem.features.markets.portfolio.impl.ui.MyPortfolio +import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -19,20 +27,41 @@ import dagger.assisted.AssistedInject internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: MarketsPortfolioComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, ) : AppComponentContext by context, MarketsPortfolioComponent { private val model: MarketsPortfolioModel = getOrCreateModel(params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TokenReceiveConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + override fun setTokenNetworks(networks: List) = model.setTokenNetworks(networks) override fun setNoNetworksAvailable() = model.setNoNetworksAvailable() @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() MyPortfolio(modifier = modifier, state = state) + bottomSheet.child?.instance?.BottomSheet() } + private fun bottomSheetChild( + config: TokenReceiveConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + @AssistedFactory interface Factory : MarketsPortfolioComponent.Factory { override fun create( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt index e17c925595..60673724c0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt @@ -2,6 +2,8 @@ package com.tangem.features.markets.portfolio.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -15,24 +17,31 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.markets.SaveMarketTokensUseCase import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.ArtworkModel +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase +import com.tangem.domain.transaction.usecase.GetEnsNameUseCase import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.markets.impl.R import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent import com.tangem.features.markets.portfolio.impl.loader.PortfolioData import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -43,7 +52,7 @@ import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class MarketsPortfolioModel @Inject constructor( @@ -60,6 +69,9 @@ internal class MarketsPortfolioModel @Inject constructor( private val getCardImageUseCase: GetCardImageUseCase, private val addToPortfolioManager: AddToPortfolioManager, private val analyticsEventHandler: AnalyticsEventHandler, + private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, + private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, + private val getEnsNameUseCase: GetEnsNameUseCase, ) : Model() { val state: StateFlow get() = _state @@ -80,6 +92,8 @@ internal class MarketsPortfolioModel @Inject constructor( private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel()) + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + private val currentAppCurrency = getSelectedAppCurrencyUseCase() .map { maybeAppCurrency -> maybeAppCurrency.getOrElse { AppCurrency.Default } @@ -128,7 +142,11 @@ internal class MarketsPortfolioModel @Inject constructor( tokenActionsHandler = tokenActionsIntentsFactory.create( currentAppCurrency = Provider { currentAppCurrency.value }, updateTokenReceiveBSConfig = { updateBlock -> - updateTokensState { it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig)) } + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled.not()) { + updateTokensState { + it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig)) + } + } }, onHandleQuickAction = { handledAction -> analyticsEventHandler.send( @@ -137,6 +155,9 @@ internal class MarketsPortfolioModel @Inject constructor( blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name, ), ) + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { + configureReceiveAddresses(handledAction) + } }, ), updateTokens = { updateBlock -> @@ -359,4 +380,51 @@ internal class MarketsPortfolioModel @Inject constructor( block(tokensState) } } + + private fun configureReceiveAddresses(quickAction: TokenActionsHandler.HandledQuickAction) { + when (quickAction.action) { + TokenActionsBSContentUM.Action.Receive -> { + val addresses = quickAction.cryptoCurrencyData.status.value.networkAddress ?: return + val cryptoCurrency = quickAction.cryptoCurrencyData.status.currency + modelScope.launch { + val ensName = getEnsNameUseCase.invoke( + userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, + network = cryptoCurrency.network, + address = addresses.defaultAddress.value, + ) + + val receiveAddresses = buildList { + ensName?.let { ens -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Ens, + value = ens, + displayName = ens, + ), + ) + } + addresses.availableAddresses.map { address -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Default, + value = address.value, + displayName = "${cryptoCurrency.name} (${cryptoCurrency.symbol})", + ), + ) + } + } + val tokenConfig = TokenReceiveConfig( + shouldShowWarning = cryptoCurrency.name !in getViewedTokenReceiveWarningUseCase(), + cryptoCurrency = cryptoCurrency, + userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, + showMemoDisclaimer = cryptoCurrency.network.transactionExtrasType != Network + .TransactionExtrasType.NONE, + receiveAddress = receiveAddresses, + ) + bottomSheetNavigation.activate(tokenConfig) + } + } + else -> Unit + } + } } \ No newline at end of file diff --git a/features/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index 0e4b9f5c67..438a815cc3 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -14,6 +14,7 @@ android { dependencies { /** Api */ implementation(projects.features.nft.api) + implementation(projects.features.tokenRecieve.api) /** Core modules */ implementation(projects.core.configToggles) @@ -34,6 +35,8 @@ dependencies { implementation(projects.domain.nft.models) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) + implementation(projects.domain.transaction) + implementation(projects.domain.tokens) /** Common */ implementation(projects.common.ui) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt index 5c4874dd5e..6aa2bc4a8f 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt @@ -22,6 +22,7 @@ import com.tangem.features.nft.details.info.NFTDetailsInfoComponent import com.tangem.features.nft.entity.NFTSendSuccessListener import com.tangem.features.nft.receive.NFTReceiveComponent import com.tangem.features.nft.traits.NFTAssetTraitsComponent +import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -35,6 +36,7 @@ internal class DefaultNFTComponent @AssistedInject constructor( @Assisted private val params: NFTComponent.Params, private val nftDetailsInfoComponentFactory: NFTDetailsInfoComponent.Factory, nftSendSuccessListener: NFTSendSuccessListener, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, ) : NFTComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -134,6 +136,7 @@ internal class DefaultNFTComponent @AssistedInject constructor( walletName = params.walletName, onBackClick = ::onChildBack, ), + tokenReceiveComponentFactory = tokenReceiveComponentFactory, ) private fun getDetailsComponent( diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt index f445c95fb7..e4bd9a112e 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/NFTReceiveComponent.kt @@ -4,29 +4,58 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.nft.receive.model.NFTReceiveModel import com.tangem.features.nft.receive.ui.NFTReceive +import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedInject internal class NFTReceiveComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, ) : ComposableContentComponent, AppComponentContext by context { private val model: NFTReceiveModel = getOrCreateModel(params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TokenReceiveConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() NFTReceive(state, modifier) + bottomSheet.child?.instance?.BottomSheet() } + private fun bottomSheetChild( + config: TokenReceiveConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + data class Params( val userWalletId: UserWalletId, val walletName: String, diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt index fc2df6f5c2..632959d7e8 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt @@ -1,5 +1,7 @@ package com.tangem.features.nft.receive.model +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -12,12 +14,20 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.Asset +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.TokenReceiveNotification import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.nft.FilterNFTAvailableNetworksUseCase +import com.tangem.domain.nft.GetNFTCurrencyUseCase import com.tangem.domain.nft.GetNFTNetworkStatusUseCase import com.tangem.domain.nft.GetNFTNetworksUseCase import com.tangem.domain.nft.analytics.NFTAnalyticsEvent +import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase +import com.tangem.domain.transaction.usecase.GetEnsNameUseCase import com.tangem.features.nft.impl.R import com.tangem.features.nft.receive.NFTReceiveComponent import com.tangem.features.nft.receive.entity.NFTReceiveUM @@ -25,6 +35,8 @@ import com.tangem.features.nft.receive.entity.transformer.ShowReceiveBottomSheet import com.tangem.features.nft.receive.entity.transformer.ToggleSearchBarTransformer import com.tangem.features.nft.receive.entity.transformer.UpdateDataStateTransformer import com.tangem.features.nft.receive.entity.transformer.UpdateSearchQueryTransformer +import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle +import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.* @@ -43,11 +55,17 @@ internal class NFTReceiveModel @Inject constructor( private val shareManager: ShareManager, private val analyticsEventHandler: AnalyticsEventHandler, private val messageSender: UiMessageSender, + private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, + private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, + private val getEnsNameUseCase: GetEnsNameUseCase, + private val getNFTCurrencyUseCase: GetNFTCurrencyUseCase, paramsContainer: ParamsContainer, ) : Model() { private val params: NFTReceiveComponent.Params = paramsContainer.require() + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + private val _state = MutableStateFlow( value = NFTReceiveUM( onBackClick = params.onBackClick, @@ -138,14 +156,23 @@ internal class NFTReceiveModel @Inject constructor( when (val value = networkStatus.value) { is NetworkStatus.Verified -> { - _state.update { - ShowReceiveBottomSheetTransformer( - network = network, - networkAddress = value.address, - onDismissBottomSheet = ::onReceiveBottomSheetDismiss, - onCopyClick = { text -> onCopyClick(text, network) }, - onShareClick = { text -> onShareClick(text, network) }, - ).transform(it) + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { + bottomSheetNavigation.activate( + configuration = configureReceiveAddresses( + addresses = value.address, + network = network, + ), + ) + } else { + _state.update { + ShowReceiveBottomSheetTransformer( + network = network, + networkAddress = value.address, + onDismissBottomSheet = ::onReceiveBottomSheetDismiss, + onCopyClick = { text -> onCopyClick(text, network) }, + onShareClick = { text -> onShareClick(text, network) }, + ).transform(it) + } } } is NetworkStatus.MissedDerivation, @@ -166,4 +193,56 @@ internal class NFTReceiveModel @Inject constructor( analyticsEventHandler.send(NFTAnalyticsEvent.Receive.ShareAddress(network.name)) shareManager.shareText(text = text) } + + private suspend fun configureReceiveAddresses(addresses: NetworkAddress, network: Network): TokenReceiveConfig { + val cryptoCurrency = getNFTCurrencyUseCase.invoke(network) + + val ensName = getEnsNameUseCase.invoke( + userWalletId = params.userWalletId, + network = network, + address = addresses.defaultAddress.value, + ) + + val receiveAddresses = buildList { + ensName?.let { ens -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Ens, + value = ens, + displayName = ens, + ), + ) + } + addresses.availableAddresses.map { address -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Default, + value = address.value, + displayName = cryptoCurrency.symbol, + ), + ) + } + } + + val notifications = buildList { + if (BlockchainUtils.isSolana(network.rawId)) { + add( + TokenReceiveNotification( + title = R.string.nft_receive_unsupported_types, + subtitle = R.string.nft_receive_unsupported_types_description, + ), + ) + } + } + + return TokenReceiveConfig( + shouldShowWarning = Asset.NFT.name !in getViewedTokenReceiveWarningUseCase(), + cryptoCurrency = cryptoCurrency, + userWalletId = params.userWalletId, + showMemoDisclaimer = false, + receiveAddress = receiveAddresses, + tokenReceiveNotification = notifications, + asset = Asset.NFT, + ) + } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/build.gradle.kts b/features/onboarding-v2/impl/build.gradle.kts index 9653f895cb..d6abeb7356 100644 --- a/features/onboarding-v2/impl/build.gradle.kts +++ b/features/onboarding-v2/impl/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(projects.features.manageTokens.api) implementation(projects.features.biometry.api) implementation(projects.features.hotWallet.api) + implementation(projects.features.tokenRecieve.api) /** Core modules */ implementation(projects.core.configToggles) @@ -49,6 +50,7 @@ dependencies { implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) implementation(projects.domain.onramp) + implementation(projects.domain.transaction) /** Tangem libraries */ implementation(projects.libs.tangemSdkApi) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt index 7f15990f3c..092e847893 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt @@ -24,8 +24,8 @@ import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent import com.tangem.features.onboarding.v2.note.impl.child.create.OnboardingNoteCreateWalletComponent -import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteModel import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteCommonState +import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteModel import com.tangem.features.onboarding.v2.note.impl.route.ONBOARDING_NOTE_STEPS_COUNT import com.tangem.features.onboarding.v2.note.impl.route.OnboardingNoteRoute import dagger.assisted.Assisted diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt index ded3238c83..9879c7252b 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt @@ -2,8 +2,8 @@ package com.tangem.features.onboarding.v2.twin.impl import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel @@ -42,7 +42,6 @@ internal class DefaultOnboardingTwinComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { BackHandler { model.onBack() } - val state by model.uiState.collectAsStateWithLifecycle() OnboardingTwin( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt index 3a705b2270..c486cce7a4 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt @@ -3,6 +3,7 @@ package com.tangem.features.send.v2.send.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN +import com.tangem.core.analytics.models.AnalyticsParam.Key.ENS_ADDRESS import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE import com.tangem.core.analytics.models.AnalyticsParam.Key.NONCE import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM @@ -23,6 +24,7 @@ internal sealed class SendAnalyticEvents( val feeType: AnalyticsParam.FeeType, val blockchain: String, val nonceNotEmpty: Boolean, + private val ensStatus: AnalyticsParam.EnsStatus, ) : SendAnalyticEvents( event = "Transaction Sent Screen Opened", params = mapOf( @@ -30,6 +32,10 @@ internal sealed class SendAnalyticEvents( FEE_TYPE to feeType.value, BLOCKCHAIN to blockchain, NONCE to nonceNotEmpty.toString().capitalize(), + ENS_ADDRESS to when (ensStatus) { + AnalyticsParam.EnsStatus.EMPTY -> false.toString() + AnalyticsParam.EnsStatus.FULL -> true.toString() + }, ), ) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt index ab9490ce77..ad01b7a5c9 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt @@ -28,6 +28,7 @@ internal class SendAnalyticHelper @Inject constructor( feeType = feeType, blockchain = cryptoCurrency.network.name, nonceNotEmpty = feeSelectorUM.nonce != null, + ensStatus = getEnsStatus(sendUM), ), ) analyticsEventHandler.send( @@ -52,4 +53,14 @@ internal class SendAnalyticHelper @Inject constructor( else -> Basic.TransactionSent.MemoType.Null } } + + private fun getEnsStatus(sendUM: SendUM): AnalyticsParam.EnsStatus { + val blockchainAddressForEns = + (sendUM.destinationUM as? DestinationUM.Content)?.addressTextField?.blockchainAddress + return if (blockchainAddressForEns != null) { + AnalyticsParam.EnsStatus.FULL + } else { + AnalyticsParam.EnsStatus.EMPTY + } + } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt index d834d13885..45ace3d940 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -47,6 +47,7 @@ import com.tangem.features.send.v2.sendnft.confirm.NFTSendConfirmComponent import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmInitialStateTransformer import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmSendingStateTransformer import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformer +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2 import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger @@ -349,6 +350,10 @@ internal class NFTSendConfirmModel @Inject constructor( updateTransactionStatus(txData) sendBalanceUpdater.scheduleUpdates() nftSendAnalyticHelper.nftSendSuccessAnalytics(cryptoCurrency, uiState.value) + if (uiState.value.isRedesignEnabled) { + params.callback.onResult(uiState.value) + params.onSendTransaction() + } }, ) } @@ -389,13 +394,23 @@ internal class NFTSendConfirmModel @Inject constructor( } _uiState.update { it.copy( - confirmUM = NFTSendConfirmationNotificationsTransformer( - feeUM = uiState.value.feeUM, - analyticsEventHandler = analyticsEventHandler, - cryptoCurrency = cryptoCurrencyStatus.currency, - analyticsCategoryName = analyticsCategoryName, - appCurrency = params.appCurrency, - ).transform(uiState.value.confirmUM), + confirmUM = if (uiState.value.isRedesignEnabled) { + NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = uiState.value.feeSelectorUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrencyStatus.currency, + appCurrency = params.appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + } else { + NFTSendConfirmationNotificationsTransformer( + feeUM = uiState.value.feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrencyStatus.currency, + analyticsCategoryName = analyticsCategoryName, + appCurrency = params.appCurrency, + ) + }.transform(uiState.value.confirmUM), ) } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt index 9232bc21da..b5bd158810 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt @@ -62,8 +62,10 @@ internal class NFTSendConfirmationNotificationsTransformer( val feeUM = feeUM as? FeeUM.Content val fee = (feeUM?.feeSelectorUM as? FeeSelectorUM.Content)?.selectedFee ?: return TextReference.EMPTY + val fiatFeeValue = feeUM.rate?.let { fee.amount.value?.multiply(it) } + val fiatFee = formatFooterFiatFee( - amount = fee.amount, + amount = fee.amount.copy(value = fiatFeeValue), isFeeConvertibleToFiat = feeUM.isFeeConvertibleToFiat, isFeeApproximate = feeUM.isFeeApproximate, appCurrency = appCurrency, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt new file mode 100644 index 0000000000..d69431a549 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt @@ -0,0 +1,92 @@ +package com.tangem.features.send.v2.sendnft.confirm.model.transformers + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.v2.api.entity.FeeSelectorUM +import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils +import com.tangem.features.send.v2.api.utils.formatFooterFiatFee +import com.tangem.features.send.v2.api.utils.getTronTokenFeeSendingText +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.impl.R +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class NFTSendConfirmationNotificationsTransformerV2( + private val feeSelectorUM: FeeSelectorUM, + private val analyticsEventHandler: AnalyticsEventHandler, + private val cryptoCurrency: CryptoCurrency, + private val appCurrency: AppCurrency, + private val analyticsCategoryName: String, +) : Transformer { + override fun transform(prevState: ConfirmUM): ConfirmUM { + val state = prevState as? ConfirmUM.Content ?: return prevState + val feeSelectorUM = feeSelectorUM as? FeeSelectorUM.Content ?: return prevState + return state.copy( + sendingFooter = getSendingFooterText(), + notifications = buildList { + addTooHighNotification(feeSelectorUM) + addTooLowNotification(feeSelectorUM) + }.toPersistentList(), + ) + } + + private fun MutableList.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) { + if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM)) { + add(NotificationUM.Warning.FeeTooLow) + analyticsEventHandler.send( + CommonSendAnalyticEvents.NoticeTransactionDelays( + categoryName = analyticsCategoryName, + token = cryptoCurrency.symbol, + ), + ) + } + } + + private fun MutableList.addTooHighNotification(feeSelectorUM: FeeSelectorUM.Content) { + val (isFeeTooHigh, diff) = FeeCalculationUtils.checkIfCustomFeeTooHigh(feeSelectorUM) + if (isFeeTooHigh) { + add(NotificationUM.Warning.TooHigh(diff)) + } + } + + private fun getSendingFooterText(): TextReference { + val feeSelectorUM = feeSelectorUM as? FeeSelectorUM.Content + val fee = feeSelectorUM?.selectedFeeItem?.fee ?: return TextReference.EMPTY + + val fiatFeeValue = feeSelectorUM.feeFiatRateUM?.rate?.let { fee.amount.value?.multiply(it) } + + val fiatFee = formatFooterFiatFee( + amount = fee.amount.copy(value = fiatFeeValue), + isFeeConvertibleToFiat = feeSelectorUM.feeFiatRateUM != null, + isFeeApproximate = feeSelectorUM.feeExtraInfo.isFeeApproximate, + appCurrency = appCurrency, + ) + + return if (fee is Fee.Tron) { + getTronTokenFeeSendingText( + fee = fee, + fiatFee = fiatFee, + fiatSending = resourceReference(R.string.common_nft), + ) + } else { + resourceReference( + id = if (feeSelectorUM.feeFiatRateUM != null) { + R.string.send_summary_transaction_description + } else { + R.string.send_summary_transaction_description_no_fiat_fee + }, + formatArgs = wrappedList( + resourceReference(R.string.common_nft), + fiatFee, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmNotificationsTransformersComparisonTest.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmNotificationsTransformersComparisonTest.kt new file mode 100644 index 0000000000..1fea9dd3ae --- /dev/null +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmNotificationsTransformersComparisonTest.kt @@ -0,0 +1,822 @@ +package com.tangem.features.send.v2.send.confirm.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.send.v2.api.entity.* +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformer +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2 +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger +import java.util.Locale +import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMV2 + +class NFTSendConfirmNotificationsTransformersComparisonTest { + + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) + private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") + private val analyticsCategoryName = "test_category" + + @Test + fun `GIVEN equivalent input data WHEN both transformers transform THEN they produce equal ConfirmUM`() = runTest { + // GIVEN + val initialConfirmUM = createTestConfirmUM() + + val feeUM = createTestFeeUM() + val feeSelectorUMV2 = createTestFeeSelectorUMV2() + + // WHEN + val transformerV1 = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val transformerV2 = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUMV2, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val resultV1 = transformerV1.transform(initialConfirmUM) + val resultV2 = transformerV2.transform(initialConfirmUM) + + // THEN + assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java) + assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java) + + val contentV1 = resultV1 as ConfirmUM.Content + val contentV2 = resultV2 as ConfirmUM.Content + + assertThat(contentV1.isPrimaryButtonEnabled).isEqualTo(contentV2.isPrimaryButtonEnabled) + assertThat(contentV1.notifications.size).isEqualTo(contentV2.notifications.size) + assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter) + assertThat(contentV1.isPrimaryButtonEnabled).isEqualTo(contentV2.isPrimaryButtonEnabled) + assertThat(contentV1.isSending).isEqualTo(contentV2.isSending) + assertThat(contentV1.showTapHelp).isEqualTo(contentV2.showTapHelp) + } + + @Test + fun `GIVEN fee too low WHEN both transformers transform THEN they produce equal notifications`() = runTest { + // GIVEN + val initialConfirmUM = createTestConfirmUM() + + val feeUM = createFeeTooLowUM() + val feeSelectorUMV2 = createFeeTooLowUMV2() + + // WHEN + val transformerV1 = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val transformerV2 = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUMV2, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val resultV1 = transformerV1.transform(initialConfirmUM) + val resultV2 = transformerV2.transform(initialConfirmUM) + + // THEN + assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java) + assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java) + + val contentV1 = resultV1 as ConfirmUM.Content + val contentV2 = resultV2 as ConfirmUM.Content + + assertThat(contentV1.isPrimaryButtonEnabled).isTrue() + assertThat(contentV2.isPrimaryButtonEnabled).isTrue() + assertThat(contentV1.notifications).hasSize(1) + assertThat(contentV2.notifications).hasSize(1) + assertThat(contentV1.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java) + assertThat(contentV2.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java) + assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter) + + // Verify analytics event was sent for both transformers + verify(exactly = 2) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN fee too high WHEN both transformers transform THEN they produce equal notifications`() = runTest { + // GIVEN + val initialConfirmUM = createTestConfirmUM() + + val feeUM = createFeeTooHighUM() + val feeSelectorUMV2 = createFeeTooHighUMV2() + + // WHEN + val transformerV1 = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val transformerV2 = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUMV2, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val resultV1 = transformerV1.transform(initialConfirmUM) + val resultV2 = transformerV2.transform(initialConfirmUM) + + // THEN + assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java) + assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java) + + val contentV1 = resultV1 as ConfirmUM.Content + val contentV2 = resultV2 as ConfirmUM.Content + + assertThat(contentV1.isPrimaryButtonEnabled).isTrue() + assertThat(contentV2.isPrimaryButtonEnabled).isTrue() + assertThat(contentV1.notifications).hasSize(1) + assertThat(contentV2.notifications).hasSize(1) + assertThat(contentV1.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java) + assertThat(contentV2.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java) + + val tooHighV1 = contentV1.notifications.first() as NotificationUM.Warning.TooHigh + val tooHighV2 = contentV2.notifications.first() as NotificationUM.Warning.TooHigh + assertThat(tooHighV1.value).isEqualTo(tooHighV2.value) + assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter) + } + + @Test + fun `GIVEN fee both too high and too low WHEN both transformers transform THEN they produce equal notifications`() = + runTest { + // GIVEN + val initialConfirmUM = createTestConfirmUM() + + val feeUM = createFeeTooHighAndTooLowUM() + val feeSelectorUMV2 = createFeeTooHighAndTooLowUMV2() + + // WHEN + val transformerV1 = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val transformerV2 = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUMV2, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val resultV1 = transformerV1.transform(initialConfirmUM) + val resultV2 = transformerV2.transform(initialConfirmUM) + + // THEN + assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java) + assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java) + + val contentV1 = resultV1 as ConfirmUM.Content + val contentV2 = resultV2 as ConfirmUM.Content + + assertThat(contentV1.isPrimaryButtonEnabled).isTrue() + assertThat(contentV2.isPrimaryButtonEnabled).isTrue() + assertThat(contentV1.notifications).hasSize(2) + assertThat(contentV2.notifications).hasSize(2) + + assertThat(contentV1.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue() + assertThat(contentV1.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue() + assertThat(contentV2.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue() + assertThat(contentV2.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue() + + assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter) + + verify(exactly = 2) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN normal fee WHEN both transformers transform THEN they produce equal notifications`() = runTest { + // GIVEN + val initialConfirmUM = createTestConfirmUM() + + val feeUM = createNormalFeeUM() + val feeSelectorUMV2 = createNormalFeeSelectorUMV2() + + // WHEN + val transformerV1 = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val transformerV2 = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUMV2, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + + val resultV1 = transformerV1.transform(initialConfirmUM) + val resultV2 = transformerV2.transform(initialConfirmUM) + + // THEN + assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java) + assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java) + + val contentV1 = resultV1 as ConfirmUM.Content + val contentV2 = resultV2 as ConfirmUM.Content + + assertThat(contentV1.notifications).isEmpty() + assertThat(contentV2.notifications).isEmpty() + assertThat(contentV1.isPrimaryButtonEnabled).isEqualTo(contentV2.isPrimaryButtonEnabled) + assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter) + } + + private fun createTestConfirmUM(): ConfirmUM.Content { + return ConfirmUM.Content( + isPrimaryButtonEnabled = true, + walletName = mockk(relaxed = true), + isSending = false, + showTapHelp = false, + sendingFooter = mockk(relaxed = true), + notifications = persistentListOf(), + ) + } + + private fun createTestFeeUM(): FeeUM.Content { + val fee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Single(fee) + return FeeUM.Content( + feeSelectorUM = FeeSelectorUM.Content( + fees = transactionFee, + selectedType = FeeType.Market, + selectedFee = fee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.001", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + nonce = BigInteger.ZERO, + ), + rate = BigDecimal("50000"), + isFeeConvertibleToFiat = true, + isFeeApproximate = false, + isTronToken = false, + isEditingDisabled = false, + isPrimaryButtonEnabled = true, + appCurrency = AppCurrency.Default, + isCustomSelected = false, + notifications = persistentListOf(), + ) + } + + private fun createTestFeeSelectorUMV2(): FeeSelectorUMV2.Content { + val fee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Single(fee) + return FeeSelectorUMV2.Content( + isPrimaryButtonEnabled = true, + fees = transactionFee, + feeItems = persistentListOf( + FeeItem.Market(fee), + ), + selectedFeeItem = FeeItem.Market(fee), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("50000"), + appCurrency = appCurrency, + ), + feeNonce = FeeNonce.Nonce( + nonce = BigInteger.ZERO, + onNonceChange = {}, + ), + ) + } + + private fun createFeeTooLowUM(): FeeUM.Content { + val fee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.0001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = fee, + priority = fee, + ) + return FeeUM.Content( + feeSelectorUM = FeeSelectorUM.Content( + fees = transactionFee, + selectedType = FeeType.Custom, + selectedFee = fee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.0001", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + nonce = BigInteger.ZERO, + ), + rate = BigDecimal("50000"), + isFeeConvertibleToFiat = true, + isFeeApproximate = false, + isTronToken = false, + isEditingDisabled = false, + isPrimaryButtonEnabled = true, + appCurrency = AppCurrency.Default, + isCustomSelected = true, + notifications = persistentListOf(), + ) + } + + private fun createFeeTooLowUMV2(): FeeSelectorUMV2.Content { + val fee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.0001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = fee, + priority = fee, + ) + return FeeSelectorUMV2.Content( + isPrimaryButtonEnabled = true, + fees = transactionFee, + feeItems = persistentListOf( + FeeItem.Custom( + fee = fee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.0001", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + ), + selectedFeeItem = FeeItem.Custom( + fee = fee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.0001", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("50000"), + appCurrency = appCurrency, + ), + feeNonce = FeeNonce.Nonce( + nonce = BigInteger.ZERO, + onNonceChange = {}, + ), + ) + } + + private fun createFeeTooHighUM(): FeeUM.Content { + val priorityFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val customFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.01"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = minimumFee, + priority = priorityFee, + ) + return FeeUM.Content( + feeSelectorUM = FeeSelectorUM.Content( + fees = transactionFee, + selectedType = FeeType.Custom, + selectedFee = customFee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.01", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + nonce = BigInteger.ZERO, + ), + rate = BigDecimal("50000"), + isFeeConvertibleToFiat = true, + isFeeApproximate = false, + isTronToken = false, + isEditingDisabled = false, + isPrimaryButtonEnabled = true, + appCurrency = AppCurrency.Default, + isCustomSelected = true, + notifications = persistentListOf(), + ) + } + + private fun createFeeTooHighUMV2(): FeeSelectorUMV2.Content { + val priorityFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val customFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.01"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = minimumFee, + priority = priorityFee, + ) + return FeeSelectorUMV2.Content( + isPrimaryButtonEnabled = true, + fees = transactionFee, + feeItems = persistentListOf( + FeeItem.Custom( + fee = customFee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.01", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + ), + selectedFeeItem = FeeItem.Custom( + fee = customFee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.01", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("50000"), + appCurrency = appCurrency, + ), + feeNonce = FeeNonce.Nonce( + nonce = BigInteger.ZERO, + onNonceChange = {}, + ), + ) + } + + private fun createFeeTooHighAndTooLowUM(): FeeUM.Content { + val priorityFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.01"), + decimals = 8, + ), + ) + val customFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.008"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = minimumFee, + priority = priorityFee, + ) + return FeeUM.Content( + feeSelectorUM = FeeSelectorUM.Content( + fees = transactionFee, + selectedType = FeeType.Custom, + selectedFee = customFee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.008", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + nonce = BigInteger.ZERO, + ), + rate = BigDecimal("50000"), + isFeeConvertibleToFiat = true, + isFeeApproximate = false, + isTronToken = false, + isEditingDisabled = false, + isPrimaryButtonEnabled = true, + appCurrency = AppCurrency.Default, + isCustomSelected = true, + notifications = persistentListOf(), + ) + } + + private fun createFeeTooHighAndTooLowUMV2(): FeeSelectorUMV2.Content { + val priorityFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.01"), + decimals = 8, + ), + ) + val customFee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.008"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = minimumFee, + priority = priorityFee, + ) + return FeeSelectorUMV2.Content( + isPrimaryButtonEnabled = true, + fees = transactionFee, + feeItems = persistentListOf( + FeeItem.Custom( + fee = customFee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.008", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + ), + selectedFeeItem = FeeItem.Custom( + fee = customFee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.008", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("50000"), + appCurrency = appCurrency, + ), + feeNonce = FeeNonce.Nonce( + nonce = BigInteger.ZERO, + onNonceChange = {}, + ), + ) + } + + private fun createNormalFeeUM(): FeeUM.Content { + val fee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Single(fee) + return FeeUM.Content( + feeSelectorUM = FeeSelectorUM.Content( + fees = transactionFee, + selectedType = FeeType.Market, + selectedFee = fee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.001", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "TST", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + nonce = BigInteger.ZERO, + ), + rate = BigDecimal("50000"), + isFeeConvertibleToFiat = true, + isFeeApproximate = false, + isTronToken = false, + isEditingDisabled = false, + isPrimaryButtonEnabled = true, + appCurrency = AppCurrency.Default, + isCustomSelected = false, + notifications = persistentListOf(), + ) + } + + private fun createNormalFeeSelectorUMV2(): FeeSelectorUMV2.Content { + val fee = Fee.Common( + amount = Amount( + currencySymbol = "TST", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Single(fee) + return FeeSelectorUMV2.Content( + isPrimaryButtonEnabled = true, + fees = transactionFee, + feeItems = persistentListOf( + FeeItem.Market(fee), + ), + selectedFeeItem = FeeItem.Market(fee), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("50000"), + appCurrency = appCurrency, + ), + feeNonce = FeeNonce.Nonce( + nonce = BigInteger.ZERO, + onNonceChange = {}, + ), + ) + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpLocale() { + Locale.setDefault(Locale.US) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerTest.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerTest.kt new file mode 100644 index 0000000000..e7f065638a --- /dev/null +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerTest.kt @@ -0,0 +1,381 @@ +package com.tangem.features.send.v2.send.confirm.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformer +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger +import java.util.Locale + +class NFTSendConfirmationNotificationsTransformerTest { + + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) + private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") + private val analyticsCategoryName = "test_category" + + @Test + fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest { + // GIVEN + val feeUM: FeeUM = mockk(relaxed = true) + val transformer = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState: ConfirmUM = ConfirmUM.Empty + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isEqualTo(initialState) + } + + @Test + fun `GIVEN fee UM not content WHEN transform THEN returns original state`() = runTest { + // GIVEN + val feeUM: FeeUM = FeeUM.Empty() + val transformer = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState: ConfirmUM.Content = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isEqualTo(initialState) + } + + @Test + fun `GIVEN normal fee WHEN transform THEN returns state with footer and no notifications`() = runTest { + // GIVEN + val feeUM = createNormalFeeUM() + val transformer = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + val content = result as ConfirmUM.Content + assertThat(content.notifications).isEmpty() + assertThat(content.sendingFooter).isNotEqualTo(initialState.sendingFooter) + } + + @Test + fun `GIVEN fee too high WHEN transform THEN returns state with too high notification`() = runTest { + // GIVEN + val feeUM = createFeeTooHighUM() + val transformer = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + val content = result as ConfirmUM.Content + assertThat(content.notifications).hasSize(1) + assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java) + } + + @Test + fun `GIVEN fee too low WHEN transform THEN returns state with too low notification`() = runTest { + // GIVEN + val feeUM = createFeeTooLowUM() + val transformer = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + val content = result as ConfirmUM.Content + assertThat(content.notifications).hasSize(1) + assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java) + verify { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN both fee too high and too low WHEN transform THEN returns state with both notifications`() = runTest { + // GIVEN + val feeUM = createFeeTooHighAndTooLowUM() + val transformer = NFTSendConfirmationNotificationsTransformer( + feeUM = feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + val content = result as ConfirmUM.Content + assertThat(content.notifications).hasSize(2) + assertThat(content.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue() + assertThat(content.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue() + } + + private fun createTestConfirmUM(): ConfirmUM.Content { + return ConfirmUM.Content( + isPrimaryButtonEnabled = true, + walletName = mockk(relaxed = true), + isSending = false, + showTapHelp = false, + sendingFooter = mockk(relaxed = true), + notifications = persistentListOf(), + ) + } + + private fun createNormalFeeUM(): FeeUM.Content { + val fee = Fee.Common( + amount = com.tangem.blockchain.common.Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Single(fee) + return FeeUM.Content( + feeSelectorUM = FeeSelectorUM.Content( + fees = transactionFee, + selectedType = FeeType.Market, + selectedFee = fee, + customValues = persistentListOf(), + nonce = BigInteger.ZERO, + ), + rate = BigDecimal("50000"), + isFeeConvertibleToFiat = true, + isFeeApproximate = false, + isTronToken = false, + isEditingDisabled = false, + isPrimaryButtonEnabled = true, + appCurrency = AppCurrency.Default, + isCustomSelected = false, + notifications = persistentListOf(), + ) + } + + private fun createFeeTooHighUM(): FeeUM.Content { + val priorityFee = Fee.Common( + amount = com.tangem.blockchain.common.Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = com.tangem.blockchain.common.Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val customFee = Fee.Common( + amount = com.tangem.blockchain.common.Amount( + currencySymbol = "SOL", + value = BigDecimal("0.01"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = minimumFee, + priority = priorityFee, + ) + return FeeUM.Content( + feeSelectorUM = FeeSelectorUM.Content( + fees = transactionFee, + selectedType = FeeType.Custom, + selectedFee = customFee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.01", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "SOL", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + nonce = BigInteger.ZERO, + ), + rate = BigDecimal("50000"), + isFeeConvertibleToFiat = true, + isFeeApproximate = false, + isTronToken = false, + isEditingDisabled = false, + isPrimaryButtonEnabled = true, + appCurrency = AppCurrency.Default, + isCustomSelected = false, + notifications = persistentListOf(), + ) + } + + private fun createFeeTooLowUM(): FeeUM.Content { + val fee = Fee.Common( + amount = com.tangem.blockchain.common.Amount( + currencySymbol = "SOL", + value = BigDecimal("0.0001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = com.tangem.blockchain.common.Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = fee, + priority = fee, + ) + return FeeUM.Content( + feeSelectorUM = FeeSelectorUM.Content( + fees = transactionFee, + selectedType = FeeType.Custom, + selectedFee = fee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.0001", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "SOL", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + nonce = BigInteger.ZERO, + ), + rate = BigDecimal("50000"), + isFeeConvertibleToFiat = true, + isFeeApproximate = false, + isTronToken = false, + isEditingDisabled = false, + isPrimaryButtonEnabled = true, + appCurrency = AppCurrency.Default, + isCustomSelected = false, + notifications = persistentListOf(), + ) + } + + private fun createFeeTooHighAndTooLowUM(): FeeUM.Content { + val priorityFee = Fee.Common( + amount = com.tangem.blockchain.common.Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = com.tangem.blockchain.common.Amount( + currencySymbol = "SOL", + value = BigDecimal("0.01"), + decimals = 8, + ), + ) + val customFee = Fee.Common( + amount = com.tangem.blockchain.common.Amount( + currencySymbol = "SOL", + value = BigDecimal("0.008"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = minimumFee, + priority = priorityFee, + ) + return FeeUM.Content( + feeSelectorUM = FeeSelectorUM.Content( + fees = transactionFee, + selectedType = FeeType.Custom, + selectedFee = customFee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.008", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "SOL", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + nonce = BigInteger.ZERO, + ), + rate = BigDecimal("50000"), + isFeeConvertibleToFiat = true, + isFeeApproximate = false, + isTronToken = false, + isEditingDisabled = false, + isPrimaryButtonEnabled = true, + appCurrency = AppCurrency.Default, + isCustomSelected = false, + notifications = persistentListOf(), + ) + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpLocale() { + Locale.setDefault(Locale.US) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt new file mode 100644 index 0000000000..e79d598f5e --- /dev/null +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt @@ -0,0 +1,450 @@ +package com.tangem.features.send.v2.send.confirm.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.send.v2.api.entity.* +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2 +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger +import java.util.Locale + +class NFTSendConfirmationNotificationsTransformerV2Test { + + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) + private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") + private val analyticsCategoryName = "test_category" + + @Test + fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest { + // GIVEN + val feeSelectorUM: FeeSelectorUM = mockk(relaxed = true) + val transformer = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState: ConfirmUM = ConfirmUM.Empty + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isEqualTo(initialState) + } + + @Test + fun `GIVEN fee selector not content WHEN transform THEN returns original state`() = runTest { + // GIVEN + val feeSelectorUM: FeeSelectorUM = FeeSelectorUM.Loading + val transformer = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isEqualTo(initialState) + } + + @Test + fun `GIVEN normal fee WHEN transform THEN returns state with footer and no notifications`() = runTest { + // GIVEN + val feeSelectorUM = createNormalFeeSelectorUM() + val transformer = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + val content = result as ConfirmUM.Content + assertThat(content.notifications).isEmpty() + assertThat(content.sendingFooter).isNotEqualTo(initialState.sendingFooter) + } + + @Test + fun `GIVEN fee too high WHEN transform THEN returns state with too high notification`() = runTest { + // GIVEN + val feeSelectorUM = createFeeTooHighUM() + val transformer = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + val content = result as ConfirmUM.Content + assertThat(content.notifications).hasSize(1) + assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java) + } + + @Test + fun `GIVEN fee too low WHEN transform THEN returns state with too low notification`() = runTest { + // GIVEN + val feeSelectorUM = createFeeTooLowUM() + val transformer = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + val content = result as ConfirmUM.Content + assertThat(content.notifications).hasSize(1) + assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java) + verify { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN both fee too high and too low WHEN transform THEN returns state with both notifications`() = runTest { + // GIVEN + val feeSelectorUM = createFeeTooHighAndTooLowUM() + val transformer = NFTSendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + val content = result as ConfirmUM.Content + assertThat(content.notifications).hasSize(2) + assertThat(content.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue() + assertThat(content.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue() + } + + private fun createTestConfirmUM(): ConfirmUM.Content { + return ConfirmUM.Content( + isPrimaryButtonEnabled = true, + walletName = mockk(relaxed = true), + isSending = false, + showTapHelp = false, + sendingFooter = mockk(relaxed = true), + notifications = persistentListOf(), + ) + } + + private fun createNormalFeeSelectorUM(): FeeSelectorUM.Content { + val fee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Single(fee) + return FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = transactionFee, + feeItems = persistentListOf(FeeItem.Market(fee)), + selectedFeeItem = FeeItem.Market(fee), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("50000"), + appCurrency = appCurrency, + ), + feeNonce = FeeNonce.Nonce( + nonce = BigInteger.ZERO, + onNonceChange = {}, + ), + ) + } + + private fun createFeeTooHighUM(): FeeSelectorUM.Content { + val priorityFee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = minimumFee, + priority = priorityFee, + ) + return FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = transactionFee, + feeItems = persistentListOf( + FeeItem.Custom( + fee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.01"), + decimals = 8, + ), + ), + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.01", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "SOL", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + ), + selectedFeeItem = FeeItem.Custom( + fee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.01"), + decimals = 8, + ), + ), + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.01", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "SOL", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("50000"), + appCurrency = appCurrency, + ), + feeNonce = FeeNonce.Nonce( + nonce = BigInteger.ZERO, + onNonceChange = {}, + ), + ) + } + + private fun createFeeTooLowUM(): FeeSelectorUM.Content { + val fee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.0001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = fee, + priority = fee, + ) + return FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = transactionFee, + feeItems = persistentListOf( + FeeItem.Custom( + fee = fee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.0001", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "SOL", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + ), + selectedFeeItem = FeeItem.Custom( + fee = fee, + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.0001", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "SOL", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("50000"), + appCurrency = appCurrency, + ), + feeNonce = FeeNonce.Nonce( + nonce = BigInteger.ZERO, + onNonceChange = {}, + ), + ) + } + + private fun createFeeTooHighAndTooLowUM(): FeeSelectorUM.Content { + val priorityFee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.001"), + decimals = 8, + ), + ) + val minimumFee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.01"), + decimals = 8, + ), + ) + val transactionFee = TransactionFee.Choosable( + minimum = minimumFee, + normal = minimumFee, + priority = priorityFee, + ) + return FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = transactionFee, + feeItems = persistentListOf( + FeeItem.Custom( + fee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.008"), + decimals = 8, + ), + ), + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.008", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "SOL", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + ), + selectedFeeItem = FeeItem.Custom( + fee = Fee.Common( + amount = Amount( + currencySymbol = "SOL", + value = BigDecimal("0.008"), + decimals = 8, + ), + ), + customValues = persistentListOf( + CustomFeeFieldUM( + value = "0.008", + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + symbol = "SOL", + decimals = 8, + title = mockk(relaxed = true), + footer = mockk(relaxed = true), + ), + ), + ), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + ), + feeFiatRateUM = FeeFiatRateUM( + rate = BigDecimal("50000"), + appCurrency = appCurrency, + ), + feeNonce = FeeNonce.Nonce( + nonce = BigInteger.ZERO, + onNonceChange = {}, + ), + ) + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpLocale() { + Locale.setDefault(Locale.US) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/TransformersComparisonTest.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmNotificationsTransformersComparisonTest.kt similarity index 99% rename from features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/TransformersComparisonTest.kt rename to features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmNotificationsTransformersComparisonTest.kt index b07143a6b1..4c7c95d94f 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/TransformersComparisonTest.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmNotificationsTransformersComparisonTest.kt @@ -27,7 +27,7 @@ import java.util.Locale import com.tangem.domain.tokens.model.Amount as DomainAmount import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMV2 -class TransformersComparisonTest { +class SendConfirmNotificationsTransformersComparisonTest { private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) diff --git a/features/token-recieve/api/.gitignore b/features/token-recieve/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/token-recieve/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/token-recieve/api/build.gradle.kts b/features/token-recieve/api/build.gradle.kts new file mode 100644 index 0000000000..5b31ee2af9 --- /dev/null +++ b/features/token-recieve/api/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("kotlin-parcelize") + id("configuration") +} + +android { + namespace = "com.tangem.features.tokenrecieve.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Common */ + implementation(projects.common.ui) + + /** Domain */ + implementation(projects.domain.models) + +} \ No newline at end of file diff --git a/features/token-recieve/api/src/main/kotlin/com/tangem/features/tokenreceive/TokenReceiveComponent.kt b/features/token-recieve/api/src/main/kotlin/com/tangem/features/tokenreceive/TokenReceiveComponent.kt new file mode 100644 index 0000000000..c37becdbb2 --- /dev/null +++ b/features/token-recieve/api/src/main/kotlin/com/tangem/features/tokenreceive/TokenReceiveComponent.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tokenreceive + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.TokenReceiveConfig + +interface TokenReceiveComponent : ComposableBottomSheetComponent { + + data class Params( + val config: TokenReceiveConfig, + val onDismiss: () -> Unit, + ) + + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): TokenReceiveComponent + } +} \ No newline at end of file diff --git a/features/token-recieve/api/src/main/kotlin/com/tangem/features/tokenreceive/TokenReceiveFeatureToggle.kt b/features/token-recieve/api/src/main/kotlin/com/tangem/features/tokenreceive/TokenReceiveFeatureToggle.kt new file mode 100644 index 0000000000..62175072dd --- /dev/null +++ b/features/token-recieve/api/src/main/kotlin/com/tangem/features/tokenreceive/TokenReceiveFeatureToggle.kt @@ -0,0 +1,6 @@ +package com.tangem.features.tokenreceive + +interface TokenReceiveFeatureToggle { + + val isNewTokenReceiveEnabled: Boolean +} \ No newline at end of file diff --git a/features/token-recieve/impl/.gitignore b/features/token-recieve/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/token-recieve/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/token-recieve/impl/build.gradle.kts b/features/token-recieve/impl/build.gradle.kts new file mode 100644 index 0000000000..b1b8d7b048 --- /dev/null +++ b/features/token-recieve/impl/build.gradle.kts @@ -0,0 +1,58 @@ +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") +} + +android { + namespace = "com.tangem.features.tokenreceive.impl" +} + +dependencies { + + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.navigation) + implementation(deps.compose.navigation.hilt) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.ui.utils) + + implementation(deps.kotlin.immutable.collections) + implementation(deps.timber) + implementation(deps.lifecycle.compose) + implementation(deps.kotlin.serialization) + implementation(deps.decompose.ext.compose) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /** Common */ + implementation(projects.common.ui) + + /** Core modules */ + implementation(projects.common.routing) + implementation(projects.core.navigation) + implementation(projects.core.ui) + implementation(projects.core.utils) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.decompose) + implementation(projects.core.res) + implementation(projects.core.configToggles) + + /** Domain modules */ + implementation(projects.domain.models) + implementation(projects.domain.transaction) + implementation(projects.domain.transaction.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + + /** Feature Apis */ + implementation(projects.features.tokenRecieve.api) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/DefaultTokenReceiveFeatureToggle.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/DefaultTokenReceiveFeatureToggle.kt new file mode 100644 index 0000000000..209317d97b --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/DefaultTokenReceiveFeatureToggle.kt @@ -0,0 +1,11 @@ +package com.tangem.features.tokenreceive + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager + +internal class DefaultTokenReceiveFeatureToggle( + private val featureTogglesManager: FeatureTogglesManager, +) : TokenReceiveFeatureToggle { + + override val isNewTokenReceiveEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("NEW_TOKEN_RECEIVE_ENABLED") +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt new file mode 100644 index 0000000000..83b0cf3b6f --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/DefaultTokenReceiveComponent.kt @@ -0,0 +1,131 @@ +package com.tangem.features.tokenreceive.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tokenreceive.TokenReceiveComponent +import com.tangem.features.tokenreceive.model.TokenReceiveModel +import com.tangem.features.tokenreceive.route.TokenReceiveRoutes +import com.tangem.features.tokenreceive.ui.TokenReceiveContentSheet +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultTokenReceiveComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: TokenReceiveComponent.Params, +) : TokenReceiveComponent, AppComponentContext by appComponentContext { + + private val model: TokenReceiveModel = getOrCreateModel(params) + private val innerRouter = InnerRouter( + stackNavigation = model.stackNavigation, + popCallback = { onChildBack() }, + ) + + private val contentStack = childStack( + key = "TokenReceiveFlowStack", + source = model.stackNavigation, + serializer = TokenReceiveRoutes.serializer(), + initialConfiguration = getInitialConfig(model.params.config.shouldShowWarning), + handleBackButton = false, + childFactory = ::screenChild, + ) + + @Composable + override fun BottomSheet() { + val content by contentStack.subscribeAsState() + val currentRoute = content.active.configuration + + TokenReceiveContentSheet( + route = currentRoute, + onCloseClick = ::dismiss, + onBackClick = ::onChildBack, + contentStack = content, + ) + } + + override fun dismiss() { + model.params.onDismiss() + } + + private fun onChildBack() { + when (contentStack.value.active.configuration) { + is TokenReceiveRoutes.QrCode -> model.stackNavigation.pop() + TokenReceiveRoutes.ReceiveAssets, + TokenReceiveRoutes.Warning, + -> dismiss() + } + } + + private fun screenChild( + config: TokenReceiveRoutes, + componentContext: ComponentContext, + ): ComposableContentComponent { + val appComponentContext = childByContext( + componentContext = componentContext, + router = innerRouter, + ) + return when (config) { + is TokenReceiveRoutes.QrCode -> TokenReceiveQrCodeComponent( + appComponentContext = appComponentContext, + params = TokenReceiveQrCodeComponent.TokenReceiveQrCodeParams( + network = model.params.config.cryptoCurrency.network.name, + address = model.state.value.addresses[config.addressId] ?: error("Address has to be there"), + callback = model, + onDismiss = ::dismiss, + id = config.addressId, + ), + ) + TokenReceiveRoutes.ReceiveAssets -> TokenReceiveAssetsComponent( + appComponentContext = appComponentContext, + params = TokenReceiveAssetsComponent.TokenReceiveAssetsParams( + addresses = model.state.value.addresses, + callback = model, + onDismiss = ::dismiss, + notificationConfigs = model.state.value.notificationConfigs, + showMemoDisclaimer = model.params.config.showMemoDisclaimer, + fullName = model.params.config.cryptoCurrency.network.name, + tokenName = model.getTokenName(), + ), + ) + TokenReceiveRoutes.Warning -> TokenReceiveWarningComponent( + appComponentContext = appComponentContext, + params = TokenReceiveWarningComponent.TokenReceiveWarningParams( + iconState = model.state.value.iconState, + callback = model, + onDismiss = ::dismiss, + network = model.params.config.cryptoCurrency.network, + ), + ) + } + } + + private fun getInitialConfig(shouldShowWarning: Boolean): TokenReceiveRoutes { + return if (shouldShowWarning) { + TokenReceiveRoutes.Warning + } else { + TokenReceiveRoutes.ReceiveAssets + } + } + + @AssistedFactory + interface Factory : TokenReceiveComponent.Factory { + override fun create( + context: AppComponentContext, + params: TokenReceiveComponent.Params, + ): DefaultTokenReceiveComponent + } +} + +internal interface TokenReceiveModelCallback : + TokenReceiveAssetsComponent.TokenReceiveAssetsModelCallback, + TokenReceiveQrCodeComponent.TokenReceiveQrCodeModelCallback, + TokenReceiveWarningComponent.TokenReceiveWarningModelCallback \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveAssetsComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveAssetsComponent.kt new file mode 100644 index 0000000000..7ece6e356d --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveAssetsComponent.kt @@ -0,0 +1,44 @@ +package com.tangem.features.tokenreceive.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tokenreceive.entity.ReceiveAddress +import com.tangem.features.tokenreceive.model.TokenReceiveAssetsModel +import com.tangem.features.tokenreceive.ui.TokenReceiveAssetsContent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +internal class TokenReceiveAssetsComponent( + appComponentContext: AppComponentContext, + private val params: TokenReceiveAssetsParams, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TokenReceiveAssetsModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + TokenReceiveAssetsContent(assetsUM = state) + } + + internal interface TokenReceiveAssetsModelCallback { + fun onQrCodeClick(id: Int) + fun onCopyClick(id: Int) + } + + data class TokenReceiveAssetsParams( + val notificationConfigs: ImmutableList, + val addresses: ImmutableMap, + val callback: TokenReceiveAssetsModelCallback, + val onDismiss: () -> Unit, + val showMemoDisclaimer: Boolean, + val fullName: String, + val tokenName: String, + ) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt new file mode 100644 index 0000000000..8191f9a208 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveQrCodeComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.tokenreceive.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tokenreceive.entity.ReceiveAddress +import com.tangem.features.tokenreceive.model.TokenReceiveQrCodeModel +import com.tangem.features.tokenreceive.ui.TokenReceiveQrCodeContent + +internal class TokenReceiveQrCodeComponent( + appComponentContext: AppComponentContext, + private val params: TokenReceiveQrCodeParams, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TokenReceiveQrCodeModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + TokenReceiveQrCodeContent(qrCodeUM = state) + } + + internal interface TokenReceiveQrCodeModelCallback { + fun onCopyClick(id: Int) + fun onShareClick(address: String) + } + + data class TokenReceiveQrCodeParams( + val id: Int, + val network: String, + val address: ReceiveAddress, + val callback: TokenReceiveQrCodeModelCallback, + val onDismiss: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveWarningComponent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveWarningComponent.kt new file mode 100644 index 0000000000..46ce303258 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/component/TokenReceiveWarningComponent.kt @@ -0,0 +1,38 @@ +package com.tangem.features.tokenreceive.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.network.Network +import com.tangem.features.tokenreceive.model.TokenReceiveWarningModel +import com.tangem.features.tokenreceive.ui.TokenReceiveWarningContent + +internal class TokenReceiveWarningComponent( + appComponentContext: AppComponentContext, + private val params: TokenReceiveWarningParams, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TokenReceiveWarningModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + TokenReceiveWarningContent(warningUM = state) + } + + internal interface TokenReceiveWarningModelCallback { + fun onWarningAcknowledged() + } + + data class TokenReceiveWarningParams( + val iconState: CurrencyIconState, + val callback: TokenReceiveWarningModelCallback, + val onDismiss: () -> Unit, + val network: Network, + ) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/di/TokenReceiveModule.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/di/TokenReceiveModule.kt new file mode 100644 index 0000000000..44cfdf6d8b --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/di/TokenReceiveModule.kt @@ -0,0 +1,68 @@ +package com.tangem.features.tokenreceive.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.tokenreceive.DefaultTokenReceiveFeatureToggle +import com.tangem.features.tokenreceive.TokenReceiveComponent +import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle +import com.tangem.features.tokenreceive.component.DefaultTokenReceiveComponent +import com.tangem.features.tokenreceive.model.TokenReceiveAssetsModel +import com.tangem.features.tokenreceive.model.TokenReceiveModel +import com.tangem.features.tokenreceive.model.TokenReceiveQrCodeModel +import com.tangem.features.tokenreceive.model.TokenReceiveWarningModel +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object FeatureToggleModule { + + @Provides + @Singleton + fun provideTokenReceiveFeatureToggle(featureTogglesManager: FeatureTogglesManager): TokenReceiveFeatureToggle { + return DefaultTokenReceiveFeatureToggle( + featureTogglesManager = featureTogglesManager, + ) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindComponent(factory: DefaultTokenReceiveComponent.Factory): TokenReceiveComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(TokenReceiveModel::class) + fun bindsTokenReceiveModel(model: TokenReceiveModel): Model + + @Binds + @IntoMap + @ClassKey(TokenReceiveAssetsModel::class) + fun bindsTokenReceiveAssetsModel(model: TokenReceiveAssetsModel): Model + + @Binds + @IntoMap + @ClassKey(TokenReceiveQrCodeModel::class) + fun bindsTokenReceiveQrCodeModel(model: TokenReceiveQrCodeModel): Model + + @Binds + @IntoMap + @ClassKey(TokenReceiveWarningModel::class) + fun bindsTokenReceiveWarningModel(model: TokenReceiveWarningModel): Model +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt new file mode 100644 index 0000000000..960dda1224 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt @@ -0,0 +1,16 @@ +package com.tangem.features.tokenreceive.entity + +import com.tangem.core.ui.extensions.TextReference + +internal data class ReceiveAddress( + val value: String, + val type: Type, +) { + sealed interface Type { + data object Ens : Type + + data class Default( + val displayName: TextReference, + ) : Type + } +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveAssetsModel.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveAssetsModel.kt new file mode 100644 index 0000000000..bf37b011e3 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveAssetsModel.kt @@ -0,0 +1,59 @@ +package com.tangem.features.tokenreceive.model + +import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.tokens.model.analytics.TokenReceiveNewAnalyticsEvent +import com.tangem.features.tokenreceive.component.TokenReceiveAssetsComponent +import com.tangem.features.tokenreceive.entity.ReceiveAddress +import com.tangem.features.tokenreceive.ui.state.ReceiveAssetsUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TokenReceiveAssetsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + private val params = paramsContainer.require() + + init { + analyticsEventHandler.send( + TokenReceiveNewAnalyticsEvent.ReceiveScreenOpened( + token = params.tokenName, + blockchainName = params.fullName, + ensStatus = configureEnsStatus(), + ), + ) + } + + internal val state: StateFlow + field = MutableStateFlow( + ReceiveAssetsUM( + onCopyClick = params.callback::onCopyClick, + onOpenQrCodeClick = params.callback::onQrCodeClick, + addresses = params.addresses, + showMemoDisclaimer = params.showMemoDisclaimer, + isEnsResultLoading = false, + notificationConfigs = params.notificationConfigs, + fullName = params.fullName, + ), + ) + + private fun configureEnsStatus(): AnalyticsParam.EnsStatus { + val hasEnsAddress = params.addresses.values.any { it.type == ReceiveAddress.Type.Ens } + return if (hasEnsAddress) { + AnalyticsParam.EnsStatus.FULL + } else { + AnalyticsParam.EnsStatus.EMPTY + } + } +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveModel.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveModel.kt new file mode 100644 index 0000000000..183d11d046 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveModel.kt @@ -0,0 +1,219 @@ +package com.tangem.features.tokenreceive.model + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.push +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.ui.R +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.models.Asset +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.ens.EnsAddress +import com.tangem.domain.models.network.Network +import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase +import com.tangem.domain.tokens.model.analytics.TokenReceiveNewAnalyticsEvent +import com.tangem.domain.transaction.usecase.GetReverseResolvedEnsAddressUseCase +import com.tangem.features.tokenreceive.TokenReceiveComponent +import com.tangem.features.tokenreceive.component.TokenReceiveModelCallback +import com.tangem.features.tokenreceive.entity.ReceiveAddress +import com.tangem.features.tokenreceive.route.TokenReceiveRoutes +import com.tangem.features.tokenreceive.ui.state.TokenReceiveUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@Suppress("LongParameterList") +@ModelScoped +internal class TokenReceiveModel @Inject constructor( + private val clipboardManager: ClipboardManager, + private val shareManager: ShareManager, + private val getReverseResolvedEnsAddressUseCase: GetReverseResolvedEnsAddressUseCase, + private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase, + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model(), TokenReceiveModelCallback { + + private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) + + val params = paramsContainer.require() + + val stackNavigation = StackNavigation() + + internal val state: StateFlow + field = MutableStateFlow(getInitState()) + + init { + modelScope.launch { + load() + } + } + + override fun onQrCodeClick(id: Int) { + analyticsEventHandler.send( + TokenReceiveNewAnalyticsEvent.QrScreenOpened( + token = getTokenName(), + blockchainName = params.config.cryptoCurrency.network.name, + ), + ) + stackNavigation.push(configuration = TokenReceiveRoutes.QrCode(addressId = id)) + } + + override fun onCopyClick(id: Int) { + val addressToCopy = state.value.addresses[id] ?: return + sendCopyActionAnalytic(addressToCopy) + clipboardManager.setText(text = addressToCopy.value, isSensitive = true) + } + + override fun onShareClick(address: String) { + shareManager.shareText(text = address) + } + + override fun onWarningAcknowledged() { + modelScope.launch { + saveViewedTokenReceiveWarningUseCase.invoke( + when (val asset = params.config.asset) { + Asset.Currency -> params.config.cryptoCurrency.name + Asset.NFT -> asset.name + }, + ) + stackNavigation.push(configuration = TokenReceiveRoutes.ReceiveAssets) + } + } + + internal fun getTokenName(): String { + return when (val asset = params.config.asset) { + Asset.Currency -> params.config.cryptoCurrency.symbol + Asset.NFT -> asset.name + } + } + + private fun mapAddresses(addresses: List): ImmutableMap { + return buildMap { + addresses.mapIndexed { index, model -> + val type = when (model.nameService) { + ReceiveAddressModel.NameService.Default -> { + ReceiveAddress.Type.Default( + displayName = stringReference(model.displayName), + ) + } + ReceiveAddressModel.NameService.Ens -> ReceiveAddress.Type.Ens + } + put( + key = index, + value = ReceiveAddress( + value = model.value, + type = type, + ), + ) + } + }.toPersistentMap() + } + + private suspend fun load() = withContext(dispatchers.default) { + state.value = state.value.copy( + isEnsResultLoading = true, + ) + + val reverseResolveResult = + if (params.config.cryptoCurrency.network.nameResolvingType == Network.NameResolvingType.ENS) { + getReverseResolvedEnsAddressUseCase( + userWalletId = params.config.userWalletId, + network = params.config.cryptoCurrency.network, + addresses = params.config.receiveAddress.map { it.value }, + ) + } else { + emptyList() + } + + val currentAddressValues = state.value.addresses.values.map { it.value }.toSet() + + val newEnsAddresses = reverseResolveResult + .filterIsInstance() + .filterNot { it.name in currentAddressValues } + .map { ensAddress -> ReceiveAddress(value = ensAddress.name, type = ReceiveAddress.Type.Ens) } + + val combinedAddresses = (state.value.addresses.values + newEnsAddresses) + .sortedWith(compareByDescending { it.type is ReceiveAddress.Type.Ens }) + + val updatedAddresses = combinedAddresses + .mapIndexed { index, address -> index to address } + .toMap() + .toPersistentMap() + + state.value = state.value.copy( + isEnsResultLoading = false, + addresses = updatedAddresses, + ) + } + + private fun getNotifications(): List { + return buildList { + add( + NotificationUM.Info( + title = resourceReference( + R.string.receive_bottom_sheet_warning_title, + wrappedList( + getTokenName(), + params.config.cryptoCurrency.network.name, + ), + ), + subtitle = resourceReference(R.string.receive_bottom_sheet_warning_message_description), + iconTint = NotificationConfig.IconTint.Accent, + ), + ) + + params.config.tokenReceiveNotification.map { notification -> + add( + NotificationUM.Warning( + title = resourceReference(notification.title), + subtitle = resourceReference(notification.subtitle), + ), + ) + } + } + } + + private fun getInitState(): TokenReceiveUM { + return TokenReceiveUM( + addresses = mapAddresses(params.config.receiveAddress), + iconState = iconStateConverter.convert(params.config.cryptoCurrency), + network = params.config.cryptoCurrency.network.name, + isEnsResultLoading = false, + notificationConfigs = getNotifications().toImmutableList(), + ) + } + + private fun sendCopyActionAnalytic(receiveAddress: ReceiveAddress) { + val event = when (receiveAddress.type) { + is ReceiveAddress.Type.Default -> { + TokenReceiveNewAnalyticsEvent.ButtonCopyAddress( + token = getTokenName(), + blockchainName = params.config.cryptoCurrency.network.name, + ) + } + ReceiveAddress.Type.Ens -> { + TokenReceiveNewAnalyticsEvent.ButtonCopyEns( + token = getTokenName(), + blockchainName = params.config.cryptoCurrency.network.name, + ) + } + } + analyticsEventHandler.send(event) + } +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveQrCodeModel.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveQrCodeModel.kt new file mode 100644 index 0000000000..68246b663f --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveQrCodeModel.kt @@ -0,0 +1,35 @@ +package com.tangem.features.tokenreceive.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.tokenreceive.component.TokenReceiveQrCodeComponent +import com.tangem.features.tokenreceive.entity.ReceiveAddress +import com.tangem.features.tokenreceive.ui.state.QrCodeUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TokenReceiveQrCodeModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + + internal val state: StateFlow + field = MutableStateFlow( + QrCodeUM( + network = params.network, + addressValue = params.address.value, + addressName = (params.address.type as? ReceiveAddress.Type.Default)?.displayName ?: TextReference.EMPTY, + onCopyClick = { params.callback.onCopyClick(params.id) }, + onShareClick = params.callback::onShareClick, + ), + ) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveWarningModel.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveWarningModel.kt new file mode 100644 index 0000000000..8c686c07a7 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/model/TokenReceiveWarningModel.kt @@ -0,0 +1,33 @@ +package com.tangem.features.tokenreceive.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.iconResId +import com.tangem.features.tokenreceive.component.TokenReceiveWarningComponent +import com.tangem.features.tokenreceive.ui.state.WarningUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TokenReceiveWarningModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + + internal val state: StateFlow + field = MutableStateFlow( + WarningUM( + iconState = params.iconState, + onWarningAcknowledged = params.callback::onWarningAcknowledged, + network = params.network.name, + networkIcon = params.network.iconResId, + ), + ) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/route/TokenReceiveRoutes.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/route/TokenReceiveRoutes.kt new file mode 100644 index 0000000000..c98a6e3c12 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/route/TokenReceiveRoutes.kt @@ -0,0 +1,19 @@ +package com.tangem.features.tokenreceive.route + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +internal sealed interface TokenReceiveRoutes : Route { + + @Serializable + data object Warning : TokenReceiveRoutes + + @Serializable + data object ReceiveAssets : TokenReceiveRoutes + + @Serializable + data class QrCode(val addressId: Int) : TokenReceiveRoutes +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/ContainerWithSnackbarHost.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/ContainerWithSnackbarHost.kt new file mode 100644 index 0000000000..94415b927d --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/ContainerWithSnackbarHost.kt @@ -0,0 +1,23 @@ +package com.tangem.features.tokenreceive.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.snackbar.CopiedTextSnackbarHost + +@Composable +internal fun ContainerWithSnackbarHost(snackbarHostState: SnackbarHostState, content: @Composable () -> Unit) { + Box { + content() + CopiedTextSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 80.dp), + ) + } +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt new file mode 100644 index 0000000000..10f41e7e05 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt @@ -0,0 +1,346 @@ +package com.tangem.features.tokenreceive.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.res.getStringSafe +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.components.SpacerW8 +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.components.buttons.small.TangemIconButton +import com.tangem.core.ui.components.icons.identicon.IdentIcon +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tokenreceive.entity.ReceiveAddress +import com.tangem.features.tokenreceive.ui.state.ReceiveAssetsUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.launch + +@Composable +internal fun TokenReceiveAssetsContent(assetsUM: ReceiveAssetsUM) { + val snackbarHostState = remember(::SnackbarHostState) + + ContainerWithSnackbarHost(snackbarHostState = snackbarHostState) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.background.tertiary) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 8.dp, + ), + ) { + SpacerH8() + + Info( + showMemoDisclaimer = assetsUM.showMemoDisclaimer, + notificationConfigs = assetsUM.notificationConfigs, + ) + + SpacerH12() + + AddressBlock( + onCopyClick = assetsUM.onCopyClick, + onOpenQrCodeClick = assetsUM.onOpenQrCodeClick, + addresses = assetsUM.addresses, + snackbarHostState = snackbarHostState, + fullName = assetsUM.fullName, + ) + + if (assetsUM.isEnsResultLoading) { + LoadingBlock() + } + } + } +} + +@Composable +private fun Info( + notificationConfigs: ImmutableList, + showMemoDisclaimer: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(space = 16.dp), + ) { + if (showMemoDisclaimer) { + Text( + modifier = Modifier + .padding(horizontal = 18.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.receive_bottom_sheet_no_memo_required_message), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } + + notificationConfigs.fastForEach { + key(it.hashCode()) { + Notification(config = it.config) + } + } + } +} + +@Composable +private fun AddressBlock( + onOpenQrCodeClick: (id: Int) -> Unit, + onCopyClick: (id: Int) -> Unit, + addresses: ImmutableMap, + snackbarHostState: SnackbarHostState, + fullName: String, +) { + val hapticFeedback = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val resources = context.resources + + addresses.entries.toList().fastForEach { entry -> + when (entry.value.type) { + is ReceiveAddress.Type.Default -> { + key(entry.key) { + AddressItem( + onCopyClick = { + onCopyClick(entry.key) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = resources.getStringSafe( + R.string.wallet_notification_address_copied, + ), + ) + } + }, + onOpenQrCodeClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onOpenQrCodeClick(entry.key) + }, + fullName = fullName, + address = entry.value.value, + ) + SpacerH8() + } + } + ReceiveAddress.Type.Ens -> { + key(entry.key) { + EnsItem( + onCopyClick = { + onCopyClick(entry.key) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = resources.getStringSafe( + R.string.wallet_notification_address_copied, + ), + ) + } + }, + address = entry.value.value, + ) + SpacerH8() + } + } + } + } +} + +@Composable +private fun AddressItem( + onOpenQrCodeClick: () -> Unit, + fullName: String, + onCopyClick: () -> Unit, + address: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = TangemTheme.colors.background.action), + onClick = onOpenQrCodeClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IdentIcon( + address = address, + modifier = Modifier + .size(size = 36.dp) + .clip(shape = RoundedCornerShape(18.dp)), + ) + + SpacerW12() + + Column(modifier = Modifier.weight(1f)) { + EllipsisText( + text = stringResourceSafe(R.string.domain_receive_assets_onboarding_network_name, fullName), + ellipsis = TextEllipsis.Middle, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + ) + + EllipsisText( + text = address, + ellipsis = TextEllipsis.Middle, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + ) + } + + SpacerW12() + + TangemIconButton( + modifier = Modifier.size(TangemTheme.dimens.size28), + iconRes = R.drawable.ic_qrcode_new_24, + onClick = onOpenQrCodeClick, + ) + + SpacerW8() + + TangemIconButton( + modifier = Modifier.size(TangemTheme.dimens.size28), + iconRes = R.drawable.ic_copy_new_24, + onClick = onCopyClick, + ) + } + } +} + +@Composable +private fun EnsItem(onCopyClick: () -> Unit, address: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = TangemTheme.colors.background.action), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + modifier = Modifier.size(36.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_ens_36), + contentDescription = null, + ) + + SpacerW12() + + EllipsisText( + modifier = Modifier.weight(1f), + text = address, + ellipsis = TextEllipsis.Middle, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + ) + + TangemIconButton( + modifier = Modifier.size(28.dp), + iconRes = R.drawable.ic_copy_new_24, + onClick = onCopyClick, + ) + } + } +} + +@Composable +private fun LoadingBlock(modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = TangemTheme.colors.background.action), + ) { + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 64.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = TangemTheme.colors.icon.informative, + modifier = Modifier.padding(TangemTheme.dimens.spacing8), + ) + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_TokenReceiveAssetsContent( + @PreviewParameter(TokenReceiveAssetsContentProvider::class) params: ReceiveAssetsUM, +) { + TangemThemePreview { + TokenReceiveAssetsContent(assetsUM = params) + } +} + +private class TokenReceiveAssetsContentProvider : PreviewParameterProvider { + val address = ReceiveAddress( + value = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d", + type = ReceiveAddress.Type.Default( + displayName = stringReference("Etherium address"), + ), + ) + private val config = ReceiveAssetsUM( + notificationConfigs = + persistentListOf( + NotificationUM.Warning( + title = stringReference("Send only XLM on the Ethereum network"), + subtitle = resourceReference(R.string.receive_bottom_sheet_warning_message_description), + ), + ), + addresses = persistentMapOf( + 0 to address, + 1 to address.copy(type = ReceiveAddress.Type.Ens, value = "papasha.eth"), + ), + showMemoDisclaimer = false, + onCopyClick = {}, + onOpenQrCodeClick = {}, + isEnsResultLoading = true, + fullName = "Etherium", + ) + + override val values: Sequence + get() = sequenceOf(config) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContent.kt new file mode 100644 index 0000000000..75ee1fb93f --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveContent.kt @@ -0,0 +1,93 @@ +package com.tangem.features.tokenreceive.ui + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tokenreceive.route.TokenReceiveRoutes + +@Composable +internal fun TokenReceiveContentSheet( + route: TokenReceiveRoutes, + onCloseClick: () -> Unit, + onBackClick: () -> Unit, + contentStack: ChildStack, +) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onCloseClick, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = onBackClick, + containerColor = TangemTheme.colors.background.tertiary, + title = { + Title( + route = route, + onBackClick = onBackClick, + onCloseClick = onCloseClick, + ) + }, + content = { + TokenReceiveContent( + stackState = contentStack, + modifier = Modifier, + ) + }, + ) +} + +@Composable +private fun Title(route: TokenReceiveRoutes, onBackClick: () -> Unit, onCloseClick: () -> Unit) { + when (route) { + is TokenReceiveRoutes.QrCode -> { + TangemModalBottomSheetTitle( + startIconRes = R.drawable.ic_back_24, + onStartClick = onBackClick, + endIconRes = R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) + } + TokenReceiveRoutes.ReceiveAssets -> { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.domain_receive_assets_navigation_title), + endIconRes = R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) + } + TokenReceiveRoutes.Warning -> { + TangemModalBottomSheetTitle( + endIconRes = R.drawable.ic_close_24, + onEndClick = onCloseClick, + ) + } + } +} + +@Composable +internal fun TokenReceiveContent( + stackState: ChildStack, + modifier: Modifier = Modifier, +) { + Children( + stack = stackState, + animation = stackAnimation(fade()), + modifier = modifier + .fillMaxSize() + .animateContentSize(), + ) { + it.instance.Content(Modifier.fillMaxSize()) + } +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt new file mode 100644 index 0000000000..d248c1b34d --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt @@ -0,0 +1,195 @@ +package com.tangem.features.tokenreceive.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.res.getStringSafe +import com.tangem.core.ui.components.* +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tokenreceive.impl.R +import com.tangem.features.tokenreceive.ui.state.QrCodeUM +import kotlinx.coroutines.launch + +@Composable +internal fun TokenReceiveQrCodeContent(qrCodeUM: QrCodeUM) { + val snackbarHostState = remember(::SnackbarHostState) + val qrCodePainter = rememberQrPainter(content = qrCodeUM.addressValue) + + ContainerWithSnackbarHost(snackbarHostState = snackbarHostState) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.background.tertiary) + .padding(bottom = 16.dp) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpacerH8() + + QrCodePage( + addressFullName = qrCodeUM.addressName, + addressValue = qrCodeUM.addressValue, + network = qrCodeUM.network, + qrCodePainter = qrCodePainter, + ) + SpacerH24() + + Buttons( + onShareClick = { qrCodeUM.onShareClick(qrCodeUM.addressValue) }, + onCopyClick = qrCodeUM.onCopyClick, + snackbarHostState = snackbarHostState, + ) + } + } +} + +@Composable +private fun QrCodePage(addressFullName: TextReference, addressValue: String, network: String, qrCodePainter: Painter) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column(modifier = Modifier.padding(horizontal = TangemTheme.dimens.size36)) { + Text( + text = stringResourceSafe( + R.string.receive_bottom_sheet_warning_message_compact, + addressFullName.resolveReference(), + network, + ), + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + style = TangemTheme.typography.h3, + ) + + SpacerH(20.dp) + + Image( + painter = qrCodePainter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(248.dp), + ) + + SpacerH24() + } + Text( + text = stringResourceSafe(R.string.wc_common_address), + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.subtitle2, + ) + + SpacerH2() + + Text( + text = addressValue, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + style = TangemTheme.typography.subtitle1, + ) + } +} + +@Composable +private fun Buttons( + snackbarHostState: SnackbarHostState, + onShareClick: () -> Unit, + onCopyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val hapticFeedback = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val resources = context.resources + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + SecondaryButtonIconStart( + modifier = Modifier.weight(1f), + text = stringResourceSafe(id = R.string.common_copy), + iconResId = R.drawable.ic_copy_24, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onCopyClick() + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = resources.getStringSafe(R.string.wallet_notification_address_copied), + ) + } + }, + ) + + SecondaryButtonIconStart( + modifier = Modifier.weight(1f), + text = stringResourceSafe(id = R.string.common_share), + iconResId = R.drawable.ic_share_24, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onShareClick() + }, + ) + } +} + +@Composable +private fun rememberQrPainter(content: String, size: Dp = 248.dp, padding: Dp = 0.dp): BitmapPainter { + val density = LocalDensity.current + return remember(content) { + BitmapPainter( + content.toQrCode( + sizePx = with(density) { size.roundToPx() }, + paddingPx = with(density) { padding.roundToPx() }, + ).asImageBitmap(), + ) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_TokenReceiveQrCodeContent( + @PreviewParameter(TokenReceiveQrCodeContentPreviewProvider::class) qrCodeUM: QrCodeUM, +) { + TangemThemePreview { + TokenReceiveQrCodeContent(qrCodeUM = qrCodeUM) + } +} + +private class TokenReceiveQrCodeContentPreviewProvider : PreviewParameterProvider { + private val config = QrCodeUM( + network = "Ethereum", + addressName = stringReference("Etherium"), + addressValue = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d", + onCopyClick = {}, + onShareClick = {}, + ) + + override val values: Sequence + get() = sequenceOf(config) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt new file mode 100644 index 0000000000..2aa8aeff9c --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt @@ -0,0 +1,142 @@ +package com.tangem.features.tokenreceive.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tokenreceive.impl.R +import com.tangem.features.tokenreceive.ui.state.WarningUM + +@Composable +internal fun TokenReceiveWarningContent(warningUM: WarningUM) { + val hapticFeedback = LocalHapticFeedback.current + + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.background.tertiary) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CurrencyIcon( + modifier = Modifier.size(size = 56.dp), + state = warningUM.iconState, + shouldDisplayNetwork = false, + iconSize = 56.dp, + ) + + SpacerH24() + + WarningBlock(networkIcon = warningUM.networkIcon, networkName = warningUM.network) + + SpacerH12() + + Text( + textAlign = TextAlign.Center, + text = stringResourceSafe(R.string.domain_receive_assets_onboarding_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + + SpacerH(48.dp) + + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_got_it), + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + warningUM.onWarningAcknowledged() + }, + ) + } +} + +@Composable +fun WarningBlock(networkName: String, networkIcon: Int, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + textAlign = TextAlign.Center, + text = stringResourceSafe(R.string.domain_receive_assets_onboarding_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = networkIcon), + tint = Color.Unspecified, + contentDescription = null, + ) + + Text( + textAlign = TextAlign.Center, + text = stringResourceSafe(R.string.domain_receive_assets_onboarding_network_name, networkName), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_TokenReceiveWarningContent( + @PreviewParameter(TokenReceiveWarningContentProvider::class) warningUM: WarningUM, +) { + TangemThemePreview { + TokenReceiveWarningContent(warningUM = warningUM) + } +} + +private class TokenReceiveWarningContentProvider : PreviewParameterProvider { + val iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = null, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + showCustomBadge = false, + ) + + override val values: Sequence + get() = sequenceOf( + WarningUM( + iconState = iconState, + onWarningAcknowledged = {}, + network = "Etherium", + networkIcon = R.drawable.ic_eth_16, + ), + ) +} \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/QrCodeUM.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/QrCodeUM.kt new file mode 100644 index 0000000000..f9425a4fc6 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/QrCodeUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.tokenreceive.ui.state + +import com.tangem.core.ui.extensions.TextReference + +internal data class QrCodeUM( + val onCopyClick: () -> Unit, + val onShareClick: (String) -> Unit, + val addressName: TextReference, + val addressValue: String, + val network: String, +) \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/ReceiveAssetsUM.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/ReceiveAssetsUM.kt new file mode 100644 index 0000000000..3953389152 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/ReceiveAssetsUM.kt @@ -0,0 +1,16 @@ +package com.tangem.features.tokenreceive.ui.state + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.features.tokenreceive.entity.ReceiveAddress +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +internal data class ReceiveAssetsUM( + val showMemoDisclaimer: Boolean, + val addresses: ImmutableMap, + val onOpenQrCodeClick: (id: Int) -> Unit, + val onCopyClick: (id: Int) -> Unit, + val isEnsResultLoading: Boolean, + val notificationConfigs: ImmutableList, + val fullName: String, +) \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/TokenReceiveUM.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/TokenReceiveUM.kt new file mode 100644 index 0000000000..27eecde6a3 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/TokenReceiveUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.tokenreceive.ui.state + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.features.tokenreceive.entity.ReceiveAddress +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +internal data class TokenReceiveUM( + val network: String, + val iconState: CurrencyIconState, + val addresses: ImmutableMap, + val isEnsResultLoading: Boolean, + val notificationConfigs: ImmutableList, +) \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/WarningUM.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/WarningUM.kt new file mode 100644 index 0000000000..c31262fd58 --- /dev/null +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/state/WarningUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.tokenreceive.ui.state + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState + +internal data class WarningUM( + val network: String, + val networkIcon: Int, + val iconState: CurrencyIconState, + val onWarningAcknowledged: () -> Unit, +) \ No newline at end of file diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 4bcd8ce768..5da95d55a3 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -101,5 +101,8 @@ dependencies { implementation(projects.features.swap.api) implementation(projects.features.txhistory.api) implementation(projects.features.sendV2.api) + implementation(projects.features.tokenRecieve.api) + + implementation(deps.decompose.ext.compose) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index 410b089abe..2291d82965 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -4,16 +4,24 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.essenty.lifecycle.subscribe import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.NavigationBar3ButtonsScrim +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsModel import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.tokendetails.TokenDetailsComponent +import com.tangem.features.tokenreceive.TokenReceiveComponent import com.tangem.features.txhistory.component.TxHistoryComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -25,6 +33,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( @Assisted params: TokenDetailsComponent.Params, tokenMarketBlockComponentFactory: TokenMarketBlockComponent.Factory, txHistoryComponentFactory: TxHistoryComponent.Factory, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, ) : TokenDetailsComponent, AppComponentContext by appComponentContext { private val model: TokenDetailsModel = getOrCreateModel(params) @@ -37,6 +46,13 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( ), ) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TokenReceiveConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + init { lifecycle.subscribe( onPause = model::onPause, @@ -54,12 +70,14 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() NavigationBar3ButtonsScrim() TokenDetailsScreen( state = state, tokenMarketBlockComponent = tokenMarketBlockComponent, txHistoryComponent = txHistoryComponent, ) + bottomSheet.child?.instance?.BottomSheet() } private fun CryptoCurrency.toTokenMarketParam(): TokenMarketBlockComponent.Params? { @@ -68,6 +86,17 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( return TokenMarketBlockComponent.Params(cryptoCurrency = this) } + private fun bottomSheetChild( + config: TokenReceiveConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + @AssistedFactory interface Factory : TokenDetailsComponent.Factory { override fun create( 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 f5352430b4..7f2de4ef2c 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 @@ -4,6 +4,8 @@ import androidx.compose.runtime.Stable import androidx.paging.cachedIn import arrow.core.getOrElse import arrow.core.merge +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate import com.tangem.blockchain.common.address.AddressType import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter @@ -35,8 +37,11 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -60,10 +65,7 @@ import com.tangem.domain.transaction.error.AssociateAssetError import com.tangem.domain.transaction.error.IncompleteTransactionError import com.tangem.domain.transaction.error.OpenTrustlineError import com.tangem.domain.transaction.error.SendTransactionError -import com.tangem.domain.transaction.usecase.AssociateAssetUseCase -import com.tangem.domain.transaction.usecase.DismissIncompleteTransactionUseCase -import com.tangem.domain.transaction.usecase.OpenTrustlineUseCase -import com.tangem.domain.transaction.usecase.RetryIncompleteTransactionUseCase +import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase @@ -82,6 +84,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.e import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R +import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter import com.tangem.utils.Provider import com.tangem.utils.coroutines.* @@ -137,6 +140,9 @@ internal class TokenDetailsModel @Inject constructor( private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener, private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val sendFeatureToggles: SendFeatureToggles, + private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, + private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, + private val getEnsNameUseCase: GetEnsNameUseCase, ) : Model(), TokenDetailsClickIntents { private val params = paramsContainer.require() @@ -159,6 +165,8 @@ internal class TokenDetailsModel @Inject constructor( /** Transaction id to check for status */ private val waitForFirstExpressStatusEmmit = MutableStateFlow(false) + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + private val stateFactory = TokenDetailsStateFactory( currentStateProvider = Provider { uiState.value }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), @@ -594,20 +602,27 @@ internal class TokenDetailsModel @Inject constructor( } modelScope.launch { - analyticsEventsHandler.send(TokenReceiveAnalyticsEvent.ReceiveScreenOpened(cryptoCurrency.symbol)) - - internalUiState.value = stateFactory.getStateWithReceiveBottomSheet( - currency = cryptoCurrency, - networkAddress = networkAddress, - onCopyClick = { - analyticsEventsHandler.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(cryptoCurrency.symbol)) - clipboardManager.setText(text = it, isSensitive = true) - }, - onShareClick = { - analyticsEventsHandler.send(TokenReceiveAnalyticsEvent.ButtonShareAddress(cryptoCurrency.symbol)) - shareManager.shareText(text = it) - }, - ) + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { + bottomSheetNavigation.activate( + configuration = configureReceiveAddresses(addresses = networkAddress), + ) + } else { + analyticsEventsHandler.send(TokenReceiveAnalyticsEvent.ReceiveScreenOpened(cryptoCurrency.symbol)) + internalUiState.value = stateFactory.getStateWithReceiveBottomSheet( + currency = cryptoCurrency, + networkAddress = networkAddress, + onCopyClick = { + analyticsEventsHandler.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(cryptoCurrency.symbol)) + clipboardManager.setText(text = it, isSensitive = true) + }, + onShareClick = { + analyticsEventsHandler.send( + TokenReceiveAnalyticsEvent.ButtonShareAddress(cryptoCurrency.symbol), + ) + shareManager.shareText(text = it) + }, + ) + } } } @@ -1061,6 +1076,43 @@ internal class TokenDetailsModel @Inject constructor( .launchIn(modelScope) } + private suspend fun configureReceiveAddresses(addresses: NetworkAddress): TokenReceiveConfig { + val ensName = getEnsNameUseCase.invoke( + userWalletId = userWalletId, + network = cryptoCurrency.network, + address = addresses.defaultAddress.value, + ) + + val receiveAddresses = buildList { + ensName?.let { ens -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Ens, + value = ens, + displayName = ens, + ), + ) + } + addresses.availableAddresses.map { address -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Default, + value = address.value, + displayName = "${cryptoCurrency.name} (${cryptoCurrency.symbol})", + ), + ) + } + } + + return TokenReceiveConfig( + shouldShowWarning = cryptoCurrency.name !in getViewedTokenReceiveWarningUseCase(), + cryptoCurrency = cryptoCurrency, + userWalletId = userWalletId, + showMemoDisclaimer = cryptoCurrency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, + receiveAddress = receiveAddresses, + ) + } + private companion object { const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L } diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 5d506674c2..a8fffc2ea0 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -96,6 +96,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.notifications) implementation(projects.domain.notifications.toggles) + implementation(projects.domain.transaction) /** Feature Apis */ implementation(projects.features.details.api) @@ -114,6 +115,7 @@ dependencies { implementation(projects.features.nft.api) implementation(projects.features.sendV2.api) implementation(projects.features.kyc.api) + implementation(projects.features.tokenRecieve.api) /** Common modules */ implementation(projects.common) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index 8ee7b2c6c2..8b75c0188c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -24,11 +24,13 @@ import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.markets.entry.MarketsEntryComponent import com.tangem.features.pushnotifications.api.PushNotificationsBottomSheetComponent import com.tangem.features.pushnotifications.api.PushNotificationsParams +import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.launch +@Suppress("LongParameterList") internal class WalletComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted navigate: (WalletRoute) -> Unit, @@ -36,6 +38,7 @@ internal class WalletComponent @AssistedInject constructor( private val marketsEntryComponentFactory: MarketsEntryComponent.Factory, private val askBiometryComponentFactory: AskBiometryComponent.Factory, private val pushNotificationsBottomSheetComponent: PushNotificationsBottomSheetComponent.Factory, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, ) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: WalletModel = getOrCreateModel() @@ -78,6 +81,15 @@ internal class WalletComponent @AssistedInject constructor( source = AppRoute.PushNotification.Source.Main, ), ) + is WalletDialogConfig.TokenReceive -> { + tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = dialogConfig.tokenReceiveConfig, + onDismiss = model.innerWalletRouter.dialogNavigation::dismiss, + ), + ) + } } }, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index 29c08244a6..71d5e76c5e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -29,6 +29,8 @@ import com.tangem.domain.core.utils.lceError import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.ReceiveAddressModel +import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -41,6 +43,7 @@ import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.IsCryptoCurrencyCoinCouldHideUseCase import com.tangem.domain.tokens.RemoveCurrencyUseCase import com.tangem.domain.tokens.legacy.TradeCryptoAction @@ -49,6 +52,7 @@ import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Companion.AVAILABLE import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Companion.toReasonAnalyticsText +import com.tangem.domain.transaction.usecase.GetEnsNameUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase @@ -61,6 +65,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender +import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @@ -128,6 +133,9 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val shareManager: ShareManager, private val appRouter: AppRouter, private val rampStateManager: RampStateManager, + private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, + private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, + private val getEnsNameUseCase: GetEnsNameUseCase, ) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { override fun onSendClick( @@ -172,13 +180,22 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( event?.let { analyticsEventHandler.send(it) } - stateHolder.showBottomSheet( - createReceiveBottomSheetContent( - currency = cryptoCurrencyStatus.currency, - addresses = cryptoCurrencyStatus.value.networkAddress ?: return, - ), - userWalletId, - ) + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { + stateHolder.hideBottomSheet() + modelScope.launch { + configureReceiveAddresses(cryptoCurrencyStatus = cryptoCurrencyStatus)?.let { + router.openTokenReceiveBottomSheet(it) + } + } + } else { + stateHolder.showBottomSheet( + createReceiveBottomSheetContent( + currency = cryptoCurrencyStatus.currency, + addresses = cryptoCurrencyStatus.value.networkAddress ?: return, + ), + userWalletId, + ) + } } override fun onCopyAddressLongClick(cryptoCurrencyStatus: CryptoCurrencyStatus): TextReference? { @@ -639,4 +656,45 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( targetRoute } } + + private suspend fun configureReceiveAddresses(cryptoCurrencyStatus: CryptoCurrencyStatus): TokenReceiveConfig? { + val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return null + val userWalletId = stateHolder.getSelectedWalletId() + + val ensName = getEnsNameUseCase.invoke( + userWalletId = userWalletId, + network = cryptoCurrencyStatus.currency.network, + address = networkAddress.defaultAddress.value, + ) + + val receiveAddresses = buildList { + ensName?.let { ens -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Ens, + value = ens, + displayName = ens, + ), + ) + } + networkAddress.availableAddresses.map { address -> + add( + ReceiveAddressModel( + nameService = ReceiveAddressModel.NameService.Default, + value = address.value, + displayName = "${cryptoCurrencyStatus.currency.name} (${cryptoCurrencyStatus.currency.symbol})", + ), + ) + } + } + + return TokenReceiveConfig( + shouldShowWarning = cryptoCurrencyStatus.currency.name !in getViewedTokenReceiveWarningUseCase(), + cryptoCurrency = cryptoCurrencyStatus.currency, + userWalletId = userWalletId, + showMemoDisclaimer = cryptoCurrencyStatus.currency.network.transactionExtrasType != Network + .TransactionExtrasType.NONE, + receiveAddress = receiveAddresses, + ) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 8c39cfc9e5..602de14421 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -1,11 +1,13 @@ package com.tangem.feature.wallet.presentation.router import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute.ManageTokens.Source import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet @@ -98,4 +100,10 @@ internal class DefaultWalletRouter @Inject constructor( ), ) } + + override fun openTokenReceiveBottomSheet(tokenReceiveConfig: TokenReceiveConfig) { + dialogNavigation.activate( + configuration = WalletDialogConfig.TokenReceive(tokenReceiveConfig), + ) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index b1670c0110..946a3e7692 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.router import androidx.compose.runtime.Stable import com.arkivanov.decompose.router.slot.SlotNavigation +import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet @@ -54,4 +55,6 @@ internal interface InnerWalletRouter { /** Open NFT collections screen */ fun openNFT(userWallet: UserWallet) + + fun openTokenReceiveBottomSheet(tokenReceiveConfig: TokenReceiveConfig) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt index 1cb651abd1..de8c2ee08a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt @@ -90,6 +90,16 @@ internal class WalletStateController @Inject constructor() { ) } + fun hideBottomSheet() { + update( + transformer = OpenBottomSheetTransformer( + userWalletId = getSelectedWalletId(), + content = TangemBottomSheetConfigContent.Empty, + onDismissBottomSheet = {}, + ), + ) + } + private fun getInitialState(): WalletScreenState { return WalletScreenState( topBarConfig = WalletTopBarConfig(onDetailsClick = {}), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt index 2134c40ef5..9bce963c5f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.model +import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable @@ -19,4 +20,7 @@ internal sealed interface WalletDialogConfig { @Serializable data object AskForPushNotifications : WalletDialogConfig + + @Serializable + data class TokenReceive(val tokenReceiveConfig: TokenReceiveConfig) : WalletDialogConfig } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 35d08f3116..d785e00c38 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -278,6 +278,9 @@ include(":features:welcome:impl") include(":features:account:api") include(":features:account:impl") + +include(":features:token-recieve:api") +include(":features:token-recieve:impl") // endregion Feature modules // region Domain modules diff --git a/tangem-android-tools b/tangem-android-tools index 794a8187e6..428b83bb37 160000 --- a/tangem-android-tools +++ b/tangem-android-tools @@ -1 +1 @@ -Subproject commit 794a8187e6d248ca3c21661df199a34ffeb0037a +Subproject commit 428b83bb378b615209e23afa05c88c454d06a9f1