Updated on 2026-08-14

This commit is contained in:
Tangem 2024-10-07 17:00:36 +04:00
commit 507169a2c3
492 changed files with 5734 additions and 12510 deletions

View file

@ -1,6 +0,0 @@
package com.tangem.features.details
interface DetailsFeatureToggles {
val isRedesignEnabled: Boolean
}

View file

@ -1,11 +0,0 @@
package com.tangem.features.details
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
internal class DefaultDetailsFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : DetailsFeatureToggles {
override val isRedesignEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("DETAILS_REDESIGN_ENABLED")
}

View file

@ -1,21 +0,0 @@
package com.tangem.features.details.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.details.DefaultDetailsFeatureToggles
import com.tangem.features.details.DetailsFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object FeatureModule {
@Provides
@Singleton
fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): DetailsFeatureToggles {
return DefaultDetailsFeatureToggles(featureTogglesManager)
}
}

View file

@ -7,6 +7,9 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
@ -42,6 +45,8 @@ internal class DetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val appStateHolder: ReduxStateHolder,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
@ -94,7 +99,9 @@ internal class DetailsModel @Inject constructor(
val scanResponse = getSelectedWalletSyncUseCase().getOrNull()?.scanResponse
?: error("Selected wallet is null")
appStateHolder.dispatch(LegacyAction.SendEmailSupport(scanResponse))
val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch
sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(cardInfo = cardInfo))
}
}

View file

@ -1,5 +0,0 @@
package com.tangem.features.managetokens
interface ManageTokensToggles {
val isFeatureEnabled: Boolean
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
interface OnboardingManageTokensComponent : ComposableContentComponent {
data class Params(val userWalletId: UserWalletId)
interface Factory : ComponentFactory<Params, OnboardingManageTokensComponent>
}

View file

@ -23,8 +23,9 @@ dependencies {
implementation(projects.core.analytics)
/* Project - Domain */
implementation(projects.domain.manageTokens)
implementation(projects.domain.card)
implementation(projects.domain.legacy)
implementation(projects.domain.manageTokens)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
@ -49,4 +50,5 @@ dependencies {
implementation(deps.kotlin.immutable.collections)
implementation(deps.decompose.ext.compose)
implementation(deps.timber)
implementation(deps.reKotlin) // need for legacy onboarding
}

View file

@ -1,11 +0,0 @@
package com.tangem.features.managetokens
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
internal class DefaultManageTokensToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : ManageTokensToggles {
override val isFeatureEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("NEW_MANAGE_TOKENS")
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.managetokens.component.impl
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.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.model.OnboardingManageTokensModel
import com.tangem.features.managetokens.ui.OnboardingManageTokensContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnboardingManageTokensComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: OnboardingManageTokensComponent.Params,
) : OnboardingManageTokensComponent, AppComponentContext by context {
private val model: OnboardingManageTokensModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
OnboardingManageTokensContent(modifier = modifier, state = state)
}
@AssistedFactory
interface Factory : OnboardingManageTokensComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnboardingManageTokensComponent.Params,
): DefaultOnboardingManageTokensComponent
}
}

View file

@ -55,8 +55,8 @@ internal class PreviewCustomTokenSelectorComponent(
network = Network(
id = n.id,
backendId = n.id.value,
name = "",
currencySymbol = "",
name = "Network $index",
currencySymbol = "N$index",
derivationPath = Network.DerivationPath.Card(""),
isTestnet = false,
standardType = Network.StandardType.ERC20,

View file

@ -26,7 +26,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
internal class PreviewManageTokensComponent(
private val isLoading: Boolean = false,
private val isLoading: Boolean,
params: ManageTokensComponent.Params,
) : ManageTokensComponent {
private val changedItemsIds: MutableSet<String> = mutableSetOf()
@ -36,14 +37,21 @@ internal class PreviewManageTokensComponent(
value = ManageTokensUM.ManageContent(
popBack = {},
items = items,
topBar = ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = {},
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = {},
),
),
topBar = if (params.userWalletId != null) {
ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = {},
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = {},
),
)
} else {
ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_search_tokens),
onBackButtonClick = {},
)
},
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
@ -173,6 +181,7 @@ internal class PreviewManageTokensComponent(
)
is CurrencyItemUM.Custom,
is CurrencyItemUM.Loading,
is CurrencyItemUM.SearchNothingFound,
-> return
}
@ -210,6 +219,7 @@ internal class PreviewManageTokensComponent(
}
is CurrencyItemUM.Custom,
is CurrencyItemUM.Loading,
is CurrencyItemUM.SearchNothingFound,
-> return
}

View file

@ -0,0 +1,114 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.managetokens.OnboardingManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.OnboardingManageTokensContent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
internal class PreviewOnboardingManageTokensComponent(
private val isLoading: Boolean = false,
) : OnboardingManageTokensComponent {
private val state = OnboardingManageTokensUM(
items = initItems(),
isInitialBatchLoading = false,
isNextBatchLoading = true,
loadMore = { false },
onBack = {},
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
),
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Later(
onClick = {},
showProgress = false,
),
)
@Composable
override fun Content(modifier: Modifier) {
OnboardingManageTokensContent(state = state, modifier = modifier)
}
private fun initItems() = List(size = 30) { index ->
if (isLoading) {
CurrencyItemUM.Loading(index)
} else {
if (index < 2) {
getCustomItem(index)
} else {
getBasicItem(index)
}
}
}.toPersistentList()
private fun getCustomItem(index: Int) = CurrencyItemUM.Custom(
id = ManagedCryptoCurrency.ID(index.toString()),
name = "Custom token $index",
symbol = "CT$index",
icon = CurrencyIconState.CustomTokenIcon(
tint = Color.White,
background = Color.Black,
topBadgeIconResId = R.drawable.img_eth_22,
isGrayscale = false,
showCustomBadge = true,
),
onRemoveClick = {},
)
private fun getBasicItem(index: Int) = CurrencyItemUM.Basic(
id = ManagedCryptoCurrency.ID(index.toString()),
name = "Currency $index",
symbol = "C$index",
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_btc_22,
isGrayscale = false,
showCustomBadge = false,
),
networks = if (index == 2) {
CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks())
} else {
CurrencyItemUM.Basic.NetworksUM.Collapsed
},
onExpandClick = {},
)
private fun getCurrencyNetworks() = List(size = 3) { networkIndex ->
CurrencyNetworkUM(
network = Network(
id = Network.ID(networkIndex.toString()),
backendId = networkIndex.toString(),
name = "Network $networkIndex",
currencySymbol = "N$networkIndex",
derivationPath = Network.DerivationPath.Card(""),
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = false,
canHandleTokens = false,
),
name = "NETWORK$networkIndex",
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = networkIndex == 0,
isSelected = false,
onSelectedStateChange = {},
onLongClick = {},
)
}.toImmutableList()
}

View file

@ -16,6 +16,12 @@ internal interface ComponentModule {
@Singleton
fun bindManageTokensComponentFactory(factory: DefaultManageTokensComponent.Factory): ManageTokensComponent.Factory
@Binds
@Singleton
fun bindOnboardingManageTokensComponentFactory(
factory: DefaultOnboardingManageTokensComponent.Factory,
): OnboardingManageTokensComponent.Factory
@Binds
@Singleton
fun bindAddCustomTokenComponentFactory(

View file

@ -1,20 +0,0 @@
package com.tangem.features.managetokens.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.managetokens.DefaultManageTokensToggles
import com.tangem.features.managetokens.ManageTokensToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object FeatureModule {
@Provides
@Singleton
fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): ManageTokensToggles =
DefaultManageTokensToggles(featureTogglesManager = featureTogglesManager)
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.managetokens.model.CustomTokenFormModel
import com.tangem.features.managetokens.model.CustomTokenSelectorModel
import com.tangem.features.managetokens.model.ManageTokensModel
import com.tangem.features.managetokens.model.OnboardingManageTokensModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -20,6 +21,11 @@ internal interface ModelModule {
@ClassKey(ManageTokensModel::class)
fun provideManageTokensModel(model: ManageTokensModel): Model
@Binds
@IntoMap
@ClassKey(OnboardingManageTokensModel::class)
fun provideOnboardingManageTokensModel(model: OnboardingManageTokensModel): Model
@Binds
@IntoMap
@ClassKey(CustomTokenFormModel::class)

View file

@ -48,4 +48,11 @@ internal sealed class CurrencyItemUM {
override val symbol: String = "loading"
override val icon: CurrencyIconState = CurrencyIconState.Loading
}
data object SearchNothingFound : CurrencyItemUM() {
override val id: ManagedCryptoCurrency.ID = ManagedCryptoCurrency.ID(value = "not found text")
override val name: String = "content not found"
override val symbol: String = "content not found"
override val icon: CurrencyIconState = CurrencyIconState.Loading
}
}

View file

@ -14,7 +14,7 @@ internal sealed class ManageTokensUM {
abstract val isInitialBatchLoading: Boolean
abstract val isNextBatchLoading: Boolean
abstract val items: ImmutableList<CurrencyItemUM>
abstract val topBar: ManageTokensTopBarUM
abstract val topBar: ManageTokensTopBarUM?
abstract val search: SearchBarUM
abstract val loadMore: () -> Boolean
abstract val scrollToTop: StateEvent<Unit>
@ -24,7 +24,7 @@ internal sealed class ManageTokensUM {
override val isInitialBatchLoading: Boolean,
override val isNextBatchLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val topBar: ManageTokensTopBarUM?,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),
@ -35,7 +35,7 @@ internal sealed class ManageTokensUM {
override val isInitialBatchLoading: Boolean,
override val isNextBatchLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val topBar: ManageTokensTopBarUM?,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),

View file

@ -0,0 +1,41 @@
package com.tangem.features.managetokens.entity.managetokens
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import kotlinx.collections.immutable.ImmutableList
internal data class OnboardingManageTokensUM(
val onBack: () -> Unit,
val isInitialBatchLoading: Boolean,
val isNextBatchLoading: Boolean,
val items: ImmutableList<CurrencyItemUM>,
val loadMore: () -> Boolean,
val search: SearchBarUM,
val scrollToTop: StateEvent<Unit> = consumedEvent(),
val actionButtonConfig: ActionButtonConfig,
) {
sealed class ActionButtonConfig {
abstract val onClick: () -> Unit
abstract val showProgress: Boolean
data class Continue(
override val onClick: () -> Unit,
override val showProgress: Boolean = false,
val showTangemIcon: Boolean,
) : ActionButtonConfig()
data class Later(
override val onClick: () -> Unit,
override val showProgress: Boolean = false,
) : ActionButtonConfig()
fun copySealed(showProgress: Boolean): ActionButtonConfig {
return when (this) {
is Continue -> copy(showProgress = showProgress)
is Later -> copy(showProgress = showProgress)
}
}
}
}

View file

@ -75,7 +75,7 @@ internal class ManageTokensModel @Inject constructor(
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(params)
manageTokensListManager.launchPagination(source = params.source, userWalletId = params.userWalletId)
}
}
@ -159,12 +159,7 @@ internal class ManageTokensModel @Inject constructor(
}
}
.sample(periodMillis = 1_000)
.onEach { query ->
manageTokensListManager.search(
userWalletId = params.userWalletId,
query = query,
)
}
.onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) }
.launchIn(modelScope)
}
@ -235,10 +230,12 @@ internal class ManageTokensModel @Inject constructor(
},
)
}
is PaginationStatus.EndOfPagination -> state.copySealed(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
is PaginationStatus.EndOfPagination -> {
state.copySealed(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
}
}
}
@ -270,10 +267,7 @@ internal class ManageTokensModel @Inject constructor(
if (state.isInitialBatchLoading || state.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(
userWalletId = params.userWalletId,
query = state.search.query,
)
manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query)
}
return true

View file

@ -0,0 +1,297 @@
package com.tangem.features.managetokens.model
import arrow.core.flatten
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.card.HasMissedDerivationsUseCase
import com.tangem.domain.managetokens.SaveManagedTokensUseCase
import com.tangem.domain.redux.OnboardingManageTokensAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.managetokens.OnboardingManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.list.ChangedCurrencies
import com.tangem.features.managetokens.utils.list.ManageTokensListManager
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ComponentScoped
internal class OnboardingManageTokensModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val manageTokensListManager: ManageTokensListManager,
private val messageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
private val saveManagedTokensUseCase: SaveManagedTokensUseCase,
private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer,
) : Model() {
private val params: OnboardingManageTokensComponent.Params = paramsContainer.require()
val state: MutableStateFlow<OnboardingManageTokensUM> = MutableStateFlow(getInitialState())
init {
manageTokensListManager.uiItems
.onEach { items -> updateItems(items) }
.launchIn(modelScope)
manageTokensListManager.paginationStatus
.onEach { status -> updatePaginationStatus(status) }
.launchIn(modelScope)
manageTokensListManager.currenciesToAdd
.onEach(::handleNewAddedCurrencies)
.launchIn(modelScope)
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(
source = ManageTokensSource.ONBOARDING,
userWalletId = params.userWalletId,
)
}
}
private fun getInitialState(): OnboardingManageTokensUM {
analyticsEventHandler.send(ManageTokensAnalyticEvent.ScreenOpened(source = ManageTokensSource.ONBOARDING))
return OnboardingManageTokensUM(
isInitialBatchLoading = true,
isNextBatchLoading = false,
items = getLoadingItems(),
loadMore = ::loadMoreItems,
onBack = {},
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = ::searchCurrencies,
isActive = false,
onActiveChange = ::toggleSearchBar,
),
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Later(
onClick = ::onLaterClick,
showProgress = false,
),
)
}
@OptIn(FlowPreview::class)
private fun observeSearchQueryChanges() {
state
.distinctUntilChanged { old, new ->
// It's also used to skip search activation to avoid searching an empty query
old.search.query == new.search.query &&
(old.search.isActive == new.search.isActive || new.search.isActive)
}
.transform { state ->
val query = state.search.query
if (state.search.isActive) {
emit(query)
}
}
.sample(periodMillis = 1_000)
.onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) }
.launchIn(modelScope)
}
private fun updateItems(items: ImmutableList<CurrencyItemUM>) {
val updatedState = state.updateAndGet { state -> state.copy(items = items) }
if (updatedState.items.isEmpty() && updatedState.search.isActive) {
val event = ManageTokensAnalyticEvent.TokensIsNotFound(
query = updatedState.search.query,
source = ManageTokensSource.ONBOARDING,
)
analyticsEventHandler.send(event)
}
}
private fun updatePaginationStatus(status: PaginationStatus<*>) {
state.update { state ->
when (status) {
is PaginationStatus.None,
is PaginationStatus.InitialLoading,
-> {
if (state.search.isActive) {
state.copy(items = getLoadingItems())
} else {
state.copy(items = getLoadingItems(), isInitialBatchLoading = true)
}
}
is PaginationStatus.NextBatchLoading -> state.copy(isNextBatchLoading = true)
is PaginationStatus.InitialLoadingError -> {
val message = SnackbarMessage(
message = status.throwable.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
state.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
is PaginationStatus.Paginating -> {
(status.lastResult as? BatchFetchResult.Error)?.let { fetchError ->
Timber.e(fetchError.throwable)
}
state.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
scrollToTop = if (state.isInitialBatchLoading && state.items.isNotEmpty()) {
triggeredEvent(
data = Unit,
onConsume = ::consumeScrollToTopEvent,
)
} else {
state.scrollToTop
},
)
}
is PaginationStatus.EndOfPagination -> state.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
}
}
private fun getLoadingItems(): ImmutableList<CurrencyItemUM> {
return List(size = 10) { index ->
CurrencyItemUM.Loading(index)
}.toPersistentList()
}
private fun consumeScrollToTopEvent() {
state.update { state -> state.copy(scrollToTop = consumedEvent()) }
}
private suspend fun handleNewAddedCurrencies(currenciesToAdd: ChangedCurrencies) {
if (currenciesToAdd.isEmpty()) {
state.update { state ->
state.copy(
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Later(onClick = ::onLaterClick),
)
}
} else {
val hasMissedDerivations = hasMissedDerivationsUseCase.invoke(
userWalletId = params.userWalletId,
networksWithDerivationPath = currenciesToAdd.values
.flatten()
.toSet()
.associate { it.backendId to null },
)
state.update { state ->
state.copy(
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Continue(
onClick = ::saveChanges,
showTangemIcon = hasMissedDerivations,
),
)
}
}
}
private fun loadMoreItems(): Boolean {
val state = state.value
if (state.isInitialBatchLoading || state.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query)
}
return true
}
private fun saveChanges() = resource(
acquire = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = true))
}
},
release = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = false))
}
},
) {
val event = ManageTokensAnalyticEvent.TokenAdded(
tokensCount = manageTokensListManager.currenciesToAdd.value.values.sumOf { it.size },
source = ManageTokensSource.ONBOARDING,
)
analyticsEventHandler.send(event)
saveManagedTokensUseCase(
userWalletId = requireNotNull(params.userWalletId),
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).getOrElse {
Timber.e(it, "Failed to save changes")
return@resource
}
reduxStateHolder.dispatch(OnboardingManageTokensAction.CurrenciesSaved)
}
private fun onLaterClick() = resource(
acquire = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = true))
}
},
release = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = false))
}
},
) {
reduxStateHolder.dispatch(OnboardingManageTokensAction.CurrenciesSaved)
}
private fun searchCurrencies(query: String) {
state.update { state ->
state.copy(
search = state.search.copy(
query = query,
isActive = true,
),
)
}
}
private fun toggleSearchBar(isActive: Boolean) {
state.update { state ->
state.copy(
search = state.search.copy(
isActive = isActive,
),
)
}
}
}

View file

