Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-25 15:32:01 +03:00
commit 57f3a55298
88 changed files with 4510 additions and 68 deletions

View file

@ -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)

View file

@ -154,4 +154,10 @@ internal object NFTDomainModule {
): ObserveAndClearNFTCacheIfNeedUseCase {
return ObserveAndClearNFTCacheIfNeedUseCase(nftRepository, currenciesRepository)
}
@Provides
@Singleton
fun provideGetNftCurrencyUseCase(nftRepository: NFTRepository): GetNFTCurrencyUseCase {
return GetNFTCurrencyUseCase(nftRepository)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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,
),
)

View file

@ -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"
}
}

View file

@ -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"
}
]

View file

@ -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<String>(),
defaultValue = emptySet(),
),
produceFile = { context.dataStoreFile(fileName = "token_receive_warnings_viewed") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
)
}
}

View file

@ -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<Set<String>>,
) : TokenReceiveWarningActionStore {
override suspend fun getSync(): Set<String> {
return persistenceStore.data.firstOrNull() ?: emptySet()
}
override suspend fun store(symbol: String) {
persistenceStore.updateData { data -> data.plus(symbol) }
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.datasource.local.token
interface TokenReceiveWarningActionStore {
suspend fun getSync(): Set<String>
suspend fun store(symbol: String)
}

View file

@ -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)

View file

@ -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,
)
}
}
}

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M21.099,15C21.099,13.56 21.098,12.571 20.998,11.828C20.901,11.111 20.729,10.759 20.485,10.514C20.241,10.27 19.889,10.098 19.172,10.002C18.429,9.902 17.439,9.9 16,9.9H15C13.56,9.9 12.571,9.902 11.828,10.002C11.111,10.098 10.759,10.27 10.514,10.514C10.27,10.759 10.098,11.111 10.002,11.828C9.902,12.571 9.9,13.56 9.9,15V16C9.9,17.44 9.902,18.429 10.002,19.172C10.098,19.889 10.27,20.241 10.514,20.485C10.759,20.729 11.111,20.902 11.828,20.998C12.571,21.098 13.56,21.1 15,21.1H16C17.439,21.1 18.429,21.098 19.172,20.998C19.889,20.902 20.241,20.729 20.485,20.485C20.729,20.241 20.901,19.889 20.998,19.172C21.098,18.429 21.099,17.44 21.099,16V15ZM22.9,16C22.9,17.389 22.902,18.521 22.782,19.412C22.659,20.329 22.392,21.123 21.757,21.758C21.123,22.392 20.328,22.659 19.412,22.782C18.521,22.902 17.389,22.9 16,22.9H15C13.611,22.9 12.479,22.902 11.588,22.782C10.671,22.659 9.876,22.392 9.242,21.758C8.608,21.123 8.341,20.329 8.217,19.412C8.098,18.521 8.099,17.389 8.099,16V15C8.099,13.611 8.098,12.479 8.217,11.588C8.341,10.671 8.608,9.876 9.242,9.242C9.876,8.608 10.671,8.341 11.588,8.218C12.479,8.098 13.611,8.099 15,8.099H16C17.389,8.099 18.521,8.098 19.412,8.218C20.328,8.341 21.123,8.608 21.757,9.242C22.392,9.876 22.659,10.671 22.782,11.588C22.902,12.479 22.9,13.611 22.9,15V16Z"
android:fillColor="#919191"/>
<path
android:pathData="M2.099,9.999V8.999L2.099,8.945C2.099,7.581 2.099,6.466 2.217,5.587C2.34,4.67 2.607,3.876 3.241,3.241C3.876,2.607 4.67,2.34 5.587,2.217C6.466,2.099 7.581,2.099 8.945,2.099L8.999,2.099H9.999L10.054,2.099C11.417,2.099 12.532,2.099 13.411,2.217C14.328,2.34 15.123,2.607 15.757,3.241C15.978,3.463 16.16,3.709 16.307,3.983C16.542,4.42 16.378,4.966 15.941,5.201C15.503,5.437 14.957,5.273 14.722,4.835C14.656,4.713 14.579,4.608 14.484,4.514C14.24,4.27 13.888,4.098 13.171,4.001C12.428,3.901 11.439,3.9 9.999,3.9H8.999C7.559,3.9 6.57,3.901 5.827,4.001C5.11,4.098 4.758,4.27 4.514,4.514C4.269,4.758 4.097,5.11 4.001,5.827C3.901,6.57 3.899,7.559 3.899,8.999V9.999C3.899,11.439 3.901,12.428 4.001,13.171C4.097,13.888 4.269,14.24 4.514,14.484C4.692,14.663 4.923,14.797 5.292,14.895C5.772,15.022 6.059,15.515 5.932,15.995C5.812,16.446 5.371,16.726 4.921,16.654L4.831,16.635L4.606,16.569C4.09,16.404 3.633,16.149 3.241,15.757C2.607,15.123 2.34,14.328 2.217,13.411C2.099,12.532 2.099,11.417 2.099,10.054L2.099,9.999Z"
android:fillColor="#919191"/>
</vector>

