Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-18 12:51:39 +03:00
commit a56bcff7dd
438 changed files with 9017 additions and 4452 deletions

View file

@ -5,10 +5,8 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.block.model.BlockUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.impl.BuildConfig
import com.tangem.features.details.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -76,17 +74,6 @@ internal class ItemsBuilder @Inject constructor(private val router: Router) {
onClick = { router.push(AppRoute.AppSettings) },
),
).let(::add)
if (BuildConfig.TESTER_MENU_ENABLED) {
DetailsItemUM.Basic.Item(
id = "tester_menu",
block = BlockUM(
text = stringReference(value = "Tester menu"),
iconRes = R.drawable.ic_alert_24,
onClick = { router.push(AppRoute.TesterMenu) },
),
).let(::add)
}
}.toImmutableList(),
)

View file

@ -14,6 +14,7 @@ android {
dependencies {
/** Api */
implementation(projects.features.hotWallet.api)
implementation(projects.features.pushNotifications.api)
/** Core modules */
implementation(projects.core.configToggles)
@ -26,6 +27,10 @@ dependencies {
implementation(projects.core.navigation)
implementation(projects.core.datasource)
/** Domain */
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)
@ -36,6 +41,8 @@ dependencies {
implementation(tangemDeps.card.android) {
exclude(module = "joda-time")
}
implementation(tangemDeps.hot.core)
implementation(tangemDeps.hot.android)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)

View file

@ -6,9 +6,11 @@ import com.arkivanov.decompose.router.stack.push
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.features.hotwallet.setaccesscode.SetAccessCodeComponent
import com.tangem.features.hotwallet.addexistingwallet.start.AddExistingWalletStartComponent
import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent
import com.tangem.features.hotwallet.addexistingwallet.root.routing.AddExistingWalletRoute
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
@ -20,6 +22,8 @@ internal class AddExistingWalletModel @Inject constructor(
val addExistingWalletStartModelCallbacks = AddExistingWalletStartModelCallbacks()
val addExistingWalletImportModelCallbacks = AddExistingWalletImportModelCallbacks()
val pushNotificationsComponentModelCallbacks = PushNotificationsComponentModelCallbacks()
val accessCodeModelCallbacks = AccessCodeModelCallbacks()
val stackNavigation = StackNavigation<AddExistingWalletRoute>()
@ -38,4 +42,20 @@ internal class AddExistingWalletModel @Inject constructor(
stackNavigation.pop()
}
}
inner class PushNotificationsComponentModelCallbacks : PushNotificationsComponent.ModelCallbacks {
override fun onResult() {
// TODO [REDACTED_TASK_KEY]
}
}
inner class AccessCodeModelCallbacks : SetAccessCodeComponent.ModelCallbacks {
override fun onBackClick() {
// TODO [REDACTED_TASK_KEY]
}
override fun onAccessCodeSet() {
// TODO [REDACTED_TASK_KEY]
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.hotwallet.addexistingwallet.root.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.AddExistingWalletComponent
import com.tangem.features.hotwallet.setaccesscode.SetAccessCodeModel
import com.tangem.features.hotwallet.addexistingwallet.root.AddExistingWalletModel
import com.tangem.features.hotwallet.addexistingwallet.root.DefaultAddExistingWalletComponent
import com.tangem.features.hotwallet.addexistingwallet.start.AddExistingWalletStartModel
@ -42,4 +43,9 @@ internal interface AddExistingWalletModuleBinds {
@IntoMap
@ClassKey(AddExistingWalletImportModel::class)
fun bindAddExistingWalletImportModel(model: AddExistingWalletImportModel): Model
@Binds
@IntoMap
@ClassKey(SetAccessCodeModel::class)
fun bindAccessCodeModel(model: SetAccessCodeModel): Model
}

View file

@ -2,12 +2,16 @@ package com.tangem.features.hotwallet.addexistingwallet.root.routing
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.hotwallet.setaccesscode.SetAccessCodeComponent
import com.tangem.features.hotwallet.addexistingwallet.root.AddExistingWalletModel
import com.tangem.features.hotwallet.addexistingwallet.start.AddExistingWalletStartComponent
import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import javax.inject.Inject
internal class AddExistingWalletChildFactory @Inject constructor() {
internal class AddExistingWalletChildFactory @Inject constructor(
private val pushNotificationsComponent: PushNotificationsComponent.Factory,
) {
fun createChild(
route: AddExistingWalletRoute,
@ -27,6 +31,18 @@ internal class AddExistingWalletChildFactory @Inject constructor() {
callbacks = model.addExistingWalletImportModelCallbacks,
),
)
is AddExistingWalletRoute.PushNotifications -> pushNotificationsComponent.create(
context = childContext,
params = PushNotificationsComponent.Params.Callbacks(
callbacks = model.pushNotificationsComponentModelCallbacks,
),
)
is AddExistingWalletRoute.AccessCode -> SetAccessCodeComponent(
context = childContext,
params = SetAccessCodeComponent.Params(
callbacks = model.accessCodeModelCallbacks,
),
)
}
}
}

View file

@ -10,4 +10,10 @@ internal sealed class AddExistingWalletRoute : Route {
@Serializable
object Import : AddExistingWalletRoute()
@Serializable
object PushNotifications : AddExistingWalletRoute()
@Serializable
object AccessCode : AddExistingWalletRoute()
}

View file

@ -1,18 +1,30 @@
package com.tangem.features.hotwallet.createmobilewallet
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.hotwallet.createmobilewallet.entity.CreateMobileWalletUM
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
internal class CreateMobileWalletModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
private val saveUserWalletUseCase: SaveWalletUseCase,
private val router: Router,
private val tangemHotSdk: TangemHotSdk,
) : Model() {
internal val uiState: StateFlow<CreateMobileWalletUM>
@ -20,10 +32,30 @@ internal class CreateMobileWalletModel @Inject constructor(
CreateMobileWalletUM(
onBackClick = { router.pop() },
onCreateClick = ::onCreateClick,
createButtonLoading = false,
),
)
private fun onCreateClick() {
// TODO create a wallet
modelScope.launch {
uiState.update {
it.copy(createButtonLoading = true)
}
runCatching {
val hotWalletId = tangemHotSdk.generateWallet(HotAuth.NoAuth, mnemonicType = MnemonicType.Words12)
val hotUserWalletBuilder = hotUserWalletBuilderFactory.create(hotWalletId)
saveUserWalletUseCase(
hotUserWalletBuilder.build(),
)
router.push(AppRoute.Wallet)
}.onFailure {
Timber.e(it)
uiState.update {
it.copy(createButtonLoading = false)
}
}
}
}
}

View file

@ -12,10 +12,12 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
internal class DefaultCreateMobileWalletComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Unit,
) : CreateMobileWalletComponent, AppComponentContext by context {
private val model: CreateMobileWalletModel = getOrCreateModel(params)
@Composable

View file

@ -10,18 +10,12 @@ 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 CreateMobileWalletModule
@Module
@InstallIn(SingletonComponent::class)
internal interface CreateMobileWalletModuleBinds {
@Binds
@Singleton
fun bindCreateMobileWalletComponentFactory(
impl: DefaultCreateMobileWalletComponent.Factory,
): CreateMobileWalletComponent.Factory

View file

@ -1,6 +1,7 @@
package com.tangem.features.hotwallet.createmobilewallet.entity
internal data class CreateMobileWalletUM(
val createButtonLoading: Boolean,
val onBackClick: () -> Unit,
val onCreateClick: () -> Unit,
)

View file

@ -85,7 +85,7 @@ internal fun CreateMobileWalletContent(state: CreateMobileWalletUM, modifier: Mo
.fillMaxWidth()
.padding(16.dp),
text = stringResourceSafe(R.string.common_create),
showProgress = false,
showProgress = state.createButtonLoading,
enabled = true,
onClick = state.onCreateClick,
)
@ -133,6 +133,7 @@ private fun PreviewCreateWalletContent() {
CreateMobileWalletContent(
state = CreateMobileWalletUM(
onBackClick = {},
createButtonLoading = false,
onCreateClick = {},
),
)

View file

@ -0,0 +1,43 @@
package com.tangem.features.hotwallet.setaccesscode
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.core.ui.security.DisableScreenshotsDisposableEffect
import com.tangem.features.hotwallet.setaccesscode.ui.SetAccessCodeContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
internal class SetAccessCodeComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by context {
private val model: SetAccessCodeModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
SetAccessCodeContent(
state = state,
onBack = { model.onBack() },
modifier = modifier,
)
DisableScreenshotsDisposableEffect()
}
interface ModelCallbacks {
fun onBackClick()
fun onAccessCodeSet()
}
data class Params(
val callbacks: ModelCallbacks,
)
}

View file

@ -0,0 +1,84 @@
package com.tangem.features.hotwallet.setaccesscode
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.features.hotwallet.setaccesscode.entity.SetAccessCodeUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@Stable
@ModelScoped
internal class SetAccessCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<SetAccessCodeComponent.Params>()
internal val uiState: StateFlow<SetAccessCodeUM>
field = MutableStateFlow(getInitialState())
fun onBack() {
when (uiState.value.step) {
SetAccessCodeUM.Step.AccessCode -> {
params.callbacks.onBackClick()
}
SetAccessCodeUM.Step.ConfirmAccessCode -> {
uiState.update {
it.copy(
step = SetAccessCodeUM.Step.AccessCode,
accessCodeSecond = "",
)
}
}
}
}
private fun getInitialState() = SetAccessCodeUM(
step = SetAccessCodeUM.Step.AccessCode,
accessCodeFirst = "",
accessCodeSecond = "",
onAccessCodeFirstChange = ::onAccessCodeFirstChange,
onAccessCodeSecondChange = ::onAccessCodeSecondChange,
buttonEnabled = false,
onContinue = ::onContinue,
)
private fun onAccessCodeFirstChange(value: String) {
uiState.update {
it.copy(
accessCodeFirst = value,
buttonEnabled = value.length == uiState.value.accessCodeLength,
)
}
}
private fun onAccessCodeSecondChange(value: String) {
uiState.update {
it.copy(
accessCodeSecond = value,
buttonEnabled = uiState.value.accessCodeFirst == uiState.value.accessCodeSecond,
)
}
}
private fun onContinue() {
when (uiState.value.step) {
SetAccessCodeUM.Step.AccessCode -> {
uiState.update {
it.copy(
step = SetAccessCodeUM.Step.ConfirmAccessCode,
)
}
}
SetAccessCodeUM.Step.ConfirmAccessCode -> {
params.callbacks.onAccessCodeSet()
}
}
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.hotwallet.setaccesscode.entity
internal data class SetAccessCodeUM(
val step: Step,
val accessCodeFirst: String,
val accessCodeSecond: String,
val onAccessCodeFirstChange: (String) -> Unit,
val onAccessCodeSecondChange: (String) -> Unit,
val buttonEnabled: Boolean,
val onContinue: () -> Unit,
) {
val accessCodeLength: Int = ACCESS_CODE_LENGTH
enum class Step {
AccessCode,
ConfirmAccessCode,
}
companion object {
private const val ACCESS_CODE_LENGTH = 6
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.features.hotwallet.setaccesscode.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemAnimations
import com.tangem.features.hotwallet.setaccesscode.entity.SetAccessCodeUM
import com.tangem.features.hotwallet.setaccesscode.entity.SetAccessCodeUM.Step.*
import com.tangem.core.res.R
@Composable
internal fun SetAccessCodeContent(state: SetAccessCodeUM, onBack: () -> Unit, modifier: Modifier = Modifier) {
BackHandler(onBack = onBack)
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
AnimatedContent(
modifier = Modifier.weight(1f),
targetState = state.step,
transitionSpec = TangemAnimations.AnimatedContent
.slide { initial, target -> target.ordinal > initial.ordinal },
label = "AnimatedContent",
) { step ->
when (step) {
AccessCode -> SetAccessCodeEnter(
modifier = Modifier.padding(top = 16.dp),
state = state,
reEnterAccessCodeState = false,
)
ConfirmAccessCode -> SetAccessCodeEnter(
modifier = Modifier.padding(top = 16.dp),
state = state,
reEnterAccessCodeState = true,
)
}
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.imePadding(),
text = if (state.step == ConfirmAccessCode) {
stringResourceSafe(R.string.common_confirm)
} else {
stringResourceSafe(R.string.common_continue)
},
onClick = state.onContinue,
)
}
}

View file

@ -0,0 +1,125 @@
package com.tangem.features.hotwallet.setaccesscode.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.res.R
import com.tangem.core.ui.components.fields.PinTextField
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.hotwallet.setaccesscode.entity.SetAccessCodeUM
@Composable
internal fun SetAccessCodeEnter(
state: SetAccessCodeUM,
reEnterAccessCodeState: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier
.padding(top = 56.dp)
.align(Alignment.CenterHorizontally),
text = if (reEnterAccessCodeState) {
stringResourceSafe(R.string.access_code_confirm_title)
} else {
stringResourceSafe(R.string.access_code_create_title)
},
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.padding(16.dp)
.align(Alignment.CenterHorizontally),
text = if (reEnterAccessCodeState) {
stringResourceSafe(R.string.access_code_confirm_description)
} else {
stringResourceSafe(
R.string.access_code_create_description,
state.accessCodeLength,
)
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
contentAlignment = Alignment.Center,
) {
PinTextField(
length = state.accessCodeLength,
isPasswordVisual = true,
value = if (reEnterAccessCodeState) {
state.accessCodeSecond
} else {
state.accessCodeFirst
},
onValueChange = if (reEnterAccessCodeState) {
state.onAccessCodeSecondChange
} else {
state.onAccessCodeFirstChange
},
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
SetAccessCodeEnter(
reEnterAccessCodeState = false,
state = SetAccessCodeUM(
step = SetAccessCodeUM.Step.AccessCode,
accessCodeFirst = "",
accessCodeSecond = "",
onAccessCodeFirstChange = {},
onAccessCodeSecondChange = {},
buttonEnabled = false,
onContinue = {},
),
)
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview2() {
TangemThemePreview {
SetAccessCodeEnter(
reEnterAccessCodeState = true,
state = SetAccessCodeUM(
step = SetAccessCodeUM.Step.ConfirmAccessCode,
accessCodeFirst = "",
accessCodeSecond = "",
onAccessCodeFirstChange = {},
onAccessCodeSecondChange = {},
buttonEnabled = false,
onContinue = {},
),
)
}
}

View file

@ -74,7 +74,6 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.navigation)
implementation(projects.core.deepLinks)
/* Common */
implementation(projects.common.ui)

View file

@ -3,7 +3,7 @@ package com.tangem.features.markets.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency

View file

@ -25,7 +25,6 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.features.markets.impl.R
@ -194,8 +193,7 @@ internal class MarketsPortfolioModel @Inject constructor(
private fun loadArtworks(wallets: List<UserWallet>) {
modelScope.launch {
loadArtworksMutex.withLock {
wallets.forEach { wallet ->
wallet.requireColdWallet() // TODO [REDACTED_TASK_KEY]
wallets.filterIsInstance<UserWallet.Cold>().forEach { wallet ->
if (!loadedArtworks.containsKey(wallet.walletId)) {
val artwork = getCardImageUseCase(
cardId = wallet.cardId,

View file

@ -1,8 +1,6 @@
package com.tangem.features.nft
interface NFTFeatureToggles {
val isNFTEnabled: Boolean
val isNFTEVMEnabled: Boolean
val isNFTSolanaEnabled: Boolean
val isNFTMediaContentEnabled: Boolean
}

View file

@ -5,14 +5,6 @@ import com.tangem.core.configtoggle.feature.FeatureTogglesManager
internal class DefaultNFTFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : NFTFeatureToggles {
override val isNFTEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NFT_ENABLED")
override val isNFTEVMEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NFT_EVM_ENABLED")
override val isNFTSolanaEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NFT_SOLANA_ENABLED")
override val isNFTMediaContentEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NFT_MEDIA_CONTENT_ENABLED")

View file

@ -1,8 +1,8 @@
package com.tangem.features.nft.collections.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
@ -17,31 +17,28 @@ import com.tangem.features.nft.impl.R
internal fun NFTCollections(state: NFTCollectionsStateUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackClick)
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
AppBarWithBackButton(
modifier = Modifier.statusBarsPadding(),
onBackClick = state.onBackClick,
text = stringResourceSafe(id = R.string.nft_collections_title),
iconRes = R.drawable.ic_back_24,
)
},
content = { innerPadding ->
TangemPullToRefreshContainer(
config = state.pullToRefreshConfig,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
) {
when (val content = state.content) {
is NFTCollectionsUM.Content -> NFTCollectionsContent(content)
is NFTCollectionsUM.Empty -> NFTCollectionsEmpty(content)
is NFTCollectionsUM.Failed -> NFTCollectionsFailed(content)
is NFTCollectionsUM.Loading -> NFTCollectionsLoading(content)
}
Column(
modifier = modifier
.background(TangemTheme.colors.background.secondary),
) {
AppBarWithBackButton(
modifier = Modifier,
onBackClick = state.onBackClick,
text = stringResourceSafe(id = R.string.nft_collections_title),
iconRes = R.drawable.ic_back_24,
)
TangemPullToRefreshContainer(
config = state.pullToRefreshConfig,
modifier = Modifier
.fillMaxSize(),
) {
when (val content = state.content) {
is NFTCollectionsUM.Content -> NFTCollectionsContent(content)
is NFTCollectionsUM.Empty -> NFTCollectionsEmpty(content)
is NFTCollectionsUM.Failed -> NFTCollectionsFailed(content)
is NFTCollectionsUM.Loading -> NFTCollectionsLoading(content)
}
},
)
}
}
}

View file

@ -52,14 +52,14 @@ internal fun NFTCollectionsEmpty(state: NFTCollectionsUM.Empty, modifier: Modifi
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
PrimaryButton(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing48)
.widthIn(min = TangemTheme.dimens.size158),
text = stringResourceSafe(R.string.nft_collections_receive),
onClick = state.onReceiveClick,
)
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
text = stringResourceSafe(R.string.nft_collections_receive),
onClick = state.onReceiveClick,
)
}
}

View file

@ -19,8 +19,8 @@ import com.tangem.features.nft.common.NFTRoute
@Composable
internal fun NFTContent(stackState: ChildStack<NFTRoute, ComposableContentComponent>) {
Column(
modifier = Modifier.Companion
.background(color = TangemTheme.colors.background.tertiary)
modifier = Modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),

View file

@ -1,5 +1,6 @@
package com.tangem.features.nft.details.entity.transformer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
@ -8,6 +9,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.nft.models.NFTSalePrice
import com.tangem.features.nft.details.entity.NFTAssetUM
import com.tangem.features.nft.details.entity.NFTDetailsUM
import com.tangem.features.nft.impl.R
import com.tangem.utils.transformer.Transformer
internal class NFTPriceChangeTransformer(
@ -18,35 +20,50 @@ internal class NFTPriceChangeTransformer(
override fun transform(prevState: NFTDetailsUM): NFTDetailsUM {
val topInfo = prevState.nftAsset.topInfo as? NFTAssetUM.TopInfo.Content ?: return prevState
return prevState.copy(
nftAsset = prevState.nftAsset.copy(
topInfo = topInfo.copy(
salePrice = when (nftSalePrice) {
is NFTSalePrice.Empty,
is NFTSalePrice.Error,
-> NFTAssetUM.SalePrice.Empty
is NFTSalePrice.Loading -> NFTAssetUM.SalePrice.Loading
is NFTSalePrice.Value -> NFTAssetUM.SalePrice.Content(
isFlickering = false,
cryptoPrice = stringReference(
nftSalePrice.value.format {
crypto(
symbol = nftSalePrice.symbol,
decimals = nftSalePrice.decimals,
)
},
),
fiatPrice = stringReference(
nftSalePrice.fiatValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
val salePrice = when (nftSalePrice) {
is NFTSalePrice.Empty,
is NFTSalePrice.Error,
-> NFTAssetUM.SalePrice.Empty
is NFTSalePrice.Loading -> NFTAssetUM.SalePrice.Loading
is NFTSalePrice.Value -> NFTAssetUM.SalePrice.Content(
isFlickering = false,
cryptoPrice = stringReference(
nftSalePrice.value.format {
crypto(
symbol = nftSalePrice.symbol,
decimals = nftSalePrice.decimals,
)
},
),
fiatPrice = stringReference(
nftSalePrice.fiatValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
)
}
val hasSalePrice = salePrice !is NFTAssetUM.SalePrice.Empty
val newTopInfo = if (
topInfo.rarity is NFTAssetUM.Rarity.Empty &&
topInfo.description.isNullOrEmpty() &&
!hasSalePrice
) {
NFTAssetUM.TopInfo.Empty
} else {
topInfo.copy(
title = resourceReference(R.string.nft_details_last_sale_price).takeIf { hasSalePrice },
salePrice = salePrice,
)
}
return prevState.copy(
nftAsset = prevState.nftAsset.copy(
topInfo = newTopInfo,
),
)
}

View file

@ -1,13 +1,10 @@
package com.tangem.features.nft.details.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.TangemTopAppBar
@ -22,25 +19,25 @@ import com.tangem.features.nft.impl.R
internal fun NFTDetails(state: NFTDetailsUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackClick)
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary),
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
modifier = Modifier,
startButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_back_24,
onIconClicked = state.onBackClick,
),
title = state.nftAsset.name,
)
},
content = { innerPadding ->
TangemPullToRefreshContainer(
config = state.pullToRefreshConfig,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
) {
NFTDetailsAsset(
state = state.nftAsset,
@ -49,16 +46,19 @@ internal fun NFTDetails(state: NFTDetailsUM, modifier: Modifier = Modifier) {
onExploreClick = state.onExploreClick,
)
}
},
floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
PrimaryButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResourceSafe(id = R.string.common_send),
onClick = state.onSendClick,
)
},
)
}
PrimaryButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
)
.fillMaxWidth(),
text = stringResourceSafe(id = R.string.common_send),
onClick = state.onSendClick,
)
}
}

View file

@ -173,8 +173,7 @@ internal fun NFTDetailsGroupBlock(
} else {
onBlockClick?.invoke()
}
}
.padding(top = TangemTheme.dimens.spacing4),
},
text = value.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,

View file

@ -1,8 +1,8 @@
package com.tangem.features.nft.receive.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet
@ -22,41 +22,33 @@ import com.tangem.features.nft.receive.entity.NFTReceiveUM
internal fun NFTReceive(state: NFTReceiveUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackClick)
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
startButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_close_24,
onIconClicked = state.onBackClick,
),
title = stringResourceSafe(id = R.string.nft_receive_title),
subtitle = state.appBarSubtitle.resolveReference(),
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
) {
SearchBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
state = state.search,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
Column(
modifier = modifier
.background(TangemTheme.colors.background.secondary),
) {
TangemTopAppBar(
modifier = Modifier,
startButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_close_24,
onIconClicked = state.onBackClick,
),
title = stringResourceSafe(id = R.string.nft_receive_title),
subtitle = state.appBarSubtitle.resolveReference(),
)
when (val networks = state.networks) {
is NFTReceiveUM.Networks.Content -> NFTReceiveNetworksContent(networks)
is NFTReceiveUM.Networks.Empty -> NFTReceiveNetworksEmpty()
}
}
},
)
SearchBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
state = state.search,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
when (val networks = state.networks) {
is NFTReceiveUM.Networks.Content -> NFTReceiveNetworksContent(networks)
is NFTReceiveUM.Networks.Empty -> NFTReceiveNetworksEmpty()
}
}
ShowBottomSheet(state.bottomSheetConfig)
}

View file

@ -1,9 +1,8 @@
package com.tangem.features.nft.traits.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.appbar.TangemTopAppBar
@ -17,25 +16,21 @@ import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM
internal fun NFTAssetTraits(state: NFTAssetTraitsUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackClick)
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
startButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_back_24,
onIconClicked = state.onBackClick,
),
title = stringResourceSafe(R.string.nft_traits_title),
)
},
content = { innerPadding ->
NFTAssetTraitsContent(
modifier = Modifier
.padding(innerPadding),
state = state,
)
},
)
Column(
modifier = modifier
.background(TangemTheme.colors.background.secondary),
) {
TangemTopAppBar(
modifier = Modifier,
startButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_back_24,
onIconClicked = state.onBackClick,
),
title = stringResourceSafe(R.string.nft_traits_title),
)
NFTAssetTraitsContent(
state = state,
)
}
}