@ -20,9 +20,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.bottomFade
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.components.isOpened
import com.tangem.core.ui.components.keyboardAsState
@ -60,18 +62,24 @@ internal fun CustomTokenFormContent(model: CustomTokenFormUM, modifier: Modifier
.fillMaxSize()
.background(color = TangemTheme.colors.background.secondary),
) {
val scrollState = rememberScrollState()
Column(
Box(
modifier = Modifier
.verticalScroll(scrollState)
.fillMaxSize()
.padding(bottom = TangemTheme.dimens.spacing76),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
.bottomFade(),
) {
AddCustomTokenDescription()
FormContent(model)
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(scrollState)
.fillMaxSize()
.padding(bottom = 128.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
AddCustomTokenDescription()
FormContent(model)
}
}
PrimaryButton(

View file

@ -21,7 +21,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.bottomFade
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.rows.ChainRow
import com.tangem.core.ui.components.rows.model.ChainRowUM
@ -46,11 +48,11 @@ internal fun CustomTokenSelectorContent(model: CustomTokenSelectorUM, modifier:
val lastIndex = model.items.lastIndex
LazyColumn(
modifier = modifier.background(
color = TangemTheme.colors.background.secondary,
),
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.bottomFade(),
contentPadding = PaddingValues(
bottom = TangemTheme.dimens.spacing16 + bottomBarHeight,
bottom = 96.dp + bottomBarHeight,
),
) {
item {

View file

@ -13,10 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -62,7 +59,9 @@ import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM
@ -131,19 +130,21 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi
}
@Composable
private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, search: SearchBarUM, modifier: Modifier = Modifier) {
private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM?, search: SearchBarUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.background(TangemTheme.colors.background.primary),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
TangemTopAppBar(
title = topBar.title.resolveReference(),
startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick),
endButton = when (topBar) {
is ManageTokensTopBarUM.ManageContent -> topBar.endButton
is ManageTokensTopBarUM.ReadContent -> null
},
)
if (topBar != null) {
TangemTopAppBar(
title = topBar.title.resolveReference(),
startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick),
endButton = when (topBar) {
is ManageTokensTopBarUM.ManageContent -> topBar.endButton
is ManageTokensTopBarUM.ReadContent -> null
},
)
}
SearchBar(
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing12)
@ -199,7 +200,7 @@ private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) {
}
@Composable
private fun Currencies(
internal fun Currencies(
listState: LazyListState,
items: ImmutableList<CurrencyItemUM>,
showLoadingItem: Boolean,
@ -241,6 +242,9 @@ private fun Currencies(
modifier = Modifier.fillMaxWidth(),
)
}
is CurrencyItemUM.SearchNothingFound -> {
SearchNothingFoundText(modifier = Modifier.fillParentMaxSize())
}
}
}
@ -262,6 +266,20 @@ private fun Currencies(
)
}
@Composable
private fun SearchNothingFoundText(modifier: Modifier = Modifier) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.markets_search_token_no_result_title),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
private fun ProgressIndicator(modifier: Modifier = Modifier) {
Box(
@ -452,8 +470,24 @@ private fun Preview_ManageTokens(
private class PreviewManageTokensComponentProvider : PreviewParameterProvider<ManageTokensComponent> {
override val values: Sequence<ManageTokensComponent>
get() = sequenceOf(
PreviewManageTokensComponent(),
PreviewManageTokensComponent(isLoading = true),
PreviewManageTokensComponent(
isLoading = true,
params = ManageTokensComponent.Params(
source = ManageTokensSource.ONBOARDING,
userWalletId = UserWalletId("wallet_id"),
),
),
PreviewManageTokensComponent(
isLoading = false,
params = ManageTokensComponent.Params(source = ManageTokensSource.ONBOARDING, userWalletId = null),
),
PreviewManageTokensComponent(
isLoading = false,
params = ManageTokensComponent.Params(
source = ManageTokensSource.ONBOARDING,
userWalletId = UserWalletId("wallet_id"),
),
),
)
}
// endregion Preview

View file

@ -0,0 +1,167 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.component.preview.PreviewOnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.managetokens.OnboardingManageTokensUM
import com.tangem.features.managetokens.impl.R
@Composable
internal fun OnboardingManageTokensContent(state: OnboardingManageTokensUM, modifier: Modifier = Modifier) {
val keyboardController = LocalSoftwareKeyboardController.current
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
keyboardController?.hide()
return super.onPreScroll(available, source)
}
}
}
Scaffold(
modifier = modifier.nestedScroll(nestedScrollConnection),
containerColor = TangemTheme.colors.background.primary,
contentWindowInsets = WindowInsetsZero,
topBar = {
SearchBar(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing12, horizontal = TangemTheme.dimens.spacing16),
state = state.search,
)
},
content = { innerPadding ->
Content(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
state = state,
)
},
snackbarHost = {
TangemSnackbarHost(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
hostState = LocalSnackbarHostState.current,
)
},
floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
FloatingActionButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
config = state.actionButtonConfig,
)
},
)
}
@Composable
private fun Content(state: OnboardingManageTokensUM, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
Box(modifier = modifier) {
Currencies(
modifier = Modifier.fillMaxSize(),
listState = listState,
items = state.items,
showLoadingItem = state.isNextBatchLoading,
onLoadMore = state.loadMore,
isEditable = true,
)
BottomFade(modifier = Modifier.align(Alignment.BottomCenter))
}
EventEffect(event = state.scrollToTop) {
listState.animateScrollToItem(index = 0)
}
}
@Composable
private fun FloatingActionButton(config: OnboardingManageTokensUM.ActionButtonConfig, modifier: Modifier = Modifier) {
when (config) {
is OnboardingManageTokensUM.ActionButtonConfig.Continue -> ContinueButton(config = config, modifier = modifier)
is OnboardingManageTokensUM.ActionButtonConfig.Later -> SecondaryButton(
modifier = modifier,
text = stringResource(id = R.string.common_later),
showProgress = config.showProgress,
onClick = config.onClick,
)
}
}
@Composable
private fun ContinueButton(
config: OnboardingManageTokensUM.ActionButtonConfig.Continue,
modifier: Modifier = Modifier,
) {
if (config.showTangemIcon) {
PrimaryButtonIconEnd(
modifier = modifier,
text = stringResource(id = R.string.common_continue),
iconResId = R.drawable.ic_tangem_24,
showProgress = config.showProgress,
onClick = config.onClick,
)
} else {
PrimaryButton(
modifier = modifier,
text = stringResource(id = R.string.common_continue),
showProgress = config.showProgress,
onClick = config.onClick,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 800)
@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ManageTokens(
@PreviewParameter(PreviewOnboardingManageTokensComponentProvider::class) component: OnboardingManageTokensComponent,
) {
TangemThemePreview {
component.Content(Modifier.fillMaxWidth())
}
}
private class PreviewOnboardingManageTokensComponentProvider :
CollectionPreviewParameterProvider<PreviewOnboardingManageTokensComponent>(
collection = listOf(
PreviewOnboardingManageTokensComponent(isLoading = false),
PreviewOnboardingManageTokensComponent(isLoading = true),
),
)
// endregion

View file

@ -166,6 +166,7 @@ internal class CustomCurrencyValidator @Inject constructor(
validatedForm: AddCustomTokenForm.Validated.All?,
) {
val currency = createCustomCurrencyUseCase(
userWalletId = userWalletId,
networkId = networkId,
derivationPath = derivationPath,
formValues = validatedForm,

View file

@ -16,10 +16,10 @@ import com.tangem.domain.managetokens.model.*
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.impl.R
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
@ -80,9 +80,9 @@ internal class ManageTokensListManager @Inject constructor(
.distinctUntilChanged()
val uiItems: Flow<ImmutableList<CurrencyItemUM>> = uiManager.items
suspend fun launchPagination(params: ManageTokensComponent.Params) = coroutineScope {
suspend fun launchPagination(source: ManageTokensSource, userWalletId: UserWalletId?) = coroutineScope {
scope = this
source = params.source
this@ManageTokensListManager.source = source
val batchFlow = getManagedTokensUseCase(
context = ManageTokensListBatchingContext(
@ -92,13 +92,13 @@ internal class ManageTokensListManager @Inject constructor(
)
batchFlow.state
.onEach { state -> updateState(state, params.userWalletId) }
.onEach { state -> updateState(state, userWalletId) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
// Initial load
reload(params.userWalletId)
reload(userWalletId)
}
suspend fun reload(userWalletId: UserWalletId?) {
@ -119,7 +119,7 @@ internal class ManageTokensListManager @Inject constructor(
}
suspend fun search(userWalletId: UserWalletId?, query: String) {
state.value = ManageTokensListState()
state.value = ManageTokensListState(searchQuery = query)
actionsFlow.emit(
BatchAction.Reload(
requestParams = ManageTokensListConfig(
@ -140,6 +140,28 @@ internal class ManageTokensListManager @Inject constructor(
)
}
// Search nothing found
if (
state.value.searchQuery.isNullOrEmpty().not() &&
batchListState.status is PaginationStatus.EndOfPagination &&
batchListState.data.isEmpty()
) {
state.update { state ->
state.copy(
userWalletId = userWalletId,
currencyBatches = emptyList(),
uiBatches = listOf(
Batch(
key = Int.MAX_VALUE,
data = listOf(CurrencyItemUM.SearchNothingFound),
),
),
)
}
return
}
state.update { state ->
val newBatches = batchListState.data
val currentBatches = state.currencyBatches

View file

@ -17,6 +17,7 @@ internal data class ManageTokensListState(
val uiBatches: List<Batch<Int, List<CurrencyItemUM>>> = mutableListOf(),
val currencyBatches: List<Batch<Int, List<ManagedCryptoCurrency>>> = mutableListOf(),
val canEditItems: Boolean = true,
val searchQuery: String? = null,
) {
fun batchIndexByCurrencyId(currencyId: ManagedCryptoCurrency.ID): Int {

View file

@ -18,6 +18,7 @@ internal fun CurrencyItemUM.toggleExpanded(
return when (this) {
is CurrencyItemUM.Custom,
is CurrencyItemUM.Loading,
is CurrencyItemUM.SearchNothingFound,
-> this
is CurrencyItemUM.Basic -> {
val isExpanded = networks !is NetworksUM.Expanded
@ -41,6 +42,7 @@ internal fun CurrencyItemUM.update(currency: ManagedCryptoCurrency): CurrencyIte
return when (this) {
is CurrencyItemUM.Custom,
is CurrencyItemUM.Loading,
is CurrencyItemUM.SearchNothingFound,
-> this
is CurrencyItemUM.Basic -> {
if (currency !is ManagedCryptoCurrency.Token) {

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.feedback)
implementation(projects.domain.manageTokens)
implementation(projects.domain.markets)
implementation(projects.domain.staking.models)

View file

@ -1,6 +1,7 @@
package com.tangem.features.markets.details.impl.model
import androidx.compose.runtime.Stable
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
@ -19,18 +20,18 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.markets.*
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter
import com.tangem.features.markets.details.impl.model.converters.ExchangeItemStateConverter
import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter
import com.tangem.features.markets.details.impl.model.formatter.*
import com.tangem.features.markets.details.impl.model.formatter.formatAsPrice
import com.tangem.features.markets.details.impl.model.formatter.getChangePercentBetween
import com.tangem.features.markets.details.impl.model.formatter.getPercentByInterval
import com.tangem.features.markets.details.impl.model.state.QuotesStateUpdater
import com.tangem.features.markets.details.impl.model.state.TokenNetworksState
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.ExchangesBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
import com.tangem.lib.crypto.BlockchainUtils
@ -57,6 +58,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase,
private val getTokenExchangesUseCase: GetTokenExchangesUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
@ -76,9 +79,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private val infoConverter = TokenMarketInfoConverter(
appCurrency = Provider { currentAppCurrency.value },
onInfoClick = {
showInfoBottomSheet(it)
},
onInfoClick = { showBottomSheet(it) },
onListedOnClick = ::onListedOnClick,
onLinkClick = { link ->
urlOpener.openUrl(link.url)
// === Analytics ===
@ -106,10 +108,20 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private val descriptionConverter = DescriptionConverter(
onReadModeClicked = {
showInfoBottomSheet(it)
showBottomSheet(it)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked())
},
onGeneratedAINotificationClick = {
modelScope.launch {
sendFeedbackEmailUseCase(
type = FeedbackEmailType.CurrencyDescriptionError(
currencyId = params.token.id,
currencyName = params.token.name,
),
)
}
},
)
private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) {
@ -179,7 +191,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
markerSet = false,
body = MarketsTokenDetailsUM.Body.Loading,
triggerPriceChange = consumedEvent(),
infoBottomSheet = TangemBottomSheetConfig(
bottomSheetConfig = TangemBottomSheetConfig(
isShow = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
@ -269,52 +281,56 @@ internal class MarketsTokenDetailsModel @Inject constructor(
)
}
val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval)
chart.onRight {
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
y = it.priceY.toImmutableList(),
x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted()
updateLook {
chart
.onRight { updateTokenChart(it) }
.onLeft {
state.update {
it.copy(
xAxisFormatter = xAxisFormatter,
type = state.value.priceChangeType.toChartType(),
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.ERROR,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Error) {
MarketsTokenDetailsUM.Body.Nothing
} else {
it.body
},
)
}
}
state.update {
it.copy(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.DATA,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) {
MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked)
} else {
it.body
},
)
}
}.onLeft {
state.update {
it.copy(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.ERROR,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Error) {
MarketsTokenDetailsUM.Body.Nothing
} else {
it.body
},
)
}
}
}.saveIn(loadChartJobHolder)
}
private suspend fun updateTokenChart(tokenChart: TokenChart) {
val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval)
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
y = tokenChart.priceY.toImmutableList(),
x = tokenChart.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted()
updateLook {
it.copy(
xAxisFormatter = xAxisFormatter,
type = state.value.priceChangeType.toChartType(),
)
}
}
state.update {
it.copy(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.DATA,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) {
MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked)
} else {
it.body
},
)
}
}
private fun loadInfo() {
state.update {
it.copy(
@ -482,24 +498,22 @@ internal class MarketsTokenDetailsModel @Inject constructor(
}
}
private fun showInfoBottomSheet(content: InfoBottomSheetContent) {
private fun showBottomSheet(content: TangemBottomSheetConfigContent) {
state.update { stateToUpdate ->
stateToUpdate.copy(
infoBottomSheet = stateToUpdate.infoBottomSheet.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(
isShow = true,
onDismissRequest = ::hideInfoBottomSheet,
onDismissRequest = ::hideBottomSheet,
content = content,
),
)
}
}
private fun hideInfoBottomSheet() {
private fun hideBottomSheet() {
state.update { stateToUpdate ->
stateToUpdate.copy(
infoBottomSheet = stateToUpdate.infoBottomSheet.copy(
isShow = false,
),
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(isShow = false),
)
}
}
@ -519,6 +533,39 @@ internal class MarketsTokenDetailsModel @Inject constructor(
}
}
private fun onListedOnClick(exchangesCount: Int) {
showBottomSheet(content = ExchangesBottomSheetContent.Loading(exchangesCount))
modelScope.launch {
val maybeExchanges = getTokenExchangesUseCase(tokenId = params.token.id)
updateExchangeBSContent(maybeExchanges = maybeExchanges, exchangesCount = exchangesCount)
}
}
private fun updateExchangeBSContent(
maybeExchanges: Either<Throwable, List<TokenMarketExchange>>,
exchangesCount: Int,
) {
val content = maybeExchanges
.fold(
ifLeft = {
ExchangesBottomSheetContent.Error(onRetryClick = { onListedOnClick(exchangesCount) })
},
ifRight = {
ExchangesBottomSheetContent.Content(
exchangeItems = ExchangeItemStateConverter.convertList(it).toImmutableList(),
)
},
)
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(content = content),
)
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {

View file

@ -12,7 +12,8 @@ import com.tangem.utils.converter.Converter
@Stable
internal class DescriptionConverter(
val onReadModeClicked: (InfoBottomSheetContent) -> Unit,
private val onReadModeClicked: (InfoBottomSheetContent) -> Unit,
private val onGeneratedAINotificationClick: () -> Unit,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.Description?> {
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? {
@ -32,7 +33,9 @@ internal class DescriptionConverter(
),
),
body = stringReference(value.fullDescription ?: ""),
showGeneratedAINotification = true,
generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM(
onClick = onGeneratedAINotificationClick,
),
),
)
},

View file

@ -0,0 +1,64 @@
package com.tangem.features.markets.details.impl.model.converters
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.markets.TokenMarketExchange
import com.tangem.domain.markets.TokenMarketExchange.TrustScore
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
/**
* Converter from [TokenMarketExchange] to [TokenItemState]
*
[REDACTED_AUTHOR]
*/
internal object ExchangeItemStateConverter : Converter<TokenMarketExchange, TokenItemState> {
override fun convert(value: TokenMarketExchange): TokenItemState {
return TokenItemState.Content(
id = value.id,
iconState = CurrencyIconState.CoinIcon(
url = value.imageUrl,
fallbackResId = R.drawable.ic_alert_24,
isGrayscale = false,
showCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(text = value.name),
fiatAmountState = TokenItemState.FiatAmountState.Content(
text = BigDecimalFormatter.formatFiatPriceUncapped(
fiatAmount = value.volumeInUsd,
fiatCurrencyCode = "USD",
fiatCurrencySymbol = "$",
),
),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = if (value.isCentralized) "CEX" else "DEX",
),
subtitle2State = TokenItemState.Subtitle2State.LabelContent(
auditLabelUM = value.trustScore.toAuditLabelUM(),
),
onItemClick = null,
onItemLongClick = null,
)
}
private fun TrustScore.toAuditLabelUM(): AuditLabelUM {
return when (this) {
TrustScore.Risky -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_risky),
type = AuditLabelUM.Type.Prohibition,
)
TrustScore.Caution -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_caution),
type = AuditLabelUM.Type.Warning,
)
TrustScore.Trusted -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_trusted),
type = AuditLabelUM.Type.Permit,
)
}
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.details.impl.ui.state.ListedOnUM
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@ -14,6 +15,7 @@ import com.tangem.utils.converter.Converter
internal class TokenMarketInfoConverter(
private val appCurrency: Provider<AppCurrency>,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
private val onListedOnClick: (Int) -> Unit,
onLinkClick: (LinksUM.Link) -> Unit,
onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit,
onInsightsIntervalChanged: (PriceChangeInterval) -> Unit,
@ -41,6 +43,7 @@ internal class TokenMarketInfoConverter(
onInfoClick = onInfoClick,
)
val exchangesAmount = value.exchangesAmount
return MarketsTokenDetailsUM.InformationBlocks(
insights = value.insights?.let { insightsConverter.convert(it) },
securityScore = null,
@ -51,6 +54,11 @@ internal class TokenMarketInfoConverter(
currentPrice = value.quotes.currentPrice,
)
},
listedOn = if (exchangesAmount != null && exchangesAmount > 0) {
ListedOnUM.Content(onClick = { onListedOnClick(exchangesAmount) }, amount = exchangesAmount)
} else {
ListedOnUM.Empty
},
links = value.links?.let { linksConverter.convert(it) },
)
}

View file

@ -37,9 +37,12 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.components.ExchangesBottomSheet
import com.tangem.features.markets.details.impl.ui.components.InfoBottomSheet
import com.tangem.features.markets.details.impl.ui.components.MarketTokenDetailsChart
import com.tangem.features.markets.details.impl.ui.components.tokenMarketDetailsBody
import com.tangem.features.markets.details.impl.ui.state.ExchangesBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
@ -67,7 +70,10 @@ internal fun MarketsTokenDetailsContent(
addTopBarStatusBarInsets = addTopBarStatusBarPadding,
)
InfoBottomSheet(config = state.infoBottomSheet)
when (state.bottomSheetConfig.content) {
is InfoBottomSheetContent -> InfoBottomSheet(config = state.bottomSheetConfig)
is ExchangesBottomSheetContent -> ExchangesBottomSheet(config = state.bottomSheetConfig)
}
}
@Suppress("LongParameterList")
@ -299,7 +305,7 @@ private fun Preview() {
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = { },
body = MarketsTokenDetailsUM.Body.Loading,
infoBottomSheet = TangemBottomSheetConfig(
bottomSheetConfig = TangemBottomSheetConfig(
isShow = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,

View file

@ -0,0 +1,206 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.details.impl.ui.state.ExchangesBottomSheetContent
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.toImmutableList
/**
* Exchanges bottom sheet
*
* @param config bottom sheet config
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun ExchangesBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<ExchangesBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { Title(textResId = it.titleResId, onBackClick = config.onDismissRequest) },
content = { content ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(state = rememberScrollState()),
) {
Subtitle(
subtitleRes = content.subtitleResId,
volumeReference = content.volumeReference,
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing8,
),
)
when (content) {
is ExchangesBottomSheetContent.Content,
is ExchangesBottomSheetContent.Loading,
-> {
content.exchangeItems.fastForEach { item ->
key(item.id) {
TokenItem(state = item, isBalanceHidden = false)
}
}
}
is ExchangesBottomSheetContent.Error -> {
Error(
content = content,
modifier = Modifier
.align(alignment = Alignment.CenterHorizontally)
.padding(horizontal = 16.dp)
.weight(1f),
)
}
}
SpacerH(bottomBarHeight)
}
},
)
}
@Composable
private fun Title(@StringRes textResId: Int, onBackClick: () -> Unit) {
TangemTopAppBar(
title = stringResource(id = textResId),
startButton = TopAppBarButtonUM.Back(onBackClicked = onBackClick),
)
}
@Composable
private fun Subtitle(@StringRes subtitleRes: Int, volumeReference: TextReference, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
SubtitleText(textReference = resourceReference(id = subtitleRes))
SubtitleText(textReference = volumeReference)
}
}
@Composable
private fun SubtitleText(textReference: TextReference) {
Text(
text = textReference.resolveReference(),
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun Error(content: ExchangesBottomSheetContent.Error, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = content.message),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption1,
)
SpacerH12()
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(id = R.string.alert_button_try_again),
onClick = content.onRetryClick,
),
)
}
}
@Preview
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ExchangesBottomSheet(
@PreviewParameter(ExchangesBottomSheetContentProvider::class) content: ExchangesBottomSheetContent,
) {
TangemThemePreview {
ExchangesBottomSheet(
config = TangemBottomSheetConfig(
onDismissRequest = {},
content = content,
isShow = true,
),
)
}
}
private class ExchangesBottomSheetContentProvider : CollectionPreviewParameterProvider<ExchangesBottomSheetContent>(
listOf(
ExchangesBottomSheetContent.Loading(exchangesCount = 13),
ExchangesBottomSheetContent.Error(onRetryClick = {}),
ExchangesBottomSheetContent.Content(
exchangeItems = List(size = 13) { index ->
TokenItemState.Content(
id = index.toString(),
iconState = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.ic_facebook_24,
isGrayscale = false,
showCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(text = "OKX"),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "$67.52M"),
subtitleState = TokenItemState.SubtitleState.TextContent(value = "CEX"),
subtitle2State = TokenItemState.Subtitle2State.LabelContent(
auditLabelUM = AuditLabelUM(
text = stringReference("Caution"),
type = AuditLabelUM.Type.Warning,
),
),
onItemClick = {},
onItemLongClick = {},
)
}
.toImmutableList(),
),
),
)

View file

@ -27,14 +27,14 @@ internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
config = config,
addBottomInsets = false,
title = { TangemBottomSheetTitle(title = it.title) },
content = {
content = { content ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
MarkdownText(
markdown = it.body.resolveReference(),
markdown = content.body.resolveReference(),
disableLinkMovementMethod = true,
linkifyMask = 0,
syntaxHighlightColor = TangemTheme.colors.text.secondary,
@ -43,8 +43,9 @@ internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
),
)
if (it.showGeneratedAINotification) {
if (content.generatedAINotificationUM != null) {
AdditionalInfoNotification(
onClick = content.generatedAINotificationUM.onClick,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
@ -58,11 +59,13 @@ internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
}
@Composable
private fun AdditionalInfoNotification(modifier: Modifier = Modifier) {
private fun AdditionalInfoNotification(onClick: () -> Unit, modifier: Modifier = Modifier) {
Notification(
config = NotificationConfig(
subtitle = TextReference.Res(id = R.string.information_generated_with_ai),
iconResId = R.drawable.ic_magic_28,
onClick = onClick,
showArrowIcon = false,
),
modifier = modifier,
subtitleColor = TangemTheme.colors.text.primary1,

View file

@ -0,0 +1,135 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.R
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.details.impl.ui.state.ListedOnUM
import kotlinx.coroutines.delay
/**
* "Listed on" block
*
* @param state block state
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun ListedOnBlock(state: ListedOnUM, modifier: Modifier = Modifier) {
Box {
InformationBlock(
title = {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = modifier.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
},
) {
Description(
state = state,
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
)
}
if (state is ListedOnUM.Content) {
Icon(
painter = painterResource(id = R.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = TangemTheme.dimens.spacing12),
)
}
}
}
@Composable
internal fun ListedOnBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
title = {
TextShimmer(
style = TangemTheme.typography.subtitle2,
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
)
},
modifier = modifier,
) {
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.fillMaxWidth(fraction = 0.3f)
.padding(bottom = TangemTheme.dimens.spacing12),
)
}
}
@Composable
private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) {
Text(
text = state.description.resolveReference(),
modifier = modifier,
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
@Preview(widthDp = 328, heightDp = 68)
@Preview(name = "Dark Theme", widthDp = 328, heightDp = 68, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ListedOnBlock(@PreviewParameter(ListenOnUMProvider::class) state: ListedOnUM?) {
TangemThemePreview {
if (state == null) {
ListedOnBlockPlaceholder()
} else {
ListedOnBlock(state = state)
}
}
}
@Preview
@Composable
private fun Preview_ListedOnBlock_StateChanging() {
var state by remember { mutableStateOf<ListedOnUM?>(value = null) }
Preview_ListedOnBlock(state = state)
LaunchedEffect(key1 = null) {
delay(timeMillis = 3000)
state = ListedOnUM.Empty
}
}
private class ListenOnUMProvider : CollectionPreviewParameterProvider<ListedOnUM?>(
collection = listOf(
ListedOnUM.Empty,
ListedOnUM.Content(onClick = {}, amount = 5),
null,
),
)

View file

@ -115,6 +115,10 @@ internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.Informati
}
}
item(key = "listedOn") {
ListedOnBlock(state = state.listedOn, modifier = Modifier.blockPaddings())
}
if (state.links != null) {
item("links") {
LinksBlock(
@ -143,6 +147,10 @@ private fun LazyListScope.loadingInfoBlocks() {
PricePerformanceBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item(key = "listedOn-loading") {
ListedOnBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("links-loading") {
LinksBlockPlaceholder(modifier = Modifier.blockPaddings())
}

View file

@ -0,0 +1,73 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.annotation.StringRes
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.plus
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.markets.impl.R
import com.tangem.utils.StringsSigns.DOT
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* Exchanges bottom sheet content
*
[REDACTED_AUTHOR]
*/
internal sealed interface ExchangesBottomSheetContent : TangemBottomSheetConfigContent {
/** Title of bottom sheet. Like, app bar. */
@get:StringRes
val titleResId: Int
get() = R.string.markets_token_details_exchanges_title
/** Subtitle */
@get:StringRes
val subtitleResId: Int
get() = R.string.markets_token_details_exchange
/** Volume info */
@get:StringRes
val volumeReference: TextReference
get() = resourceReference(id = R.string.markets_token_details_volume) +
stringReference(value = " $DOT ") +
resourceReference(id = R.string.markets_selector_interval_24h_title)
/** Exchange items */
val exchangeItems: ImmutableList<TokenItemState>
/**
* Loading state
*
* @property exchangesCount count of exchanges
*/
data class Loading(val exchangesCount: Int) : ExchangesBottomSheetContent {
override val exchangeItems: ImmutableList<TokenItemState>
get() = List(size = exchangesCount) { index -> TokenItemState.Loading(id = "loading#$index") }
.toImmutableList()
}
/**
* Content state
*
* @property exchangeItems exchanges
*/
data class Content(
override val exchangeItems: ImmutableList<TokenItemState>,
) : ExchangesBottomSheetContent
/** Error state */
data class Error(
val onRetryClick: () -> Unit,
) : ExchangesBottomSheetContent {
override val exchangeItems: ImmutableList<TokenItemState> = persistentListOf()
@StringRes
val message: Int = R.string.markets_loading_error_title
}
}

View file

@ -6,5 +6,8 @@ import com.tangem.core.ui.extensions.TextReference
internal data class InfoBottomSheetContent(
val title: TextReference,
val body: TextReference,
val showGeneratedAINotification: Boolean = false,
) : TangemBottomSheetConfigContent
val generatedAINotificationUM: GeneratedAINotificationUM? = null,
) : TangemBottomSheetConfigContent {
data class GeneratedAINotificationUM(val onClick: () -> Unit)
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.markets.impl.R
/**
* "Listed on" block UI model
*
[REDACTED_AUTHOR]
*/
internal sealed interface ListedOnUM {
/** Title */
val title: TextReference
get() = resourceReference(id = R.string.markets_token_details_listed_on)
/** Description */
val description: TextReference
/** Empty state. No exchanges found */
data object Empty : ListedOnUM {
override val description = resourceReference(id = R.string.markets_token_details_empty_exchanges)
}
/**
* Content with number of exchanges
*
* @property onClick lambda be invoked when button is clicked
* @property amount amount of exchanges
*/
data class Content(
val onClick: () -> Unit,
private val amount: Int,
) : ListedOnUM {
override val description: TextReference = pluralReference(
id = R.plurals.markets_token_details_amount_exchanges,
count = amount,
formatArgs = wrappedList(amount),
)
}
}

View file

@ -20,7 +20,7 @@ internal data class MarketsTokenDetailsUM(
val markerSet: Boolean,
val chartState: ChartState,
val onSelectedIntervalChange: (PriceChangeInterval) -> Unit,
val infoBottomSheet: TangemBottomSheetConfig,
val bottomSheetConfig: TangemBottomSheetConfig,
val triggerPriceChange: StateEvent<PriceChangeType>,
val body: Body,
) {
@ -41,6 +41,7 @@ internal data class MarketsTokenDetailsUM(
val securityScore: SecurityScoreUM?,
val metrics: MetricsUM?,
val pricePerformance: PricePerformanceUM?,
val listedOn: ListedOnUM,
val links: LinksUM?,
)

View file

@ -195,7 +195,7 @@ internal class MarketsPortfolioModel @Inject constructor(
hasMissedDerivationsUseCase.invoke(
userWalletId = selectedWalletId,
networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty()
.associate { Network.ID(it.networkId) to null },
.associate { it.networkId to null },
)
} else {
false

View file

@ -151,6 +151,7 @@ internal class TokenActionsHandler @AssistedInject constructor(
AppRoute.Swap(
currency = cryptoCurrencyData.status.currency,
userWalletId = cryptoCurrencyData.userWallet.walletId,
isInitialReverseOrder = true,
),
)
}

View file

@ -102,7 +102,8 @@ private class PortfolioTokenUMProvider : CollectionPreviewParameterProvider<Port
tokenUM.copy(
tokenItemState = tokenUM.tokenItemState.copy(
fiatAmountState = contentFiatAmount.copy(text = DASH_SIGN),
cryptoAmountState = tokenUM.tokenItemState.cryptoAmountState.copy(text = DASH_SIGN),
subtitle2State = (tokenUM.tokenItemState.subtitle2State as TokenItemState.Subtitle2State.TextContent)
.copy(text = DASH_SIGN),
),
),
tokenUM.copy(isBalanceHidden = true),

View file

@ -51,7 +51,7 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider<MyPortfol
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = "My wallet"),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "486,65 \$"),
cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "733,71097 MATIC"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "733,71097 MATIC"),
subtitleState = TokenItemState.SubtitleState.TextContent(value = "XRP Ledger token"),
onItemClick = {},
onItemLongClick = {},
@ -63,6 +63,6 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider<MyPortfol
onQuickActionLongClick = {},
),
isBalanceHidden = false,
walletId = UserWalletId("walletId"),
walletId = UserWalletId(""),
)
}

View file

@ -1,9 +0,0 @@
package com.tangem.features.pushnotifications.api.featuretoggles
/**
* Push notifications feature toggles
*/
interface PushNotificationsFeatureToggles {
/** Availability of push notifications */
val isPushNotificationsEnabled: Boolean
}

View file

@ -1,24 +0,0 @@
package com.tangem.features.pushnotifications.impl.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles
import com.tangem.features.pushnotifications.impl.featuretoggles.DefaultPushNotificationsFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* DI module provides implementation of [PushNotificationsFeatureToggles]
*/
@Module
@InstallIn(SingletonComponent::class)
internal object PushNotificationsFeatureTogglesModule {
@Provides
@Singleton
fun provideSendFeatureToggles(featureTogglesManager: FeatureTogglesManager): PushNotificationsFeatureToggles {
return DefaultPushNotificationsFeatureToggles(featureTogglesManager = featureTogglesManager)
}
}

View file

@ -1,11 +0,0 @@
package com.tangem.features.pushnotifications.impl.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles
internal class DefaultPushNotificationsFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : PushNotificationsFeatureToggles {
override val isPushNotificationsEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("PUSH_NOTIFICATIONS_ENABLED")
}

View file

@ -9,7 +9,6 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.StartReferralBody
import com.tangem.datasource.demo.DemoModeDatasource
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.referral.converters.ReferralConverter
@ -68,7 +67,6 @@ internal class ReferralRepositoryImpl @Inject constructor(
override suspend fun getCryptoCurrency(userWalletId: UserWalletId, tokenData: TokenData): CryptoCurrency? {
val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("Wallet $userWalletId not found")
val derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider
val blockchain = Blockchain.fromNetworkId(tokenData.networkId)
?: error("Blockchain ${tokenData.networkId} not found")
@ -89,13 +87,13 @@ internal class ReferralRepositoryImpl @Inject constructor(
sdkToken = sdkToken,
blockchain = blockchain,
extraDerivationPath = null,
derivationStyleProvider = derivationStyleProvider,
scanResponse = userWallet.scanResponse,
)
} else {
cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = null,
derivationStyleProvider = derivationStyleProvider,
scanResponse = userWallet.scanResponse,
)
}
}

View file

@ -1,19 +0,0 @@
package com.tangem.features.send.impl.presentation.errors
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
internal class FeeErrorStateMapper {
fun getFeeError(loadFeeError: GetFeeError?, tokenName: String): FeeSelectorState.Error {
return when (loadFeeError) {
GetFeeError.BlockchainErrors.TronActivationError -> FeeSelectorState.Error.TronAccountActivationError(
tokenName,
)
is GetFeeError.DataError,
GetFeeError.UnknownError,
null,
-> FeeSelectorState.Error.NetworkError
}
}
}

View file

@ -1,58 +1,35 @@
package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.send.impl.R
@Immutable
internal sealed class SendAlertState {
abstract val title: TextReference?
abstract val message: TextReference
open val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
open val onConfirmClick: (() -> Unit)? = null
internal sealed class SendAlertUM : AlertUM {
data class GenericError(
override val title: TextReference? = resourceReference(id = R.string.send_alert_transaction_failed_title),
override val onConfirmClick: (() -> Unit),
) : SendAlertState() {
) : SendAlertUM() {
override val message: TextReference = resourceReference(R.string.common_unknown_error)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.common_support)
}
data class TransactionError(
val code: String,
val cause: String?,
val causeTextReference: TextReference? = null,
override val onConfirmClick: (() -> Unit),
) : SendAlertState() {
override val title: TextReference = resourceReference(id = R.string.send_alert_transaction_failed_title)
override val message: TextReference = resourceReference(
id = R.string.send_alert_transaction_failed_text,
formatArgs = wrappedList(causeTextReference ?: cause.orEmpty(), code),
)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.common_support)
}
data class DemoMode(
data class FeeIncreased(
override val onConfirmClick: () -> Unit,
) : SendAlertState() {
override val title: TextReference = resourceReference(id = R.string.warning_demo_mode_title)
override val message: TextReference = resourceReference(id = R.string.warning_demo_mode_message)
}
data object FeeIncreased : SendAlertState() {
) : SendAlertUM() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_notification_high_fee_title)
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
}
data class FeeTooLow(
override val onConfirmClick: () -> Unit,
) : SendAlertState() {
) : SendAlertUM() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_alert_fee_too_low_text)
override val confirmButtonText: TextReference = resourceReference(R.string.common_continue)
@ -61,7 +38,7 @@ internal sealed class SendAlertState {
data class FeeTooHigh(
val times: String,
override val onConfirmClick: () -> Unit,
) : SendAlertState() {
) : SendAlertUM() {
override val title: TextReference? = null
override val message: TextReference =
resourceReference(id = R.string.send_alert_fee_too_high_text, wrappedList(times))
@ -70,7 +47,7 @@ internal sealed class SendAlertState {
data class FeeUnreachableError(
override val onConfirmClick: (() -> Unit),
) : SendAlertState() {
) : SendAlertUM() {
override val title: TextReference = resourceReference(R.string.send_fee_unreachable_error_title)
override val message: TextReference = resourceReference(R.string.send_fee_unreachable_error_text)
override val confirmButtonText = resourceReference(R.string.warning_button_refresh)

View file

@ -1,6 +1,7 @@
package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
@ -8,5 +9,5 @@ internal sealed class SendEvent {
data class ShowSnackBar(val text: TextReference) : SendEvent()
data class ShowAlert(val alert: SendAlertState) : SendEvent()
data class ShowAlert(val alert: AlertUM) : SendEvent()
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.alerts.TransactionErrorAlertConverter
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.domain.transaction.error.SendTransactionError
@ -24,9 +25,10 @@ internal class SendEventStateFactory(
private val clickIntents: SendClickIntents,
private val feeStateFactory: FeeStateFactory,
) {
private val sendTransactionErrorConverter by lazy(LazyThreadSafetyMode.NONE) {
SendTransactionAlertConverter(
clickIntents = clickIntents,
private val transactionErrorAlertConverter by lazy(LazyThreadSafetyMode.NONE) {
TransactionErrorAlertConverter(
popBackStack = clickIntents::popBackStack,
onFailedTxEmailClick = clickIntents::onFailedTxEmailClick,
)
}
@ -37,7 +39,7 @@ internal class SendEventStateFactory(
fun getSendTransactionErrorState(error: SendTransactionError?, onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
val event = error?.let {
sendTransactionErrorConverter.convert(error)?.let {
transactionErrorAlertConverter.convert(error)?.let {
triggeredEvent<SendEvent>(SendEvent.ShowAlert(it), onConsume)
}
}
@ -68,7 +70,7 @@ internal class SendEventStateFactory(
return if (newFeeValue > oldFeeValue) {
updateFeeState.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(SendAlertState.FeeIncreased),
data = SendEvent.ShowAlert(SendAlertUM.FeeIncreased(onConsume)),
onConsume = onConsume,
),
)
@ -83,7 +85,7 @@ internal class SendEventStateFactory(
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeTooLow(
SendAlertUM.FeeTooLow(
onConfirmClick = clickIntents::showSend,
),
),
@ -96,7 +98,7 @@ internal class SendEventStateFactory(
return currentStateProvider().copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeTooHigh(
SendAlertUM.FeeTooHigh(
onConfirmClick = clickIntents::showSend,
times = diff,
),
@ -111,7 +113,7 @@ internal class SendEventStateFactory(
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.GenericError(
SendAlertUM.GenericError(
onConfirmClick = { clickIntents.onFailedTxEmailClick(error?.localizedMessage.orEmpty()) },
),
),
@ -125,7 +127,7 @@ internal class SendEventStateFactory(
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeUnreachableError(onConfirmClick = clickIntents::feeReload),
SendAlertUM.FeeUnreachableError(onConfirmClick = clickIntents::feeReload),
),
onConsume = onConsume,
),

View file

@ -1,250 +0,0 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.R
import java.math.BigDecimal
internal sealed class SendNotification(val config: NotificationConfig) {
sealed class Error(
title: TextReference,
subtitle: TextReference,
iconResId: Int = R.drawable.ic_alert_24,
buttonState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : SendNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = iconResId,
buttonsState = buttonState,
onCloseClick = onCloseClick,
),
) {
data object TotalExceedsBalance : Error(
title = resourceReference(R.string.send_notification_exceed_balance_title),
subtitle = resourceReference(R.string.send_notification_exceed_balance_text),
)
data object InvalidAmount : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(R.string.send_notification_invalid_amount_text),
)
data class MinimumAmountError(val amount: String) : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(
R.string.send_notification_invalid_minimum_amount_text,
wrappedList(amount, amount),
),
)
data class TransactionLimitError(
val cryptoCurrency: String,
val utxoLimit: String,
val amountLimit: String,
val onConfirmClick: () -> Unit,
) : Error(
title = resourceReference(R.string.send_notification_transaction_limit_title),
subtitle = resourceReference(
R.string.send_notification_transaction_limit_text,
wrappedList(cryptoCurrency, utxoLimit, amountLimit),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_leave_button, wrappedList(amountLimit)),
onClick = onConfirmClick,
),
)
data class ExceedsBalance(
val networkIconId: Int,
val currencyName: String,
val feeName: String,
val feeSymbol: String,
val networkName: String,
val mergeFeeNetworkName: Boolean = false,
val onClick: (() -> Unit)? = null,
) : Error(
title = resourceReference(
id = R.string.warning_send_blocked_funds_for_fee_title,
wrappedList(feeName),
),
subtitle = resourceReference(
id = R.string.warning_send_blocked_funds_for_fee_message,
formatArgs = wrappedList(currencyName, networkName, currencyName, feeName, feeSymbol),
),
iconResId = networkIconId,
buttonState = onClick?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(
R.string.common_buy_currency,
wrappedList(
if (mergeFeeNetworkName) {
"$currencyName ($feeSymbol)"
} else {
feeName
},
),
),
onClick = onClick,
)
},
)
data class ExistentialDeposit(val deposit: String, val onConfirmClick: () -> Unit) : Error(
title = resourceReference(R.string.send_notification_existential_deposit_title),
subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.common_ok),
onClick = onConfirmClick,
),
)
data class ReserveAmount(val amount: String) : Error(
title = resourceReference(
id = R.string.send_notification_invalid_reserve_amount_title,
wrappedList(amount),
),
subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text),
)
}
sealed class Warning(
title: TextReference,
subtitle: TextReference,
buttonsState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : SendNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.img_attention_20,
buttonsState = buttonsState,
onCloseClick = onCloseClick,
),
) {
data class HighFeeError(
val currencyName: String,
val amount: String,
val onConfirmClick: () -> Unit,
val onCloseClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(currencyName, amount)),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)),
onClick = onConfirmClick,
),
onCloseClick = onCloseClick,
)
data object FeeTooLow : Warning(
title = resourceReference(id = R.string.send_notification_transaction_delay_title),
subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text),
)
data class TooHigh(
val value: String,
) : Warning(
title = resourceReference(id = R.string.send_notification_fee_too_high_title),
subtitle = resourceReference(id = R.string.send_notification_fee_too_high_text, wrappedList(value)),
)
data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = onRefresh,
),
)
data class TronAccountNotActivated(val tokenName: String) : Warning(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(
R.string.send_tron_account_activation_error,
wrappedList(tokenName),
),
)
data class FeeCoverageNotification(val cryptoAmount: String, val fiatAmount: String) : Warning(
title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(
R.string.common_network_fee_warning_content,
wrappedList(cryptoAmount, fiatAmount),
),
)
}
sealed interface Cardano {
data class MinAdaValueCharged(val tokenName: String, val minAdaValue: String) : Warning(
title = resourceReference(id = R.string.cardano_coin_will_be_send_with_token_title),
subtitle = resourceReference(
id = R.string.cardano_coin_will_be_send_with_token_description,
formatArgs = wrappedList(minAdaValue, tokenName),
),
)
data object InsufficientBalanceToTransferCoin : Error(
title = resourceReference(id = R.string.cardano_max_amount_has_token_title),
subtitle = resourceReference(id = R.string.cardano_max_amount_has_token_description),
)
data class InsufficientBalanceToTransferToken(val tokenName: String) : Error(
title = resourceReference(id = R.string.cardano_insufficient_balance_to_send_token_title),
subtitle = resourceReference(
id = R.string.cardano_insufficient_balance_to_send_token_description,
formatArgs = wrappedList(tokenName),
),
)
}
sealed interface Koinos {
data class InsufficientRecoverableMana(
val mana: BigDecimal,
val maxMana: BigDecimal,
) : Error(
title = resourceReference(R.string.koinos_insufficient_mana_to_send_koin_title),
subtitle = resourceReference(
R.string.koinos_insufficient_mana_to_send_koin_description,
formatArgs = wrappedList(
BigDecimalFormatter.formatCryptoAmountShorted(mana, "", Blockchain.Koinos.decimals()),
BigDecimalFormatter.formatCryptoAmountShorted(maxMana, "", Blockchain.Koinos.decimals()),
),
),
)
data object InsufficientBalance : Error(
title = resourceReference(R.string.koinos_insufficient_balance_to_send_koin_title),
subtitle = resourceReference(R.string.koinos_insufficient_balance_to_send_koin_description),
)
data class ManaExceedsBalance(
val availableKoinForTransfer: BigDecimal,
val onReduceClick: () -> Unit,
) : Error(
title = resourceReference(R.string.koinos_mana_exceeds_koin_balance_title),
subtitle = resourceReference(
R.string.koinos_mana_exceeds_koin_balance_description,
formatArgs = wrappedList(
BigDecimalFormatter.formatCryptoAmount(
availableKoinForTransfer,
Blockchain.Koinos.currency,
Blockchain.Koinos.decimals(),
),
),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_to, wrappedList(availableKoinForTransfer)),
onClick = onReduceClick,
),
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.TransactionData
import com.tangem.common.ui.amountScreen.converters.AmountStateConverter
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.event.consumedEvent
import com.tangem.domain.appcurrency.model.AppCurrency
@ -164,14 +165,14 @@ internal class SendStateFactory(
)
}
fun getSendNotificationState(notifications: ImmutableList<SendNotification>): SendUiState {
fun getSendNotificationState(notifications: ImmutableList<NotificationUM>): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState ?: return state
val reducedBy = sendState.reduceAmountBy.takeIf {
notifications.none {
it is SendNotification.Error.ExistentialDeposit ||
it is SendNotification.Error.TransactionLimitError ||
it is SendNotification.Warning.HighFeeError
it is NotificationUM.Error.ExistentialDeposit ||
it is NotificationUM.Error.TransactionLimitError ||
it is NotificationUM.Warning.HighFeeError
}
}
return state.copy(
@ -199,10 +200,10 @@ internal class SendStateFactory(
private fun isPrimaryButtonEnabled(
state: SendUiState,
isSending: Boolean,
notifications: ImmutableList<SendNotification>,
notifications: ImmutableList<NotificationUM>,
): Boolean {
val feeState = state.getFeeState(stateRouterProvider().isEditState) ?: return false
val hasErrorNotifications = notifications.any { it is SendNotification.Error }
val hasErrorNotifications = notifications.any { it is NotificationUM.Error }
return !hasErrorNotifications && !isSending && feeState.feeSelectorState is FeeSelectorState.Content
}
//endregion

View file

@ -1,46 +0,0 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
internal class SendTransactionAlertConverter(
private val clickIntents: SendClickIntents,
) : Converter<SendTransactionError, SendAlertState?> {
override fun convert(value: SendTransactionError): SendAlertState? {
return when (value) {
SendTransactionError.DemoCardError -> SendAlertState.DemoMode(
onConfirmClick = { clickIntents.popBackStack() },
)
is SendTransactionError.TangemSdkError -> SendAlertState.TransactionError(
code = value.code.toString(),
cause = null,
causeTextReference = resourceReference(value.messageRes, wrappedList(value.args)),
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.code.toString()) },
)
is SendTransactionError.BlockchainSdkError -> SendAlertState.TransactionError(
code = value.code.toString(),
cause = value.message,
onConfirmClick = { clickIntents.onFailedTxEmailClick("${value.code}: ${value.message.orEmpty()}") },
)
is SendTransactionError.DataError -> SendAlertState.TransactionError(
code = "",
cause = value.message,
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.NetworkError -> SendAlertState.TransactionError(
code = value.code.orEmpty(),
cause = value.message.orEmpty(),
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.UnknownError -> SendAlertState.TransactionError(
code = "",
cause = value.ex?.localizedMessage,
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.ex?.localizedMessage.orEmpty()) },
)
else -> null
}
}
}

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
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.ui.event.StateEvent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
@ -112,7 +113,7 @@ internal sealed class SendStates {
val appCurrency: AppCurrency,
val isFeeApproximate: Boolean,
val isCustomSelected: Boolean,
val notifications: ImmutableList<SendNotification>,
val notifications: ImmutableList<NotificationUM>,
val isTronToken: Boolean,
) : SendStates()
@ -129,7 +130,7 @@ internal sealed class SendStates {
val reduceAmountBy: BigDecimal?,
val isFromConfirmation: Boolean,
val showTapHelp: Boolean,
val notifications: ImmutableList<SendNotification>,
val notifications: ImmutableList<NotificationUM>,
) : SendStates()
}

View file

@ -1,31 +1,35 @@
package com.tangem.features.send.impl.presentation.state.confirm
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.minimalAmount
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.R
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.lib.crypto.BlockchainUtils
@ -41,32 +45,33 @@ import kotlinx.coroutines.flow.map
import java.math.BigDecimal
@Suppress("LongParameterList", "LargeClass")
internal class SendNotificationFactory(
internal class SendNotificationFactory constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val validateTransactionUseCase: ValidateTransactionUseCase,
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val currencyChecksRepository: CurrencyChecksRepository,
private val stateRouterProvider: Provider<StateRouter>,
private val isSubtractAvailableProvider: Provider<Boolean>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val clickIntents: SendClickIntents,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
private val validateTransactionUseCase: ValidateTransactionUseCase,
private val userWalletId: UserWalletId,
) {
fun create(): Flow<ImmutableList<SendNotification>> = stateRouterProvider().currentState
fun create(): Flow<ImmutableList<NotificationUM>> = stateRouterProvider().currentState
.filter { it.type == SendUiStateType.Send }
.map {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val balance = cryptoCurrencyStatusProvider().value.amount.orZero()
val balance = cryptoCurrencyStatus.value.amount.orZero()
val sendState = state.sendState ?: return@map persistentListOf()
val feeState = state.getFeeState(isEditState) ?: return@map persistentListOf()
val amountState = state.getAmountState(isEditState) as? AmountState.Data ?: return@map persistentListOf()
val recipientAddress = state.recipientState?.addressTextField?.value.orEmpty()
val amountValue = amountState.amountTextField.cryptoAmount.value.orZero()
val feeValue = feeState.fee?.amount?.value.orZero()
val reduceAmountBy = sendState.reduceAmountBy.orZero()
@ -78,42 +83,38 @@ internal class SendNotificationFactory(
reduceAmountBy = reduceAmountBy,
)
val sendingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isFeeCoverage,
cryptoCurrencyStatus = cryptoCurrencyStatusProvider(),
isAmountSubtractAvailable = isSubtractAvailableProvider(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
val feeError = (feeState.feeSelectorState as? FeeSelectorState.Error)?.error
val currencyCheck = getCurrencyCheckUseCase(
userWalletId = userWalletId,
currencyStatus = cryptoCurrencyStatus,
amount = amountValue,
fee = feeValue,
)
buildList {
// errors
addFeeUnreachableNotification(feeState.feeSelectorState)
addExceedBalanceNotification(feeValue, sendingAmount)
addExceedsBalanceNotification(feeState.fee)
addDustWarningNotificationForSpecificBlockchains(feeValue, sendingAmount)
addTransactionLimitErrorNotification(feeValue, sendingAmount)
addReserveAmountErrorNotification(recipientAddress, sendingAmount)
// warnings
addExistentialWarningNotification(feeValue, amountValue)
addFeeCoverageNotification(
isFeeCoverage = isFeeCoverage,
amountField = amountState.amountTextField,
sendingValue = sendingAmount,
)
addHighFeeWarningNotification(amountValue, sendState.ignoreAmountReduce)
addTooHighNotification(feeState.feeSelectorState)
addTooLowNotification(feeState)
// blockchain specific
addValidateTransactionNotifications(
addErrorNotifications(
feeError = feeError,
sendingAmount = sendingAmount,
fee = feeState.fee,
state = state,
feeValue = feeValue,
currencyCheck = currencyCheck,
)
addWarningNotifications(
amountState = amountState,
feeState = feeState,
sendState = sendState,
sendingAmount = sendingAmount,
isFeeCoverage = isFeeCoverage,
currencyCheck = currencyCheck,
)
}.toImmutableList()
}
fun dismissNotificationState(clazz: Class<out SendNotification>, isIgnored: Boolean = false): SendUiState {
fun dismissNotificationState(clazz: Class<out NotificationUM>, isIgnored: Boolean = false): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState ?: return state
val notificationsToRemove = sendState.notifications.filterIsInstance(clazz)
@ -128,161 +129,120 @@ internal class SendNotificationFactory(
)
}
private fun MutableList<SendNotification>.addFeeUnreachableNotification(feeSelectorState: FeeSelectorState) {
when (feeSelectorState) {
is FeeSelectorState.Error.TronAccountActivationError -> add(
SendNotification.Warning.TronAccountNotActivated(
feeSelectorState.tokenName,
),
)
is FeeSelectorState.Error.NetworkError -> add(
SendNotification.Warning.NetworkFeeUnreachable(clickIntents::feeReload),
)
else -> {
/* do nothing */
}
}
}
private fun MutableList<SendNotification>.addExceedBalanceNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
if (!isSubtractAvailableProvider()) return
val showNotification = receivedAmount + feeAmount > balance
if (showNotification) {
add(SendNotification.Error.TotalExceedsBalance)
}
}
private suspend fun MutableList<SendNotification>.addReserveAmountErrorNotification(
recipientAddress: String,
private suspend fun MutableList<NotificationUM>.addErrorNotifications(
feeError: GetFeeError?,
sendingAmount: BigDecimal,
feeValue: BigDecimal,
currencyCheck: CryptoCurrencyCheck,
) {
val userWalletId = userWalletProvider().walletId
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val isAccountFunded = currencyChecksRepository.checkIfAccountFunded(
userWalletId,
cryptoCurrency.network,
recipientAddress,
)
val minimumAmount = currencyChecksRepository.getReserveAmount(userWalletId, cryptoCurrency.network)
if (!isAccountFunded && minimumAmount != null && minimumAmount > sendingAmount) {
add(
SendNotification.Error.ReserveAmount(
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = minimumAmount,
cryptoCurrency = cryptoCurrency,
),
),
)
}
}
private suspend fun MutableList<SendNotification>.addTransactionLimitErrorNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val userWalletId = userWalletProvider().walletId
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val utxoLimit = currencyChecksRepository.checkUtxoAmountLimit(
userWalletId = userWalletId,
network = cryptoCurrency.network,
amount = receivedAmount,
fee = feeAmount,
)
if (utxoLimit != null) {
add(
SendNotification.Error.TransactionLimitError(
cryptoCurrency = cryptoCurrency.name,
utxoLimit = utxoLimit.maxLimit.toPlainString(),
amountLimit = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = utxoLimit.maxAmount,
cryptoCurrency = cryptoCurrency,
),
onConfirmClick = {
clickIntents.onAmountReduceClick(
reduceAmountTo = utxoLimit.maxAmount,
clazz = SendNotification.Error.TransactionLimitError::class.java,
)
},
),
)
}
}
private suspend fun MutableList<SendNotification>.addExistentialWarningNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val userWalletId = userWalletProvider().walletId
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val balance = cryptoCurrencyStatus.value.amount ?: return
val spendingAmount = if (cryptoCurrency is CryptoCurrency.Token) {
feeAmount
} else {
receivedAmount
}
val currencyDeposit = currencyChecksRepository.getExistentialDeposit(
userWalletId,
cryptoCurrency.network,
val currency = cryptoCurrencyStatusProvider().currency
val currencyWarning = getBalanceNotEnoughForFeeWarningUseCase(
fee = feeValue,
userWalletId = userWalletId,
tokenStatus = cryptoCurrencyStatus,
coinStatus = feeCryptoCurrencyStatusProvider() ?: cryptoCurrencyStatus,
).getOrNull()
addFeeUnreachableNotification(
feeError = feeError,
tokenName = currency.name,
onReload = clickIntents::feeReload,
)
val diff = balance.minus(spendingAmount)
if (currencyDeposit != null && diff >= BigDecimal.ZERO && currencyDeposit > diff) {
add(
SendNotification.Error.ExistentialDeposit(
deposit = BigDecimalFormatter.formatCryptoAmountUncapped(
cryptoAmount = currencyDeposit,
cryptoCurrency = cryptoCurrency,
addExceedBalanceNotification(
feeAmount = feeValue,
sendingAmount = sendingAmount,
isSubtractionAvailable = isSubtractAvailableProvider(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
)
addExceedsBalanceNotification(
cryptoCurrencyWarning = currencyWarning,
cryptoCurrencyStatus = cryptoCurrencyStatus,
shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(currency.network.backendId),
onClick = clickIntents::onTokenDetailsClick,
onAnalyticsEvent = {
analyticsEventHandler.send(
SendAnalyticEvents.NoticeNotEnoughFee(
token = cryptoCurrencyStatus.currency.symbol,
blockchain = cryptoCurrencyStatus.currency.network.name,
),
onConfirmClick = {
clickIntents.onAmountReduceClick(
reduceAmountByDiff = currencyDeposit.minus(diff),
reduceAmountBy = currencyDeposit,
clazz = SendNotification.Error.ExistentialDeposit::class.java,
)
},
),
)
},
)
if (!BlockchainUtils.isCardano(currency.id.value)) {
addDustWarningNotification(
dustValue = currencyCheck.dustValue,
feeValue = feeValue,
sendingAmount = sendingAmount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCurrencyStatus = feeCryptoCurrencyStatusProvider(),
)
}
addTransactionLimitErrorNotification(
utxoLimit = currencyCheck.utxoAmountLimit,
cryptoCurrency = currency,
onReduceClick = clickIntents::onAmountReduceToClick,
)
addReserveAmountErrorNotification(
reserveAmount = currencyCheck.reserveAmount,
sendingAmount = sendingAmount,
cryptoCurrency = currency,
isAccountFunded = false,
)
}
private fun MutableList<SendNotification>.addFeeCoverageNotification(
private suspend fun MutableList<NotificationUM>.addWarningNotifications(
amountState: AmountState.Data,
feeState: SendStates.FeeState,
sendState: SendStates.SendState,
sendingAmount: BigDecimal,
isFeeCoverage: Boolean,
amountField: AmountFieldModel,
sendingValue: BigDecimal,
currencyCheck: CryptoCurrencyCheck,
) {
if (isFeeCoverage) {
analyticsEventHandler.send(SendAnalyticEvents.NoticeFeeCoverage)
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val amountValue = amountField.cryptoAmount.value ?: return
val cryptoDiff = amountValue.minus(sendingValue)
add(
SendNotification.Warning.FeeCoverageNotification(
cryptoAmount = BigDecimalFormatter.formatCryptoAmountUncapped(
cryptoAmount = cryptoDiff,
cryptoCurrency = cryptoCurrency,
),
fiatAmount = getFiatString(
value = cryptoDiff,
rate = fiatRate,
appCurrency = appCurrencyProvider(),
),
),
)
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val currency = cryptoCurrencyStatus.currency
val amountValue = amountState.amountTextField.cryptoAmount.value
val validationError = amountValue?.let {
validateTransactionUseCase(
userWalletId = userWalletId,
amount = amountValue.convertToSdkAmount(cryptoCurrencyStatus.currency),
fee = feeState.fee,
memo = null,
destination = "",
network = cryptoCurrencyStatus.currency.network,
).leftOrNull()
}
addExistentialWarningNotification(
existentialDeposit = currencyCheck.existentialDeposit,
feeAmount = feeState.fee?.amount?.value.orZero(),
receivedAmount = sendingAmount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
onReduceClick = clickIntents::onAmountReduceByClick,
)
addFeeCoverageNotification(
isFeeCoverage = isFeeCoverage,
amountField = amountState.amountTextField,
sendingValue = sendingAmount,
appCurrency = appCurrencyProvider(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
)
addValidateTransactionNotifications(
dustValue = currencyCheck.dustValue.orZero(),
fee = feeState.fee,
validationError = validationError,
cryptoCurrency = currency,
onReduceClick = clickIntents::onAmountReduceToClick,
)
addHighFeeWarningNotification(
amountState.amountTextField.cryptoAmount.value.orZero(),
sendState.ignoreAmountReduce,
)
addTooHighNotification(feeState.feeSelectorState)
addTooLowNotification(feeState)
}
private fun MutableList<SendNotification>.addHighFeeWarningNotification(
private fun MutableList<NotificationUM>.addHighFeeWarningNotification(
sendAmount: BigDecimal,
ignoreAmountReduce: Boolean,
) {
@ -293,61 +253,32 @@ internal class SendNotificationFactory(
val isTotalBalance = sendAmount >= balance && balance > threshold
if (!ignoreAmountReduce && isTotalBalance && isTezos) {
add(
SendNotification.Warning.HighFeeError(
NotificationUM.Warning.HighFeeError(
currencyName = cryptoCurrencyStatus.currency.name,
amount = threshold.toPlainString(),
onConfirmClick = {
clickIntents.onAmountReduceClick(
clickIntents.onAmountReduceByClick(
reduceAmountBy = threshold,
clazz = SendNotification.Warning.HighFeeError::class.java,
reduceAmountByDiff = threshold,
notification = NotificationUM.Warning.HighFeeError::class.java,
)
},
onCloseClick = {
clickIntents.onNotificationCancel(SendNotification.Warning.HighFeeError::class.java)
clickIntents.onNotificationCancel(NotificationUM.Warning.HighFeeError::class.java)
},
),
)
}
}
private suspend fun MutableList<SendNotification>.addDustWarningNotificationForSpecificBlockchains(
feeValue: BigDecimal,
sendingAmount: BigDecimal,
) {
val isCardano = BlockchainUtils.isCardano(cryptoCurrencyStatusProvider().currency.network.id.value)
if (!isCardano) {
addDustWarningNotification(feeValue, sendingAmount)
}
}
private fun checkDustLimits(feeAmount: BigDecimal, receivedAmount: BigDecimal, dustValue: BigDecimal): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val feeCurrencyStatus = feeCryptoCurrencyStatusProvider() ?: return false
val change = when (cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> {
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
balance - (feeAmount + receivedAmount)
}
is CryptoCurrency.Token -> {
val balance = feeCurrencyStatus.value.amount ?: BigDecimal.ZERO
balance - feeAmount
}
}
val isChangeLowerThanDust = change < dustValue && change > BigDecimal.ZERO
return receivedAmount < dustValue || isChangeLowerThanDust
}
private fun MutableList<SendNotification>.addTooLowNotification(feeState: SendStates.FeeState) {
private fun MutableList<NotificationUM>.addTooLowNotification(feeState: SendStates.FeeState) {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return
val minimumValue = multipleFees.minimum.amount.value ?: return
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
if (feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue) {
add(SendNotification.Warning.FeeTooLow)
add(NotificationUM.Warning.FeeTooLow)
analyticsEventHandler.send(
SendAnalyticEvents.NoticeTransactionDelays(
cryptoCurrencyStatusProvider().currency.symbol,
@ -356,206 +287,11 @@ internal class SendNotificationFactory(
}
}
private fun MutableList<SendNotification>.addTooHighNotification(feeSelectorState: FeeSelectorState) {
private fun MutableList<NotificationUM>.addTooHighNotification(feeSelectorState: FeeSelectorState) {
if (feeSelectorState !is FeeSelectorState.Content) return
checkIfFeeTooHigh(feeSelectorState) { diff ->
add(SendNotification.Warning.TooHigh(diff))
}
}
private suspend fun MutableList<SendNotification>.addExceedsBalanceNotification(fee: Fee?) {
val feeValue = fee?.amount?.value ?: BigDecimal.ZERO
val userWalletId = userWalletProvider().walletId
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val feeCurrencyStatus = feeCryptoCurrencyStatusProvider() ?: return
val warning = getBalanceNotEnoughForFeeWarningUseCase(
fee = feeValue,
userWalletId = userWalletId,
tokenStatus = cryptoCurrencyStatus,
coinStatus = feeCurrencyStatus,
).getOrNull() ?: return
val mergeFeeNetworkName = cryptoCurrencyStatus.shouldMergeFeeNetworkName()
when (warning) {
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> {
add(
SendNotification.Error.ExceedsBalance(
networkIconId = warning.coinCurrency.networkIconResId,
networkName = warning.coinCurrency.name,
currencyName = cryptoCurrencyStatus.currency.name,
feeName = warning.coinCurrency.name,
feeSymbol = warning.coinCurrency.symbol,
mergeFeeNetworkName = mergeFeeNetworkName,
onClick = {
clickIntents.onTokenDetailsClick(
userWalletId = userWalletId,
currency = warning.coinCurrency,
)
},
),
)
analyticsEventHandler.send(
SendAnalyticEvents.NoticeNotEnoughFee(
token = cryptoCurrencyStatus.currency.symbol,
blockchain = cryptoCurrencyStatus.currency.network.name,
),
)
}
is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> {
val currency = warning.feeCurrency
add(
SendNotification.Error.ExceedsBalance(
networkIconId = currency?.networkIconResId ?: R.drawable.ic_alert_24,
currencyName = warning.currency.name,
feeName = warning.feeCurrencyName,
feeSymbol = warning.feeCurrencySymbol,
networkName = warning.networkName,
mergeFeeNetworkName = mergeFeeNetworkName,
onClick = currency?.let {
{
clickIntents.onTokenDetailsClick(
userWalletId,
currency,
)
}
},
),
)
analyticsEventHandler.send(
SendAnalyticEvents.NoticeNotEnoughFee(
token = warning.currency.symbol,
blockchain = warning.networkName,
),
)
}
else -> Unit
}
}
// workaround for networks that users have misunderstanding
private fun CryptoCurrencyStatus.shouldMergeFeeNetworkName(): Boolean {
return Blockchain.fromNetworkId(this.currency.network.backendId) == Blockchain.Arbitrum
}
private suspend fun MutableList<SendNotification>.addValidateTransactionNotifications(
sendingAmount: BigDecimal,
fee: Fee?,
state: SendUiState,
) {
val sendingCurrency = cryptoCurrencyStatusProvider().currency
validateTransactionUseCase(
amount = sendingAmount.convertToSdkAmount(sendingCurrency),
fee = fee ?: return,
memo = state.recipientState?.memoTextField?.value,
destination = requireNotNull(state.recipientState?.addressTextField?.value),
userWalletId = userWalletProvider().walletId,
network = sendingCurrency.network,
).fold(
ifLeft = {
when (it) {
is BlockchainSdkError.Cardano -> addCardanoTransactionValidationError(
error = it,
sendingCurrency = sendingCurrency,
)
is BlockchainSdkError.Koinos -> addKoinosTransactionValidationError(error = it)
else -> return
}
},
ifRight = {
(fee as? Fee.CardanoToken)?.let {
add(
SendNotification.Cardano.MinAdaValueCharged(
tokenName = sendingCurrency.name,
minAdaValue = it.minAdaValue.parseBigDecimal(sendingCurrency.decimals),
),
)
}
},
)
}
private suspend fun MutableList<SendNotification>.addCardanoTransactionValidationError(
error: BlockchainSdkError.Cardano,
sendingCurrency: CryptoCurrency,
) {
when (error) {
BlockchainSdkError.Cardano.InsufficientMinAdaBalanceToSendToken -> {
add(SendNotification.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name))
}
BlockchainSdkError.Cardano.InsufficientRemainingBalanceToWithdrawTokens -> {
when (sendingCurrency) {
is CryptoCurrency.Coin -> SendNotification.Cardano.InsufficientBalanceToTransferCoin
is CryptoCurrency.Token -> {
SendNotification.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name)
}
}.let(::add)
}
BlockchainSdkError.Cardano.InsufficientRemainingBalance,
BlockchainSdkError.Cardano.InsufficientSendingAdaAmount,
-> {
val dustValue = currencyChecksRepository.getDustValue(
userWalletId = userWalletProvider().walletId,
network = sendingCurrency.network,
) ?: return
add(
SendNotification.Error.MinimumAmountError(
amount = dustValue.parseBigDecimal(sendingCurrency.decimals),
),
)
}
}
}
private fun MutableList<SendNotification>.addKoinosTransactionValidationError(error: BlockchainSdkError.Koinos) {
when (error) {
is BlockchainSdkError.Koinos.InsufficientBalance -> {
add(SendNotification.Koinos.InsufficientBalance)
}
is BlockchainSdkError.Koinos.InsufficientMana -> {
add(
SendNotification.Koinos.InsufficientRecoverableMana(
mana = error.manaBalance ?: BigDecimal.ZERO,
maxMana = error.maxMana ?: BigDecimal.ZERO,
),
)
}
is BlockchainSdkError.Koinos.ManaFeeExceedsBalance -> {
add(
SendNotification.Koinos.ManaExceedsBalance(
availableKoinForTransfer = error.availableKoinForTransfer,
onReduceClick = {
clickIntents.onAmountReduceClick(
reduceAmountTo = error.availableKoinForTransfer,
clazz = SendNotification.Koinos.InsufficientRecoverableMana::class.java,
)
},
),
)
}
else -> {}
}
}
private suspend fun MutableList<SendNotification>.addDustWarningNotification(
feeValue: BigDecimal,
sendingAmount: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val dustValue = currencyChecksRepository.getDustValue(
userWalletProvider().walletId,
cryptoCurrencyStatus.currency.network,
) ?: return
if (checkDustLimits(feeValue, sendingAmount, dustValue)) {
add(
SendNotification.Error.MinimumAmountError(
amount = dustValue.parseBigDecimal(cryptoCurrencyStatus.currency.decimals),
),
)
add(NotificationUM.Warning.TooHigh(diff))
}
}
}

View file

@ -1,6 +1,6 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
@ -25,24 +25,10 @@ internal class FeeNotificationFactory(
val feeState = state.getFeeState(stateRouterProvider().isEditState) ?: return@map persistentListOf()
buildList {
addFeeUnreachableNotification(
feeState.feeSelectorState,
feeError = (feeState.feeSelectorState as? FeeSelectorState.Error)?.error,
tokenName = state.cryptoCurrencyName,
onReload = clickIntents::feeReload,
)
}.toImmutableList()
}
private fun MutableList<SendNotification>.addFeeUnreachableNotification(feeSelectorState: FeeSelectorState) {
when (feeSelectorState) {
is FeeSelectorState.Error.TronAccountActivationError -> add(
SendNotification.Warning.TronAccountNotActivated(
feeSelectorState.tokenName,
),
)
is FeeSelectorState.Error.NetworkError -> add(
SendNotification.Warning.NetworkFeeUnreachable(clickIntents::feeReload),
)
else -> {
/* do nothing */
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.send.impl.presentation.state.fee
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -17,10 +18,9 @@ internal sealed class FeeSelectorState {
data object Loading : FeeSelectorState()
sealed class Error : FeeSelectorState() {
data object NetworkError : Error()
data class TronAccountActivationError(val tokenName: String) : Error()
}
data class Error(
val error: GetFeeError?,
) : FeeSelectorState()
}
enum class FeeType {

View file

@ -4,12 +4,13 @@ import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.extensions.isZero
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
@ -102,13 +103,13 @@ internal class FeeStateFactory(
)
}
fun onFeeOnErrorState(feeError: FeeSelectorState.Error): SendUiState {
fun onFeeOnErrorState(feeError: GetFeeError?): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
return state.copyWrapped(
isEditState = isEditState,
feeState = state.getFeeState(isEditState)?.copy(
feeSelectorState = feeError,
feeSelectorState = FeeSelectorState.Error(feeError),
),
sendState = state.sendState?.copy(
isPrimaryButtonEnabled = false,
@ -152,7 +153,7 @@ internal class FeeStateFactory(
)
}
fun getFeeNotificationState(notifications: ImmutableList<SendNotification>): SendUiState {
fun getFeeNotificationState(notifications: ImmutableList<NotificationUM>): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val feeState = state.getFeeState(isEditState) ?: return state
@ -167,7 +168,7 @@ internal class FeeStateFactory(
private fun isPrimaryButtonEnabled(
feeState: SendStates.FeeState,
notifications: ImmutableList<SendNotification>,
notifications: ImmutableList<NotificationUM>,
): Boolean {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return false
val customValue = feeSelectorState.customValues.firstOrNull()
@ -178,7 +179,7 @@ internal class FeeStateFactory(
} else {
false
}
val noErrors = notifications.none { it is SendNotification.Error }
val noErrors = notifications.none { it is NotificationUM.Error }
return noErrors && (isNotEmptyCustom || isNotCustom)
}

View file

@ -103,6 +103,6 @@ internal object FeeStatePreviewData {
)
val errorFeeState = feeState.copy(
feeSelectorState = FeeSelectorState.Error.NetworkError,
feeSelectorState = FeeSelectorState.Error(null),
)
}

View file

@ -1,9 +1,8 @@
package com.tangem.features.send.impl.presentation.state.previewdata
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import java.math.BigDecimal
@ -24,11 +23,12 @@ internal object SendClickIntentsStub : SendClickIntents {
override fun onFailedTxEmailClick(errorMessage: String) {}
override fun onTokenDetailsClick(userWalletId: UserWalletId, currency: CryptoCurrency) {}
override fun onTokenDetailsClick(currency: CryptoCurrency) {}
override fun onAmountValueChange(value: String) {}
override fun onCurrencyChangeClick(isFiat: Boolean) {}
override fun onAmountNext() {}
override fun onMaxValueClick() {}
@ -61,13 +61,13 @@ internal object SendClickIntentsStub : SendClickIntents {
override fun onShareClick() {}
override fun onAmountReduceClick(
reduceAmountBy: BigDecimal?,
reduceAmountByDiff: BigDecimal?,
reduceAmountTo: BigDecimal?,
clazz: Class<out SendNotification>,
) {
}
override fun onAmountReduceByClick(
reduceAmountBy: BigDecimal,
reduceAmountByDiff: BigDecimal,
notification: Class<out NotificationUM>,
) {}
override fun onNotificationCancel(clazz: Class<out SendNotification>) {}
override fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class<out NotificationUM>) {}
override fun onNotificationCancel(clazz: Class<out NotificationUM>) {}
}

View file

@ -5,19 +5,19 @@ import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendAlertState
import com.tangem.features.send.impl.presentation.state.SendEvent
@Composable
internal fun SendEventEffect(event: StateEvent<SendEvent>, snackbarHostState: SnackbarHostState) {
val resources = LocalContext.current.resources
var alertConfig by remember { mutableStateOf<SendAlertState?>(value = null) }
var alertConfig by remember { mutableStateOf<AlertUM?>(value = null) }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(key1 = alertConfig) {
@ -44,7 +44,7 @@ internal fun SendEventEffect(event: StateEvent<SendEvent>, snackbarHostState: Sn
}
@Composable
internal fun SendAlert(state: SendAlertState, onDismiss: () -> Unit) {
internal fun SendAlert(state: AlertUM, onDismiss: () -> Unit) {
val confirmButton: DialogButtonUM
val dismissButton: DialogButtonUM?

View file

@ -5,14 +5,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.ui.Modifier
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendNotification
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.notifications(
notifications: ImmutableList<SendNotification>,
notifications: ImmutableList<NotificationUM>,
modifier: Modifier = Modifier,
hasPaddingAbove: Boolean = false,
isClickDisabled: Boolean = false,
@ -33,17 +33,18 @@ internal fun LazyListScope.notifications(
.padding(top = topPadding)
.animateItemPlacement(),
containerColor = when (item) {
is SendNotification.Error.ExceedsBalance,
is SendNotification.Warning.NetworkFeeUnreachable,
is SendNotification.Warning.HighFeeError,
is NotificationUM.Error.TokenExceedsBalance,
is NotificationUM.Warning.NetworkFeeUnreachable,
is NotificationUM.Warning.HighFeeError,
-> TangemTheme.colors.background.action
else -> TangemTheme.colors.button.disabled
},
iconTint = when (item) {
is SendNotification.Error.ExceedsBalance,
is SendNotification.Warning,
is NotificationUM.Error.TokenExceedsBalance,
is NotificationUM.Warning,
-> null
is SendNotification.Error -> TangemTheme.colors.icon.warning
is NotificationUM.Error -> TangemTheme.colors.icon.warning
is NotificationUM.Info -> null
},
isEnabled = !isClickDisabled,
)

View file

@ -1,10 +1,9 @@
package com.tangem.features.send.impl.presentation.viewmodel
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import java.math.BigDecimal
@ -25,7 +24,7 @@ internal interface SendClickIntents : AmountScreenClickIntents {
fun onFailedTxEmailClick(errorMessage: String)
fun onTokenDetailsClick(userWalletId: UserWalletId, currency: CryptoCurrency)
fun onTokenDetailsClick(currency: CryptoCurrency)
// region Recipient
fun onRecipientAddressValueChange(value: String, type: EnterAddressSource? = null)
@ -58,13 +57,14 @@ internal interface SendClickIntents : AmountScreenClickIntents {
fun onShareClick()
fun onAmountReduceClick(
reduceAmountBy: BigDecimal? = null,
reduceAmountByDiff: BigDecimal? = reduceAmountBy,
reduceAmountTo: BigDecimal? = null,
clazz: Class<out SendNotification>,
fun onAmountReduceByClick(
reduceAmountBy: BigDecimal,
reduceAmountByDiff: BigDecimal,
notification: Class<out NotificationUM>,
)
fun onNotificationCancel(clazz: Class<out SendNotification>)
fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class<out NotificationUM>)
fun onNotificationCancel(clazz: Class<out NotificationUM>)
// endregion
}

View file

@ -6,29 +6,33 @@ import androidx.lifecycle.*
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.bundle.unbundle
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.utils.parseBigDecimal
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.feedback.GetCardInfoUseCase
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.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.ValidateAddressError
import com.tangem.domain.transaction.usecase.*
@ -47,7 +51,6 @@ import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.analytics.SendScreenSource
import com.tangem.features.send.impl.presentation.analytics.utils.SendScreenAnalyticSender
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.errors.FeeErrorStateMapper
import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.amount.AmountStateFactory
import com.tangem.features.send.impl.presentation.state.confirm.SendNotificationFactory
@ -56,6 +59,7 @@ import com.tangem.features.send.impl.presentation.state.recipient.RecipientSendF
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import com.tangem.utils.extensions.stripZeroPlainString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@ -83,7 +87,6 @@ internal class SendViewModel @Inject constructor(
private val sendTransactionUseCase: SendTransactionUseCase,
private val createTransactionUseCase: CreateTransactionUseCase,
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
private val reduxStateHolder: ReduxStateHolder,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
@ -97,9 +100,12 @@ internal class SendViewModel @Inject constructor(
private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase,
private val isUtxoConsolidationAvailableUseCase: IsUtxoConsolidationAvailableUseCase,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
@DelayedWork private val coroutineScope: CoroutineScope,
validateTransactionUseCase: ValidateTransactionUseCase,
currencyChecksRepository: CurrencyChecksRepository,
getCurrencyCheckUseCase: GetCurrencyCheckUseCase,
isFeeApproximateUseCase: IsFeeApproximateUseCase,
getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
savedStateHandle: SavedStateHandle,
@ -124,8 +130,6 @@ internal class SendViewModel @Inject constructor(
var stateRouter: StateRouter by Delegates.notNull()
private set
private val feeErrorHandler = FeeErrorStateMapper()
private val stateFactory = SendStateFactory(
clickIntents = this,
stateRouterProvider = Provider { stateRouter },
@ -174,18 +178,18 @@ internal class SendViewModel @Inject constructor(
)
private val sendNotificationFactory = SendNotificationFactory(
analyticsEventHandler = analyticsEventHandler,
validateTransactionUseCase = validateTransactionUseCase,
getCurrencyCheckUseCase = getCurrencyCheckUseCase,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
feeCryptoCurrencyStatusProvider = Provider { feeCryptoCurrencyStatus },
currentStateProvider = Provider { uiState.value },
userWalletProvider = Provider { userWallet },
stateRouterProvider = Provider { stateRouter },
isSubtractAvailableProvider = Provider { isAmountSubtractAvailable },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
currencyChecksRepository = currencyChecksRepository,
clickIntents = this,
analyticsEventHandler = analyticsEventHandler,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
validateTransactionUseCase = validateTransactionUseCase,
userWalletId = userWalletId,
)
private val sendScreenAnalyticSender by lazy(LazyThreadSafetyMode.NONE) {
@ -524,21 +528,34 @@ internal class SendViewModel @Inject constructor(
} else {
null
}
reduxStateHolder.dispatch(
LegacyAction.SendEmailTransactionFailed(
cryptoCurrency = cryptoCurrency,
userWalletId = userWalletId,
amount = receivingAmount,
fee = feeValue,
destinationAddress = recipient,
val amount = receivingAmount?.convertToSdkAmount(cryptoCurrency)
saveBlockchainErrorUseCase(
error = BlockchainErrorInfo(
errorMessage = errorMessage,
scanResponse = userWallet.scanResponse,
blockchainId = cryptoCurrency.network.id.value,
derivationPath = cryptoCurrency.network.derivationPath.value,
destinationAddress = recipient.orEmpty(),
tokenSymbol = if (amount?.type is AmountType.Token) {
amount.currencySymbol
} else {
""
},
amount = amount?.value?.stripZeroPlainString() ?: "unknown",
fee = feeValue?.convertToSdkAmount(cryptoCurrency)
?.value?.stripZeroPlainString() ?: "unknown",
),
)
val cardInfo = getCardInfoUseCase(userWallet.scanResponse).getOrNull() ?: return
viewModelScope.launch {
sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo))
}
}
override fun onTokenDetailsClick(userWalletId: UserWalletId, currency: CryptoCurrency) =
innerRouter.openTokenDetails(userWalletId, currency)
override fun onTokenDetailsClick(currency: CryptoCurrency) = innerRouter.openTokenDetails(userWalletId, currency)
private fun onFeeNext(): Boolean {
val feeState = uiState.value.getFeeState(stateRouter.isEditState)
@ -730,7 +747,7 @@ internal class SendViewModel @Inject constructor(
private fun onFeeLoadFailed(isShowStatus: Boolean, loadFeeError: GetFeeError?) {
if (isShowStatus) {
uiState.value = feeStateFactory.onFeeOnErrorState(
feeErrorHandler.getFeeError(loadFeeError, cryptoCurrency.name),
loadFeeError,
)
}
}
@ -814,26 +831,27 @@ internal class SendViewModel @Inject constructor(
analyticsEventHandler.send(SendAnalyticEvents.ShareButtonClicked)
}
override fun onAmountReduceClick(
reduceAmountBy: BigDecimal?,
reduceAmountByDiff: BigDecimal?,
reduceAmountTo: BigDecimal?,
clazz: Class<out SendNotification>,
) {
uiState.value = when {
reduceAmountBy != null && reduceAmountByDiff != null -> amountStateFactory.getOnAmountReduceByState(
reduceAmountBy = reduceAmountBy,
reduceAmountByDiff = reduceAmountByDiff,
)
reduceAmountTo != null -> amountStateFactory.getOnAmountReduceToState(reduceAmountTo)
else -> return
}
uiState.value = sendNotificationFactory.dismissNotificationState(clazz)
override fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class<out NotificationUM>) {
uiState.value = amountStateFactory.getOnAmountReduceToState(reduceAmountTo)
uiState.value = sendNotificationFactory.dismissNotificationState(notification)
updateNotifications()
}
override fun onNotificationCancel(clazz: Class<out SendNotification>) {
override fun onAmountReduceByClick(
reduceAmountBy: BigDecimal,
reduceAmountByDiff: BigDecimal,
notification: Class<out NotificationUM>,
) {
uiState.value = amountStateFactory.getOnAmountReduceByState(
reduceAmountBy = reduceAmountBy,
reduceAmountByDiff = reduceAmountByDiff,
)
uiState.value = sendNotificationFactory.dismissNotificationState(notification)
updateNotifications()
}
override fun onNotificationCancel(clazz: Class<out NotificationUM>) {
uiState.value = sendNotificationFactory.dismissNotificationState(clazz = clazz, isIgnored = true)
}
@ -972,7 +990,7 @@ internal class SendViewModel @Inject constructor(
private fun onCheckFeeUpdate() {
val sendState = uiState.value.sendState ?: return
val isSuccess = sendState.isSuccess
val noErrorNotifications = sendState.notifications.none { it is SendNotification.Error }
val noErrorNotifications = sendState.notifications.none { it is NotificationUM.Error }
if (!isSuccess && noErrorNotifications) {
viewModelScope.launch {

View file

@ -1,6 +1,6 @@
package com.tangem.features.staking.impl.presentation.state.events
import com.tangem.common.ui.alerts.SendTransactionAlertConverter
import com.tangem.common.ui.alerts.TransactionErrorAlertConverter
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.features.staking.impl.presentation.state.StakingStateController
@ -22,7 +22,7 @@ internal class StakingEventFactory(
fun createSendTransactionErrorAlert(error: SendTransactionError?) {
val alert = error?.let {
SendTransactionAlertConverter(
TransactionErrorAlertConverter(
popBackStack = popBackStack,
onFailedTxEmailClick = onFailedTxEmailClick,
).convert(error)

View file

@ -16,7 +16,7 @@ import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionSt
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.SendMultipleTransactionUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.staking.impl.analytics.StakingAnalyticsEvents
@ -40,7 +40,7 @@ internal class StakingTransactionSender @AssistedInject constructor(
private val stakingBalanceUpdater: StakingBalanceUpdater.Factory,
private val getStakingTransactionUseCase: GetStakingTransactionUseCase,
private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase,
private val sendMultipleTransactionUseCase: SendMultipleTransactionUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val submitHashUseCase: SubmitHashUseCase,
private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase,
@ -210,7 +210,7 @@ internal class StakingTransactionSender @AssistedInject constructor(
onSendSuccess: (txUrl: String) -> Unit,
onSendError: (SendTransactionError?) -> Unit,
) {
sendMultipleTransactionUseCase(
sendTransactionUseCase(
txsData = fullTransactionsData.map { it.tangemTransaction },
userWallet = userWallet,
network = cryptoCurrencyStatus.currency.network,

View file

@ -136,13 +136,11 @@ internal class AddStakingNotificationsTransformer(
val cryptoCurrency = cryptoCurrencyStatus.currency
val network = cryptoCurrency.network
if (feeError != null) {
addFeeUnreachableNotification(
feeError = feeError,
tokenName = cryptoCurrencyStatusProvider().currency.name,
onReload = onReload,
)
}
addFeeUnreachableNotification(
feeError = feeError,
tokenName = cryptoCurrencyStatusProvider().currency.name,
onReload = onReload,
)
addStakeExceedBalanceNotification(
feeAmount = feeValue,
sendingAmount = sendingAmount,

View file

@ -8,15 +8,17 @@ import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.utils.extensions.orZero
import com.tangem.features.staking.impl.presentation.state.utils.getRewardSchedule
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
internal class SetConfirmationStateLoadingTransformer(
private val yield: Yield,
private val appCurrency: AppCurrency,
private val cryptoCurrency: CryptoCurrency,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
@ -38,7 +40,7 @@ internal class SetConfirmationStateLoadingTransformer(
availableValidators = filteredValidators,
),
notifications = persistentListOf(),
footerText = getFooter(prevState, chosenValidator),
footerText = getFooter(prevState),
transactionDoneState = TransactionDoneState.Empty,
pendingAction = possibleConfirmationState?.pendingAction,
pendingActions = possibleConfirmationState?.pendingActions,
@ -49,30 +51,25 @@ internal class SetConfirmationStateLoadingTransformer(
)
}
private fun getFooter(state: StakingUiState, validator: Yield.Validator): TextReference {
private fun getFooter(state: StakingUiState): TextReference {
val amountState = state.amountState as? AmountState.Data
val isEnterAction = state.actionType == StakingActionCommonType.ENTER
val apr = validator.apr.orZero()
val amountDecimal = amountState?.amountTextField?.fiatAmount?.value
val potentialReward = amountDecimal?.multiply(apr)
val amountValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = amountDecimal,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
val potentialRewardValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = potentialReward,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
withApproximateSign = true,
val rewardSchedule = getRewardSchedule(
yield.metadata.rewardSchedule,
cryptoCurrency.network.id.value,
)
return if (isEnterAction && amountDecimal != null && potentialReward != null) {
return if (isEnterAction && amountDecimal != null && rewardSchedule != null) {
resourceReference(
id = R.string.staking_summary_description_text,
formatArgs = wrappedList(amountValue, potentialRewardValue),
formatArgs = wrappedList(amountValue, rewardSchedule),
)
} else {
TextReference.EMPTY

View file

@ -9,7 +9,6 @@ import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.core.serialization.SerializedBigDecimal
import com.tangem.domain.staking.model.stakekit.BalanceItem
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -19,6 +18,7 @@ import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType
import com.tangem.features.staking.impl.presentation.state.converters.RewardsValidatorStateConverter
import com.tangem.features.staking.impl.presentation.state.converters.YieldBalancesConverter
import com.tangem.features.staking.impl.presentation.state.utils.getRewardSchedule
import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents
import com.tangem.lib.crypto.BlockchainUtils.isPolkadot
import com.tangem.utils.Provider
@ -102,20 +102,18 @@ internal class SetInitialDataStateTransformer(
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
return listOfNotNull(
createAnnualPercentageRateItem(yield.validators),
createAnnualPercentageRateItem(),
createAvailableItem(cryptoCurrencyStatus),
createUnbondingPeriodItem(yield.metadata.cooldownPeriod?.days),
createMinimumRequirementItem(
cryptoCurrencyStatus = cryptoCurrencyStatus,
minimumCryptoAmount = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum,
),
createRewardClaimingItem(yield.metadata.rewardClaiming),
createWarmupPeriodItem(yield.metadata.warmupPeriod.days),
createRewardScheduleItem(yield.metadata.rewardSchedule),
createUnbondingPeriodItem(),
createMinimumRequirementItem(cryptoCurrencyStatus),
createRewardClaimingItem(),
createWarmupPeriodItem(),
createRewardScheduleItem(),
).toPersistentList()
}
private fun createAnnualPercentageRateItem(validators: List<Yield.Validator>): RoundedListWithDividersItemData {
private fun createAnnualPercentageRateItem(): RoundedListWithDividersItemData {
val validators = yield.validators
return RoundedListWithDividersItemData(
id = R.string.staking_details_annual_percentage_rate,
startText = TextReference.Res(R.string.staking_details_annual_percentage_rate),
@ -140,8 +138,8 @@ internal class SetInitialDataStateTransformer(
)
}
private fun createUnbondingPeriodItem(cooldownPeriodDays: Int?): RoundedListWithDividersItemData? {
cooldownPeriodDays ?: return null
private fun createUnbondingPeriodItem(): RoundedListWithDividersItemData? {
val cooldownPeriodDays = yield.metadata.cooldownPeriod?.days ?: return null
return RoundedListWithDividersItemData(
id = R.string.staking_details_unbonding_period,
startText = TextReference.Res(R.string.staking_details_unbonding_period),
@ -156,9 +154,8 @@ internal class SetInitialDataStateTransformer(
private fun createMinimumRequirementItem(
cryptoCurrencyStatus: CryptoCurrencyStatus,
minimumCryptoAmount: SerializedBigDecimal?,
): RoundedListWithDividersItemData? {
if (minimumCryptoAmount == null) return null
val minimumCryptoAmount = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum ?: return null
if (!isPolkadot(cryptoCurrencyStatus.currency.network.id.value)) return null
val formattedAmount = BigDecimalFormatter.formatCryptoAmount(
@ -174,9 +171,8 @@ internal class SetInitialDataStateTransformer(
)
}
private fun createRewardClaimingItem(
rewardClaiming: Yield.Metadata.RewardClaiming,
): RoundedListWithDividersItemData? {
private fun createRewardClaimingItem(): RoundedListWithDividersItemData? {
val rewardClaiming = yield.metadata.rewardClaiming
val endTextId = rewardClaimingResources[rewardClaiming] ?: return null
return RoundedListWithDividersItemData(
@ -187,7 +183,8 @@ internal class SetInitialDataStateTransformer(
)
}
private fun createWarmupPeriodItem(warmupPeriodDays: Int): RoundedListWithDividersItemData? {
private fun createWarmupPeriodItem(): RoundedListWithDividersItemData? {
val warmupPeriodDays = yield.metadata.warmupPeriod.days
if (warmupPeriodDays == 0) return null
return RoundedListWithDividersItemData(
@ -202,10 +199,8 @@ internal class SetInitialDataStateTransformer(
)
}
private fun createRewardScheduleItem(
rewardSchedule: Yield.Metadata.RewardSchedule,
): RoundedListWithDividersItemData? {
val endTextReference = getRewardScheduleText(rewardSchedule) ?: return null
private fun createRewardScheduleItem(): RoundedListWithDividersItemData? {
val endTextReference = getRewardScheduleText() ?: return null
return RoundedListWithDividersItemData(
id = R.string.staking_details_reward_schedule,
@ -257,17 +252,16 @@ internal class SetInitialDataStateTransformer(
return resourceReference(R.string.common_range, wrappedList(formattedMinApr, formattedMaxApr))
}
private fun getRewardScheduleText(rewardSchedule: Yield.Metadata.RewardSchedule): TextReference? {
return when (rewardSchedule) {
Yield.Metadata.RewardSchedule.BLOCK -> resourceReference(R.string.staking_reward_schedule_block)
Yield.Metadata.RewardSchedule.WEEK -> resourceReference(R.string.staking_reward_schedule_week)
Yield.Metadata.RewardSchedule.HOUR -> resourceReference(R.string.staking_reward_schedule_hour)
Yield.Metadata.RewardSchedule.DAY -> resourceReference(R.string.staking_reward_schedule_each_day)
Yield.Metadata.RewardSchedule.MONTH -> resourceReference(R.string.staking_reward_schedule_month)
Yield.Metadata.RewardSchedule.ERA -> resourceReference(R.string.staking_reward_schedule_era)
Yield.Metadata.RewardSchedule.EPOCH -> resourceReference(R.string.staking_reward_schedule_epoch)
Yield.Metadata.RewardSchedule.UNKNOWN -> null
}
private fun getRewardScheduleText(): TextReference? {
val rewardSchedule = getRewardSchedule(
yield.metadata.rewardSchedule,
cryptoCurrencyStatusProvider().currency.network.id.value,
) ?: return null
return resourceReference(
R.string.staking_reward_schedule_each,
wrappedList(rewardSchedule),
)
}
private companion object {

View file

@ -0,0 +1,52 @@
package com.tangem.features.staking.impl.presentation.state.utils
import com.tangem.core.ui.extensions.*
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.COSMOS_SCHEDULE
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.SOLANA_SCHEDULE
import com.tangem.lib.crypto.BlockchainUtils.isCosmos
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.StringsSigns.MINUS
import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE
private data object StakingRewardSchedule {
val COSMOS_SCHEDULE = 5 to 12
val SOLANA_SCHEDULE = 2 to 3
}
internal fun getRewardSchedule(schedule: Yield.Metadata.RewardSchedule, networkId: String): TextReference? =
when (schedule) {
Yield.Metadata.RewardSchedule.BLOCK,
Yield.Metadata.RewardSchedule.EPOCH,
Yield.Metadata.RewardSchedule.ERA,
-> getCustomRewardSchedule(networkId)
Yield.Metadata.RewardSchedule.WEEK -> resourceReference(R.string.common_week)
Yield.Metadata.RewardSchedule.HOUR -> resourceReference(R.string.common_hour)
Yield.Metadata.RewardSchedule.DAY -> pluralReference(R.plurals.common_days_no_param, count = 1)
Yield.Metadata.RewardSchedule.MONTH -> resourceReference(R.string.common_month)
Yield.Metadata.RewardSchedule.UNKNOWN -> null
}
private fun getCustomRewardSchedule(networkId: String): TextReference? {
return when {
isSolana(networkId) -> {
combinedReference(
stringReference("${SOLANA_SCHEDULE.first}$MINUS${SOLANA_SCHEDULE.second}$NON_BREAKING_SPACE"),
pluralReference(
id = R.plurals.common_days_no_param,
count = SOLANA_SCHEDULE.second,
),
)
}
isCosmos(networkId) -> {
combinedReference(
stringReference("${COSMOS_SCHEDULE.first}$MINUS${COSMOS_SCHEDULE.second}$NON_BREAKING_SPACE"),
resourceReference(R.string.common_second_no_param),
)
}
isTron(networkId) -> pluralReference(R.plurals.common_days_no_param, count = 1)
else -> null
}
}

View file

@ -50,29 +50,17 @@ internal fun StakingEventEffect(event: StateEvent<StakingEvent>, snackbarHostSta
@Composable
internal fun StakingAlert(state: AlertUM, onDismiss: () -> Unit) {
val confirmButton: DialogButtonUM
val dismissButton: DialogButtonUM?
val onActionClick = state.onConfirmClick
if (onActionClick != null) {
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = {
onActionClick()
onDismiss()
},
)
dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
onClick = onDismiss,
)
} else {
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = onDismiss,
)
dismissButton = null
}
val confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = {
state.onConfirmClick()
onDismiss()
},
)
val dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
onClick = onDismiss,
)
BasicDialog(
message = state.message.resolveReference(),

View file

@ -18,9 +18,9 @@ import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.feedback.FeedbackManager
import com.tangem.domain.feedback.GetCardInfoUseCase
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.staking.InvalidatePendingTransactionsUseCase
@ -92,7 +92,6 @@ internal class StakingViewModel @Inject constructor(
private val getAllowanceUseCase: GetAllowanceUseCase,
private val isApproveNeededUseCase: IsApproveNeededUseCase,
private val vibratorHapticManager: VibratorHapticManager,
private val feedbackManager: FeedbackManager,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
@ -105,6 +104,7 @@ internal class StakingViewModel @Inject constructor(
private val stakingFeeTransactionLoader: StakingFeeTransactionLoader.Factory,
private val stakingBalanceUpdater: StakingBalanceUpdater.Factory,
private val analyticsEventHandler: AnalyticsEventHandler,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
@DelayedWork private val coroutineScope: CoroutineScope,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver, StakingClickIntents {
@ -222,7 +222,13 @@ internal class StakingViewModel @Inject constructor(
when {
isInitState() -> {
stateController.update(SetConfirmationStateResetAssentTransformer)
stateController.update(SetConfirmationStateLoadingTransformer(yield, appCurrency))
stateController.update(
SetConfirmationStateLoadingTransformer(
yield = yield,
appCurrency = appCurrency,
cryptoCurrency = cryptoCurrencyStatus.currency,
),
)
if (balanceState != null) {
stateController.update(SetPossiblePendingTransactionTransformer(balanceState, cryptoCurrencyStatus))
}
@ -263,7 +269,13 @@ internal class StakingViewModel @Inject constructor(
}
override fun getFee(pendingAction: PendingAction?, pendingActions: ImmutableList<PendingAction>?) {
stateController.update(SetConfirmationStateLoadingTransformer(yield, appCurrency))
stateController.update(
SetConfirmationStateLoadingTransformer(
yield = yield,
appCurrency = appCurrency,
cryptoCurrency = cryptoCurrencyStatus.currency,
),
)
viewModelScope.launch {
feeLoader.getFee(
pendingAction = pendingAction,
@ -766,7 +778,7 @@ internal class StakingViewModel @Inject constructor(
unsignedTransactions = transactionsInProgress.map { it.unsignedTransaction },
)
feedbackManager.sendEmail(email)
sendFeedbackEmailUseCase(email)
}
}

View file

@ -20,14 +20,13 @@ import com.tangem.datasource.api.express.models.response.SwapPair
import com.tangem.datasource.api.express.models.response.SwapPairsWithProviders
import com.tangem.datasource.api.express.models.response.TxDetails
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.converters.*
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.ExpressException
import com.tangem.feature.swap.domain.models.createFromAmountWithOffset
import com.tangem.feature.swap.domain.models.domain.*
@ -196,7 +195,7 @@ internal class DefaultSwapRepository @Inject constructor(
toDecimals: Int,
providerId: String,
rateType: RateType,
): Either<DataError, QuoteModel> {
): Either<ExpressDataError, QuoteModel> {
return withContext(coroutineDispatcher.io) {
try {
val response = tangemExpressApi.getExchangeQuote(
@ -234,7 +233,7 @@ internal class DefaultSwapRepository @Inject constructor(
toAddress: String,
refundAddress: String?, // for cex only
refundExtraId: String?, // for cex only
): Either<DataError, SwapDataModel> {
): Either<ExpressDataError, SwapDataModel> {
return withContext(coroutineDispatcher.io) {
try {
val requestId = UUID.randomUUID().toString()
@ -256,12 +255,12 @@ internal class DefaultSwapRepository @Inject constructor(
).getOrThrow()
if (dataSignatureVerifier.verifySignature(response.signature, response.txDetailsJson)) {
val txDetails = parseTxDetails(response.txDetailsJson)
?: return@withContext DataError.UnknownError.left()
?: return@withContext ExpressDataError.UnknownError.left()
if (txDetails.requestId != requestId) {
return@withContext DataError.InvalidRequestIdError().left()
return@withContext ExpressDataError.InvalidRequestIdError().left()
}
if (!toAddress.equals(txDetails.payoutAddress, ignoreCase = true)) {
return@withContext DataError.InvalidPayoutAddressError().left()
return@withContext ExpressDataError.InvalidPayoutAddressError().left()
}
expressDataConverter.convert(
ExchangeDataResponseWithTxDetails(
@ -270,7 +269,7 @@ internal class DefaultSwapRepository @Inject constructor(
),
).right()
} else {
DataError.InvalidSignatureError().left()
ExpressDataError.InvalidSignatureError().left()
}
} catch (ex: Exception) {
getDataError(ex).left()
@ -285,7 +284,7 @@ internal class DefaultSwapRepository @Inject constructor(
payInAddress: String,
txHash: String,
payInExtraId: String?,
): Either<DataError, Unit> = withContext(coroutineDispatcher.io) {
): Either<ExpressDataError, Unit> = withContext(coroutineDispatcher.io) {
try {
tangemExpressApi.exchangeSent(
ExchangeSentRequestBody(
@ -390,21 +389,20 @@ internal class DefaultSwapRepository @Inject constructor(
cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = null,
derivationStyleProvider = requireNotNull(
scanResponse = requireNotNull(
userWalletsListManager
.selectedUserWalletSync
?.scanResponse
?.derivationStyleProvider,
?.scanResponse,
),
),
)
}
private fun getDataError(ex: Exception): DataError {
private fun getDataError(ex: Exception): ExpressDataError {
return if (ex is ApiResponseError.HttpException) {
errorsDataConverter.convert(ex.errorBody ?: "")
} else {
DataError.UnknownError
ExpressDataError.UnknownError
}
}
}

View file

@ -3,76 +3,77 @@ package com.tangem.feature.swap.converters
import com.squareup.moshi.JsonAdapter
import com.tangem.datasource.api.express.models.response.ExpressError
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.createFromAmountWithOffset
import com.tangem.utils.converter.Converter
internal class ErrorsDataConverter(
private val jsonAdapter: JsonAdapter<ExpressErrorResponse>,
) : Converter<String, DataError> {
) : Converter<String, ExpressDataError> {
@Suppress("MagicNumber", "CyclomaticComplexMethod")
override fun convert(value: String): DataError {
override fun convert(value: String): ExpressDataError {
try {
val error = jsonAdapter.fromJson(value)?.error ?: return DataError.UnknownError
val error = jsonAdapter.fromJson(value)?.error ?: return ExpressDataError.UnknownError
return when (error.code) {
2010 -> DataError.BadRequest(code = error.code)
2200 -> DataError.SwapsAreUnavailableNowError(code = error.code)
2210 -> DataError.ExchangeProviderNotFoundError(code = error.code)
2220 -> DataError.ExchangeProviderNotActiveError(code = error.code)
2230 -> DataError.ExchangeProviderNotAvailableError(code = error.code)
2231 -> DataError.ExchangeProviderProviderInternalError(code = error.code)
2240 -> DataError.ExchangeNotPossibleError(code = error.code)
2010 -> ExpressDataError.BadRequest(code = error.code)
2200 -> ExpressDataError.SwapsAreUnavailableNowError(code = error.code)
2210 -> ExpressDataError.ExchangeProviderNotFoundError(code = error.code)
2220 -> ExpressDataError.ExchangeProviderNotActiveError(code = error.code)
2230 -> ExpressDataError.ExchangeProviderNotAvailableError(code = error.code)
2231 -> ExpressDataError.ExchangeProviderProviderInternalError(code = error.code)
2240 -> ExpressDataError.ExchangeNotPossibleError(code = error.code)
2250 -> tryParseExchangeTooSmallAmountError(error = error)
2251 -> tryParseExchangeTooBigAmountError(error = error)
2260 -> tryParseExchangeNotEnoughAllowanceError(error = error)
2270 -> DataError.ExchangeNotEnoughBalanceError(code = error.code)
2280 -> DataError.ExchangeInvalidAddressError(code = error.code)
2270 -> ExpressDataError.ExchangeNotEnoughBalanceError(code = error.code)
2280 -> ExpressDataError.ExchangeInvalidAddressError(code = error.code)
2290 -> tryParseExchangeInvalidFromDecimalsError(error = error)
else -> DataError.UnknownErrorWithCode(error.code)
else -> ExpressDataError.UnknownErrorWithCode(error.code)
}
} catch (e: Exception) {
return DataError.UnknownError
return ExpressDataError.UnknownError
}
}
private fun tryParseExchangeTooSmallAmountError(error: ExpressError): DataError {
val minAmount = error.value?.minAmount ?: return DataError.UnknownErrorWithCode(error.code)
val decimals = error.value?.decimals ?: return DataError.UnknownErrorWithCode(error.code)
private fun tryParseExchangeTooSmallAmountError(error: ExpressError): ExpressDataError {
val minAmount = error.value?.minAmount ?: return ExpressDataError.UnknownErrorWithCode(error.code)
val decimals = error.value?.decimals ?: return ExpressDataError.UnknownErrorWithCode(error.code)
return DataError.ExchangeTooSmallAmountError(
return ExpressDataError.ExchangeTooSmallAmountError(
code = error.code,
amount = createFromAmountWithOffset(minAmount, decimals),
)
}
private fun tryParseExchangeTooBigAmountError(error: ExpressError): DataError {
val minAmount = error.value?.maxAmount ?: return DataError.UnknownErrorWithCode(error.code)
val decimals = error.value?.decimals ?: return DataError.UnknownErrorWithCode(error.code)
private fun tryParseExchangeTooBigAmountError(error: ExpressError): ExpressDataError {
val minAmount = error.value?.maxAmount ?: return ExpressDataError.UnknownErrorWithCode(error.code)
val decimals = error.value?.decimals ?: return ExpressDataError.UnknownErrorWithCode(error.code)
return DataError.ExchangeTooBigAmountError(
return ExpressDataError.ExchangeTooBigAmountError(
code = error.code,
amount = createFromAmountWithOffset(minAmount, decimals),
)
}
private fun tryParseExchangeNotEnoughAllowanceError(error: ExpressError): DataError {
val currentAllowance = error.value?.currentAllowance ?: return DataError.UnknownErrorWithCode(error.code)
private fun tryParseExchangeNotEnoughAllowanceError(error: ExpressError): ExpressDataError {
val currentAllowance = error.value?.currentAllowance ?: return ExpressDataError.UnknownErrorWithCode(error.code)
return DataError.ExchangeNotEnoughAllowanceError(
return ExpressDataError.ExchangeNotEnoughAllowanceError(
code = error.code,
currentAllowance = currentAllowance,
)
}
private fun tryParseExchangeInvalidFromDecimalsError(error: ExpressError): DataError {
val receivedFromDecimals = error.value?.receivedFromDecimals ?: return DataError.UnknownErrorWithCode(
private fun tryParseExchangeInvalidFromDecimalsError(error: ExpressError): ExpressDataError {
val receivedFromDecimals = error.value?.receivedFromDecimals ?: return ExpressDataError.UnknownErrorWithCode(
code = error.code,
)
val expressFromDecimals = error.value?.expressFromDecimals ?: return DataError.UnknownErrorWithCode(error.code)
val expressFromDecimals =
error.value?.expressFromDecimals ?: return ExpressDataError.UnknownErrorWithCode(error.code)
return DataError.ExchangeInvalidFromDecimalsError(
return ExpressDataError.ExchangeInvalidFromDecimalsError(
code = error.code,
receivedFromDecimals = receivedFromDecimals,
expressFromDecimals = expressFromDecimals,

View file

@ -3,7 +3,7 @@ package com.tangem.feature.swap.domain.api
import arrow.core.Either
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.*
import java.math.BigDecimal
@ -27,7 +27,7 @@ interface SwapRepository {
toDecimals: Int,
providerId: String,
rateType: RateType,
): Either<DataError, QuoteModel>
): Either<ExpressDataError, QuoteModel>
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
@ -66,7 +66,7 @@ interface SwapRepository {
toAddress: String,
refundAddress: String? = null, // for cex only
refundExtraId: String? = null, // for cex only
): Either<DataError, SwapDataModel>
): Either<ExpressDataError, SwapDataModel>
// TODO: Add target error handling, remove either ([REDACTED_JIRA])
@Suppress("LongParameterList")
@ -77,7 +77,7 @@ interface SwapRepository {
payInAddress: String,
txHash: String,
payInExtraId: String?,
): Either<DataError, Unit>
): Either<ExpressDataError, Unit>
fun getNativeTokenForNetwork(networkId: String): CryptoCurrency
}

View file

@ -12,6 +12,7 @@ android {
dependencies {
/** Domain */
implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction.models)
/** Core modules */
implementation(projects.core.utils)

View file

@ -2,49 +2,52 @@ package com.tangem.feature.swap.domain.models
import java.math.BigDecimal
sealed class DataError {
sealed class ExpressDataError {
abstract val code: Int
data class BadRequest(override val code: Int) : DataError()
data class BadRequest(override val code: Int) : ExpressDataError()
data class SwapsAreUnavailableNowError(override val code: Int) : DataError()
data class SwapsAreUnavailableNowError(override val code: Int) : ExpressDataError()
data class ExchangeProviderNotFoundError(override val code: Int) : DataError()
data class ExchangeProviderNotFoundError(override val code: Int) : ExpressDataError()
data class ExchangeProviderNotActiveError(override val code: Int) : DataError()
data class ExchangeProviderNotActiveError(override val code: Int) : ExpressDataError()
data class ExchangeProviderNotAvailableError(override val code: Int) : DataError()
data class ExchangeProviderNotAvailableError(override val code: Int) : ExpressDataError()
data class ExchangeProviderProviderInternalError(override val code: Int) : DataError()
data class ExchangeProviderProviderInternalError(override val code: Int) : ExpressDataError()
data class ExchangeNotPossibleError(override val code: Int) : DataError()
data class ExchangeNotPossibleError(override val code: Int) : ExpressDataError()
data class ExchangeTooSmallAmountError(override val code: Int, val amount: SwapAmount) : DataError()
data class ExchangeTooSmallAmountError(override val code: Int, val amount: SwapAmount) : ExpressDataError()
data class ExchangeTooBigAmountError(override val code: Int, val amount: SwapAmount) : DataError()
data class ExchangeTooBigAmountError(override val code: Int, val amount: SwapAmount) : ExpressDataError()
data class ExchangeNotEnoughAllowanceError(override val code: Int, val currentAllowance: BigDecimal) : DataError()
data class ExchangeNotEnoughAllowanceError(
override val code: Int,
val currentAllowance: BigDecimal,
) : ExpressDataError()
data class ExchangeNotEnoughBalanceError(override val code: Int) : DataError()
data class ExchangeNotEnoughBalanceError(override val code: Int) : ExpressDataError()
data class ExchangeInvalidAddressError(override val code: Int) : DataError()
data class ExchangeInvalidAddressError(override val code: Int) : ExpressDataError()
data class ExchangeInvalidFromDecimalsError(
override val code: Int,
val receivedFromDecimals: Int,
val expressFromDecimals: Int,
) : DataError()
) : ExpressDataError()
data class UnknownErrorWithCode(override val code: Int) : DataError()
data class UnknownErrorWithCode(override val code: Int) : ExpressDataError()
data class InvalidSignatureError(override val code: Int = 990) : DataError()
data class InvalidSignatureError(override val code: Int = 990) : ExpressDataError()
data class InvalidRequestIdError(override val code: Int = 991) : DataError()
data class InvalidRequestIdError(override val code: Int = 991) : ExpressDataError()
data class InvalidPayoutAddressError(override val code: Int = 992) : DataError()
data class InvalidPayoutAddressError(override val code: Int = 992) : ExpressDataError()
data object UnknownError : DataError() {
data object UnknownError : ExpressDataError() {
override val code: Int = -1
}
}

View file

@ -1,3 +1,3 @@
package com.tangem.feature.swap.domain.models
class ExpressException(val dataError: DataError) : Exception()
class ExpressException(val expressDataError: ExpressDataError) : Exception()

View file

@ -1,7 +1,7 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import java.math.BigDecimal
@ -34,7 +34,7 @@ sealed interface SwapState {
data class SwapError(
val fromTokenInfo: TokenSwapInfo,
val error: DataError,
val error: ExpressDataError,
val includeFeeInAmount: IncludeFeeInAmount,
) : SwapState
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.feature.swap.domain.models.ExpressDataError
import java.math.BigDecimal
sealed class SwapTransactionState {
@ -15,17 +16,13 @@ sealed class SwapTransactionState {
val timestamp: Long,
) : SwapTransactionState()
data object UserCancelled : SwapTransactionState()
data object BlockchainError : SwapTransactionState()
data object TangemSdkError : SwapTransactionState()
data object NetworkError : SwapTransactionState()
data object UnknownError : SwapTransactionState()
data class ExpressError(val dataError: DataError) : SwapTransactionState()
data object DemoMode : SwapTransactionState()
sealed class Error : SwapTransactionState() {
data class TransactionError(val error: SendTransactionError?) : Error()
data class ExpressError(val error: ExpressDataError) : Error()
data object UnknownError : Error()
}
}

View file

@ -18,6 +18,14 @@ data class TokensDataStateExpress(
}
}
fun TokensDataStateExpress.getGroupWithReverse(isReverseFromTo: Boolean): CurrenciesGroup {
return if (isReverseFromTo) {
this.fromGroup
} else {
this.toGroup
}
}
data class CurrenciesGroup(
val available: List<CryptoCurrencySwapInfo>,
val unavailable: List<CryptoCurrencySwapInfo>,

View file

@ -2,31 +2,34 @@ package com.tangem.feature.swap.domain
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
import com.tangem.feature.swap.domain.models.ui.getGroupWithReverse
import java.math.BigDecimal
internal class DefaultInitialToCurrencyResolver(
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val swapTransactionRepository: SwapTransactionRepository,
) : InitialToCurrencyResolver {
override suspend fun tryGetFromCache(
userWallet: UserWallet,
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): CryptoCurrencyStatus? {
val selectedId = getSelectedWalletSyncUseCase().getOrNull() ?: return null
val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(selectedId.walletId) ?: return null
val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null
return if (id != initialCryptoCurrency.id.value) {
state.toGroup.available.find { it.currencyStatus.currency.id.value == id }?.currencyStatus
val group = state.getGroupWithReverse(isReverseFromTo)
group.available.find { it.currencyStatus.currency.id.value == id }?.currencyStatus
} else {
null
}
}
override fun tryGetWithMaxAmount(state: TokensDataStateExpress): CryptoCurrencyStatus? {
return state.toGroup.available.maxByOrNull {
override fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): CryptoCurrencyStatus? {
val group = state.getGroupWithReverse(isReverseFromTo)
return group.available.maxByOrNull {
it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO
}?.currencyStatus
}

View file

@ -2,14 +2,17 @@ package com.tangem.feature.swap.domain
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
interface InitialToCurrencyResolver {
suspend fun tryGetFromCache(
userWallet: UserWallet,
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): CryptoCurrencyStatus?
fun tryGetWithMaxAmount(state: TokensDataStateExpress): CryptoCurrencyStatus?
fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): CryptoCurrencyStatus?
}

View file

@ -79,9 +79,10 @@ interface SwapInteractor {
*/
fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount
suspend fun selectInitialCurrencyToSwap(
suspend fun getInitialCurrencyToSwap(
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): CryptoCurrencyStatus?
fun getNativeToken(networkId: String): CryptoCurrency

View file

@ -22,7 +22,6 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionType
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.utils.convertToSdkAmount
@ -31,7 +30,7 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
@ -227,7 +226,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
),
).getOrElse {
Timber.e(it, "Failed to create approveTransaction")
return SwapTransactionState.UnknownError
return SwapTransactionState.Error.UnknownError
}
val result = sendTransactionUseCase(
@ -243,16 +242,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
timestamp = System.currentTimeMillis(),
)
},
ifLeft = {
when (it) {
SendTransactionError.UserCancelledError -> SwapTransactionState.UserCancelled
is SendTransactionError.BlockchainSdkError -> SwapTransactionState.BlockchainError
is SendTransactionError.TangemSdkError -> SwapTransactionState.TangemSdkError
is SendTransactionError.NetworkError -> SwapTransactionState.NetworkError
is SendTransactionError.DemoCardError -> SwapTransactionState.DemoMode
else -> SwapTransactionState.UnknownError
}
},
ifLeft = { SwapTransactionState.Error.TransactionError(it) },
)
}
@ -591,7 +581,6 @@ internal class SwapInteractorImpl @AssistedInject constructor(
""".trimIndent(),
)
val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return SwapTransactionState.UnknownError
val cardId = userWallet.scanResponse.card.cardId
if (isDemoCardUseCase(cardId)) return SwapTransactionState.DemoMode
@ -693,12 +682,12 @@ internal class SwapInteractorImpl @AssistedInject constructor(
hash = dataToSign,
).getOrElse {
Timber.e(it, "Failed to create swap dex tx data")
return SwapTransactionState.UnknownError
return SwapTransactionState.Error.UnknownError
}
val result = sendTransactionUseCase(
txData = txData,
userWallet = getUserWalletUseCase(userWalletId).getOrElse { return SwapTransactionState.UnknownError },
userWallet = userWallet,
network = currencyToSendStatus.currency.network,
)
return result.fold(
@ -738,7 +727,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
timestamp = System.currentTimeMillis(),
)
},
ifLeft = { handleSendTxError(it) },
ifLeft = { SwapTransactionState.Error.TransactionError(it) },
)
}
@ -773,14 +762,14 @@ internal class SwapInteractorImpl @AssistedInject constructor(
toAddress = currencyToGet.value.networkAddress?.defaultAddress?.value.orEmpty(),
refundAddress = currencyToSend.value.networkAddress?.defaultAddress?.value,
refundExtraId = null, // currently always null
).getOrElse { return SwapTransactionState.ExpressError(it) }
).getOrElse { return SwapTransactionState.Error.ExpressError(it) }
val exchangeDataCex =
exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.UnknownError
exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError
val cardId = userWallet.scanResponse.card.cardId
if (isDemoCardUseCase(cardId)) return SwapTransactionState.UnknownError
if (isDemoCardUseCase(cardId)) return SwapTransactionState.Error.UnknownError
val txData = createTransactionUseCase(
amount = amount.value.convertToSdkAmount(currencyToSend.currency),
@ -794,11 +783,11 @@ internal class SwapInteractorImpl @AssistedInject constructor(
network = currencyToSend.currency.network,
).getOrElse {
Timber.e(it, "Failed to create swap CEX tx data")
return SwapTransactionState.UnknownError
return SwapTransactionState.Error.UnknownError
}
if (txData.extras == null && exchangeDataCex.txExtraId != null) {
return SwapTransactionState.UnknownError
return SwapTransactionState.Error.UnknownError
}
val result = sendTransactionUseCase(
@ -809,9 +798,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val derivationPath = currencyToSend.currency.network.derivationPath.value
return result.fold(
ifLeft = {
handleSendTxError(it)
},
ifLeft = { SwapTransactionState.Error.TransactionError(it) },
ifRight = { txHash ->
repository.exchangeSent(
txId = exchangeDataCex.txId,
@ -856,18 +843,6 @@ internal class SwapInteractorImpl @AssistedInject constructor(
)
}
private fun handleSendTxError(txError: SendTransactionError?): SwapTransactionState {
return when (txError) {
SendTransactionError.UserCancelledError -> SwapTransactionState.UserCancelled
is SendTransactionError.BlockchainSdkError -> SwapTransactionState.BlockchainError
is SendTransactionError.TangemSdkError -> SwapTransactionState.TangemSdkError
is SendTransactionError.NetworkError -> SwapTransactionState.NetworkError
is SendTransactionError.DemoCardError -> SwapTransactionState.DemoMode
else -> SwapTransactionState.UnknownError
}
}
@Suppress("LongMethod")
private fun getFeeForTransaction(fee: TxFee, blockchain: Blockchain): Fee {
val feeAmountValue = fee.feeValue
val feeAmount = Amount(
@ -1108,13 +1083,15 @@ internal class SwapInteractorImpl @AssistedInject constructor(
return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals)
}
override suspend fun selectInitialCurrencyToSwap(
override suspend fun getInitialCurrencyToSwap(
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
isReverseFromTo: Boolean,
): CryptoCurrencyStatus? {
return initialToCurrencyResolver.tryGetFromCache(initialCryptoCurrency, state)
?: initialToCurrencyResolver.tryGetWithMaxAmount(state)
?: state.toGroup.available.firstOrNull()?.currencyStatus
val group = state.getGroupWithReverse(isReverseFromTo)
return initialToCurrencyResolver.tryGetFromCache(userWallet, initialCryptoCurrency, state, isReverseFromTo)
?: initialToCurrencyResolver.tryGetWithMaxAmount(state, isReverseFromTo)
?: group.available.firstOrNull()?.currencyStatus
}
override fun getNativeToken(networkId: String): CryptoCurrency {
@ -1222,7 +1199,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
@Suppress("LongMethod")
private suspend fun getQuotesState(
provider: SwapProvider,
quoteDataModel: Either<DataError, QuoteModel>,
quoteDataModel: Either<ExpressDataError, QuoteModel>,
amount: SwapAmount,
fromToken: CryptoCurrencyStatus,
toToken: CryptoCurrencyStatus,
@ -1301,7 +1278,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
fromToken = fromToken,
amount = amount,
includeFeeInAmount = includeFeeInAmount,
dataError = error,
expressDataError = error,
)
},
)
@ -1311,7 +1288,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
fromToken: CryptoCurrencyStatus,
amount: SwapAmount,
includeFeeInAmount: IncludeFeeInAmount,
dataError: DataError,
expressDataError: ExpressDataError,
): SwapState.SwapError {
val rates = getQuotes(fromToken.currency.id)
val fromTokenSwapInfo = TokenSwapInfo(
@ -1320,7 +1297,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
)
return SwapState.SwapError(fromTokenSwapInfo, dataError, includeFeeInAmount)
return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount)
}
@Suppress("CyclomaticComplexMethod")
@ -1667,7 +1644,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
fromToken = fromTokenStatus,
amount = swapAmount,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
dataError = DataError.UnknownError,
expressDataError = ExpressDataError.UnknownError,
)
}
}

View file

@ -5,8 +5,6 @@ import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.swap.domain.*
import com.tangem.lib.crypto.TransactionManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -60,11 +58,9 @@ internal class SwapDomainModule {
@Provides
@Singleton
fun provideInitialToCurrencyResolver(
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
swapTransactionRepository: SwapTransactionRepository,
): InitialToCurrencyResolver {
return DefaultInitialToCurrencyResolver(
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
swapTransactionRepository = swapTransactionRepository,
)
}

View file

@ -24,6 +24,7 @@ dependencies {
implementation(projects.core.decompose) // For Route supertype
/** Domain modules **/
implementation(projects.domain.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.balanceHiding)
@ -31,13 +32,17 @@ dependencies {
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction)
implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.settings)
implementation(projects.domain.staking)
implementation(projects.domain.feedback)
/** Feature modules */
implementation(projects.features.swap.domain)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)
implementation(projects.domain.staking)
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -0,0 +1,33 @@
package com.tangem.feature.swap.converters
import com.tangem.common.ui.alerts.TransactionErrorAlertConverter
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
import com.tangem.feature.swap.models.SwapAlertUM
import com.tangem.feature.swap.utils.getExpressErrorMessage
import com.tangem.utils.converter.Converter
internal class SwapTransactionErrorStateConverter(
private val onDismiss: () -> Unit,
private val onSupportClick: (String) -> Unit,
) : Converter<SwapTransactionState.Error, AlertUM?> {
override fun convert(value: SwapTransactionState.Error): AlertUM? {
return when (value) {
is SwapTransactionState.Error.TransactionError -> {
when (val error = value.error) {
is SendTransactionError.UserCancelledError -> return null
null -> SwapAlertUM.GenericError(onDismiss)
else -> TransactionErrorAlertConverter(onDismiss, onSupportClick).convert(error)
}
}
is SwapTransactionState.Error.ExpressError -> {
SwapAlertUM.ExpressErrorAlert(
message = getExpressErrorMessage(value.error),
onConfirmClick = { onSupportClick(value.error.code.toString()) },
)
}
SwapTransactionState.Error.UnknownError -> SwapAlertUM.GenericError(onDismiss)
}
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.feature.swap.models
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
sealed class SwapAlertUM : AlertUM {
data class GenericError(
override val onConfirmClick: (() -> Unit),
override val message: TextReference = resourceReference(R.string.common_unknown_error),
) : SwapAlertUM() {
override val title: TextReference? = null
override val confirmButtonText: TextReference =
resourceReference(id = R.string.common_support)
}
data class ExpressErrorAlert(
override val message: TextReference = resourceReference(R.string.common_unknown_error),
override val onConfirmClick: (() -> Unit),
) : SwapAlertUM() {
override val title: TextReference? = null
override val confirmButtonText: TextReference =
resourceReference(id = R.string.common_support)
}
data class FeesAlert(
override val message: TextReference,
override val onConfirmClick: (() -> Unit),
) : SwapAlertUM() {
override val title: TextReference = resourceReference(
com.tangem.feature.swap.presentation.R.string.swapping_alert_title,
)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.common_ok)
}
}

View file

@ -6,18 +6,21 @@ import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.events.SwapEvent
data class SwapStateHolder(
internal data class SwapStateHolder(
val sendCardData: SwapCardState,
val receiveCardData: SwapCardState,
val blockchainId: String, // not the same as networkId, its local id in app
val warnings: List<SwapWarning> = emptyList(),
val alert: SwapWarning.GenericWarning? = null,
val event: StateEvent<SwapEvent> = consumedEvent(),
val changeCardsButtonState: ChangeCardsButtonState = ChangeCardsButtonState.ENABLED,
val providerState: ProviderState,
@ -110,7 +113,6 @@ sealed interface SwapWarning {
data class GenericWarning(
val title: TextReference? = null,
val message: TextReference? = null,
val type: GenericWarningType = GenericWarningType.OTHER,
val onClick: () -> Unit,
) : SwapWarning
@ -133,10 +135,6 @@ sealed interface SwapWarning {
}
}
enum class GenericWarningType {
NETWORK, OTHER
}
enum class ChangeCardsButtonState {
ENABLED, DISABLED, UPDATE_IN_PROGRESS
}

View file

@ -0,0 +1,11 @@
package com.tangem.feature.swap.models.states.events
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.alerts.models.AlertUM
@Immutable
internal sealed class SwapEvent {
data class ShowAlert(val alert: AlertUM) : SwapEvent()
data class ShowShareDialog(val txUrl: String) : SwapEvent()
}

View file

@ -2,24 +2,31 @@ package com.tangem.feature.swap.ui
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.common.ui.bottomsheet.permission.state.*
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.*
import com.tangem.feature.swap.models.states.events.SwapEvent
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.getExpressErrorMessage
import com.tangem.feature.swap.utils.getExpressErrorTitle
import com.tangem.feature.swap.viewmodels.SwapProcessDataState
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.DASH_SIGN
@ -625,13 +632,13 @@ internal class StateBuilder(
fromToken: TokenSwapInfo,
toToken: CryptoCurrencyStatus?,
includeFeeInAmount: IncludeFeeInAmount,
dataError: DataError,
expressDataError: ExpressDataError,
isReverseSwapPossible: Boolean,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = mutableListOf<SwapWarning>()
warnings.add(getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency))
warnings.add(getWarningForError(expressDataError, fromToken.cryptoCurrencyStatus.currency))
if (includeFeeInAmount is IncludeFeeInAmount.Included && uiStateHolder.fee is FeeItemState.Content) {
val feeCoverageNotification = createNetworkFeeCoverageNotificationConfig(
uiStateHolder.fee.amountCrypto,
@ -642,7 +649,7 @@ internal class StateBuilder(
val providerState = getProviderStateForError(
swapProvider = swapProvider,
fromToken = fromToken.cryptoCurrencyStatus.currency,
dataError = dataError,
expressDataError = expressDataError,
onProviderClick = actions.onProviderClick,
selectionType = ProviderState.SelectionType.CLICK,
)
@ -698,28 +705,28 @@ internal class StateBuilder(
private fun getProviderStateForError(
swapProvider: SwapProvider,
fromToken: CryptoCurrency,
dataError: DataError,
expressDataError: ExpressDataError,
onProviderClick: (String) -> Unit,
selectionType: ProviderState.SelectionType,
): ProviderState {
return when (dataError) {
is DataError.ExchangeTooSmallAmountError -> {
return when (expressDataError) {
is ExpressDataError.ExchangeTooSmallAmountError -> {
swapProvider.convertToAvailableFromProviderState(
swapProvider = swapProvider,
alertText = resourceReference(
R.string.express_provider_min_amount,
wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)),
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
selectionType = selectionType,
onProviderClick = onProviderClick,
)
}
is DataError.ExchangeTooBigAmountError -> {
is ExpressDataError.ExchangeTooBigAmountError -> {
swapProvider.convertToAvailableFromProviderState(
swapProvider = swapProvider,
alertText = resourceReference(
R.string.express_provider_max_amount,
wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)),
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
selectionType = selectionType,
onProviderClick = onProviderClick,
@ -731,25 +738,25 @@ internal class StateBuilder(
}
}
private fun getWarningForError(dataError: DataError, fromToken: CryptoCurrency): SwapWarning {
val providerErrorMessage = getProviderErrorMessage(dataError)
val providerErrorTitle = getProviderErrorTitle(dataError)
return when (dataError) {
is DataError.ExchangeTooSmallAmountError -> SwapWarning.GeneralError(
private fun getWarningForError(expressDataError: ExpressDataError, fromToken: CryptoCurrency): SwapWarning {
val providerErrorMessage = getExpressErrorMessage(expressDataError)
val providerErrorTitle = getExpressErrorTitle(expressDataError)
return when (expressDataError) {
is ExpressDataError.ExchangeTooSmallAmountError -> SwapWarning.GeneralError(
notificationConfig = NotificationConfig(
title = resourceReference(
id = R.string.warning_express_too_minimal_amount_title,
formatArgs = wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)),
formatArgs = wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
subtitle = resourceReference(R.string.warning_express_wrong_amount_description),
iconResId = R.drawable.ic_alert_circle_24,
),
)
is DataError.ExchangeTooBigAmountError -> SwapWarning.GeneralError(
is ExpressDataError.ExchangeTooBigAmountError -> SwapWarning.GeneralError(
notificationConfig = NotificationConfig(
title = resourceReference(
id = R.string.warning_express_too_maximum_amount_title,
formatArgs = wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)),
formatArgs = wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
subtitle = resourceReference(R.string.warning_express_wrong_amount_description),
iconResId = R.drawable.ic_alert_circle_24,
@ -1045,81 +1052,43 @@ internal class StateBuilder(
)
}
fun createErrorTransaction(
fun createErrorTransactionAlert(
uiState: SwapStateHolder,
swapTransactionState: SwapTransactionState,
onAlertClick: () -> Unit,
error: SwapTransactionState.Error,
onDismiss: () -> Unit,
onSupportClick: (String) -> Unit,
): SwapStateHolder {
val errorAlert = SwapTransactionErrorStateConverter(
onSupportClick = onSupportClick,
onDismiss = onDismiss,
).convert(error)
return uiState.copy(
alert = SwapWarning.GenericWarning(
message = if (swapTransactionState is SwapTransactionState.ExpressError) {
getProviderErrorMessage(swapTransactionState.dataError)
} else {
null
},
onClick = onAlertClick,
type = if (swapTransactionState is SwapTransactionState.NetworkError) {
GenericWarningType.NETWORK
} else {
GenericWarningType.OTHER
},
),
event = errorAlert?.let {
triggeredEvent(
data = SwapEvent.ShowAlert(errorAlert),
onConsume = onDismiss,
)
} ?: consumedEvent(),
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
)
}
fun createDemoModeAlert(uiState: SwapStateHolder, onAlertClick: () -> Unit): SwapStateHolder {
fun createDemoModeAlert(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder {
return uiState.copy(
alert = SwapWarning.GenericWarning(
title = resourceReference(id = R.string.warning_demo_mode_title),
message = resourceReference(id = R.string.warning_demo_mode_message),
onClick = onAlertClick,
type = GenericWarningType.OTHER,
event = triggeredEvent(
data = SwapEvent.ShowAlert(AlertDemoModeUM(onDismiss)),
onConsume = onDismiss,
),
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
)
}
private fun getProviderErrorMessage(dataError: DataError): TextReference {
return when (dataError) {
is DataError.SwapsAreUnavailableNowError -> resourceReference(
id = R.string.express_error_swap_unavailable,
formatArgs = wrappedList(dataError.code),
)
is DataError.ExchangeNotPossibleError -> resourceReference(
id = R.string.warning_express_pair_unavailable_message,
formatArgs = wrappedList(dataError.code),
)
is DataError.UnknownError -> resourceReference(R.string.common_unknown_error)
is DataError.ExchangeProviderNotActiveError,
is DataError.ExchangeProviderNotFoundError,
is DataError.ExchangeProviderNotAvailableError,
is DataError.ExchangeProviderProviderInternalError,
-> resourceReference(
id = R.string.express_error_swap_pair_unavailable,
formatArgs = wrappedList(dataError.code),
)
else -> resourceReference(R.string.express_error_code, wrappedList(dataError.code.toString()))
}
}
private fun getProviderErrorTitle(dataError: DataError): TextReference {
return when (dataError) {
is DataError.ExchangeNotPossibleError -> resourceReference(
id = R.string.warning_express_pair_unavailable_title,
formatArgs = wrappedList(dataError.code),
)
is DataError.UnknownError -> resourceReference(R.string.common_error)
else -> resourceReference(R.string.warning_express_refresh_required_title)
}
}
fun createAlert(
uiState: SwapStateHolder,
isPriceImpact: Boolean,
token: String,
providerType: ExchangeProviderType,
onAlertClick: () -> Unit,
onDismiss: () -> Unit,
): SwapStateHolder {
val message = when (providerType) {
ExchangeProviderType.CEX -> resourceReference(R.string.swapping_alert_cex_description, wrappedList(token))
@ -1136,29 +1105,38 @@ internal class StateBuilder(
}
}
return uiState.copy(
alert = SwapWarning.GenericWarning(
title = resourceReference(R.string.swapping_alert_title),
message = message,
onClick = onAlertClick,
type = GenericWarningType.OTHER,
event = triggeredEvent(
SwapEvent.ShowAlert(
SwapAlertUM.FeesAlert(
message = message,
onConfirmClick = onDismiss,
),
),
onConsume = onDismiss,
),
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
)
}
fun addAlert(uiState: SwapStateHolder, message: TextReference? = null, onClick: () -> Unit): SwapStateHolder {
fun addAlert(
uiState: SwapStateHolder,
message: TextReference = resourceReference(R.string.common_unknown_error),
onDismiss: () -> Unit = { clearAlert(uiState) },
): SwapStateHolder {
return uiState.copy(
alert = SwapWarning.GenericWarning(
message = message,
onClick = onClick,
event = triggeredEvent(
SwapEvent.ShowAlert(
SwapAlertUM.GenericError(onDismiss, message),
),
onConsume = onDismiss,
),
)
}
fun clearAlert(uiState: SwapStateHolder): SwapStateHolder = uiState.copy(alert = null)
fun clearAlert(uiState: SwapStateHolder): SwapStateHolder = uiState.copy(event = consumedEvent())
fun addWarning(uiState: SwapStateHolder, message: TextReference?, onClick: () -> Unit): SwapStateHolder {
val renewWarnings = uiState.warnings.filterNot { it is SwapWarning.GenericWarning }.toMutableList()
val renewWarnings = uiState.warnings.toMutableList()
renewWarnings.add(
SwapWarning.GenericWarning(
message = message,
@ -1421,7 +1399,7 @@ internal class StateBuilder(
is SwapState.SwapError -> getProviderStateForError(
swapProvider = provider,
fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency,
dataError = state.error,
expressDataError = state.error,
onProviderClick = onProviderSelect,
selectionType = ProviderState.SelectionType.SELECT,
)

View file

@ -0,0 +1,67 @@
package com.tangem.feature.swap.ui
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.shareText
import com.tangem.feature.swap.models.states.events.SwapEvent
import com.tangem.feature.swap.presentation.R
@Composable
internal fun SwapEventEffect(event: StateEvent<SwapEvent>) {
val context = LocalContext.current
var alertConfig by remember { mutableStateOf<AlertUM?>(value = null) }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(key1 = alertConfig) {
keyboardController?.hide()
}
alertConfig?.let {
SwapAlert(state = it, onDismiss = { alertConfig = null })
}
EventEffect(
event = event,
onTrigger = { value ->
when (value) {
is SwapEvent.ShowAlert -> {
alertConfig = value.alert
}
is SwapEvent.ShowShareDialog -> {
context.shareText(value.txUrl)
}
}
},
)
}
@Composable
internal fun SwapAlert(state: AlertUM, onDismiss: () -> Unit) {
val confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = {
state.onConfirmClick()
onDismiss()
},
)
val dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
onClick = onDismiss,
)
BasicDialog(
message = state.message.resolveReference(),
confirmButton = confirmButton,
onDismissDialog = onDismiss,
title = state.title?.resolveReference(),
dismissButton = dismissButton,
)
}

View file

@ -101,22 +101,9 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi
)
}
if (state.alert != null) {
val message = if (state.alert.type == GenericWarningType.NETWORK) {
stringResource(id = R.string.disclaimer_error_loading)
} else {
state.alert.message?.resolveReference() ?: stringResource(id = R.string.common_unknown_error)
}
BasicDialog(
title = state.alert.title?.resolveReference(),
message = message,
confirmButton = DialogButtonUM(
title = stringResource(id = R.string.common_ok),
onClick = state.alert.onClick,
),
onDismissDialog = state.alert.onClick,
)
}
SwapEventEffect(
event = state.event,
)
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.feature.swap.utils
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.presentation.R
internal fun getExpressErrorMessage(expressDataError: ExpressDataError): TextReference {
return when (expressDataError) {
is ExpressDataError.SwapsAreUnavailableNowError -> resourceReference(
id = R.string.express_error_swap_unavailable,
formatArgs = wrappedList(expressDataError.code),
)
is ExpressDataError.ExchangeNotPossibleError -> resourceReference(
id = R.string.warning_express_pair_unavailable_message,
formatArgs = wrappedList(expressDataError.code),
)
is ExpressDataError.UnknownError -> resourceReference(R.string.common_unknown_error)
is ExpressDataError.ExchangeProviderNotActiveError,
is ExpressDataError.ExchangeProviderNotFoundError,
is ExpressDataError.ExchangeProviderNotAvailableError,
is ExpressDataError.ExchangeProviderProviderInternalError,
-> resourceReference(
id = R.string.express_error_swap_pair_unavailable,
formatArgs = wrappedList(expressDataError.code),
)
else -> resourceReference(R.string.express_error_code, wrappedList(expressDataError.code.toString()))
}
}
internal fun getExpressErrorTitle(expressDataError: ExpressDataError): TextReference {
return when (expressDataError) {
is ExpressDataError.ExchangeNotPossibleError -> resourceReference(
id = R.string.warning_express_pair_unavailable_title,
formatArgs = wrappedList(expressDataError.code),
)
is ExpressDataError.UnknownError -> resourceReference(R.string.common_error)
else -> resourceReference(R.string.warning_express_refresh_required_title)
}
}

View file

@ -19,17 +19,24 @@ import com.tangem.core.ui.utils.InputNumberFormatter
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.feedback.GetCardInfoUseCase
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.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.ExpressException
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
@ -64,7 +71,6 @@ typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
@Suppress("LargeClass", "LongParameterList")
@HiltViewModel
internal class SwapViewModel @Inject constructor(
private val swapInteractorFactory: SwapInteractor.Factory,
private val blockchainInteractor: BlockchainInteractor,
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
@ -73,6 +79,11 @@ internal class SwapViewModel @Inject constructor(
private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
swapInteractorFactory: SwapInteractor.Factory,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver {
@ -84,9 +95,13 @@ internal class SwapViewModel @Inject constructor(
?.unbundle(UserWalletId.serializer())
?: error("no expected parameter UserWalletId found")
private val isInitiallyReversed: Boolean = savedStateHandle.get<Boolean>(AppRoute.Swap.IS_INITIAL_REVERSE_ORDER)
?: false
private val swapInteractor = swapInteractorFactory.create(userWalletId)
private lateinit var initialCryptoCurrencyStatus: CryptoCurrencyStatus
private var userWallet: UserWallet by Delegates.notNull()
private var isBalanceHidden = true
@ -120,7 +135,10 @@ internal class SwapViewModel @Inject constructor(
private val isUserResolvableError: (SwapState) -> Boolean = {
it is SwapState.SwapError &&
(it.error is DataError.ExchangeTooSmallAmountError || it.error is DataError.ExchangeTooBigAmountError)
(
it.error is ExpressDataError.ExchangeTooSmallAmountError ||
it.error is ExpressDataError.ExchangeTooBigAmountError
)
}
private val fromTokenBalanceJobHolder = JobHolder()
@ -133,11 +151,13 @@ internal class SwapViewModel @Inject constructor(
viewModelScope.launch(dispatchers.io) {
val cryptoCurrencyStatus =
getCryptoCurrencyStatusUseCase(userWalletId, initialCryptoCurrency.id).getOrNull()
if (cryptoCurrencyStatus == null) {
uiState = stateBuilder.addAlert(uiState = uiState, onClick = swapRouter::back)
val wallet = getUserWalletUseCase(userWalletId).getOrNull()
if (cryptoCurrencyStatus == null || wallet == null) {
uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back)
} else {
userWallet = wallet
initialCryptoCurrencyStatus = cryptoCurrencyStatus
initTokens()
initTokens(isInitiallyReversed)
}
}
}
@ -180,18 +200,21 @@ internal class SwapViewModel @Inject constructor(
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(availableTokens = isAnyAvailableTokens))
}
private fun initTokens() {
private fun initTokens(isReverseFromTo: Boolean) {
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
swapInteractor.getTokensDataState(initialCryptoCurrency)
}.onSuccess { state ->
updateTokensState(state)
val selectedCurrency = swapInteractor.getInitialCurrencyToSwap(
initialCryptoCurrency = initialCryptoCurrency,
state = state,
isReverseFromTo = isReverseFromTo,
)
applyInitialTokenChoice(
state,
swapInteractor.selectInitialCurrencyToSwap(
initialCryptoCurrency,
state,
),
state = state,
selectedCurrency = selectedCurrency,
isReverseFromTo = isReverseFromTo,
)
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let {
@ -215,11 +238,12 @@ internal class SwapViewModel @Inject constructor(
applyInitialTokenChoice(
state = TokensDataStateExpress.EMPTY,
selectedCurrency = null,
isReverseFromTo = isReverseFromTo,
)
uiState = stateBuilder.createInitialErrorState(
uiState,
(it as? ExpressException)?.dataError?.code ?: DataError.UnknownError.code,
(it as? ExpressException)?.expressDataError?.code ?: ExpressDataError.UnknownError.code,
) {
uiState = stateBuilder.createInitialLoadingState(
initialCurrency = initialCryptoCurrency,
@ -227,33 +251,43 @@ internal class SwapViewModel @Inject constructor(
initialCryptoCurrency.network.backendId,
),
)
initTokens()
initTokens(isReverseFromTo)
}
}
}
}
private fun applyInitialTokenChoice(state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?) {
val fromCurrencyStatus = initialCryptoCurrencyStatus
dataState = dataState.copy(
fromCryptoCurrency = fromCurrencyStatus,
toCryptoCurrency = selectedCurrency,
tokensDataState = state,
)
private fun applyInitialTokenChoice(
state: TokensDataStateExpress,
selectedCurrency: CryptoCurrencyStatus?,
isReverseFromTo: Boolean,
) {
// exceptional case
if (selectedCurrency == null) {
analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap)
uiState = stateBuilder.createNoAvailableTokensToSwapState(
uiStateHolder = uiState,
fromToken = fromCurrencyStatus,
)
} else {
startLoadingQuotes(
fromToken = fromCurrencyStatus,
toToken = selectedCurrency,
amount = lastAmount.value,
toProvidersList = findSwapProviders(fromCurrencyStatus, selectedCurrency),
fromToken = initialCryptoCurrencyStatus,
)
return
}
isOrderReversed = isReverseFromTo
val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed) {
selectedCurrency to initialCryptoCurrencyStatus
} else {
initialCryptoCurrencyStatus to selectedCurrency
}
dataState = dataState.copy(
fromCryptoCurrency = fromCurrencyStatus,
toCryptoCurrency = toCurrencyStatus,
tokensDataState = state,
)
startLoadingQuotes(
fromToken = fromCurrencyStatus,
toToken = toCurrencyStatus,
amount = lastAmount.value,
toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus),
)
}
private fun updateTokensState(tokenDataState: TokensDataStateExpress) {
@ -397,7 +431,7 @@ internal class SwapViewModel @Inject constructor(
swapProvider = provider,
fromToken = state.fromTokenInfo,
toToken = dataState.toCryptoCurrency,
dataError = state.error,
expressDataError = state.error,
includeFeeInAmount = state.includeFeeInAmount,
isReverseSwapPossible = isReverseSwapPossible(),
)
@ -406,7 +440,7 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun sendErrorAnalyticsEvent(error: DataError, provider: SwapProvider) {
private fun sendErrorAnalyticsEvent(error: ExpressDataError, provider: SwapProvider) {
val receiveToken = dataState.toCryptoCurrency?.currency?.let {
"${it.network.backendId}:${it.symbol}"
}
@ -556,20 +590,19 @@ internal class SwapViewModel @Inject constructor(
swapRouter.openScreen(SwapNavScreen.Success)
}
is SwapTransactionState.UserCancelled -> {
startLoadingQuotesFromLastState()
}
is SwapTransactionState.DemoMode -> {
startLoadingQuotesFromLastState()
SwapTransactionState.DemoMode -> {
uiState = stateBuilder.createDemoModeAlert(uiState) {
uiState = stateBuilder.clearAlert(uiState)
}
}
else -> {
is SwapTransactionState.Error -> {
startLoadingQuotesFromLastState()
uiState = stateBuilder.createErrorTransaction(uiState, it) {
uiState = stateBuilder.clearAlert(uiState)
}
uiState = stateBuilder.createErrorTransactionAlert(
uiState = uiState,
error = it,
onDismiss = { uiState = stateBuilder.clearAlert(uiState) },
onSupportClick = ::onFailedTxEmailClick,
)
}
}
}.onFailure {
@ -640,23 +673,28 @@ internal class SwapViewModel @Inject constructor(
uiState = stateBuilder.dismissBottomSheet(uiState)
startLoadingQuotesFromLastState(isSilent = true)
}
is SwapTransactionState.UserCancelled -> Unit
else -> {
uiState = stateBuilder.createErrorTransaction(uiState, it) {
is SwapTransactionState.Error -> {
uiState = stateBuilder.createErrorTransactionAlert(
uiState = uiState,
error = it,
onDismiss = { uiState = stateBuilder.clearAlert(uiState) },
onSupportClick = ::onFailedTxEmailClick,
)
}
SwapTransactionState.DemoMode -> {
uiState = stateBuilder.createDemoModeAlert(uiState) {
uiState = stateBuilder.clearAlert(uiState)
}
}
}
}.onFailure { makeDefaultAlert() }
}.onFailure { showGenericError(it.message.orEmpty()) }
}.onFailure {
Timber.e(it.message.orEmpty())
makeDefaultAlert()
}
}
}
private fun showGenericError(message: String) {
makeDefaultAlert(resourceReference(R.string.common_unknown_error))
Timber.e(message)
}
private fun onSearchEntered(searchQuery: String) {
viewModelScope.launch(dispatchers.io) {
val tokenDataState = dataState.tokensDataState ?: return@launch
@ -863,15 +901,11 @@ internal class SwapViewModel @Inject constructor(
}
private fun makeDefaultAlert() {
uiState = stateBuilder.addAlert(uiState) {
uiState = stateBuilder.clearAlert(uiState)
}
uiState = stateBuilder.addAlert(uiState)
}
private fun makeDefaultAlert(message: TextReference) {
uiState = stateBuilder.addAlert(uiState, message) {
uiState = stateBuilder.clearAlert(uiState)
}
uiState = stateBuilder.addAlert(uiState, message)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -1113,7 +1147,8 @@ internal class SwapViewModel @Inject constructor(
toToken.currency.id.value
}
return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers ?: emptyList()
return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers
?: emptyList()
}
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
@ -1201,6 +1236,33 @@ internal class SwapViewModel @Inject constructor(
analyticsEventHandler.send(event = event)
}
private fun onFailedTxEmailClick(errorMessage: String) {
viewModelScope.launch {
val network = initialCryptoCurrencyStatus.currency.network
val cardInfo = getCardInfoUseCase(userWallet.scanResponse).getOrElse { error("CardInfo must be not null") }
saveBlockchainErrorUseCase(
error = BlockchainErrorInfo(
errorMessage = errorMessage,
blockchainId = network.id.value,
derivationPath = network.derivationPath.value,
destinationAddress = dataState.swapDataModel?.transaction?.txTo.orEmpty(),
tokenSymbol = initialCryptoCurrency.symbol,
amount = dataState.amount.orEmpty(),
fee = dataState.selectedFee?.feeCryptoFormatted.orEmpty(),
),
)
val email = FeedbackEmailType.SwapProblem(
cardInfo = cardInfo,
providerName = dataState.selectedProvider?.name.orEmpty(),
txId = dataState.swapDataModel?.transaction?.txId.orEmpty(),
)
sendFeedbackEmailUseCase(email)
}
}
private companion object {
const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L

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