View file

@ -0,0 +1,38 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="36dp"
android:height="37dp"
android:viewportWidth="36"
android:viewportHeight="37">
<path
android:pathData="M0,18.658C0,8.717 8.059,0.658 18,0.658C27.941,0.658 36,8.717 36,18.658C36,28.599 27.941,36.658 18,36.658C8.059,36.658 0,28.599 0,18.658Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="7.08"
android:startY="6.638"
android:endX="38.587"
android:endY="33.361"
android:type="linear">
<item android:offset="0" android:color="#FF8BA0FF"/>
<item android:offset="0.524" android:color="#FF65B1FF"/>
<item android:offset="1" android:color="#FF91D0FF"/>
</gradient>
</aapt:attr>
</path>
<group>
<clip-path
android:pathData="M9.314,8.766h17.372v19.784h-17.372z"/>
<path
android:pathData="M17.55,9.187L12.219,17.958C12.177,18.027 12.08,18.035 12.028,17.973C11.559,17.416 9.811,15.045 11.974,12.885C13.948,10.913 16.463,9.507 17.395,9.021C17.501,8.966 17.612,9.085 17.55,9.187Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M17.255,28.285C17.362,28.36 17.493,28.233 17.422,28.124C16.231,26.313 12.273,20.287 11.726,19.383C11.187,18.491 10.126,17.008 10.038,15.739C10.029,15.613 9.854,15.587 9.809,15.706C9.738,15.898 9.663,16.127 9.592,16.389C8.702,19.695 9.995,23.203 12.802,25.168L17.255,28.285Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M17.988,28.129L23.319,19.358C23.36,19.289 23.458,19.281 23.509,19.343C23.979,19.9 25.727,22.271 23.564,24.431C21.589,26.403 19.075,27.809 18.143,28.295C18.037,28.35 17.926,28.231 17.988,28.129Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M18.288,9.03C18.182,8.955 18.051,9.082 18.122,9.191C19.313,11.002 23.271,17.028 23.818,17.932C24.357,18.824 25.418,20.307 25.506,21.576C25.515,21.702 25.69,21.728 25.734,21.609C25.805,21.417 25.881,21.188 25.951,20.926C26.841,17.62 25.549,14.112 22.742,12.147L18.288,9.03Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.816,11.215C3.377,11.215 2.667,10.515 2.667,9.036V4.846C2.667,3.367 3.377,2.667 4.816,2.667H9.066C10.505,2.667 11.215,3.367 11.215,4.846V9.036C11.215,10.515 10.505,11.215 9.066,11.215H4.816ZM14.934,11.215C13.485,11.215 12.785,10.515 12.785,9.036V4.846C12.785,3.367 13.485,2.667 14.934,2.667H19.184C20.624,2.667 21.333,3.367 21.333,4.846V9.036C21.333,10.515 20.624,11.215 19.184,11.215H14.934ZM4.776,9.635H9.096C9.455,9.635 9.635,9.455 9.635,9.086V4.796C9.635,4.426 9.455,4.246 9.096,4.246H4.776C4.416,4.246 4.246,4.426 4.246,4.796V9.086C4.246,9.455 4.416,9.635 4.776,9.635ZM14.894,9.635H19.224C19.584,9.635 19.754,9.455 19.754,9.086V4.796C19.754,4.426 19.584,4.246 19.224,4.246H14.894C14.535,4.246 14.355,4.426 14.355,4.796V9.086C14.355,9.455 14.535,9.635 14.894,9.635ZM6.046,8.046C5.886,8.046 5.836,7.976 5.836,7.796V6.076C5.836,5.906 5.886,5.836 6.046,5.836H7.826C7.976,5.836 8.046,5.906 8.046,6.076V7.796C8.046,7.976 7.976,8.046 7.826,8.046H6.046ZM16.204,8.046C16.054,8.046 16.004,7.976 16.004,7.796V6.076C16.004,5.906 16.054,5.836 16.204,5.836H17.984C18.144,5.836 18.204,5.906 18.204,6.076V7.796C18.204,7.976 18.144,8.046 17.984,8.046H16.204ZM4.816,21.333C3.377,21.333 2.667,20.633 2.667,19.144V14.965C2.667,13.485 3.377,12.785 4.816,12.785H9.066C10.505,12.785 11.215,13.485 11.215,14.965V19.144C11.215,20.633 10.505,21.333 9.066,21.333H4.816ZM13.475,15.454C13.325,15.454 13.275,15.384 13.275,15.204V13.475C13.275,13.315 13.325,13.245 13.475,13.245H15.254C15.414,13.245 15.474,13.315 15.474,13.475V15.204C15.474,15.384 15.414,15.454 15.254,15.454H13.475ZM18.834,15.454C18.674,15.454 18.624,15.384 18.624,15.204V13.475C18.624,13.315 18.674,13.245 18.834,13.245H20.614C20.764,13.245 20.823,13.315 20.823,13.475V15.204C20.823,15.384 20.764,15.454 20.614,15.454H18.834ZM4.776,19.754H9.096C9.455,19.754 9.635,19.574 9.635,19.204V14.915C9.635,14.535 9.455,14.365 9.096,14.365H4.776C4.416,14.365 4.246,14.535 4.246,14.915V19.204C4.246,19.574 4.416,19.754 4.776,19.754ZM16.174,18.124C16.024,18.124 15.974,18.064 15.974,17.884V16.154C15.974,15.984 16.024,15.924 16.174,15.924H17.964C18.114,15.924 18.174,15.984 18.174,16.154V17.884C18.174,18.064 18.114,18.124 17.964,18.124H16.174ZM6.046,18.154C5.886,18.154 5.836,18.094 5.836,17.914V16.184C5.836,16.014 5.886,15.954 6.046,15.954H7.826C7.976,15.954 8.046,16.014 8.046,16.184V17.914C8.046,18.094 7.976,18.154 7.826,18.154H6.046ZM13.475,20.803C13.325,20.803 13.275,20.733 13.275,20.553V18.834C13.275,18.664 13.325,18.594 13.475,18.594H15.254C15.414,18.594 15.474,18.664 15.474,18.834V20.553C15.474,20.733 15.414,20.803 15.254,20.803H13.475ZM18.834,20.803C18.674,20.803 18.624,20.733 18.624,20.553V18.834C18.624,18.664 18.674,18.594 18.834,18.594H20.614C20.764,18.594 20.823,18.664 20.823,18.834V20.553C20.823,20.733 20.764,20.803 20.614,20.803H18.834Z"
android:fillColor="#919191"/>
</vector>