View file

@ -71,13 +71,11 @@ internal fun MultiWalletAccessCodeEnter(
state.onAccessCodeFirstChange
},
label = stringResourceSafe(id = R.string.onboarding_wallet_info_title_third),
isError = state.codesNotMatchError || state.atLeast4CharError,
isError = state.codesNotMatchError,
visualTransformation = PasswordVisualTransformation(),
caption = when {
state.codesNotMatchError && reEnterAccessCodeState ->
stringResourceSafe(R.string.onboarding_access_codes_doesnt_match)
state.atLeast4CharError && !reEnterAccessCodeState ->
stringResourceSafe(R.string.onboarding_access_code_too_short)
else -> null
},
)

View file

@ -204,12 +204,9 @@ internal class OnboardingNoteTopUpModel @Inject constructor(
}
private fun loadAvailableForBuy(cryptoCurrencyStatus: CryptoCurrencyStatus) {
val userWalletId = userWallet?.walletId ?: return
modelScope.launch {
val availableForBuy = rampStateManager.availableForBuy(
scanResponse = scanResponse,
userWalletId = userWalletId,
userWallet = userWallet ?: return@launch,
cryptoCurrency = cryptoCurrencyStatus.currency,
)
_uiState.update {

View file

@ -13,7 +13,6 @@ dependencies {
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.deepLinks)
/* Project - Domain */
implementation(projects.domain.onramp.models)

View file

@ -1,20 +0,0 @@
package com.tangem.features.onramp.deeplink
import com.tangem.core.deeplink.DeepLink
import kotlinx.coroutines.CoroutineScope
@Deprecated("Use OnrampDeepLinkHandler")
abstract class OnrampDeepLink : DeepLink() {
override val uri = "tangem://onramp"
interface Factory {
fun create(coroutineScope: CoroutineScope): OnrampDeepLink
}
}
interface OnrampDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): OnrampDeepLinkHandler
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.onramp.deeplink
import kotlinx.coroutines.CoroutineScope
interface OnrampDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): OnrampDeepLinkHandler
}
}

View file

@ -22,7 +22,6 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.deepLinks)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.utils)

View file