View file

@ -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,
)
}
}

View file

@ -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<String> {
return tokenReceiveWarningActionStore.getSync()
}
override suspend fun view(symbol: String) {
tokenReceiveWarningActionStore.store(symbol)
}
}

View file

@ -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,

View file

@ -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<ReceiveAddressModel>,
val tokenReceiveNotification: List<TokenReceiveNotification> = 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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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<String, String> = 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,
),
)
}

View file

@ -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<String> {
return tokenReceiveWarningsViewedRepository.getViewedWarnings()
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.tokens.repository
interface TokenReceiveWarningsViewedRepository {
suspend fun getViewedWarnings(): Set<String>
suspend fun view(symbol: String)
}

View file

@ -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

View file

@ -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,
)
}
}

View file

@ -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<String>,
): List<EnsAddress> {
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)
}
}
}

View file

@ -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)

View file

@ -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<Boolean> = 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)
}
}
}

View file

@ -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.

View file

@ -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<TokenMarketInfo.Network>) = 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(

View file

@ -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<MyPortfolioUM> get() = _state
@ -80,6 +92,8 @@ internal class MarketsPortfolioModel @Inject constructor(
private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel())
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = 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
}
}
}

View file

@ -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)

View file

@ -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<NFTRoute>()
@ -134,6 +136,7 @@ internal class DefaultNFTComponent @AssistedInject constructor(
walletName = params.walletName,
onBackClick = ::onChildBack,
),
tokenReceiveComponentFactory = tokenReceiveComponentFactory,
)
private fun getDetailsComponent(

View file

@ -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,

View file

@ -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<TokenReceiveConfig> = 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,
)
}
}

View file

@ -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)

View file

@ -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

View file

@ -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(

View file

@ -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()
},
),
)

View file

@ -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
}
}
}

View file

@ -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),
)
}
}

View file

@ -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,

View file

@ -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<ConfirmUM> {
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<NotificationUM>.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) {
if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM)) {
add(NotificationUM.Warning.FeeTooLow)
analyticsEventHandler.send(
CommonSendAnalyticEvents.NoticeTransactionDelays(
categoryName = analyticsCategoryName,
token = cryptoCurrency.symbol,
),
)
}
}
private fun MutableList<NotificationUM>.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,
),
)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)

1
features/token-recieve/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -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)
}

View file

@ -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<Params, TokenReceiveComponent> {
override fun create(context: AppComponentContext, params: Params): TokenReceiveComponent
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.tokenreceive
interface TokenReceiveFeatureToggle {
val isNewTokenReceiveEnabled: Boolean
}

View file

@ -0,0 +1 @@
/build

View file

@ -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)
}

View file

@ -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")
}

View file

@ -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<TokenReceiveRoutes>(
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

View file

@ -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<NotificationUM>,
val addresses: ImmutableMap<Int, ReceiveAddress>,
val callback: TokenReceiveAssetsModelCallback,
val onDismiss: () -> Unit,
val showMemoDisclaimer: Boolean,
val fullName: String,
val tokenName: String,
)
}

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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<TokenReceiveAssetsComponent.TokenReceiveAssetsParams>()
init {
analyticsEventHandler.send(
TokenReceiveNewAnalyticsEvent.ReceiveScreenOpened(
token = params.tokenName,
blockchainName = params.fullName,
ensStatus = configureEnsStatus(),
),
)
}
internal val state: StateFlow<ReceiveAssetsUM>
field = MutableStateFlow<ReceiveAssetsUM>(
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
}
}
}

View file

@ -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<TokenReceiveComponent.Params>()
val stackNavigation = StackNavigation<TokenReceiveRoutes>()
internal val state: StateFlow<TokenReceiveUM>
field = MutableStateFlow<TokenReceiveUM>(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<ReceiveAddressModel>): ImmutableMap<Int, ReceiveAddress> {
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<EnsAddress.Address>()
.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<NotificationUM> {
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)
}
}

View file

@ -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<TokenReceiveQrCodeComponent.TokenReceiveQrCodeParams>()
internal val state: StateFlow<QrCodeUM>
field = MutableStateFlow<QrCodeUM>(
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,
),
)
}

View file

@ -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<TokenReceiveWarningComponent.TokenReceiveWarningParams>()
internal val state: StateFlow<WarningUM>
field = MutableStateFlow<WarningUM>(
WarningUM(
iconState = params.iconState,
onWarningAcknowledged = params.callback::onWarningAcknowledged,
network = params.network.name,
networkIcon = params.network.iconResId,
),
)
}

View file

@ -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
}

View file

@ -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),
)
}
}

View file

@ -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<NotificationUM>,
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<Int, ReceiveAddress>,
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<ReceiveAssetsUM> {
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<ReceiveAssetsUM>
get() = sequenceOf(config)
}

View file

@ -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<TokenReceiveRoutes, ComposableContentComponent>,
) {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
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<TokenReceiveRoutes, ComposableContentComponent>,
modifier: Modifier = Modifier,
) {
Children(
stack = stackState,
animation = stackAnimation(fade()),
modifier = modifier
.fillMaxSize()
.animateContentSize(),
) {
it.instance.Content(Modifier.fillMaxSize())
}
}

View file

@ -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<QrCodeUM> {
private val config = QrCodeUM(
network = "Ethereum",
addressName = stringReference("Etherium"),
addressValue = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d",
onCopyClick = {},
onShareClick = {},
)
override val values: Sequence<QrCodeUM>
get() = sequenceOf(config)
}

View file

@ -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<WarningUM> {
val iconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = null,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
showCustomBadge = false,
)
override val values: Sequence<WarningUM>
get() = sequenceOf(
WarningUM(
iconState = iconState,
onWarningAcknowledged = {},
network = "Etherium",
networkIcon = R.drawable.ic_eth_16,
),
)
}

View file

@ -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,
)

View file

@ -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<Int, ReceiveAddress>,
val onOpenQrCodeClick: (id: Int) -> Unit,
val onCopyClick: (id: Int) -> Unit,
val isEnsResultLoading: Boolean,
val notificationConfigs: ImmutableList<NotificationUM>,
val fullName: String,
)

View file

@ -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<Int, ReceiveAddress>,
val isEnsResultLoading: Boolean,
val notificationConfigs: ImmutableList<NotificationUM>,
)

View file

@ -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,
)

View file

@ -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)
}

View file

@ -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(

View file

@ -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<TokenDetailsComponent.Params>()
@ -159,6 +165,8 @@ internal class TokenDetailsModel @Inject constructor(
/** Transaction id to check for status */
private val waitForFirstExpressStatusEmmit = MutableStateFlow(false)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = 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
}

View file

@ -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)

View file

@ -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,
),
)
}
}
},
)

View file

@ -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,
)
}
}

View file

@ -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),
)
}
}

View file

@ -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)
}

View file

@ -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 = {}),

View file

@ -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
}

View file

@ -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

@ -1 +1 @@
Subproject commit 794a8187e6d248ca3c21661df199a34ffeb0037a
Subproject commit 428b83bb378b615209e23afa05c88c454d06a9f1