@ -1,48 +0,0 @@
package com.tangem.features.onramp.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.onramp.success.OnrampSuccessScreenTrigger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
internal class DefaultOnrampDeepLink @AssistedInject constructor(
private val appRouter: AppRouter,
private val onrampSuccessScreenTrigger: OnrampSuccessScreenTrigger,
@Assisted private val scope: CoroutineScope,
) : OnrampDeepLink() {
override fun onReceive(params: Map<String, String>) {
val txId = params[TX_ID_KEY]
val result = OnrampRedirectResult.getResult(params[RESULT_KEY])
when {
!txId.isNullOrEmpty() -> {
// finish current onramp flow and show onramp success screen
val replaceOnrampScreens = appRouter.stack
.filterNot { it is AppRoute.Onramp }
.toMutableList()
replaceOnrampScreens.add(AppRoute.OnrampSuccess(txId))
appRouter.replaceAll(*replaceOnrampScreens.toTypedArray())
}
result != OnrampRedirectResult.Unknown -> {
scope.launch {
onrampSuccessScreenTrigger.triggerOnrampSuccess(result == OnrampRedirectResult.Success)
}
}
}
}
@AssistedFactory
interface Factory : OnrampDeepLink.Factory {
override fun create(coroutineScope: CoroutineScope): DefaultOnrampDeepLink
}
private companion object {
const val TX_ID_KEY = "tx_id"
const val RESULT_KEY = "result"
}
}

View file

@ -11,10 +11,6 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
internal interface OnrampDeeplinkModule {
@Binds
@Singleton
fun bindFactory(impl: DefaultOnrampDeepLink.Factory): OnrampDeepLink.Factory
@Binds
@Singleton
fun bindOnrampDeepLinkHandlerFactory(impl: DefaultOnrampDeepLinkHandler.Factory): OnrampDeepLinkHandler.Factory

View file

@ -83,8 +83,7 @@ internal class OnrampOperationModel @Inject constructor(
fun onHotTokenClick(status: CryptoCurrencyStatus) {
modelScope.launch {
val unavailabilityReason = rampStateManager.availableForBuy(
scanResponse = selectedUserWallet.scanResponse,
userWalletId = params.userWalletId,
userWallet = selectedUserWallet,
cryptoCurrency = status.currency,
)

View file

@ -147,7 +147,7 @@ internal class OnrampTokenListModel @Inject constructor(
private fun Lce<TokenListError, TokenList>.isInsufficientBalanceForSell(): Boolean {
return if (params.filterOperation == OnrampOperation.SELL) {
isContent {
(it.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isZero() ?: false
(it.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isZero() == true
}
} else {
false
@ -229,13 +229,16 @@ internal class OnrampTokenListModel @Inject constructor(
return when (params.filterOperation) {
OnrampOperation.BUY -> {
rampStateManager.availableForBuy(
scanResponse = userWallet.scanResponse,
userWalletId = params.userWalletId,
userWallet = userWallet,
cryptoCurrency = status.currency,
).isAvailable()
}
OnrampOperation.SELL -> {
rampStateManager.availableForSell(userWallet = userWallet, status = status).isRight()
rampStateManager.availableForSell(
userWalletId = userWallet.walletId,
status = status,
sendUnavailabilityReason = null,
).isRight()
}
OnrampOperation.SWAP -> {
val isAvailable = rampStateManager.availableForSwap(

View file

@ -16,4 +16,7 @@ dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.analytics.models)
/** Common */
implementation(projects.common.routing)
}

View file

@ -1,9 +1,19 @@
package com.tangem.features.pushnotifications.api
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface PushNotificationsComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Unit, PushNotificationsComponent>
interface Factory : ComponentFactory<Params, PushNotificationsComponent>
interface ModelCallbacks {
fun onResult()
}
sealed class Params {
data class Callbacks(val callbacks: ModelCallbacks) : Params()
data class Route(val route: AppRoute) : Params()
}
}

View file

@ -19,7 +19,7 @@ import dagger.assisted.AssistedInject
internal class DefaultPushNotificationsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
@Assisted params: PushNotificationsComponent.Params,
) : PushNotificationsComponent, AppComponentContext by appComponentContext {
private val model: PushNotificationsModel = getOrCreateModel(params)
@ -41,6 +41,9 @@ internal class DefaultPushNotificationsComponent @AssistedInject constructor(
@AssistedFactory
interface Factory : PushNotificationsComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultPushNotificationsComponent
override fun create(
context: AppComponentContext,
params: PushNotificationsComponent.Params,
): DefaultPushNotificationsComponent
}
}

View file

@ -1,15 +1,16 @@
package com.tangem.features.pushnotifications.impl.model
import androidx.compose.runtime.Stable
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
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.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.settings.NeverRequestPermissionUseCase
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.features.pushnotifications.impl.presentation.state.PushNotificationsUM
@ -21,15 +22,19 @@ import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class PushNotificationsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase,
private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase,
private val appRouter: AppRouter,
private val analyticHandler: AnalyticsEventHandler,
private val notificationsFeatureToggles: NotificationsFeatureToggles,
notificationsFeatureToggles: NotificationsFeatureToggles,
) : Model(), PushNotificationsClickIntents {
private val params: PushNotificationsComponent.Params = paramsContainer.require()
private val _state = MutableStateFlow(
PushNotificationsUM(
showInfoAboutNotifications = notificationsFeatureToggles.isNotificationsEnabled,
@ -51,7 +56,7 @@ internal class PushNotificationsModel @Inject constructor(
modelScope.launch {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
appRouter.push(AppRoute.Home)
onResult()
}
}
@ -62,7 +67,7 @@ internal class PushNotificationsModel @Inject constructor(
modelScope.launch {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
appRouter.push(AppRoute.Home)
onResult()
}
}
@ -73,7 +78,18 @@ internal class PushNotificationsModel @Inject constructor(
modelScope.launch {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
appRouter.push(AppRoute.Home)
onResult()
}
}
private fun onResult() {
when (params) {
is PushNotificationsComponent.Params.Callbacks -> {
params.callbacks.onResult()
}
is PushNotificationsComponent.Params.Route -> {
appRouter.push(params.route)
}
}
}
}

View file

@ -18,6 +18,7 @@ import com.google.mlkit.vision.common.InputImage
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.SystemBarsIconsDisposable
import com.tangem.domain.qrscanning.models.QrResultSource
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.model.QrScanningModel
import com.tangem.feature.qrscanning.presentation.QrScanningContent
@ -41,10 +42,10 @@ class DefaultQrScanningComponent @AssistedInject constructor(
// Camera requires its own analyzer instance due to flow of frames needed to be analyzed.
// Each new frame can cancel previous analysis e.i. image from the gallery can be skipped.
private val cameraAnalyzer: MLKitBarcodeAnalyzer by lazy(LazyThreadSafetyMode.NONE) {
MLKitBarcodeAnalyzer(model::onQrScanned)
MLKitBarcodeAnalyzer { qrCode -> model.onQrScanned(qrCode, QrResultSource.CAMERA) }
}
private val analyzer: MLKitBarcodeAnalyzer by lazy(LazyThreadSafetyMode.NONE) {
MLKitBarcodeAnalyzer(model::onQrScanned)
MLKitBarcodeAnalyzer { qrCode -> model.onQrScanned(qrCode, QrResultSource.GALLERY) }
}
init {

View file

@ -1,5 +1,6 @@
package com.tangem.feature.qrscanning.model
import com.tangem.domain.qrscanning.models.QrResultSource
import kotlinx.coroutines.flow.SharedFlow
internal interface QrScanningClickIntents {
@ -8,7 +9,7 @@ internal interface QrScanningClickIntents {
fun onBackClick()
fun onQrScanned(qrCode: String)
fun onQrScanned(qrCode: String, source: QrResultSource)
fun onGalleryClicked()

View file

@ -10,6 +10,8 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.domain.qrscanning.models.QrResultSource
import com.tangem.domain.qrscanning.models.RawQrResult
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.feature.qrscanning.QrScanningComponent
import com.tangem.feature.qrscanning.presentation.QrScanningState
@ -71,10 +73,11 @@ internal class QrScanningModel @Inject constructor(
override fun onBackClick() = appRouter.pop()
override fun onQrScanned(qrCode: String) {
override fun onQrScanned(qrCode: String, source: QrResultSource) {
if (qrCode.isNotBlank()) {
modelScope.launch(dispatchers.mainImmediate) {
emitQrScannedEventUseCase.invoke(params.source, qrCode)
val qrCode = RawQrResult(qrCode, source, params.source)
emitQrScannedEventUseCase.invoke(qrCode)
}
if (!isScanned) {
appRouter.pop()

View file

@ -4,6 +4,7 @@ import com.tangem.core.ui.clipboard.ClipboardManager
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.qrscanning.models.QrResultSource
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.impl.R
import com.tangem.feature.qrscanning.model.QrScanningClickIntents
@ -28,7 +29,7 @@ internal class InitializeQrScanningStateTransformer(
topBarConfig = constructTopBarConfig(),
message = message,
onBackClick = clickIntents::onBackClick,
onQrScanned = clickIntents::onQrScanned,
onQrScanned = { qrCode -> clickIntents.onQrScanned(qrCode, QrResultSource.CAMERA) },
onGalleryClick = clickIntents::onGalleryClicked,
pasteAction = constructPasteAction(),
)
@ -50,7 +51,7 @@ internal class InitializeQrScanningStateTransformer(
private fun constructPasteAction(): PasteAction {
val uri = clipboardManager.getText()
return if (uri != null) {
PasteAction.Perform { clickIntents.onQrScanned(uri) }
PasteAction.Perform { clickIntents.onQrScanned(uri, QrResultSource.CLIPBOARD) }
} else {
PasteAction.None
}

View file

@ -11,7 +11,6 @@ import com.tangem.datasource.api.tangemTech.models.StartReferralBody
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.feature.referral.converters.ReferralConverter
import com.tangem.feature.referral.domain.ReferralRepository
import com.tangem.feature.referral.domain.models.ReferralData
@ -97,13 +96,13 @@ internal class ReferralRepositoryImpl @Inject constructor(
sdkToken = sdkToken,
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = userWallet.requireColdWallet().scanResponse, // TODO [REDACTED_TASK_KEY]
userWallet = userWallet,
)
} else {
cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = userWallet.requireColdWallet().scanResponse, // TODO [REDACTED_TASK_KEY]
userWallet = userWallet,
)
}
}

View file

@ -22,7 +22,6 @@ dependencies {
implementation(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.decompose)
implementation(projects.core.deepLinks)
implementation(projects.libs.crypto)
implementation(projects.common.routing)

View file

@ -8,6 +8,10 @@ android {
namespace = "com.tangem.features.send.v2.api"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Core */
implementation(projects.core.decompose)
@ -34,4 +38,14 @@ dependencies {
/** Other */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
testImplementation(projects.domain.staking.models)
// endregion
}

View file

@ -21,11 +21,9 @@ sealed class FeeSelectorUM {
val fees: TransactionFee,
val feeItems: ImmutableList<FeeItem>,
val selectedFeeItem: FeeItem,
val isFeeApproximate: Boolean,
val feeExtraInfo: FeeExtraInfo,
val feeFiatRateUM: FeeFiatRateUM?,
val displayNonceInput: Boolean,
val nonce: BigInteger?,
val onNonceChange: (String) -> Unit,
val feeNonce: FeeNonce,
) : FeeSelectorUM()
}
@ -35,6 +33,21 @@ data class FeeFiatRateUM(
val appCurrency: AppCurrency,
)
@Immutable
data class FeeExtraInfo(
val isFeeApproximate: Boolean,
val isFeeConvertibleToFiat: Boolean,
val isTronToken: Boolean,
)
sealed class FeeNonce {
data object None : FeeNonce()
data class Nonce(
val nonce: BigInteger?,
val onNonceChange: (String) -> Unit,
) : FeeNonce()
}
@Immutable
sealed class FeeItem {
abstract val fee: Fee

View file

@ -13,6 +13,7 @@ sealed class FeeSelectorParams {
abstract val state: FeeSelectorUM
abstract val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>
abstract val cryptoCurrencyStatus: CryptoCurrencyStatus
abstract val feeCryptoCurrencyStatus: CryptoCurrencyStatus
abstract val suggestedFeeState: SuggestedFeeState
abstract val feeDisplaySource: FeeDisplaySource
@ -20,6 +21,7 @@ sealed class FeeSelectorParams {
override val state: FeeSelectorUM,
override val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
override val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
override val suggestedFeeState: SuggestedFeeState,
override val feeDisplaySource: FeeDisplaySource,
) : FeeSelectorParams()
@ -28,6 +30,7 @@ sealed class FeeSelectorParams {
override val state: FeeSelectorUM,
override val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
override val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
override val suggestedFeeState: SuggestedFeeState,
override val feeDisplaySource: FeeDisplaySource,
val callback: FeeSelectorModelCallback,

View file

@ -6,6 +6,8 @@ import com.tangem.features.send.v2.api.subcomponents.destination.entity.Destinat
interface SendDestinationBlockComponent : ComposableContentComponent {
fun updateState(destinationUM: DestinationUM)
interface Factory {
fun create(
context: AppComponentContext,

View file

@ -0,0 +1,99 @@
package com.tangem.features.send.v2.api.subcomponents.feeSelector.utils
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
import java.math.RoundingMode
object FeeCalculationUtils {
private val FEE_MAX_DIFF = BigDecimal("5")
/**
* Check and calculates subtracted amount
*/
fun checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus,
amountValue: BigDecimal,
feeValue: BigDecimal,
reduceAmountBy: BigDecimal,
): BigDecimal {
val balance = cryptoCurrencyStatus.value.amount ?: return amountValue
val isFeeCoverage = checkFeeCoverage(
isSubtractAvailable = isAmountSubtractAvailable,
balance = balance,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
return if (isFeeCoverage) {
balance.minus(reduceAmountBy).minus(feeValue)
} else {
amountValue
}
}
/**
* Check if custom fee is too high
*/
fun checkIfCustomFeeTooHigh(feeSelectorUM: FeeSelectorUM.Content): Pair<Boolean, String> {
val defaultResult = false to ""
if (feeSelectorUM.selectedFeeItem !is FeeItem.Custom) return defaultResult
val customAmount = feeSelectorUM.selectedFeeItem.customValues.firstOrNull() ?: return defaultResult
val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return defaultResult
val highValue = multipleFees.priority.amount.value ?: return defaultResult
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = if (highValue > BigDecimal.ZERO) {
customValue / highValue
} else {
BigDecimal.ZERO
}
val isFeeTooHigh = diff > FEE_MAX_DIFF
return isFeeTooHigh to diff.parseBigDecimal(0, RoundingMode.HALF_UP)
}
/**
* Check if custom fee is too low
*/
fun checkIfCustomFeeTooLow(feeSelectorUM: FeeSelectorUM.Content): Boolean {
if (feeSelectorUM.selectedFeeItem !is FeeItem.Custom) return false
val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return false
val minimumValue = multipleFees.minimum.amount.value ?: return false
val customAmount = feeSelectorUM.selectedFeeItem.customValues.firstOrNull() ?: return false
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
return minimumValue > customValue
}
/**
* Checks if sending amount with fee is greater than balance
*/
fun checkFeeCoverage(
isSubtractAvailable: Boolean,
balance: BigDecimal,
amountValue: BigDecimal,
feeValue: BigDecimal,
reduceAmountBy: BigDecimal?,
): Boolean {
if (!isSubtractAvailable) return false
val reducedBy = balance - (reduceAmountBy ?: BigDecimal.ZERO)
return reducedBy < amountValue + feeValue && reducedBy > feeValue && reducedBy >= amountValue
}
/**
* Checks if fee exceeds fee paid currency balance
*/
fun checkExceedBalance(feeBalance: BigDecimal?, feeAmount: BigDecimal?): Boolean {
return feeAmount == null || feeBalance == null || feeAmount.isZero() || feeAmount > feeBalance
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.v2.api.subcomponents.notifications
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import kotlinx.coroutines.flow.Flow
interface SendNotificationsUpdateListener {
/** Flow triggers notifications update */
val updateTriggerFlow: Flow<NotificationData>
/** Flow returns whether there is error notifications */
val hasErrorFlow: Flow<Boolean>
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.send.v2.api.subcomponents.notifications
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
interface SendNotificationsUpdateTrigger {
/** Trigger return callback with check result */
suspend fun callbackHasError(hasError: Boolean)
/** Trigger fee check reload */
suspend fun triggerUpdate(data: NotificationData)
}

View file

@ -0,0 +1,188 @@
package com.tangem.features.send.v2.api.subcomponents.feeSelector.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class FeeCalculationUtilsTest {
@Test
fun `GIVEN amount subtract available and fee coverage needed WHEN checkAndCalculateSubtractedAmount THEN returns subtracted amount`() {
// GIVEN
val isAmountSubtractAvailable = true
val cryptoCurrencyStatus = createCryptoCurrencyStatus(BigDecimal("6"))
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(BigDecimal("3"))
}
@Test
fun `GIVEN amount subtract not available WHEN checkAndCalculateSubtractedAmount THEN returns original amount`() {
// GIVEN
val isAmountSubtractAvailable = false
val cryptoCurrencyStatus = createCryptoCurrencyStatus(BigDecimal("10"))
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(amountValue)
}
@Test
fun `GIVEN sufficient balance for amount and fee WHEN checkAndCalculateSubtractedAmount THEN returns original amount`() {
// GIVEN
val isAmountSubtractAvailable = true
val cryptoCurrencyStatus = createCryptoCurrencyStatus(BigDecimal("10"))
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(amountValue)
}
@Test
fun `GIVEN no balance WHEN checkAndCalculateSubtractedAmount THEN returns original amount`() {
// GIVEN
val isAmountSubtractAvailable = true
val cryptoCurrencyStatus = createCryptoCurrencyStatus(null)
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(amountValue)
}
@Test
fun `GIVEN fee exceeds balance WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance = BigDecimal("5")
val feeAmount = BigDecimal("10")
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
@Test
fun `GIVEN fee within balance WHEN checkExceedBalance THEN returns false`() {
// GIVEN
val feeBalance = BigDecimal("10")
val feeAmount = BigDecimal("5")
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isFalse()
}
@Test
fun `GIVEN null fee amount WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance = BigDecimal("10")
val feeAmount: BigDecimal? = null
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
@Test
fun `GIVEN null fee balance WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance: BigDecimal? = null
val feeAmount = BigDecimal("5")
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
@Test
fun `GIVEN zero fee amount WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance = BigDecimal("10")
val feeAmount = BigDecimal.ZERO
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
private fun createCryptoCurrencyStatus(amount: BigDecimal?): CryptoCurrencyStatus {
val value = if (amount != null) {
CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = BigDecimal.ZERO,
fiatRate = BigDecimal.ZERO,
priceChange = BigDecimal.ZERO,
yieldBalance = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
} else {
CryptoCurrencyStatus.NoAmount(
priceChange = BigDecimal.ZERO,
fiatRate = BigDecimal.ZERO,
)
}
return CryptoCurrencyStatus(
currency = mockk(relaxed = true),
value = value,
)
}
}

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.send.v2.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.sendV2.api)
@ -79,4 +83,13 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

@ -48,10 +48,14 @@ internal sealed class CommonSendAnalyticEvents(
data class TransactionError(
val categoryName: String,
val token: String,
val blockchain: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Error - Transaction Rejected",
params = mapOf(TOKEN_PARAM to token),
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
),
)
/** Close button clicked */

View file

@ -45,6 +45,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
params = FeeSelectorParams.FeeSelectorDetailsParams(
state = model.uiState.value,
onLoadFee = params.onLoadFee,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
callback = model,
suggestedFeeState = FeeSelectorParams.SuggestedFeeState.None,

View file

@ -5,11 +5,13 @@ import com.tangem.features.send.v2.api.entity.FeeItem
internal interface FeeSelectorIntents {
fun onFeeItemSelected(feeItem: FeeItem)
fun onCustomFeeValueChange(index: Int, value: String)
fun onNonceChange(value: String)
fun onDoneClick()
}
internal class StubFeeSelectorIntents : FeeSelectorIntents {
override fun onFeeItemSelected(feeItem: FeeItem) {}
override fun onCustomFeeValueChange(index: Int, value: String) {}
override fun onNonceChange(value: String) {}
override fun onDoneClick() {}
}

View file

@ -15,10 +15,7 @@ import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.transformers.FeeItemSelectedTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.FeeSelectorCustomValueChangedTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.FeeSelectorErrorTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.FeeSelectorLoadedTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.MutableStateFlow
@ -67,6 +64,7 @@ internal class FeeSelectorModel @Inject constructor(
uiState.update(
FeeSelectorLoadedTransformer(
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
appCurrency = appCurrency,
fees = fee,
suggestedFeeState = params.suggestedFeeState,
@ -80,7 +78,7 @@ internal class FeeSelectorModel @Inject constructor(
}
private fun isFeeApproximate(amountType: AmountType): Boolean {
val networkId = params.cryptoCurrencyStatus.currency.network.id
val networkId = params.feeCryptoCurrencyStatus.currency.network.id
return isFeeApproximateUseCase(networkId = networkId, amountType = amountType)
}
@ -95,11 +93,15 @@ internal class FeeSelectorModel @Inject constructor(
value = value,
intents = this,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
),
)
}
override fun onNonceChange(value: String) {
uiState.update(FeeSelectorNonceChangeTransformer(value = value))
}
override fun onDoneClick() {
(params as? FeeSelectorParams.FeeSelectorDetailsParams)?.callback?.onFeeResult(uiState.value)
}

View file

@ -1,19 +1,21 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
@Suppress("LongParameterList")
internal class FeeSelectorLoadedTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val fees: TransactionFee,
private val suggestedFeeState: FeeSelectorParams.SuggestedFeeState,
@ -26,7 +28,7 @@ internal class FeeSelectorLoadedTransformer(
normalFee = fees.normal,
feeSelectorIntents = feeSelectorIntents,
appCurrency = appCurrency,
cryptoCurrencyStatus = cryptoCurrencyStatus,
cryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
@ -44,20 +46,32 @@ internal class FeeSelectorLoadedTransformer(
-> feeItems.find { it is FeeItem.Suggested } ?: feeItems.first { it is FeeItem.Market }
}
val nonce = ((prevState as? FeeSelectorUM.Content)?.feeNonce as? FeeNonce.Nonce)?.nonce
return FeeSelectorUM.Content(
fees = fees,
feeItems = feeItems,
selectedFeeItem = selectedFee,
isFeeApproximate = isFeeApproximate,
feeFiatRateUM = cryptoCurrencyStatus.value.fiatRate?.let { rate ->
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = isFeeApproximate,
isFeeConvertibleToFiat = feeCryptoCurrencyStatus.currency.network.hasFiatFeeRate,
isTronToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token &&
isTron(cryptoCurrencyStatus.currency.network.rawId),
),
feeFiatRateUM = feeCryptoCurrencyStatus.value.fiatRate?.let { rate ->
FeeFiatRateUM(
rate = rate,
appCurrency = appCurrency,
)
},
displayNonceInput = false,
nonce = null,
onNonceChange = {},
feeNonce = if (fees.normal is Fee.Ethereum) {
FeeNonce.Nonce(
nonce = nonce,
onNonceChange = feeSelectorIntents::onNonceChange,
)
} else {
FeeNonce.None
},
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.features.send.v2.api.entity.FeeNonce
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
internal class FeeSelectorNonceChangeTransformer(
private val value: String,
) : Transformer<FeeSelectorUM> {
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
val state = prevState as? FeeSelectorUM.Content ?: return prevState
val feeNonce = state.feeNonce as? FeeNonce.Nonce ?: return prevState
if (value.isEmpty()) {
return state.copy(feeNonce = feeNonce.copy(null))
}
val nonce = value.toBigIntegerOrNull() ?: return prevState
return state.copy(feeNonce = feeNonce.copy(nonce = nonce))
}
}

View file

@ -10,15 +10,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.extensions.stringResourceSafe
@ -29,9 +31,8 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.impl.R
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@ -50,21 +51,14 @@ internal fun FeeSelectorBlockContent(state: FeeSelectorUM, modifier: Modifier =
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
Text(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
text = stringResourceSafe(R.string.common_network_fee_title),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
Icon(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing6)
.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_token_info_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
SpacerWMax()
FeeSelectorDescription(state = state)
}
}
@Composable
private fun FeeSelectorDescription(state: FeeSelectorUM, modifier: Modifier = Modifier) {
Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween) {
FeeSelectorStaticPart(modifier = Modifier.weight(1f))
when (state) {
is FeeSelectorUM.Content -> FeeContent(state)
is FeeSelectorUM.Loading -> FeeLoading()
@ -74,7 +68,31 @@ internal fun FeeSelectorBlockContent(state: FeeSelectorUM, modifier: Modifier =
}
@Composable
private fun RowScope.FeeError() {
private fun FeeSelectorStaticPart(modifier: Modifier = Modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing4)
.weight(1f, fill = false),
text = stringResourceSafe(R.string.common_network_fee_title),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing6)
.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_token_info_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Composable
private fun FeeError() {
Text(
text = EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
@ -83,7 +101,7 @@ private fun RowScope.FeeError() {
}
@Composable
private fun RowScope.FeeLoading() {
private fun FeeLoading() {
TextShimmer(
radius = TangemTheme.dimens.radius3,
style = TangemTheme.typography.body1,
@ -92,62 +110,87 @@ private fun RowScope.FeeLoading() {
}
@Composable
private fun RowScope.FeeContent(state: FeeSelectorUM.Content) {
private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifier) {
val fiatRate = state.feeFiatRateUM
EllipsisText(
text = if (fiatRate != null) {
getFiatString(
value = state.selectedFeeItem.fee.amount.value,
rate = fiatRate.rate,
appCurrency = fiatRate.appCurrency,
approximate = state.isFeeApproximate,
)
} else {
state.selectedFeeItem.fee.amount.value.format {
crypto(
symbol = state.selectedFeeItem.fee.amount.currencySymbol,
decimals = state.selectedFeeItem.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
}
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.End,
modifier = Modifier
.weight(1f)
.padding(start = TangemTheme.dimens.spacing4),
)
Icon(
modifier = Modifier.size(width = 18.dp, height = 24.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
EllipsisText(
text = if (fiatRate != null) {
getFiatString(
value = state.selectedFeeItem.fee.amount.value,
rate = fiatRate.rate,
appCurrency = fiatRate.appCurrency,
approximate = state.feeExtraInfo.isFeeApproximate,
)
} else {
state.selectedFeeItem.fee.amount.value.format {
crypto(
symbol = state.selectedFeeItem.fee.amount.currencySymbol,
decimals = state.selectedFeeItem.fee.amount.decimals,
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
}
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.End,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
)
Icon(
modifier = Modifier.size(width = 18.dp, height = 24.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeSelectorBlockContent_Preview() {
private fun FeeSelectorBlockContent_Preview(@PreviewParameter(FeeSelectorUMProvider::class) state: FeeSelectorUM) {
TangemThemePreview {
val feeItem = FeeItem.Market(
Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum)),
)
FeeSelectorBlockContent(
modifier = Modifier.fillMaxWidth(),
state = FeeSelectorUM.Content(
feeItems = persistentListOf(feeItem),
selectedFeeItem = feeItem,
isFeeApproximate = false,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500"),
appCurrency = AppCurrency.Default,
),
displayNonceInput = false,
nonce = null,
onNonceChange = {},
fees = TransactionFee.Single(feeItem.fee),
),
)
FeeSelectorBlockContent(modifier = Modifier.fillMaxWidth(), state = state)
}
}
private class FeeSelectorUMProvider : PreviewParameterProvider<FeeSelectorUM> {
private val maxFeeItem = FeeItem.Market(
fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Ethereum)),
)
private val lowFeeItem =
FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum)))
override val values: Sequence<FeeSelectorUM> = sequenceOf(
FeeSelectorUM.Content(
feeItems = persistentListOf(lowFeeItem),
selectedFeeItem = lowFeeItem,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeNonce = FeeNonce.None,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500"),
appCurrency = AppCurrency.Default,
),
fees = TransactionFee.Single(lowFeeItem.fee),
),
FeeSelectorUM.Content(
feeItems = persistentListOf(maxFeeItem),
selectedFeeItem = maxFeeItem,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeNonce = FeeNonce.None,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500000000000"),
appCurrency = AppCurrency.Default,
),
fees = TransactionFee.Single(maxFeeItem.fee),
),
FeeSelectorUM.Error(GetFeeError.UnknownError),
FeeSelectorUM.Loading,
)
}

View file

@ -37,6 +37,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
import com.tangem.core.ui.components.inputrow.InputRowEnter
import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmountV2
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
@ -45,10 +46,7 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents
@ -57,7 +55,6 @@ import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import java.math.BigInteger
@Composable
internal fun FeeSelectorModalBottomSheet(
@ -158,7 +155,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -184,7 +181,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -210,7 +207,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -236,7 +233,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -258,9 +255,7 @@ private fun FeeSelectorItems(
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
onValueChange = feeSelectorIntents::onCustomFeeValueChange,
displayNonceInput = state.displayNonceInput,
nonce = state.nonce,
onNonceChange = state.onNonceChange,
nonce = state.feeNonce,
)
}
}
@ -275,9 +270,7 @@ private fun CustomFeeBlock(
iconBackgroundColor: Color,
iconTint: Color,
onValueChange: (Int, String) -> Unit,
displayNonceInput: Boolean,
nonce: BigInteger?,
onNonceChange: (String) -> Unit,
nonce: FeeNonce,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@ -310,9 +303,7 @@ private fun CustomFeeBlock(
ExpandedCustomFeeItems(
customFeeFields = customFee.customValues,
onValueChange = onValueChange,
displayNonceInput = displayNonceInput,
nonce = nonce,
onNonceChange = onNonceChange,
)
}
}
@ -322,14 +313,12 @@ private fun CustomFeeBlock(
private fun ExpandedCustomFeeItems(
customFeeFields: ImmutableList<CustomFeeFieldUM>,
onValueChange: (Int, String) -> Unit,
displayNonceInput: Boolean,
nonce: BigInteger?,
onNonceChange: (String) -> Unit,
nonce: FeeNonce,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
customFeeFields.fastForEachIndexed { index, field ->
val showDivider = index != customFeeFields.size - 1 || displayNonceInput
val showDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce
if (field.label != null) {
InputRowEnterInfoAmountV2(
text = field.value,
@ -338,6 +327,7 @@ private fun ExpandedCustomFeeItems(
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
info = field.label,
description = field.footer,
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
onValueChange = { onValueChange(index, it) },
@ -351,6 +341,7 @@ private fun ExpandedCustomFeeItems(
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
symbol = field.symbol,
description = field.footer,
onValueChange = { onValueChange(index, it) },
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
@ -359,18 +350,21 @@ private fun ExpandedCustomFeeItems(
}
}
if (displayNonceInput) {
// TODO implement v2 input without binding to amount
InputRowEnterInfoAmountV2(
text = nonce?.toString() ?: "",
decimals = 0,
if (nonce is FeeNonce.Nonce) {
InputRowEnter(
text = nonce.nonce?.toString().orEmpty(),
title = resourceReference(R.string.send_nonce),
titleColor = TangemTheme.colors.text.tertiary,
symbol = null,
onValueChange = onNonceChange,
description = resourceReference(R.string.send_nonce_footer),
onValueChange = nonce.onNonceChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
placeholder = resourceReference(R.string.send_nonce_hint),
titleColor = TangemTheme.colors.text.secondary,
showDivider = false,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
@ -504,14 +498,16 @@ private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider<
// amount = Amount(value = BigDecimal("0.02"), blockchain = Blockchain.Ethereum),
// ),
selectedFeeItem = customFeeItem,
isFeeApproximate = true,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = true,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal.TEN,
appCurrency = AppCurrency.Default,
),
displayNonceInput = true,
onNonceChange = {},
nonce = null,
feeNonce = FeeNonce.None,
fees = TransactionFee.Single(customFeeItem.fee),
),
),

View file

@ -21,6 +21,7 @@ import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
@ -53,6 +54,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendComponent.Params,
private val analyticsEventHandler: AnalyticsEventHandler,
private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
) : SendComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<CommonSendRoute>()
@ -298,6 +300,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess)
},
),
feeSelectorComponentFactory = feeSelectorComponentFactory,
)
} else {
model.showAlertError()

View file

@ -14,9 +14,11 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.entity.PredefinedValues
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
@ -35,6 +37,7 @@ import kotlinx.coroutines.flow.*
internal class SendConfirmComponent(
appComponentContext: AppComponentContext,
params: Params,
private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendConfirmModel = getOrCreateModel(params = params)
@ -92,6 +95,19 @@ internal class SendConfirmComponent(
onClick = model::showEditFee,
)
private val feeSelectorBlockComponent = feeSelectorComponentFactory.create(
context = appComponentContext,
params = FeeSelectorParams.FeeSelectorBlockParams(
state = model.uiState.value.feeSelectorUM,
onLoadFee = params.onLoadFee,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
suggestedFeeState = model.suggestedFeeState,
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
),
onResult = model::onFeeResult,
)
private val notificationsComponent = DefaultSendNotificationsComponent(
appComponentContext = child("sendConfirmNotifications"),
params = SendNotificationsComponent.Params(
@ -136,6 +152,7 @@ internal class SendConfirmComponent(
destinationBlockComponent = destinationBlockComponent,
amountBlockComponent = amountBlockComponent,
feeBlockComponent = feeBlockComponent,
feeSelectorBlockComponent = feeSelectorBlockComponent,
notificationsComponent = notificationsComponent,
notificationsUM = notificationState,
)

View file

@ -35,7 +35,13 @@ import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeNonce
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.SendBalanceUpdater
import com.tangem.features.send.v2.common.SendConfirmAlertFactory
@ -49,13 +55,13 @@ import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmIn
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSendingStateTransformer
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSentStateTransformer
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmationNotificationsTransformer
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmationNotificationsTransformerV2
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.model.checkAndCalculateSubtractedAmount
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.extensions.stripZeroPlainString
@ -86,13 +92,14 @@ internal class SendConfirmModel @Inject constructor(
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger,
private val sendFeeCheckReloadListener: SendFeeCheckReloadListener,
private val notificationsUpdateTrigger: NotificationsUpdateTrigger,
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val notificationsUpdateListener: SendNotificationsUpdateListener,
private val alertFactory: SendConfirmAlertFactory,
private val sendAnalyticHelper: SendAnalyticHelper,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
sendBalanceUpdaterFactory: SendBalanceUpdater.Factory,
) : Model(), SendConfirmClickIntents {
) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback {
private val params: SendConfirmComponent.Params = paramsContainer.require()
@ -115,6 +122,8 @@ internal class SendConfirmModel @Inject constructor(
get() = uiState.value.feeUM as? FeeUM.Content
private val feeSelectorUM
get() = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
private val feeUMV2
get() = uiState.value.feeSelectorUM as? FeeSelectorUMRedesigned.Content
val confirmData: ConfirmData
get() = ConfirmData(
@ -129,6 +138,7 @@ internal class SendConfirmModel @Inject constructor(
private var sendIdleTimer: Long = 0L
private var isAmountSubtractAvailable = false
internal var suggestedFeeState: FeeSelectorParams.SuggestedFeeState = FeeSelectorParams.SuggestedFeeState.None
init {
modelScope.launch {
@ -300,16 +310,25 @@ internal class SendConfirmModel @Inject constructor(
}
private fun subscribeOnNotificationsUpdateTrigger() {
notificationsUpdateTrigger.hasErrorFlow
notificationsUpdateListener.hasErrorFlow
.onEach { hasError ->
_uiState.update {
val feeUM = it.feeUM as? FeeUM.Content
val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeSelectorUM != null,
) ?: it.confirmUM,
)
if (_uiState.value.isRedesignEnabled) {
val feeUM = it.feeSelectorUM as? FeeSelectorUMRedesigned.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeUM != null,
) ?: it.confirmUM,
)
} else {
val feeUM = it.feeUM as? FeeUM.Content
val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeSelectorUM != null,
) ?: it.confirmUM,
)
}
}
}
.launchIn(modelScope)
@ -319,9 +338,18 @@ internal class SendConfirmModel @Inject constructor(
val amountValue = amountState?.amountTextField?.cryptoAmount?.value ?: return
val destination = destinationUM?.addressTextField?.actualAddress ?: return
val memo = destinationUM?.memoTextField?.value
val fee = feeSelectorUM?.selectedFee
val isRedesignEnabled = uiState.value.isRedesignEnabled
val fee = if (isRedesignEnabled) {
feeUMV2?.selectedFeeItem?.fee
} else {
feeSelectorUM?.selectedFee
}
val nonce = if (isRedesignEnabled) {
(feeUMV2?.feeNonce as? FeeNonce.Nonce)?.nonce
} else {
feeSelectorUM?.nonce
}
val feeValue = fee?.amount?.value ?: return
val nonce = feeSelectorUM?.nonce
val receivingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
@ -377,6 +405,7 @@ internal class SendConfirmModel @Inject constructor(
CommonSendAnalyticEvents.TransactionError(
categoryName = analyticsCategoryName,
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
),
)
},
@ -448,14 +477,25 @@ internal class SendConfirmModel @Inject constructor(
)
_uiState.update {
it.copy(
confirmUM = SendConfirmationNotificationsTransformer(
feeUM = uiState.value.feeUM,
amountUM = uiState.value.amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
).transform(uiState.value.confirmUM),
confirmUM = if (uiState.value.isRedesignEnabled) {
SendConfirmationNotificationsTransformerV2(
feeSelectorUM = uiState.value.feeSelectorUM,
amountUM = uiState.value.amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
).transform(uiState.value.confirmUM)
} else {
SendConfirmationNotificationsTransformer(
feeUM = uiState.value.feeUM,
amountUM = uiState.value.amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
).transform(uiState.value.confirmUM)
},
)
}
}
@ -556,6 +596,12 @@ internal class SendConfirmModel @Inject constructor(
)
}
override fun onFeeResult(feeSelectorUM: FeeSelectorUMRedesigned) {
sendIdleTimer = SystemClock.elapsedRealtime()
_uiState.update { it.copy(feeSelectorUM = feeSelectorUM) }
updateConfirmNotifications()
}
private companion object {
const val CHECK_FEE_UPDATE_DELAY = 10_000L
}

View file

@ -0,0 +1,110 @@
package com.tangem.features.send.v2.send.confirm.model.transformers
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.models.AmountState
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.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
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.common.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.common.utils.formatFooterFiatFee
import com.tangem.features.send.v2.common.utils.getTronTokenFeeSendingText
import com.tangem.features.send.v2.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList
internal class SendConfirmationNotificationsTransformerV2(
private val feeSelectorUM: FeeSelectorUM,
private val amountUM: AmountState,
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 amountUM = amountUM as? AmountState.Data
val fee = feeSelectorUM?.selectedFeeItem?.fee
if (fee == null || amountUM == null) return TextReference.EMPTY
val fiatAmountValue = amountUM.amountTextField.fiatAmount.value
val fiatFeeValue = feeSelectorUM.feeFiatRateUM?.rate?.let { fee.amount.value?.multiply(it) }
val fiatSendingValue = if (feeSelectorUM.feeFiatRateUM != null) {
fiatFeeValue?.let { fiatAmountValue?.plus(it) }
} else {
fiatAmountValue
}
val fiatSending = fiatSendingValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
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 = stringReference(fiatSending),
)
} 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(fiatSending, fiatFee),
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.send.v2.send.confirm.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@ -9,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.SpacerHMax
@ -19,6 +21,7 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.common.ui.SendingText
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.common.ui.tapHelp
@ -40,6 +43,7 @@ internal fun SendConfirmContent(
destinationBlockComponent: DefaultSendDestinationBlockComponent,
amountBlockComponent: SendAmountBlockComponent,
feeBlockComponent: SendFeeBlockComponent,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
notificationsComponent: DefaultSendNotificationsComponent,
notificationsUM: ImmutableList<NotificationUM>,
) {
@ -54,6 +58,7 @@ internal fun SendConfirmContent(
destinationBlockComponent = destinationBlockComponent,
amountBlockComponent = amountBlockComponent,
feeBlockComponent = feeBlockComponent,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
if (confirmUM != null) {
tapHelp(isDisplay = confirmUM.showTapHelp)
@ -79,18 +84,24 @@ private fun LazyListScope.blocks(
destinationBlockComponent: DefaultSendDestinationBlockComponent,
amountBlockComponent: SendAmountBlockComponent,
feeBlockComponent: SendFeeBlockComponent,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
) {
item(key = BLOCKS_KEY) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (uiState.isRedesignEnabled) {
amountBlockComponent.Content(modifier = Modifier)
destinationBlockComponent.Content(modifier = Modifier)
feeSelectorBlockComponent.Content(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
} else {
TransactionDoneTitleAnimated(uiState)
destinationBlockComponent.Content(modifier = Modifier)
amountBlockComponent.Content(modifier = Modifier)
feeBlockComponent.Content(modifier = Modifier)
}
feeBlockComponent.Content(modifier = Modifier)
}
}
}

View file

@ -39,6 +39,7 @@ import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.entity.PredefinedValues
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
@ -395,5 +396,6 @@ internal class SendModel @Inject constructor(
navigationUM = NavigationUM.Empty,
isRedesignEnabled = sendFeatureToggles.isSendRedesignEnabled,
confirmData = null,
feeSelectorUM = FeeSelectorUM.Loading,
)
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.send.v2.send.ui.state
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.send.confirm.model.ConfirmData
@ -11,6 +12,7 @@ internal data class SendUM(
val amountUM: AmountState,
val destinationUM: DestinationUM,
val feeUM: FeeUM,
val feeSelectorUM: FeeSelectorUM,
val confirmUM: ConfirmUM,
val navigationUM: NavigationUM,
val isRedesignEnabled: Boolean,

View file

@ -30,6 +30,8 @@ import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.features.nft.entity.NFTSendSuccessTrigger
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.SendBalanceUpdater
import com.tangem.features.send.v2.common.SendConfirmAlertFactory
@ -47,7 +49,6 @@ import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.stripZeroPlainString
import com.tangem.utils.transformer.update
@ -72,7 +73,8 @@ internal class NFTSendConfirmModel @Inject constructor(
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val notificationsUpdateTrigger: NotificationsUpdateTrigger,
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val notificationsUpdateListener: SendNotificationsUpdateListener,
private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger,
private val sendFeeCheckReloadListener: SendFeeCheckReloadListener,
private val alertFactory: SendConfirmAlertFactory,
@ -242,7 +244,7 @@ internal class NFTSendConfirmModel @Inject constructor(
}
private fun subscribeOnNotificationsUpdateTrigger() {
notificationsUpdateTrigger.hasErrorFlow
notificationsUpdateListener.hasErrorFlow
.onEach { hasError ->
_uiState.update {
val feeUM = it.feeUM as? FeeUM.Content
@ -310,6 +312,7 @@ internal class NFTSendConfirmModel @Inject constructor(
CommonSendAnalyticEvents.TransactionError(
categoryName = analyticsCategoryName,
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
),
)
},

View file

@ -94,7 +94,6 @@ internal class SendAmountModel @Inject constructor(
},
ifRight = { wallet ->
userWallet = wallet
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
},
)
}.launchIn(modelScope)
@ -128,6 +127,8 @@ internal class SendAmountModel @Inject constructor(
)
}
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
if (uiState.value is AmountState.Data) {
_uiState.update(
AmountBoundaryUpdateTransformer(

View file

@ -33,7 +33,7 @@ internal class DefaultSendDestinationBlockComponent @AssistedInject constructor(
}.launchIn(componentScope)
}
fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM)
override fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM)
@Composable
override fun Content(modifier: Modifier) {

View file

@ -1,28 +1,17 @@
package com.tangem.features.send.v2.subcomponents.notifications
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import kotlinx.coroutines.flow.Flow
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
interface NotificationsUpdateTrigger {
/** Flow triggers notifications update */
val updateTriggerFlow: Flow<NotificationData>
/** Flow returns whether there is error notifications */
val hasErrorFlow: Flow<Boolean>
/** Trigger return callback with check result */
suspend fun callbackHasError(hasError: Boolean)
/** Trigger fee check reload */
suspend fun triggerUpdate(data: NotificationData)
}
@Singleton
internal class DefaultNotificationsUpdateTrigger @Inject constructor() : NotificationsUpdateTrigger {
internal class DefaultNotificationsUpdateTrigger @Inject constructor() :
SendNotificationsUpdateListener,
SendNotificationsUpdateTrigger {
private val _updateTriggerFlow = MutableSharedFlow<NotificationData>()
override val updateTriggerFlow = _updateTriggerFlow.asSharedFlow()

View file

@ -1,20 +1,23 @@
package com.tangem.features.send.v2.subcomponents.notifications.di
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.subcomponents.notifications.DefaultNotificationsUpdateTrigger
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
internal object NotificationsModule {
internal interface NotificationsModule {
@Provides
@Singleton
fun providesNotificationsUpdateTrigger(): NotificationsUpdateTrigger {
return DefaultNotificationsUpdateTrigger()
}
@Binds
fun bindsNotificationsUpdateTrigger(impl: DefaultNotificationsUpdateTrigger): SendNotificationsUpdateTrigger
@Singleton
@Binds
fun bindsNotificationsUpdateListener(impl: DefaultNotificationsUpdateTrigger): SendNotificationsUpdateListener
}

View file

@ -37,12 +37,13 @@ import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.v2.subcomponents.fee.SendFeeData
import com.tangem.features.send.v2.subcomponents.fee.SendFeeReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.model.checkAndCalculateSubtractedAmount
import com.tangem.features.send.v2.subcomponents.fee.model.checkFeeCoverage
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import com.tangem.features.send.v2.subcomponents.notifications.analytics.NotificationsAnalyticEvents
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.isTron
@ -74,7 +75,8 @@ internal class NotificationsModel @Inject constructor(
private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase,
private val sendFeeReloadTrigger: SendFeeReloadTrigger,
private val sendAmountReduceTrigger: SendAmountReduceTrigger,
private val notificationsUpdateTrigger: NotificationsUpdateTrigger,
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val notificationsUpdateListener: SendNotificationsUpdateListener,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
@ -101,7 +103,7 @@ internal class NotificationsModel @Inject constructor(
}
private fun subscribeToNotificationUpdateTrigger() {
notificationsUpdateTrigger.updateTriggerFlow
notificationsUpdateListener.updateTriggerFlow
.onEach { updateState(it) }
.launchIn(modelScope)
}

View file

@ -0,0 +1,439 @@
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.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
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.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
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.api.entity.CustomFeeFieldUM
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import java.math.BigInteger
import java.util.Locale
import org.junit.jupiter.api.BeforeAll
class SendConfirmationNotificationsTransformerTest {
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 amountUM: AmountState = mockk(relaxed = true)
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
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 amountUM: AmountState = mockk(relaxed = true)
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
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 amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
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 amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
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 amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
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 amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
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 createTestAmountUM(): AmountState.Data {
val cryptoAmount = com.tangem.domain.tokens.model.Amount(
currencySymbol = "SOL",
value = BigDecimal("1.5"),
decimals = 8,
)
val fiatAmount = com.tangem.domain.tokens.model.Amount(
currencySymbol = "USD",
value = BigDecimal("50.00"),
decimals = 2,
)
return AmountState.Data(
isPrimaryButtonEnabled = true,
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),
selectedButton = 0,
isSegmentedButtonsEnabled = false,
amountTextField = AmountFieldModel(
value = "1.5",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
cryptoAmount = cryptoAmount,
fiatAmount = fiatAmount,
isFiatValue = false,
fiatValue = "50.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = mockk(relaxed = true),
),
appCurrency = appCurrency,
isEditingDisabled = false,
reduceAmountBy = BigDecimal.ZERO,
isIgnoreReduce = false,
)
}
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,505 @@
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.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountState
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 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.domain.tokens.model.Amount as DomainAmount
class SendConfirmationNotificationsTransformerV2Test {
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 amountUM: AmountState = mockk(relaxed = true)
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
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 amountUM: AmountState = mockk(relaxed = true)
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
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 amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
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 amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
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 amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
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 amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
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 createTestAmountUM(): AmountState.Data {
val cryptoAmount = DomainAmount(
currencySymbol = "SOL",
value = BigDecimal("1.5"),
decimals = 8,
)
val fiatAmount = DomainAmount(
currencySymbol = "USD",
value = BigDecimal("50.00"),
decimals = 2,
)
return AmountState.Data(
isPrimaryButtonEnabled = true,
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),
selectedButton = 0,
isSegmentedButtonsEnabled = false,
amountTextField = AmountFieldModel(
value = "1.5",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
cryptoAmount = cryptoAmount,
fiatAmount = fiatAmount,
isFiatValue = false,
fiatValue = "50.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = mockk(relaxed = true),
),
appCurrency = appCurrency,
isEditingDisabled = false,
reduceAmountBy = BigDecimal.ZERO,
isIgnoreReduce = false,
)
}
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(
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(
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(
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(
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

@ -0,0 +1,870 @@
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.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountState
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.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.domain.tokens.model.Amount as DomainAmount
import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMV2
class TransformersComparisonTest {
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 amountUM = createTestAmountUM()
val feeUM = createTestFeeUM()
val feeSelectorUMV2 = createTestFeeSelectorUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
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.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 amountUM = createTestAmountUM()
val feeUM = createFeeTooLowUM()
val feeSelectorUMV2 = createFeeTooLowUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
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).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 amountUM = createTestAmountUM()
val feeUM = createFeeTooHighUM()
val feeSelectorUMV2 = createFeeTooHighUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
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).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 amountUM = createTestAmountUM()
val feeUM = createFeeTooHighAndTooLowUM()
val feeSelectorUMV2 = createFeeTooHighAndTooLowUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
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).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 amountUM = createTestAmountUM()
val feeUM = createNormalFeeUM()
val feeSelectorUMV2 = createNormalFeeSelectorUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
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.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 createTestAmountUM(): AmountState.Data {
val cryptoAmount = DomainAmount(
currencySymbol = "TST",
value = BigDecimal("1.5"),
decimals = 8,
)
val fiatAmount = DomainAmount(
currencySymbol = "USD",
value = BigDecimal("50.00"),
decimals = 2,
)
return AmountState.Data(
isPrimaryButtonEnabled = true,
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),
selectedButton = 0,
isSegmentedButtonsEnabled = false,
amountTextField = AmountFieldModel(
value = "1.5",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
cryptoAmount = cryptoAmount,
fiatAmount = fiatAmount,
isFiatValue = false,
fiatValue = "50.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = mockk(relaxed = true),
),
appCurrency = appCurrency,
isEditingDisabled = false,
reduceAmountBy = BigDecimal.ZERO,
isIgnoreReduce = false,
)
}
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(
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(
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(
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(
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(
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

@ -47,8 +47,6 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.decompose)
implementation(projects.core.deepLinks)
/** Domain */
implementation(projects.domain.tokens)
@ -65,6 +63,7 @@ dependencies {
implementation(projects.domain.transaction)
implementation(projects.domain.transaction.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.feedback)
implementation(projects.domain.feedback.models)
implementation(projects.domain.notifications.models)

View file

@ -3,9 +3,9 @@ package com.tangem.features.staking.impl.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.domain.staking.GetStakingAvailabilityUseCase
import com.tangem.domain.staking.GetYieldUseCase
import com.tangem.domain.staking.model.StakingAvailability

View file

@ -28,7 +28,6 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.staking.*
import com.tangem.domain.staking.analytics.StakeScreenSource
@ -37,6 +36,7 @@ import com.tangem.domain.staking.model.StakingApproval
import com.tangem.domain.staking.model.stakekit.*
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction
import com.tangem.domain.staking.utils.getValidatorsCount
import com.tangem.domain.tokens.*
@ -120,6 +120,7 @@ internal class StakingModel @Inject constructor(
private val getActionsUseCase: GetActionsUseCase,
private val getYieldUseCase: GetYieldUseCase,
private val checkAccountInitializedUseCase: CheckAccountInitializedUseCase,
private val getActionRequirementAmountUseCase: GetActionRequirementAmountUseCase,
private val paramsInterceptorHolder: ParamsInterceptorHolder,
private val shareManager: ShareManager,
@DelayedWork private val coroutineScope: CoroutineScope,
@ -527,9 +528,20 @@ internal class StakingModel @Inject constructor(
val rewardPendingActionConstraints = yieldBalance?.reward?.rewardConstraints
if (rewardBlockType == RewardBlockType.RewardsRequirementsError) {
val minimumAmount = rewardPendingActionConstraints?.amountArg?.minimum
// Temporary fix, until StakeKit adds minimum requirement amount to balance response
val minimumAmountValue = if (minimumAmount == null && yieldBalance.integrationId != null) {
getActionRequirementAmountUseCase.invoke(
integrationId = yieldBalance.integrationId,
actionType = StakingActionType.CLAIM_REWARDS,
).getOrNull()
} else {
minimumAmount
}
stakingEventFactory.createStakingRewardsMinimumRequirementsErrorAlert(
cryptoCurrencyName = cryptoCurrencyStatus.currency.name,
cryptoAmountValue = rewardPendingActionConstraints?.amountArg?.minimum?.format {
cryptoAmountValue = minimumAmountValue?.format {
crypto(cryptoCurrencyStatus.currency)
}.orEmpty(),
)
@ -931,11 +943,6 @@ internal class StakingModel @Inject constructor(
isSingleWalletWithTokens = false,
)
.conflate()
.filter {
val sources = it.getOrNull()?.value?.sources ?: return@filter true
sources.networkSource == StatusSource.ACTUAL && sources.yieldBalanceSource == StatusSource.ACTUAL
}
.distinctUntilChanged()
.filter { value.currentStep == StakingStep.InitialInfo }
.onEach { maybeStatus ->

View file

@ -9,6 +9,7 @@ import java.math.BigDecimal
@Immutable
internal sealed class InnerYieldBalanceState {
data class Data(
val integrationId: String?,
val reward: YieldReward,
val isActionable: Boolean,
val balances: ImmutableList<BalanceState>,

View file

@ -50,6 +50,7 @@ internal class YieldBalancesConverter(
?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS }
InnerYieldBalanceState.Data(
integrationId = yieldBalance?.integrationId,
reward = YieldReward(
rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) },
rewardsFiat = fiatRewardsValue.format {

View file

@ -43,7 +43,6 @@ internal class StakingBalanceUpdater @AssistedInject constructor(
fetchStakingYieldBalanceUseCase(
userWalletId = userWallet.walletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
isRefactoringEnabled = true,
)
},
// we should update tx history and network for new balances
@ -64,7 +63,11 @@ internal class StakingBalanceUpdater @AssistedInject constructor(
coroutineScope {
listOf(
async {
fetchCurrencyStatus()
/*
* It is important to use NonCancellable here to ensure the update is not interrupted midway.
* For example, this can happen if the user enters and immediately leaves the screen.
*/
withContext(NonCancellable) { fetchCurrencyStatus() }
},
async {
updateStakingActions()
@ -78,7 +81,6 @@ internal class StakingBalanceUpdater @AssistedInject constructor(
fetchCurrencyStatusUseCase(
userWalletId = userWallet.walletId,
id = cryptoCurrencyStatus.currency.id,
refresh = true,
)
}

View file

@ -64,6 +64,7 @@ internal object InitialStakingStatePreview {
val stateWithYield = defaultState.copy(
yieldBalance = InnerYieldBalanceState.Data(
integrationId = null,
reward = YieldReward(
rewardsFiat = "100 $",
rewardsCrypto = "100 SOL",

View file

@ -3,6 +3,7 @@ package com.tangem.features.staking.impl.presentation.state.utils
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.features.staking.impl.presentation.state.BalanceState
@ -44,16 +45,18 @@ internal fun isSingleAction(networkId: String, activeStake: BalanceState): Boole
return isSingleAction && !isRestake || isCompositePendingActions
}
internal fun withStubUnstakeAction(networkId: String, activeStake: BalanceState) = if (isStubUnstakeAction(networkId)) {
activeStake.pendingActions.plus(
PendingAction(
type = StakingActionType.UNSTAKE,
passthrough = "",
args = null,
),
).toPersistentList()
} else {
activeStake.pendingActions
internal fun withStubUnstakeAction(networkId: String, activeStake: BalanceState): ImmutableList<PendingAction> {
return if (isStubUnstakeAction(networkId) && activeStake.type != BalanceType.REWARDS) {
activeStake.pendingActions.plus(
PendingAction(
type = StakingActionType.UNSTAKE,
passthrough = "",
args = null,
),
).toPersistentList()
} else {
activeStake.pendingActions
}
}
internal fun isTronStakedBalance(networkId: String, pendingAction: PendingAction?): Boolean {

View file

@ -34,6 +34,9 @@ dependencies {
implementation(projects.common.ui)
implementation(projects.common.routing)
/** Libs */
implementation(projects.libs.crypto)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
@ -54,6 +57,9 @@ dependencies {
implementation(projects.domain.transaction.models)
implementation(projects.domain.transaction)
implementation(projects.domain.legacy)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.settings)
/** Compose */
implementation(deps.compose.foundation)

View file

@ -19,8 +19,11 @@ internal sealed class SwapAmountUM {
abstract val primaryAmount: SwapAmountFieldUM
abstract val secondaryAmount: SwapAmountFieldUM
abstract val selectedAmountType: SwapAmountType
abstract val swapDirection: SwapDirection
data object Empty : SwapAmountUM() {
data class Empty(
override val swapDirection: SwapDirection,
) : SwapAmountUM() {
override val isPrimaryButtonEnabled = false
override val selectedAmountType = SwapAmountType.From
override val primaryAmount = SwapAmountFieldUM.Empty(SwapAmountType.From)
@ -29,16 +32,16 @@ internal sealed class SwapAmountUM {
data class Content(
override val isPrimaryButtonEnabled: Boolean,
override val swapDirection: SwapDirection,
override val selectedAmountType: SwapAmountType,
// two amount fields
override val primaryAmount: SwapAmountFieldUM,
override val secondaryAmount: SwapAmountFieldUM,
override val selectedAmountType: SwapAmountType,
val primaryCryptoCurrencyStatus: CryptoCurrencyStatus,
val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus,
val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus?,
// selected swap route
val swapDirection: SwapDirection,
val swapRateType: ExpressRateType,
// swap models

View file

@ -13,13 +13,9 @@ 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.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.swap.models.SwapQuoteModel
@ -38,17 +34,17 @@ import com.tangem.features.swap.v2.impl.amount.SwapAmountUpdateListener
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountReadyStateConverter
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapQuoteUMConverter
import com.tangem.features.swap.v2.impl.amount.model.transformers.*
import com.tangem.features.swap.v2.impl.chooseprovider.SwapChooseProviderComponent
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM.Content.DifferencePercent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
import kotlin.properties.Delegates
import com.tangem.utils.transformer.update as transformerUpdate
@ -77,18 +73,11 @@ internal class SwapAmountModel @Inject constructor(
private var userWallet = params.userWallet
private var primaryCryptoCurrency: CryptoCurrency = params.primaryCryptoCurrencyStatusFlow.value.currency
private var secondaryCryptoCurrency: CryptoCurrency? = params.secondaryCryptoCurrency
private var primaryCryptoCurrencyStatus: CryptoCurrencyStatus = params.primaryCryptoCurrencyStatusFlow.value
private var secondaryCryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = primaryCryptoCurrencyStatus.currency,
value = CryptoCurrencyStatus.Loading,
)
private var primaryMaximumAmountBoundary: EnterAmountBoundary by Delegates.notNull()
private var secondaryMaximumAmountBoundary: EnterAmountBoundary by Delegates.notNull()
private var primaryMinimumAmountBoundary: EnterAmountBoundary by Delegates.notNull()
private var secondaryMinimumAmountBoundary: EnterAmountBoundary by Delegates.notNull()
private var secondaryMaximumAmountBoundary: EnterAmountBoundary? = null
private var secondaryMinimumAmountBoundary: EnterAmountBoundary? = null
val bottomSheetNavigation: SlotNavigation<SwapChooseProviderConfig> = SlotNavigation()
@ -103,7 +92,6 @@ internal class SwapAmountModel @Inject constructor(
subscribeOnCryptoCurrencyStatusFlow()
subscribeOnAmountUpdateTriggerUpdates()
observeChooseSelectToken()
// todo observe balance hiding flow
}
fun updateState(amountUM: SwapAmountUM) {
@ -132,11 +120,10 @@ internal class SwapAmountModel @Inject constructor(
override fun onInfoClick() {
val amountUM = uiState.value as? SwapAmountUM.Content ?: return
val selectedProvider = amountUM.selectedQuote.provider ?: return
val cryptoCurrency = secondaryCryptoCurrency ?: return
swapAlertFactory.priceImpactAlert(
hasPriceImpact = (amountUM.secondaryAmount as? SwapAmountFieldUM.Content)?.priceImpact != null,
currencySymbol = cryptoCurrency.symbol,
currencySymbol = amountUM.primaryCryptoCurrencyStatus.currency.symbol,
provider = selectedProvider,
)
}
@ -193,32 +180,58 @@ internal class SwapAmountModel @Inject constructor(
}
override fun onSeparatorClick() {
// todo send with swap make swap types
// todo handle reverse pair click with swap redesign
val amountFieldData = uiState.value.primaryAmount.amountField as? AmountState.Data
val callback = (params as? SwapAmountComponentParams.AmountParams)?.callback ?: return
val primaryCryptoCurrencyStatus = (uiState.value as? SwapAmountUM.Content)?.primaryCryptoCurrencyStatus
if (primaryCryptoCurrencyStatus != null) {
uiState.transformerUpdate(
SwapAmountPrimaryReadyStateTransformer(
userWallet = userWallet,
primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus,
appCurrency = appCurrency,
swapDirection = swapDirection,
clickIntents = this,
isBalanceHidden = params.isBalanceHidingFlow.value,
),
)
}
callback.onSeparatorClick(lastAmount = amountFieldData?.amountTextField?.value.orEmpty())
callback.onAmountResult(uiState.value)
}
private fun subscribeOnCryptoCurrencyStatusFlow() {
params.primaryCryptoCurrencyStatusFlow.onEach { primaryCurrencyStatus ->
primaryCryptoCurrencyStatus = primaryCurrencyStatus
val state = uiState.value
if (state is SwapAmountUM.Content) {
secondaryCryptoCurrency = state.primaryCryptoCurrencyStatus.currency
secondaryCryptoCurrencyStatus = state.primaryCryptoCurrencyStatus
params.primaryCryptoCurrencyStatusFlow
.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes
.onEach { primaryCurrencyStatus ->
val secondaryStatus = (uiState.value as? SwapAmountUM.Content)?.secondaryCryptoCurrencyStatus
initCurrencies(
primaryStatus = state.primaryCryptoCurrencyStatus,
secondaryStatus = state.secondaryCryptoCurrencyStatus,
primaryStatus = primaryCurrencyStatus,
secondaryStatus = secondaryStatus,
)
} else {
initPairs(
primaryCryptoCurrency = primaryCryptoCurrency,
secondaryCryptoCurrency = secondaryCryptoCurrency,
)
}
}.launchIn(modelScope)
if (secondaryStatus != null) {
uiState.transformerUpdate(
SwapAmountUpdateBalanceTransformer(
cryptoCurrencyStatus = primaryCurrencyStatus,
primaryMaximumAmountBoundary = primaryMaximumAmountBoundary,
primaryMinimumAmountBoundary = primaryMinimumAmountBoundary,
),
)
} else {
uiState.transformerUpdate(
SwapAmountPrimaryReadyStateTransformer(
userWallet = userWallet,
primaryCryptoCurrencyStatus = primaryCurrencyStatus,
appCurrency = appCurrency,
swapDirection = swapDirection,
clickIntents = this,
isBalanceHidden = params.isBalanceHidingFlow.value,
),
)
}
}.launchIn(modelScope)
}
private fun subscribeOnAmountUpdateTriggerUpdates() {
@ -226,6 +239,7 @@ internal class SwapAmountModel @Inject constructor(
.onEach {
if (uiState.value is SwapAmountUM.Content) {
onAmountValueChange(it)
saveResult()
}
}
.launchIn(modelScope)
@ -246,12 +260,12 @@ internal class SwapAmountModel @Inject constructor(
amountUM
}
}
initPairs(primaryCryptoCurrency, currency)
initPairs(currency)
}
.launchIn(modelScope)
}
private fun initPairs(primaryCryptoCurrency: CryptoCurrency, secondaryCryptoCurrency: CryptoCurrency?) {
private fun initPairs(secondaryCryptoCurrency: CryptoCurrency?) {
modelScope.launch {
val cryptoCurrencyStatusList = getMultiCryptoCurrencyStatusUseCase
.invokeMultiWalletSync(userWallet.walletId)
@ -277,22 +291,22 @@ internal class SwapAmountModel @Inject constructor(
swapDirection = params.swapDirection,
)
if (secondaryStatus != null) {
initCurrencies(primaryCryptoCurrencyStatus, secondaryStatus)
this@SwapAmountModel.secondaryCryptoCurrency = secondaryCryptoCurrency
secondaryCryptoCurrencyStatus = secondaryStatus
uiState.update {
SwapAmountReadyStateConverter(
swapCurrencies = swapCurrencies,
val primaryStatus = (uiState.value as? SwapAmountUM.Content)?.primaryCryptoCurrencyStatus
if (secondaryStatus != null && primaryStatus != null) {
initCurrencies(primaryStatus, secondaryStatus)
uiState.transformerUpdate(
SwapAmountSecondaryReadyStateTransformer(
userWallet = userWallet,
primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus,
secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus,
swapCurrencies = swapCurrencies,
primaryCryptoCurrencyStatus = primaryStatus,
secondaryCryptoCurrencyStatus = secondaryStatus,
appCurrency = appCurrency,
swapDirection = swapDirection,
clickIntents = this@SwapAmountModel,
isBalanceHidden = params.isBalanceHidingFlow.value,
).convert(Unit)
}
),
)
loadQuotes()
} else {
// todo not available currency to swap
}
@ -304,7 +318,7 @@ internal class SwapAmountModel @Inject constructor(
}
}
private suspend fun initCurrencies(primaryStatus: CryptoCurrencyStatus, secondaryStatus: CryptoCurrencyStatus) {
private suspend fun initCurrencies(primaryStatus: CryptoCurrencyStatus, secondaryStatus: CryptoCurrencyStatus?) {
primaryMinimumAmountBoundary = EnterAmountBoundary(
amount = getMinimumTransactionAmountSyncUseCase
.invoke(
@ -314,21 +328,25 @@ internal class SwapAmountModel @Inject constructor(
fiatRate = primaryStatus.value.fiatRate,
fiatAmount = primaryStatus.value.fiatAmount,
)
secondaryMinimumAmountBoundary = EnterAmountBoundary(
amount = getMinimumTransactionAmountSyncUseCase
.invoke(
userWalletId = userWallet.walletId,
cryptoCurrencyStatus = secondaryStatus,
).getOrNull().orZero(),
fiatRate = secondaryStatus.value.fiatRate,
fiatAmount = secondaryStatus.value.fiatAmount,
)
primaryMaximumAmountBoundary = MaxEnterAmountConverter().convert(primaryStatus)
secondaryMaximumAmountBoundary = MaxEnterAmountConverter().convert(secondaryStatus)
if (secondaryStatus != null) {
secondaryMinimumAmountBoundary = EnterAmountBoundary(
amount = getMinimumTransactionAmountSyncUseCase
.invoke(
userWalletId = userWallet.walletId,
cryptoCurrencyStatus = secondaryStatus,
).getOrNull().orZero(),
fiatRate = secondaryStatus.value.fiatRate,
fiatAmount = secondaryStatus.value.fiatAmount,
)
secondaryMaximumAmountBoundary = MaxEnterAmountConverter().convert(secondaryStatus)
}
}
private fun loadQuotes() {
val state = uiState.value as? SwapAmountUM.Content ?: return
if (state.secondaryCryptoCurrencyStatus == null) return
val (fromCryptoCurrency, toCryptoCurrency) = when (state.swapDirection) {
SwapDirection.Direct -> {
@ -376,11 +394,17 @@ internal class SwapAmountModel @Inject constructor(
).takeIf { error is ExpressError.AmountError }
},
ifRight = { quote: SwapQuoteModel ->
convertToSwapProviderUM(
quote = quote,
provider = provider,
differencePercent = DifferencePercent.Empty,
SwapQuoteUMConverter(
primaryCurrency = fromCryptoCurrency,
secondaryCurrency = toCryptoCurrency,
swapDirection = swapDirection,
allowanceContract = quote.allowanceContract,
isApprovalNeeded = checkAllowance(state, quote),
).convert(
SwapQuoteUMConverter.Data(
quote = quote,
provider = provider,
),
)
},
)
@ -397,61 +421,19 @@ internal class SwapAmountModel @Inject constructor(
}
}
private suspend fun convertToSwapProviderUM(
quote: SwapQuoteModel,
provider: ExpressProvider,
differencePercent: DifferencePercent,
swapDirection: SwapDirection,
): SwapQuoteUM {
// todo swap allowance
private suspend fun checkAllowance(state: SwapAmountUM.Content, quote: SwapQuoteModel): Boolean {
val allowanceContract = quote.allowanceContract
return if (allowanceContract != null) {
val allowance = getAllowanceUseCase(
val allowance = if (allowanceContract != null) {
getAllowanceUseCase(
userWalletId = userWallet.walletId,
cryptoCurrency = primaryCryptoCurrencyStatus.currency,
cryptoCurrency = state.primaryCryptoCurrencyStatus.currency,
spenderAddress = allowanceContract,
).getOrNull().orZero()
val isApprovalNeeded = allowance < primaryCryptoCurrencyStatus.value.amount.orZero()
if (isApprovalNeeded) {
SwapQuoteUM.Allowance(
provider = provider,
allowanceContract = allowanceContract,
)
} else {
SwapQuoteUM.Content(
provider = provider,
quoteAmount = quote.toTokenAmount,
diffPercent = differencePercent,
quoteAmountValue = stringReference(
quote.toTokenAmount.format {
crypto(
when (swapDirection) {
SwapDirection.Direct -> secondaryCryptoCurrencyStatus.currency
SwapDirection.Reverse -> primaryCryptoCurrencyStatus.currency
},
)
},
),
)
}
).getOrNull()
} else {
SwapQuoteUM.Content(
provider = provider,
quoteAmount = quote.toTokenAmount,
diffPercent = differencePercent,
quoteAmountValue = stringReference(
quote.toTokenAmount.format {
crypto(
when (swapDirection) {
SwapDirection.Direct -> secondaryCryptoCurrencyStatus.currency
SwapDirection.Reverse -> primaryCryptoCurrencyStatus.currency
},
)
},
),
)
BigDecimal.ZERO
}
return allowance.orZero() < state.primaryCryptoCurrencyStatus.value.amount.orZero()
}
private fun saveResult() {
@ -475,20 +457,7 @@ internal class SwapAmountModel @Inject constructor(
} else {
R.drawable.ic_close_24
},
backIconClick = {
// if (!route.isEditMode) {
// todo analytics
// analyticsEventHandler.send(
// CommonSendAnalyticEvents.CloseButtonClicked(
// categoryName = params.analyticsCategoryName,
// source = SendScreenSource.Address,
// isFromSummary = false,
// isValid = state.isPrimaryButtonEnabled,
// ),
// )
// }
params.callback.onBackClick()
},
backIconClick = params.callback::onBackClick,
primaryButton = NavigationButton(
textReference = if (route.isEditMode) {
resourceReference(R.string.common_continue)

View file

@ -42,8 +42,8 @@ internal object SwapAmountQuoteUtils {
}
fun SwapAmountUM.updateAmount(
onPrimaryAmount: SwapAmountFieldUM.Content.() -> SwapAmountFieldUM,
onSecondaryAmount: SwapAmountFieldUM.Content.() -> SwapAmountFieldUM,
onPrimaryAmount: SwapAmountFieldUM.Content.(CryptoCurrencyStatus) -> SwapAmountFieldUM,
onSecondaryAmount: SwapAmountFieldUM.Content.(CryptoCurrencyStatus) -> SwapAmountFieldUM,
): SwapAmountUM {
if (this !is SwapAmountUM.Content) return this
@ -51,10 +51,11 @@ internal object SwapAmountQuoteUtils {
selectedAmountType == SwapAmountType.From && swapDirection == SwapDirection.Direct
) {
val amountFieldUM = primaryAmount as? SwapAmountFieldUM.Content ?: return this
copy(primaryAmount = amountFieldUM.onPrimaryAmount())
copy(primaryAmount = amountFieldUM.onPrimaryAmount(primaryCryptoCurrencyStatus))
} else {
if (secondaryCryptoCurrencyStatus == null) return this
val amountFieldUM = secondaryAmount as? SwapAmountFieldUM.Content ?: return this
copy(secondaryAmount = amountFieldUM.onSecondaryAmount())
copy(secondaryAmount = amountFieldUM.onSecondaryAmount(secondaryCryptoCurrencyStatus))
}
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.features.swap.v2.impl.amount.model.converter
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.swap.models.SwapQuoteModel
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM.Content.DifferencePercent
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class SwapQuoteUMConverter(
private val swapDirection: SwapDirection,
private val allowanceContract: String?,
private val isApprovalNeeded: Boolean,
private val primaryCurrency: CryptoCurrency,
private val secondaryCurrency: CryptoCurrency,
) : Converter<SwapQuoteUMConverter.Data, SwapQuoteUM> {
override fun convert(value: Data): SwapQuoteUM {
val (quote, provider) = value
return if (allowanceContract != null) {
if (isApprovalNeeded) {
SwapQuoteUM.Allowance(
provider = provider,
allowanceContract = allowanceContract,
)
} else {
SwapQuoteUM.Content(
provider = provider,
quoteAmount = quote.toTokenAmount,
diffPercent = DifferencePercent.Empty,
quoteAmountValue = stringReference(
quote.toTokenAmount.toQuoteValue(),
),
)
}
} else {
SwapQuoteUM.Content(
provider = provider,
quoteAmount = quote.toTokenAmount,
diffPercent = DifferencePercent.Empty,
quoteAmountValue = stringReference(
quote.toTokenAmount.toQuoteValue(),
),
)
}
}
private fun BigDecimal.toQuoteValue() = format {
crypto(
when (swapDirection) {
SwapDirection.Direct -> secondaryCurrency
SwapDirection.Reverse -> primaryCurrency
},
)
}
data class Data(
val quote: SwapQuoteModel,
val provider: ExpressProvider,
)
}

View file

@ -11,18 +11,18 @@ internal class SwapAmountChangeCurrencyTransformer(
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
if (prevState !is SwapAmountUM.Content) return prevState
return prevState.updateAmount(
onPrimaryAmount = {
onPrimaryAmount = { primaryStatus ->
copy(
amountField = AmountCurrencyTransformer(
cryptoCurrencyStatus = prevState.primaryCryptoCurrencyStatus,
cryptoCurrencyStatus = primaryStatus,
value = isFiatSelected,
).transform(prevState.primaryAmount.amountField),
)
},
onSecondaryAmount = {
onSecondaryAmount = { secondaryStatus ->
copy(
amountField = AmountCurrencyTransformer(
cryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
cryptoCurrencyStatus = secondaryStatus,
value = isFiatSelected,
).transform(prevState.secondaryAmount.amountField),
)

View file

@ -0,0 +1,57 @@
package com.tangem.features.swap.v2.impl.amount.model.transformers
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.swap.models.SwapCurrencies
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountFieldConverter
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongParameterList")
internal class SwapAmountPrimaryReadyStateTransformer(
private val userWallet: UserWallet,
private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val clickIntents: AmountScreenClickIntents,
private val swapDirection: SwapDirection,
private val isBalanceHidden: Boolean,
) : Transformer<SwapAmountUM> {
private val amountFieldConverter = SwapAmountFieldConverter(
swapDirection = swapDirection,
isBalanceHidden = isBalanceHidden,
userWallet = userWallet,
appCurrency = appCurrency,
clickIntents = clickIntents,
)
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
return SwapAmountUM.Content(
isPrimaryButtonEnabled = false,
primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus,
primaryAmount = amountFieldConverter.convert(
selectedType = SwapAmountType.From,
cryptoCurrencyStatus = primaryCryptoCurrencyStatus,
),
secondaryCryptoCurrencyStatus = null,
secondaryAmount = SwapAmountFieldUM.Empty(
SwapAmountType.To,
),
swapDirection = swapDirection,
swapCurrencies = SwapCurrencies.EMPTY,
selectedAmountType = prevState.selectedAmountType,
swapRateType = ExpressRateType.Float,
swapQuotes = persistentListOf(),
selectedQuote = SwapQuoteUM.Empty,
appCurrency = appCurrency,
)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.swap.v2.impl.amount.model.converter
package com.tangem.features.swap.v2.impl.amount.model.transformers
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.domain.appcurrency.model.AppCurrency
@ -9,21 +9,22 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.converter.SwapAmountFieldConverter
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.utils.converter.Converter
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongParameterList")
internal class SwapAmountReadyStateConverter(
internal class SwapAmountSecondaryReadyStateTransformer(
private val userWallet: UserWallet,
private val swapCurrencies: SwapCurrencies,
private val primaryCryptoCurrencyStatus: CryptoCurrencyStatus,
private val secondaryCryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val swapCurrencies: SwapCurrencies,
private val clickIntents: AmountScreenClickIntents,
private val swapDirection: SwapDirection,
private val isBalanceHidden: Boolean,
) : Converter<Unit, SwapAmountUM> {
) : Transformer<SwapAmountUM> {
private val amountFieldConverter = SwapAmountFieldConverter(
swapDirection = swapDirection,
@ -33,26 +34,23 @@ internal class SwapAmountReadyStateConverter(
clickIntents = clickIntents,
)
override fun convert(value: Unit): SwapAmountUM {
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
return SwapAmountUM.Content(
isPrimaryButtonEnabled = false,
primaryAmount = prevState.primaryAmount,
primaryCryptoCurrencyStatus = primaryCryptoCurrencyStatus,
secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus,
primaryAmount = amountFieldConverter.convert(
selectedType = SwapAmountType.From,
cryptoCurrencyStatus = primaryCryptoCurrencyStatus,
),
secondaryAmount = amountFieldConverter.convert(
selectedType = SwapAmountType.To,
cryptoCurrencyStatus = secondaryCryptoCurrencyStatus,
),
secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus,
swapCurrencies = swapCurrencies,
selectedAmountType = prevState.selectedAmountType,
swapDirection = swapDirection,
swapRateType = ExpressRateType.Float,
swapQuotes = persistentListOf(),
selectedQuote = SwapQuoteUM.Empty,
selectedAmountType = SwapAmountType.From,
appCurrency = appCurrency,
swapRateType = ExpressRateType.Float,
isPrimaryButtonEnabled = false,
)
}
}

View file

@ -16,8 +16,8 @@ import com.tangem.utils.transformer.Transformer
internal class SwapAmountSelectQuoteTransformer(
private val quoteUM: SwapQuoteUM,
private val secondaryMaximumAmountBoundary: EnterAmountBoundary,
private val secondaryMinimumAmountBoundary: EnterAmountBoundary,
private val secondaryMaximumAmountBoundary: EnterAmountBoundary?,
private val secondaryMinimumAmountBoundary: EnterAmountBoundary?,
) : Transformer<SwapAmountUM> {
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
if (prevState !is SwapAmountUM.Content) return prevState
@ -46,7 +46,9 @@ internal class SwapAmountSelectQuoteTransformer(
} else {
prevState.primaryAmount
},
secondaryAmount = if (prevState.selectedAmountType == SwapAmountType.From) {
secondaryAmount = if (prevState.selectedAmountType == SwapAmountType.From &&
prevState.secondaryCryptoCurrencyStatus != null && secondaryMaximumAmountBoundary != null
) {
val secondaryAmountField = prevState.secondaryAmount as? SwapAmountFieldUM.Content
val fromAmount = (prevState.primaryAmount.amountField as? AmountState.Data)
?.amountTextField?.cryptoAmount?.value.orZero()

View file

@ -17,8 +17,8 @@ import java.math.BigDecimal
internal class SwapAmountSetQuotesTransformer(
private val quotes: List<SwapQuoteUM>,
private val secondaryMaximumAmountBoundary: EnterAmountBoundary,
private val secondaryMinimumAmountBoundary: EnterAmountBoundary,
private val secondaryMaximumAmountBoundary: EnterAmountBoundary?,
private val secondaryMinimumAmountBoundary: EnterAmountBoundary?,
) : Transformer<SwapAmountUM> {
override fun transform(prevState: SwapAmountUM): SwapAmountUM {
if (prevState !is SwapAmountUM.Content) return prevState

Some files were not shown because too many files have changed in this diff Show more