Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-20 12:47:25 +03:00
commit c50840894f
739 changed files with 27664 additions and 4701 deletions

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,27 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
android {
namespace = "com.tangem.features.commonfeatures.api"
}
dependencies {
/** Api */ // todo swap delete after move portfolio selector
implementation(projects.features.account.api)
/* Project - Domain */
implementation(projects.domain.models)
implementation(projects.domain.markets)
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/* Compose */
implementation(deps.compose.runtime)
implementation(deps.kotlin.immutable.collections)
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.commonfeatures.api.addtoportfolio
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
interface AddToPortfolioComponent : ComposableBottomSheetComponent {
data class Params(
val addToPortfolioManager: AddToPortfolioManager,
)
interface Factory : ComponentFactory<Params, AddToPortfolioComponent>
}

View file

@ -0,0 +1,91 @@
package com.tangem.features.commonfeatures.api.addtoportfolio
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
interface AddToPortfolioManager : AddToPortfolioManagerInternal {
val onDismiss: Channel<Unit>
val onSuccessAdded: Channel<Result>
val portfolioFetcher: PortfolioFetcher
val state: StateFlow<State>
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>)
fun setTokenParams(token: TokenMarketParams)
sealed interface State {
data object Loading : State
data class Ready(
val availableToAddData: AvailableToAddData,
) : State {
val isAvailableToAdd: Boolean get() = availableToAddData.isAvailableToAdd
val isSinglePortfolio: Boolean get() = availableToAddData.isSinglePortfolio
}
}
@Serializable
data class AnalyticsParams(
val source: String?,
)
interface Factory {
fun create(scope: CoroutineScope, settings: Settings, analyticsParams: AnalyticsParams): AddToPortfolioManager
}
/**
* Immutable settings
*/
data class Settings(
val shouldSkipTokenActionsScreen: Boolean = false,
) {
companion object {
val DefaultMarket = Settings(
shouldSkipTokenActionsScreen = false,
)
val ChooseToken = Settings(
shouldSkipTokenActionsScreen = true,
)
}
}
/**
* Mutable parameters
* Updates may trigger reload [State]
*/
data class Params(
val networks: List<TokenMarketInfo.Network>,
val token: TokenMarketParams,
)
data class Result(
val wallet: UserWallet,
val account: AccountStatus.CryptoPortfolio,
val addedCurrency: CryptoCurrencyStatus,
)
}
/**
* primary for internal impl usage, but you can also use it externally
*/
interface AddToPortfolioManagerInternal {
val paramsFlow: SharedFlow<AddToPortfolioManager.Params>
val settings: AddToPortfolioManager.Settings
val analyticsParams: AnalyticsParams
suspend fun token(): TokenMarketParams = paramsFlow.first().token
fun onDismiss()
fun onSuccessAdded(result: AddToPortfolioManager.Result)
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add
package com.tangem.features.commonfeatures.api.addtoportfolio
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add
package com.tangem.features.commonfeatures.api.addtoportfolio
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.account.AccountId
@ -36,13 +36,13 @@ data class AvailableToAddAccount(
get() = availableNetworks.size == 1
val availableToAddNetworks: Set<TokenMarketInfo.Network> = availableNetworks
.filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } }
.filter { available -> addedNetworks.none { added -> added.rawId == available.networkId } }
.toSet()
val isAvailableToAdd: Boolean = availableToAddNetworks.isNotEmpty()
val addedMarketNetworks: Set<TokenMarketInfo.Network> = availableNetworks
.filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } }
.filter { available -> addedNetworks.any { added -> added.rawId == available.networkId } }
.toSet()
}

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,81 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.commonfeatures.impl"
}
dependencies {
/** Api */
implementation(projects.features.commonFeatures.api)
// todo swap delete after move portfolio selector
implementation(projects.features.account.api)
implementation(projects.features.tokenRecieve.api)
/** Core modules */
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.error)
implementation(projects.core.res)
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.datasource)
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.core)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.markets)
implementation(projects.domain.transaction)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.manageTokens.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/** Tangem libraries */
implementation(tangemDeps.card.core)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.uiMarkets)
implementation(projects.common.routing)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)
implementation(deps.lifecycle.runtime.ktx)
/** Compose libraries */
implementation(deps.compose.material3)
implementation(deps.compose.animation)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.decompose.ext.compose)
implementation(deps.androidx.activity.compose)
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.serialization)
implementation(deps.firebase.crashlytics)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
@ -9,6 +9,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.features.commonfeatures.impl.R
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
@ -20,8 +21,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes
import com.tangem.features.feed.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
@Composable
internal fun AddToPortfolioBottomSheet(

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -9,10 +9,10 @@ import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork
import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -36,7 +36,7 @@ internal class AddTokenComponent @AssistedInject constructor(
}
data class Params(
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val eventBuilder: Flow<PortfolioAnalyticsEvent.EventBuilder>,
val selectedPortfolio: Flow<SelectedPortfolio>,
val selectedNetwork: Flow<SelectedNetwork>,
val callbacks: Callbacks,

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -9,9 +9,9 @@ import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.ChooseNetworkContent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.ChooseNetworkContent
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
import com.arkivanov.decompose.ComponentContext
@ -12,9 +12,9 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -71,7 +71,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
}
override fun dismiss() {
params.callback.onDismiss()
model.addToPortfolioManager.onDismiss()
}
@Composable

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
import com.arkivanov.decompose.extensions.compose.subscribeAsState
@ -10,12 +10,13 @@ import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioPreselectedDataModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioPreselectedDataModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.flowOf
internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@ -37,7 +38,7 @@ internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject con
private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create(
context = child("addTokenComponent"),
params = AddTokenComponent.Params(
eventBuilder = model.eventBuilder,
eventBuilder = flowOf(model.eventBuilder),
callbacks = model,
selectedPortfolio = model.selectedPortfolio,
selectedNetwork = model.selectedNetwork,

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -8,6 +8,7 @@ import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.factory.ComponentFactory
@ -15,10 +16,9 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.TokenActionsContent
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContent
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -63,8 +63,8 @@ internal class TokenActionsComponent @AssistedInject constructor(
)
data class Params(
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val data: Flow<PortfolioData.CryptoCurrencyData>,
val eventBuilder: Flow<PortfolioAnalyticsEvent.EventBuilder>,
val data: Flow<CryptoCurrencyData>,
val callbacks: Callbacks,
)

View file

@ -0,0 +1,35 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
// todo swap unify with PortfolioAnalyticsEvent, AddToPortfolioFlow
internal sealed class EarnAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Earn", event = event, params = params) {
data class AddTokenScreenOpened(
private val tokenSymbol: String,
private val blockchain: String,
private val source: String,
) : EarnAnalyticsEvent(
event = "Add Token Screen Opened",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to tokenSymbol,
AnalyticsParam.BLOCKCHAIN to blockchain,
AnalyticsParam.SOURCE to source,
),
)
data class TokenAdded(
private val tokenSymbol: String,
private val blockchain: String,
) : EarnAnalyticsEvent(
event = "Token Added",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to tokenSymbol,
AnalyticsParam.BLOCKCHAIN to blockchain,
),
)
}

View file

@ -0,0 +1,91 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.core.analytics.models.AnalyticsEvent
// todo swap unify with EarnAnalyticsEvent, AddToPortfolioFlow
internal class PortfolioAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) {
data class EventBuilder(
val tokenSymbol: String,
val source: String?,
) {
fun popupToChooseAccount() = PortfolioAnalyticsEvent(
event = "Choose Account Opened",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun popupToConfirm() = PortfolioAnalyticsEvent(
event = "Add Token Screen Opened",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToNotMainAccount() = PortfolioAnalyticsEvent(
event = "Button - Add To Account",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addButtonClick() = PortfolioAnalyticsEvent(
event = "Button - Add Token",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(
event = "Wallet Selected",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToPortfolioContinue(blockchainNames: List<String>) = PortfolioAnalyticsEvent(
event = "Token Network Selected",
params = buildMap {
put("Count", blockchainNames.size.toString())
put("Token", tokenSymbol)
put("blockchain", blockchainNames.joinToString(separator = ", "))
if (source != null) put("Source", source)
},
)
fun tokenAdded(blockchainName: String) = PortfolioAnalyticsEvent(
event = "Token Added",
params = buildMap {
put("Token", tokenSymbol)
put("Blockchain", blockchainName)
if (source != null) put("Source", source)
},
)
fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent(
event = when (actionUM) {
TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy"
TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive"
TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange"
TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake"
else -> "error"
},
params = buildMap {
if (source != null) put("Source", source)
},
)
fun getTokenLater() = PortfolioAnalyticsEvent(
event = "Popup Get token - Button Later",
params = buildMap {
if (source != null) put("Source", source)
},
)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.converter
package com.tangem.features.commonfeatures.impl.addtoportfolio.converter
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase
@ -13,9 +13,9 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddAccount
import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddData
import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddWallet
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet
import javax.inject.Inject
internal class AvailableToAddDataConverter @Inject constructor(

View file

@ -1,15 +1,14 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.converter
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.getGreyedOutIconRes
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketInfo.Network
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.converter.Converter
/**
* Converter from [TokenMarketInfo.Network] to [BlockchainRowUM]
* Converter from [com.tangem.domain.markets.TokenMarketInfo.Network] to [com.tangem.core.ui.components.rows.model.BlockchainRowUM]
*
* @property alreadyAddedNetworks set of already added networks
*
@ -17,9 +16,9 @@ import com.tangem.utils.converter.Converter
*/
internal class BlockchainRowUMConverter(
private val alreadyAddedNetworks: Set<String>,
) : Converter<Pair<Network, Boolean>, BlockchainRowUM> {
) : Converter<Pair<TokenMarketInfo.Network, Boolean>, BlockchainRowUM> {
override fun convert(value: Pair<Network, Boolean>): BlockchainRowUM {
override fun convert(value: Pair<TokenMarketInfo.Network, Boolean>): BlockchainRowUM {
val (network, isSelected) = value
val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId)
@ -48,7 +47,10 @@ internal class BlockchainRowUMConverter(
)
}
private fun getNetworkType(network: Network, blockchainInfo: BlockchainUtils.BlockchainInfo): String {
private fun getNetworkType(
network: TokenMarketInfo.Network,
blockchainInfo: BlockchainUtils.BlockchainInfo,
): String {
val isMainNetwork = network.contractAddress == null
return when {
BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME

View file

@ -0,0 +1,28 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.di
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.DefaultAddToPortfolioManager
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface AddToPortfolioComponentModule {
@Binds
fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory
@Binds
fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory
@Binds
fun bindAddToPortfolioPreselectedDataComponent(
factory: DefaultAddToPortfolioPreselectedDataComponent.Factory,
): AddToPortfolioPreselectedDataComponent.Factory
}

View file

@ -1,8 +1,12 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.di
package com.tangem.features.commonfeatures.impl.addtoportfolio.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.*
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioPreselectedDataModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn

View file

@ -1,9 +1,11 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.popToFirst
import com.arkivanov.decompose.router.stack.pushNew
import com.arkivanov.decompose.router.stack.replaceAll
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.QuickActionsConverter.toQuickActions
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -17,15 +19,14 @@ import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.account.PortfolioSelectorController
import com.tangem.features.feed.components.market.details.portfolio.add.*
import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions
import com.tangem.features.feed.impl.R
import com.tangem.features.commonfeatures.api.addtoportfolio.*
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job
@ -61,14 +62,11 @@ internal class AddToPortfolioModel @Inject constructor(
/* Flows that hold state and provide it to child models */
val selectedNetwork: MutableSharedFlow<SelectedNetwork> = replayMutableSharedFlow()
val selectedPortfolio: MutableSharedFlow<SelectedPortfolio> = replayMutableSharedFlow()
val tokenActionsData: MutableSharedFlow<PortfolioData.CryptoCurrencyData> = replayMutableSharedFlow()
val tokenActionsData: MutableSharedFlow<CryptoCurrencyData> = replayMutableSharedFlow()
private val addToPortfolioManager = params.addToPortfolioManager
val portfolioFetcher = addToPortfolioManager.portfolioFetcher
val eventBuilder = PortfolioAnalyticsEvent.EventBuilder(
tokenSymbol = addToPortfolioManager.token.symbol,
source = addToPortfolioManager.analyticsParams?.source,
)
val addToPortfolioManager: AddToPortfolioManager = params.addToPortfolioManager
val portfolioFetcher: PortfolioFetcher = addToPortfolioManager.portfolioFetcher
val eventBuilder: MutableSharedFlow<PortfolioAnalyticsEvent.EventBuilder> = replayMutableSharedFlow()
val featureData: Flow<AddToPortfolioManager.State> = combineFeatureData()
@ -85,32 +83,27 @@ internal class AddToPortfolioModel @Inject constructor(
@Suppress("LongMethod")
private fun startAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishFlow() {
params.callback.onDismiss()
channel.close()
}
fun finishSuccessFlow(addedToken: CryptoCurrencyStatus) {
params.callback.onSuccess(addedToken.currency)
fun finishSuccessFlow(result: AddToPortfolioManager.Result) {
addToPortfolioManager.onSuccessAdded(result)
channel.close()
}
val featureDataFlow: StateFlow<AvailableToAddData> = featureData
.filterIsInstance<AddToPortfolioManager.State.AvailableToAdd>()
.filterIsInstance<AddToPortfolioManager.State.Ready>()
.map { it.availableToAddData }
.distinctUntilChanged()
.stateIn(this)
val isAccountMode = portfolioSelectorController.isAccountModeSync()
val tokenMarketParams = addToPortfolioManager.paramsFlow.first().token
val eb = PortfolioAnalyticsEvent.EventBuilder(
tokenSymbol = tokenMarketParams.symbol,
source = addToPortfolioManager.analyticsParams.source,
)
eventBuilder.tryEmit(eb)
// use snapshot data, looks like we dont need to remap at runtime
val data = featureDataFlow.value
// you must control it via [AddToPortfolioManager.state]
if (!data.isAvailableToAdd) {
finishFlow()
return@channelFlow
}
// init data flows, emits on user/code selection, updates state holder
val firstSelectedPortfolio = setupPortfolioFlow(data)
.onEach { selectedPortfolio.emit(it) }
@ -157,7 +150,7 @@ internal class AddToPortfolioModel @Inject constructor(
// line of navigation to AddToken screen is finished; cancel the job, select a new root screen
firstPartOfNavigation.cancel()
analyticsEventHandler.send(event = eventBuilder.popupToConfirm())
analyticsEventHandler.send(event = eventBuilder.first().popupToConfirm())
navigation.replaceAll(AddToPortfolioRoutes.AddToken)
var middleNavigationJob: Job? = null
@ -185,34 +178,39 @@ internal class AddToPortfolioModel @Inject constructor(
val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first()
middleNavigationJob?.cancel()
val selectedPortfolio = selectedPortfolio.first()
val result = AddToPortfolioManager.Result(
wallet = selectedPortfolio.userWallet,
account = selectedPortfolio.account.account,
addedCurrency = addedToken,
)
messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added)))
if (params.shouldSkipTokenActionsScreen) {
finishSuccessFlow(addedToken)
if (addToPortfolioManager.settings.shouldSkipTokenActionsScreen) {
finishSuccessFlow(result)
} else {
setupTokenActionsFlow(selectedPortfolio, addedToken)
.onEach { cryptoCurrencyData ->
tokenActionsData.emit(cryptoCurrencyData)
navigation.replaceAll(AddToPortfolioRoutes.TokenActions)
}
.onEmpty { finishFlow() }
.onEmpty { finishSuccessFlow(result) }
.launchIn(this)
}
callbackDelegate.onLaterClick.receiveAsFlow().first()
finishFlow()
finishSuccessFlow(result)
}
.catch { throwable ->
TangemLogger.e("Error", throwable)
params.callback.onDismiss()
addToPortfolioManager.onDismiss()
}
.launchIn(modelScope)
}
private fun logAccountSelector(isAccountMode: Boolean) {
private suspend fun logAccountSelector(isAccountMode: Boolean) {
if (isAccountMode) {
analyticsEventHandler.send(eventBuilder.popupToChooseAccount())
analyticsEventHandler.send(eventBuilder.first().popupToChooseAccount())
}
}
@ -254,7 +252,7 @@ internal class AddToPortfolioModel @Inject constructor(
private fun setupTokenActionsFlow(
selectedPortfolio: SelectedPortfolio,
addedToken: CryptoCurrencyStatus,
): Flow<PortfolioData.CryptoCurrencyData> {
): Flow<CryptoCurrencyData> {
val timeFlow = channelFlow {
val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) }
getCryptoCurrencyActionsUseCase(
@ -274,7 +272,7 @@ internal class AddToPortfolioModel @Inject constructor(
}.collect()
}
return timeFlow.map { actionsState ->
PortfolioData.CryptoCurrencyData(
CryptoCurrencyData(
userWallet = selectedPortfolio.userWallet,
status = actionsState.cryptoCurrencyStatus,
actions = actionsState.states,
@ -291,7 +289,7 @@ internal class AddToPortfolioModel @Inject constructor(
data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null
val availableToAddAccount =
availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null
if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged())
if (!isAccountMode) analyticsEventHandler.send(eventBuilder.first().addToPortfolioWalletChanged())
SelectedPortfolio(
isAccountMode = isAccountMode,
userWallet = availableToAddWallets.userWallet,
@ -327,7 +325,7 @@ internal class AddToPortfolioModel @Inject constructor(
val accountIndex = account.account.account.derivationIndex
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = addToPortfolioManager.token,
tokenMarketParams = addToPortfolioManager.token(),
network = network,
accountIndex = accountIndex,
)
@ -339,7 +337,7 @@ internal class AddToPortfolioModel @Inject constructor(
private fun combineFeatureData() = addToPortfolioManager.state.onEach { state ->
when (state) {
is AddToPortfolioManager.State.AvailableToAdd ->
is AddToPortfolioManager.State.Ready ->
portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus ->
val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId]
?: return@isEnabled false
@ -348,8 +346,7 @@ internal class AddToPortfolioModel @Inject constructor(
?.isAvailableToAdd == true
return@isEnabled isAvailableAccount
}
AddToPortfolioManager.State.Init,
AddToPortfolioManager.State.NothingToAdd,
AddToPortfolioManager.State.Loading,
-> Unit
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.replaceAll
@ -22,17 +22,19 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.account.PortfolioSelectorController
import com.tangem.features.feed.components.market.details.portfolio.add.*
import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.*
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.EarnAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import java.math.BigDecimal
import javax.inject.Inject
import kotlin.collections.get
import kotlin.collections.mapNotNull
@Suppress("LongParameterList")
internal class AddToPortfolioPreselectedDataModel @Inject constructor(
@ -163,7 +165,7 @@ internal class AddToPortfolioPreselectedDataModel @Inject constructor(
}
/**
* Creates [AvailableToAddData] for preselected network when token is already added in all networks.
* Creates [com.tangem.features.commonfeatures.api.AvailableToAddData] for preselected network when token is already added in all networks.
* This allows user to select wallet/account and do smth after it with selected info.
*/
private suspend fun createAvailableToAddDataForPreselectedNetwork(

View file

@ -1,8 +1,8 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import androidx.compose.runtime.Immutable
import com.tangem.core.decompose.navigation.Route
import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import kotlinx.serialization.Serializable
@Serializable

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.tangem.common.ui.addtoken.AddTokenUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -12,11 +12,11 @@ import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork
import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio
import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress
import com.tangem.features.feed.impl.R
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenUiBuilder.Companion.toggleProgress
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
@ -40,7 +40,6 @@ internal class AddTokenModel @Inject constructor(
) : Model() {
private val params = paramsContainer.require<AddTokenComponent.Params>()
private val analyticsEventBuilder = params.eventBuilder
private val addTokenJob = JobHolder()
val uiState: StateFlow<AddTokenUM?>
@ -86,10 +85,11 @@ internal class AddTokenModel @Inject constructor(
uiState.value = um.toggleProgress(true)
val blockchainNames = listOf(selectedNetwork.selectedNetwork)
.mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name }
val analyticsEventBuilder = params.eventBuilder.first()
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames))
analyticsEventHandler.send(analyticsEventBuilder.addButtonClick())
manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency)
manageCryptoCurrenciesUseCase.invokeAndAwait(accountId = accountId, add = listOf(cryptoCurrency))
.onLeft { throwable ->
processError(error = throwable)
uiState.value = um.toggleProgress(false)
@ -100,7 +100,8 @@ internal class AddTokenModel @Inject constructor(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
).firstOrNull()
)
.firstOrNull()
if (status == null) {
processError(error = null)
} else {

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
@ -14,10 +14,10 @@ import com.tangem.core.ui.extensions.iconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.AccountStatus.*
import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork
import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio
import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent
import com.tangem.features.feed.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import com.tangem.features.commonfeatures.impl.R
import javax.inject.Inject
@ModelScoped

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import arrow.core.getOrElse
import com.tangem.core.decompose.ui.UiMessageSender
@ -10,7 +10,7 @@ import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.feed.impl.R
import com.tangem.features.commonfeatures.impl.R
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject

View file

@ -1,13 +1,13 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM
import com.tangem.features.feed.components.market.details.portfolio.impl.model.BlockchainRowUMConverter
import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.BlockchainRowUMConverter
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow

View file

@ -1,7 +1,9 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -10,14 +12,13 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@ -52,18 +53,18 @@ internal class TokenActionsModel @Inject constructor(
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
val uiState: StateFlow<TokenActionsUM?> = params.data
.mapLatest { uiBuilder.build(it, tokenActionsHandler) }
.mapLatest { uiBuilder.build(it, tokenActionsHandler, analyticsEventBuilder.first()) }
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) {
val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action)
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch {
val event = analyticsEventBuilder.first().getTokenActionClick(actionUM = handledAction.action)
analyticsEventHandler.send(event)
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (!isReceive) return
if (!isReceive) return@launch
modelScope.launch {
val tokenConfig = receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,

View file

@ -1,16 +1,17 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter
import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import javax.inject.Inject
@ModelScoped
@ -20,7 +21,11 @@ internal class TokenActionsUiBuilder @Inject constructor(
) {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM {
fun build(
data: CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
): TokenActionsUM {
val status = data.status
val tokenUM = TokenItemState.Content(
id = status.currency.id.value,
@ -35,10 +40,10 @@ internal class TokenActionsUiBuilder @Inject constructor(
return TokenActionsUM(
token = tokenUM,
onLaterClick = {
analyticsEventHandler.send(params.eventBuilder.getTokenLater())
analyticsEventHandler.send(eventBuilder.getTokenLater())
params.callbacks.onLaterClick()
},
quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler),
quickActions = quickActions(data, tokenActionsHandler),
)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
@ -24,8 +24,8 @@ import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM
import com.tangem.features.feed.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM
import com.tangem.features.commonfeatures.impl.R
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID

View file

@ -0,0 +1,111 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.Settings
import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.AvailableToAddDataConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
internal class DefaultAddToPortfolioManager @AssistedInject constructor(
private val availableToAddDataConverter: AvailableToAddDataConverter,
@Assisted override val settings: Settings,
@Assisted override val analyticsParams: AnalyticsParams,
@Assisted val scope: CoroutineScope,
dispatchers: CoroutineDispatcherProvider,
portfolioFetcherFactory: PortfolioFetcher.Factory,
) : AddToPortfolioManager {
override val onDismiss: Channel<Unit> = Channel()
override val onSuccessAdded: Channel<AddToPortfolioManager.Result> = Channel()
override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true),
scope = scope,
)
private val internalParamsFlow = MutableStateFlow(ParamsInternal())
override val paramsFlow = internalParamsFlow
.transform { internalParams ->
val fullParams = AddToPortfolioManager.Params(
networks = internalParams.networks ?: return@transform,
token = internalParams.token ?: return@transform,
)
emit(fullParams)
}
.distinctUntilChanged()
.shareIn(scope = scope, started = SharingStarted.Eagerly, replay = 1)
override val state: MutableStateFlow<AddToPortfolioManager.State> =
MutableStateFlow(AddToPortfolioManager.State.Loading)
init {
buildFlow()
.onEach { newState -> state.update { newState } }
.flowOn(dispatchers.default)
.launchIn(scope)
}
override fun onDismiss() {
onDismiss.trySend(Unit)
}
override fun onSuccessAdded(result: AddToPortfolioManager.Result) {
onSuccessAdded.trySend(result)
}
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
updateInternal(networks = networks)
}
override fun setTokenParams(token: TokenMarketParams) {
updateInternal(token = token)
}
private fun updateInternal(networks: List<TokenMarketInfo.Network>? = null, token: TokenMarketParams? = null) {
internalParamsFlow.update { prev ->
val newParams = ParamsInternal(
networks = networks ?: prev.networks,
token = token ?: prev.token,
)
val shouldReload = prev.networks != newParams.networks || prev.token != newParams.token
if (shouldReload) state.update { AddToPortfolioManager.State.Loading }
return@update newParams
}
}
private fun buildFlow(): Flow<AddToPortfolioManager.State> = combine(
flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(),
flow2 = paramsFlow,
) { balances, (availableNetworks, token) ->
val data = availableToAddDataConverter.convert(
balances = balances,
availableNetworks = availableNetworks.toSet(),
marketParams = token,
)
AddToPortfolioManager.State.Ready(data)
}
@AssistedFactory
interface Factory : AddToPortfolioManager.Factory {
override fun create(
scope: CoroutineScope,
settings: Settings,
analyticsParams: AnalyticsParams,
): DefaultAddToPortfolioManager
}
private data class ParamsInternal(
val networks: List<TokenMarketInfo.Network>? = null,
val token: TokenMarketParams? = null,
)
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
@ -22,6 +22,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.action.QuickActionUM
import com.tangem.common.ui.markets.action.QuickActions
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
@ -37,10 +39,8 @@ import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM
import com.tangem.features.feed.impl.R
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
@ -188,7 +188,7 @@ private class TokenActionsContentPreviewProvider : PreviewParameterProvider<Toke
override val values: Sequence<TokenActionsUM>
get() = sequenceOf(
TokenActionsUM(
quickActions = PortfolioTokenUM.QuickActions(
quickActions = QuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import kotlinx.collections.immutable.ImmutableList

View file

@ -0,0 +1,10 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state
import com.tangem.common.ui.markets.action.QuickActions
import com.tangem.core.ui.components.token.state.TokenItemState
internal data class TokenActionsUM(
val token: TokenItemState,
val quickActions: QuickActions,
val onLaterClick: () -> Unit,
)

View file

@ -19,7 +19,6 @@ dependencies {
implementation(projects.features.disclaimer.api)
implementation(projects.features.tester.api)
implementation(projects.features.createWalletSelection.api)
implementation(projects.features.hotWallet.api)
implementation(projects.features.tangempay.details.api)
/* Project - Core */
@ -49,6 +48,7 @@ dependencies {
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.legacy)
implementation(projects.domain.settings)
implementation(projects.domain.visa)
/* SDK */

View file

@ -6,13 +6,15 @@ import com.tangem.core.decompose.navigation.DummyRouter
import com.tangem.core.navigation.url.DummyUrlOpener
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsUM
import com.tangem.features.details.ui.DetailsScreen
import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
internal class PreviewDetailsComponent : DetailsComponent {
@ -20,9 +22,10 @@ internal class PreviewDetailsComponent : DetailsComponent {
private val previewBlocks = runBlocking {
ItemsBuilder(
router = DummyRouter(),
hotWalletFeatureToggles = object : HotWalletFeatureToggles {
override val isWalletCreationRestrictionEnabled: Boolean = true
override val isTokenSyncEnabled: Boolean = true
hotWalletRestrictionManager = object : HotWalletRestrictionManager {
override fun isCreationEnabled(): StateFlow<Boolean> = MutableStateFlow(true)
override fun isCreationEnabledSync(): Boolean = true
override suspend fun toggleCreationEnabled() = Unit
},
).buildAll(
isWalletConnectAvailable = true,

View file

@ -22,8 +22,6 @@ import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
@ -61,7 +59,6 @@ internal class DetailsModel @Inject constructor(
private val router: Router,
private val urlOpener: UrlOpener,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val appStateHolder: ReduxStateHolder,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
@ -79,9 +76,6 @@ internal class DetailsModel @Inject constructor(
val state: MutableStateFlow<DetailsUM>
init {
// Use to save compatibility with screens that using Redux states
bootstrapScreenState()
val isWalletConnectAvailable = runBlocking {
// danger region, this works immediately, but will be refactored later with WC
checkIsWalletConnectAvailableUseCase(params.userWalletId).getOrElse { throwable ->
@ -122,10 +116,6 @@ internal class DetailsModel @Inject constructor(
.launchIn(modelScope)
}
private fun bootstrapScreenState() {
appStateHolder.dispatch(LegacyAction.PrepareDetailsScreen)
}
private fun sendFeedback() {
modelScope.launch {
val userWallets = getWalletsUseCase.invokeSync()

View file

@ -19,9 +19,8 @@ import com.tangem.domain.wallets.usecase.UnlockWalletUseCase
import com.tangem.features.details.entity.UserWalletListUM
import com.tangem.features.details.entity.WalletReorderUM
import com.tangem.features.details.impl.R
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.features.details.utils.UserWalletSaver
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import com.tangem.features.wallet.utils.UserWalletsFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
@ -40,14 +39,15 @@ internal class UserWalletListModel @Inject constructor(
private val messageSender: UiMessageSender,
override val dispatchers: CoroutineDispatcherProvider,
private val userWalletSaver: UserWalletSaver,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val hotWalletRestrictionManager: HotWalletRestrictionManager,
private val unlockWalletUseCase: UnlockWalletUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val walletFeatureToggles: WalletFeatureToggles,
private val applyUserWalletListSortingUseCase: ApplyUserWalletListSortingUseCase,
) : Model() {
private val isWalletSavingInProgress: MutableStateFlow<Boolean> = MutableStateFlow(value = false)
private val isWalletCreationRestrictionEnabled: StateFlow<Boolean> =
hotWalletRestrictionManager.isCreationEnabled()
private val userWalletsFetcher = userWalletsFetcherFactory.create(
messageSender = messageSender,
onlyMultiCurrency = false,
@ -91,7 +91,7 @@ internal class UserWalletListModel @Inject constructor(
isWalletSavingInProgress = isWalletSavingInProgress,
addNewWalletText = resourceReference(R.string.user_wallet_list_add_button),
walletReorderUM = WalletReorderUM(
isDragEnabled = walletFeatureToggles.isWalletReorderFeatureEnabled && userWallets.size > 1,
isDragEnabled = userWallets.size > 1,
onMove = ::onWalletReorder,
onDragStopped = ::onWalletDragStopped,
),
@ -101,7 +101,7 @@ internal class UserWalletListModel @Inject constructor(
private fun onAddNewWalletClick() {
analyticsEventHandler.send(SignIn.ButtonAddWallet(AnalyticsParam.ScreensSources.Settings))
if (hotWalletFeatureToggles.isWalletCreationRestrictionEnabled) {
if (isWalletCreationRestrictionEnabled.value) {
withProgress(isWalletSavingInProgress) {
userWalletSaver.scanAndSaveUserWallet(modelScope)
}

View file

@ -6,9 +6,9 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.block.model.BlockUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.impl.R
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -20,7 +20,7 @@ private const val TANGEM_PAY_ITEM_ID = "get_tangem_pay"
@ModelScoped
internal class ItemsBuilder @Inject constructor(
private val router: Router,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val hotWalletRestrictionManager: HotWalletRestrictionManager,
) {
@Suppress("LongParameterList")
@ -36,7 +36,7 @@ internal class ItemsBuilder @Inject constructor(
buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
buildUserWalletListBlock().let(::add)
if (hotWalletFeatureToggles.isWalletCreationRestrictionEnabled && hasAnyMobileWallet) {
if (hotWalletRestrictionManager.isCreationEnabledSync() && hasAnyMobileWallet) {
DetailsItemUM.UnderSectionText(
id = "only_one_mobile_wallet_explanation",
text = resourceReference(R.string.only_one_mobile_wallet_explanation),

View file

@ -1,22 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
interface AddToPortfolioComponent : ComposableBottomSheetComponent {
data class Params(
val addToPortfolioManager: AddToPortfolioManager,
val callback: Callback,
val shouldSkipTokenActionsScreen: Boolean = false,
)
interface Callback {
fun onDismiss()
// todo swap add new onSuccess with full data of added token
fun onSuccess(addedToken: CryptoCurrency)
}
interface Factory : ComponentFactory<Params, AddToPortfolioComponent>
}

View file

@ -1,44 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.account.PortfolioFetcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.Serializable
interface AddToPortfolioManager {
// todo swap make updatable
val token: TokenMarketParams
val analyticsParams: AnalyticsParams?
val portfolioFetcher: PortfolioFetcher
val state: StateFlow<State>
val allAvailableNetworks: Flow<List<TokenMarketInfo.Network>>
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>)
sealed interface State {
data object Init : State
data class AvailableToAdd(
val availableToAddData: AvailableToAddData,
) : State
data object NothingToAdd : State
}
@Serializable
data class AnalyticsParams(
val source: String,
)
interface Factory {
fun create(
scope: CoroutineScope,
token: TokenMarketParams,
analyticsParams: AnalyticsParams?,
): AddToPortfolioManager
}
}

View file

@ -1,6 +1,9 @@
package com.tangem.features.feed.entry.components
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.domain.markets.TokenMarketParams
import kotlinx.serialization.Serializable
@ -13,6 +16,9 @@ sealed interface FeedEntryRoute {
val appCurrency: AppCurrency,
val shouldShowPortfolio: Boolean,
val analyticsParams: AnalyticsParams? = null,
val preselectedSection: PreselectedTokenDetailsSection? = null,
val shouldOpenExchanges: Boolean = false,
val exchangesCount: Int? = null,
) : FeedEntryRoute {
@Serializable
@ -23,7 +29,10 @@ sealed interface FeedEntryRoute {
}
@Serializable
data object MarketTokenList : FeedEntryRoute
data class MarketTokenList(
val preselectedOrder: PreselectedMarketsOrder? = null,
val preselectedInterval: PreselectedMarketsInterval? = null,
) : FeedEntryRoute
@Serializable
data class NewsDetail(val articleId: Int, val preselectedArticlesId: List<Int>) : FeedEntryRoute

View file

@ -3,6 +3,6 @@ package com.tangem.features.feed.entry.deeplink
interface MarketsDeepLinkHandler {
interface Factory {
fun create(): MarketsDeepLinkHandler
fun create(params: Map<String, String>): MarketsDeepLinkHandler
}
}

View file

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

View file

@ -1,5 +0,0 @@
package com.tangem.features.feed.entry.featuretoggle
interface FeedFeatureToggle {
val isEarnBlockEnabled: Boolean
}

View file

@ -9,7 +9,7 @@ plugins {
android {
namespace = "com.tangem.features.feed.impl"
packaging {
resources {
merges += "paymentrequest.proto"
@ -17,6 +17,10 @@ android {
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/* Project - API */
api(projects.features.feed.api)
@ -25,6 +29,7 @@ dependencies {
api(projects.features.tokenRecieve.api)
api(projects.features.wallet.api)
api(projects.features.account.api)
api(projects.features.commonFeatures.api)
implementation(projects.features.promoBanners.api)
/* Data */
@ -101,4 +106,10 @@ dependencies {
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -29,6 +29,9 @@ import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.model.FeedEntryModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.EntryContent
import dagger.assisted.Assisted
@ -87,6 +90,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
route = FeedEntryChildFactory.Child.TokenList(
params = DefaultMarketsTokenListComponent.Params(
preselectedSortType = sortBy ?: SortByTypeUM.Rating,
preselectedInterval = MarketsListUM.TrendInterval.H24,
shouldAlwaysShowSearchBar = sortBy == null,
),
),
@ -128,8 +132,8 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
innerRouter.push(FeedEntryChildFactory.Child.Earn)
}
override fun openSearch() {
innerRouter.push(FeedEntryChildFactory.Child.Search)
override fun openSearch(source: String) {
innerRouter.push(FeedEntryChildFactory.Child.Search(source))
}
}
@ -231,11 +235,15 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
paginationConfig = null,
)
},
preselectedSection = entryRoute.preselectedSection,
shouldOpenExchanges = entryRoute.shouldOpenExchanges,
exchangesCount = entryRoute.exchangesCount,
),
)
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
is FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
DefaultMarketsTokenListComponent.Params(
preselectedSortType = SortByTypeUM.Rating,
preselectedSortType = mapOrderToSortType(entryRoute.preselectedOrder),
preselectedInterval = mapIntervalToTrendInterval(entryRoute.preselectedInterval),
shouldAlwaysShowSearchBar = false,
),
)
@ -265,4 +273,24 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}
}
private fun mapOrderToSortType(order: PreselectedMarketsOrder?): SortByTypeUM {
return when (order) {
PreselectedMarketsOrder.Rating -> SortByTypeUM.Rating
PreselectedMarketsOrder.Trending -> SortByTypeUM.Trending
PreselectedMarketsOrder.Buyers -> SortByTypeUM.ExperiencedBuyers
PreselectedMarketsOrder.Gainers -> SortByTypeUM.TopGainers
PreselectedMarketsOrder.Losers -> SortByTypeUM.TopLosers
null -> SortByTypeUM.Rating
}
}
private fun mapIntervalToTrendInterval(interval: PreselectedMarketsInterval?): MarketsListUM.TrendInterval {
return when (interval) {
PreselectedMarketsInterval.H24 -> MarketsListUM.TrendInterval.H24
PreselectedMarketsInterval.W1 -> MarketsListUM.TrendInterval.D7
PreselectedMarketsInterval.D30 -> MarketsListUM.TrendInterval.M1
null -> MarketsListUM.TrendInterval.H24
}
}
internal interface FeedEntryClickIntents : FeedModelClickIntents

View file

@ -11,7 +11,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
@ -65,7 +65,7 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data object Search : Child
data class Search(val source: String) : Child
}
@Suppress("LongMethod")
@ -144,10 +144,18 @@ internal class FeedEntryChildFactory @Inject constructor(
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
)
}
Child.Search -> DefaultSearchComponent(
is Child.Search -> DefaultSearchComponent(
appComponentContext = appComponentContext,
params = DefaultSearchComponent.Params(
onBackClick = onBackClicked,
onMarketTokenClick = { token, currency ->
feedEntryClickIntents.onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.Market.value,
)
},
sourceParams = child.source,
),
)
}

View file

@ -34,7 +34,7 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.earn.EarnModel
import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.earn.EarnContent
@ -148,6 +148,6 @@ internal class DefaultEarnComponent(
data class Params(
val onBackClick: () -> Unit,
val onSearchClicked: () -> Unit,
val onSearchClicked: (source: String) -> Unit,
)
}

View file

@ -20,8 +20,8 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent.Params
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent.Params
import com.tangem.features.feed.model.feed.FeedComponentModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.ui.feed.FeedList

View file

@ -2,7 +2,7 @@ package com.tangem.features.feed.components.feed
import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent
import com.tangem.features.feed.components.earn.EarnTypeFilterComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
internal sealed interface FeedBottomSheetRoute {

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
@ -228,6 +229,9 @@ internal class DefaultMarketsTokenDetailsComponent(
val analyticsParams: AnalyticsParams?,
val onBackClicked: () -> Unit,
val onArticleClick: (articleId: Int, preselectedArticlesId: List<Int>) -> Unit,
val preselectedSection: PreselectedTokenDetailsSection? = null,
val shouldOpenExchanges: Boolean = false,
val exchangesCount: Int? = null,
)
@Serializable

View file

@ -1,28 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.di
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface AddToPortfolioComponentModule {
@Binds
fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory
@Binds
fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory
@Binds
fun bindAddToPortfolioPreselectedDataComponent(
factory: DefaultAddToPortfolioPreselectedDataComponent.Factory,
): AddToPortfolioPreselectedDataComponent.Factory
}

View file

@ -1,71 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.add.impl.converter.AvailableToAddDataConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
internal class DefaultAddToPortfolioManager @AssistedInject constructor(
private val availableToAddDataConverter: AvailableToAddDataConverter,
@Assisted override val token: TokenMarketParams,
@Assisted override val analyticsParams: AddToPortfolioManager.AnalyticsParams?,
@Assisted val scope: CoroutineScope,
dispatchers: CoroutineDispatcherProvider,
portfolioFetcherFactory: PortfolioFetcher.Factory,
) : AddToPortfolioManager {
private val _allAvailableNetworks = MutableSharedFlow<List<TokenMarketInfo.Network>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val allAvailableNetworks: Flow<List<TokenMarketInfo.Network>> = _allAvailableNetworks.asSharedFlow()
override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true),
scope = scope,
)
override val state: StateFlow<AddToPortfolioManager.State> =
combine(
flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(),
flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(),
) { balances, availableNetworks ->
val data = availableToAddDataConverter.convert(
balances = balances,
availableNetworks = availableNetworks,
marketParams = token,
)
if (data.isAvailableToAdd) {
AddToPortfolioManager.State.AvailableToAdd(data)
} else {
AddToPortfolioManager.State.NothingToAdd
}
}
.flowOn(dispatchers.default)
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = AddToPortfolioManager.State.Init,
)
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
_allAvailableNetworks.tryEmit(networks)
}
@AssistedFactory
interface Factory : AddToPortfolioManager.Factory {
override fun create(
scope: CoroutineScope,
token: TokenMarketParams,
analyticsParams: AddToPortfolioManager.AnalyticsParams?,
): DefaultAddToPortfolioManager
}
}

View file

@ -1,10 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
internal data class TokenActionsUM(
val token: TokenItemState,
val quickActions: PortfolioTokenUM.QuickActions,
val onLaterClick: () -> Unit,
)

View file

@ -14,7 +14,7 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel
import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioRoute
@ -66,7 +66,6 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.addToPortfolioManager,
callback = model.addToPortfolioCallback,
),
)
is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create(

View file

@ -1,7 +1,7 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.analytics
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
internal class PortfolioAnalyticsEvent(
event: String,
@ -21,60 +21,6 @@ internal class PortfolioAnalyticsEvent(
},
)
fun popupToChooseAccount() = PortfolioAnalyticsEvent(
event = "Choose Account Opened",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun popupToConfirm() = PortfolioAnalyticsEvent(
event = "Add Token Screen Opened",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToNotMainAccount() = PortfolioAnalyticsEvent(
event = "Button - Add To Account",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addButtonClick() = PortfolioAnalyticsEvent(
event = "Button - Add Token",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(
event = "Wallet Selected",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToPortfolioContinue(blockchainNames: List<String>) = PortfolioAnalyticsEvent(
event = "Token Network Selected",
params = buildMap {
put("Count", blockchainNames.size.toString())
put("Token", tokenSymbol)
put("blockchain", blockchainNames.joinToString(separator = ", "))
if (source != null) put("Source", source)
},
)
fun tokenAdded(blockchainName: String) = PortfolioAnalyticsEvent(
event = "Token Added",
params = buildMap {
put("Token", tokenSymbol)
put("Blockchain", blockchainName)
if (source != null) put("Source", source)
},
)
fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) =
PortfolioAnalyticsEvent(
event = when (actionUM) {
@ -91,25 +37,5 @@ internal class PortfolioAnalyticsEvent(
put("blockchain", blockchainName)
},
)
fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent(
event = when (actionUM) {
TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy"
TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive"
TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange"
TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake"
else -> "error"
},
params = buildMap {
if (source != null) put("Source", source)
},
)
fun getTokenLater() = PortfolioAnalyticsEvent(
event = "Popup Get token - Button Later",
params = buildMap {
if (source != null) put("Source", source)
},
)
}
}

View file

@ -1,33 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.loader
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.TokenActionsState
/**
* Portfolio data. Combined data from all flows that required to setup portfolio
*
* @property walletsWithCurrencies wallets with crypto currency statuses
* @property appCurrency app currency
* @property isBalanceHidden flag that indicates if balance should be hidden
* @property walletsWithBalance wallets with total balance
*
[REDACTED_AUTHOR]
*/
internal data class PortfolioData(
val walletsWithCurrencies: Map<UserWallet, List<CryptoCurrencyData>>,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val walletsWithBalance: Map<UserWalletId, Lce<TokenListError, TotalFiatBalance>>,
) {
data class CryptoCurrencyData(
val userWallet: UserWallet,
val status: CryptoCurrencyStatus,
val actions: List<TokenActionsState.ActionState>,
)
}

View file

@ -5,6 +5,8 @@ import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
@ -28,7 +30,6 @@ import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioHeader
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioListItem
@ -248,7 +249,7 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor(
accountWithAdded.addedCurrency.forEach { currencyStatus ->
val actions = allActions[currencyStatus.currency]?.states.orEmpty()
val value = PortfolioData.CryptoCurrencyData(
val value = CryptoCurrencyData(
userWallet = userWallet,
status = currencyStatus,
actions = actions,

View file

@ -5,6 +5,8 @@ import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -12,14 +14,11 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
@ -57,16 +56,18 @@ internal class MarketsPortfolioModel @Inject constructor(
private val marketsPortfolioDelegate: MarketsPortfolioDelegate = createMarketsPortfolioDelegate()
val bottomSheetNavigation: SlotNavigation<MarketsPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency) = bottomSheetNavigation.dismiss()
}
init {
marketsPortfolioDelegate.combineData()
.onEach { state.value = it }
.flowOn(dispatchers.default)
.launchIn(modelScope)
addToPortfolioManager.onDismiss.receiveAsFlow()
.onEach { bottomSheetNavigation.dismiss() }
.launchIn(modelScope)
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
.onEach { bottomSheetNavigation.dismiss() }
.launchIn(modelScope)
}
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
@ -82,9 +83,9 @@ internal class MarketsPortfolioModel @Inject constructor(
private fun createAddToPortfolioManager(): AddToPortfolioManager {
return addToPortfolioManagerFactory.create(
scope = modelScope,
token = params.token,
analyticsParams = params.analyticsParams?.source?.let { AddToPortfolioManager.AnalyticsParams(it) },
)
settings = AddToPortfolioManager.Settings.DefaultMarket,
analyticsParams = AddToPortfolioManager.AnalyticsParams(params.analyticsParams?.source),
).also { addToPortfolioManager -> addToPortfolioManager.setTokenParams(params.token) }
}
private fun createMarketsPortfolioDelegate(): MarketsPortfolioDelegate {
@ -94,11 +95,14 @@ internal class MarketsPortfolioModel @Inject constructor(
tokenActionsHandler = tokenActionsHandler,
buttonState = addToPortfolioManager.state.map { state ->
when (state) {
is AddToPortfolioManager.State.AvailableToAdd -> {
MyPortfolioUM.Tokens.AddButtonState.Available
is AddToPortfolioManager.State.Ready -> {
if (state.isAvailableToAdd) {
MyPortfolioUM.Tokens.AddButtonState.Available
} else {
MyPortfolioUM.Tokens.AddButtonState.Unavailable
}
}
AddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading
AddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable
AddToPortfolioManager.State.Loading -> MyPortfolioUM.Tokens.AddButtonState.Loading
}
},
onAddClick = {

View file

@ -1,19 +1,16 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
/**
* Converter from [UserWallet] and [CryptoCurrencyStatus] to [PortfolioTokenUM]
@ -25,10 +22,10 @@ internal class PortfolioTokenUMConverter(
private val isBalanceHidden: Boolean,
private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit,
private val tokenActionsHandler: TokenActionsHandler,
) : Converter<PortfolioData.CryptoCurrencyData, PortfolioTokenUM> {
) : Converter<CryptoCurrencyData, PortfolioTokenUM> {
fun convertV2(
value: PortfolioData.CryptoCurrencyData,
value: CryptoCurrencyData,
isQuickActionsShown: Boolean,
onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit,
): PortfolioTokenUM {
@ -45,7 +42,7 @@ internal class PortfolioTokenUMConverter(
)
}
override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM {
override fun convert(value: CryptoCurrencyData): PortfolioTokenUM {
val tokenItemStateConverter = TokenItemStateConverter(
appCurrency = appCurrency,
titleStateProvider = { TokenItemState.TitleState.Content(text = stringReference(value.userWallet.name)) },
@ -63,62 +60,4 @@ internal class PortfolioTokenUMConverter(
quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler),
)
}
companion object {
fun quickActions(
cryptoData: PortfolioData.CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
): PortfolioTokenUM.QuickActions {
return PortfolioTokenUM.QuickActions(
actions = toQuickActions(cryptoData.actions),
onQuickActionClick = { quickActionUM ->
when (quickActionUM) {
QuickActionUM.Buy -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Buy,
cryptoCurrencyData = cryptoData,
)
is QuickActionUM.Exchange -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Exchange,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Receive -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Receive,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Stake -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Stake,
cryptoCurrencyData = cryptoData,
)
is QuickActionUM.YieldMode -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.YieldMode,
cryptoCurrencyData = cryptoData,
)
}
},
onQuickActionLongClick = { actionUM ->
if (actionUM == QuickActionUM.Receive) {
tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.CopyAddress,
cryptoCurrencyData = cryptoData,
)
}
},
)
}
fun toQuickActions(actions: List<TokenActionsState.ActionState>) = buildList {
actions.forEach { action ->
if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) {
when (action) {
is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy
is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.shouldShowBadge)
is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive
is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake
is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy)
else -> null
}?.let(::add)
}
}
}.toImmutableList()
}
}

View file

@ -1,178 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.feed.impl.R
import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList")
internal class TokenActionsHandler @AssistedInject constructor(
private val router: Router,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
) {
private val disabledActionsInDemoMode = buildSet {
add(TokenActionsBSContentUM.Action.Sell)
}
fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
onHandleQuickAction(
HandledQuickAction(
action = action,
cryptoCurrencyData = cryptoCurrencyData,
),
)
val userWallet = cryptoCurrencyData.userWallet
if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return
when (action) {
TokenActionsBSContentUM.Action.Buy -> onBuyClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Exchange -> onExchangeClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Receive -> Unit
TokenActionsBSContentUM.Action.CopyAddress -> onCopyAddress(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Sell -> onSellClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Send -> onSendClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Stake -> onStakeClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.YieldMode -> onYieldModeClick(cryptoCurrencyData)
}
}
private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean {
val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId)
val isNeededShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action)
if (isNeededShowDemoWarning) {
showDemoModeWarning()
}
return isNeededShowDemoWarning
}
private fun showDemoModeWarning() {
val message = DialogMessage(
message = resourceReference(R.string.alert_demo_feature_disabled),
)
messageSender.send(message)
}
private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val cryptoCurrencyStatus = cryptoCurrencyData.status
val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return
val addresses = networkAddress.availableAddresses
.mapToAddressModels(cryptoCurrencyStatus.currency)
.toImmutableList()
val defaultAddress = addresses.firstOrNull()?.value ?: return
clipboardManager.setText(text = defaultAddress, isSensitive = true)
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied)))
}
private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
router.push(
AppRoute.Onramp(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
source = OnrampSource.MARKETS,
),
)
}
private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
router.push(
AppRoute.Swap(
currencyFrom = cryptoCurrencyData.status.currency,
userWalletId = cryptoCurrencyData.userWallet.walletId,
isInitialReverseOrder = true,
screenSource = AnalyticsParam.ScreensSources.Markets.value,
),
)
}
private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val route = AppRoute.SendEntryPoint(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
)
router.push(route)
}
private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake }
?.let { it as TokenActionsState.ActionState.Stake }
?.option ?: return
router.push(
AppRoute.Staking(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrency = cryptoCurrencyData.status.currency,
integrationId = option.integrationId,
),
)
}
private fun onYieldModeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance<TokenActionsState.ActionState.YieldMode>()
.firstOrNull()?.apy ?: return
router.push(
AppRoute.YieldSupplyEntry(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrency = cryptoCurrencyData.status.currency,
apy = yieldSupplyApy,
),
)
}
@AssistedFactory
interface Factory {
fun create(
currentAppCurrency: Provider<AppCurrency>,
onHandleQuickAction: (HandledQuickAction) -> Unit,
): TokenActionsHandler
}
data class HandledQuickAction(
val action: TokenActionsBSContentUM.Action,
val cryptoCurrencyData: PortfolioData.CryptoCurrencyData,
)
}

View file

@ -28,6 +28,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.action.QuickActionUM
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.icons.badge.drawBadge
import com.tangem.core.ui.extensions.resolveReference
@ -36,7 +37,6 @@ import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -3,6 +3,8 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.ui.pre
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.markets.action.QuickActionUM
import com.tangem.common.ui.markets.action.QuickActions
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
@ -125,7 +127,7 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider<MyPortfol
onItemLongClick = {},
),
isQuickActionsShown = false,
quickActions = PortfolioTokenUM.QuickActions(
quickActions = QuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),

View file

@ -2,10 +2,10 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.ui.sta
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.markets.action.QuickActions
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed interface PortfolioListItem {
@ -30,10 +30,4 @@ internal data class PortfolioTokenUM(
val quickActions: QuickActions,
) : PortfolioListItem {
override val id: String = tokenItemState.id
data class QuickActions(
val actions: ImmutableList<QuickActionUM>,
val onQuickActionClick: (QuickActionUM) -> Unit,
val onQuickActionLongClick: (QuickActionUM) -> Unit,
)
}

View file

@ -1,51 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
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.feed.impl.R
@Immutable
internal sealed class QuickActionUM(
val title: TextReference,
val description: TextReference,
@DrawableRes val icon: Int,
val isLongClickAvailable: Boolean = false,
) {
data object Buy : QuickActionUM(
title = resourceReference(R.string.common_buy),
description = resourceReference(R.string.buy_token_description),
icon = R.drawable.ic_plus_24,
)
data class Exchange(
val shouldShowBadge: Boolean,
) : QuickActionUM(
title = resourceReference(R.string.common_exchange),
description = resourceReference(R.string.exсhange_token_description),
icon = R.drawable.ic_exchange_vertical_24,
)
data object Receive : QuickActionUM(
title = resourceReference(R.string.common_receive),
description = resourceReference(R.string.receive_token_description),
icon = R.drawable.ic_arrow_down_24,
isLongClickAvailable = true,
)
data object Stake : QuickActionUM(
title = resourceReference(R.string.common_stake),
description = resourceReference(R.string.stake_token_description),
icon = R.drawable.ic_staking_24,
)
data class YieldMode(
private val apy: String,
) : QuickActionUM(
title = resourceReference(R.string.common_yield_mode),
description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)),
icon = R.drawable.ic_analytics_up_mini_24,
)
}

View file

@ -1,58 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.feed.impl.R
import kotlinx.collections.immutable.ImmutableList
internal data class TokenActionsBSContentUM(
val title: String,
val actions: ImmutableList<Action>,
val onActionClick: (Action) -> Unit,
) : TangemBottomSheetConfigContent {
@Immutable
enum class Action(
val text: TextReference,
@DrawableRes val iconRes: Int,
) {
CopyAddress(
text = resourceReference(R.string.common_copy_address),
iconRes = R.drawable.ic_copy_24,
),
Send(
text = resourceReference(R.string.common_send),
iconRes = R.drawable.ic_arrow_up_24,
),
Receive(
text = resourceReference(R.string.common_receive),
iconRes = R.drawable.ic_arrow_down_24,
),
Buy(
text = resourceReference(R.string.common_buy),
iconRes = R.drawable.ic_plus_24,
),
Sell(
text = resourceReference(R.string.common_sell),
iconRes = R.drawable.ic_currency_24,
),
Exchange(
text = resourceReference(R.string.common_exchange),
iconRes = R.drawable.ic_exchange_horizontal_24,
),
Stake(
text = resourceReference(R.string.common_stake),
iconRes = R.drawable.ic_staking_24,
),
YieldMode(
text = resourceReference(R.string.common_yield_mode),
iconRes = R.drawable.ic_analytics_up_mini_24,
),
;
val order: Int = ordinal
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.features.feed.components.market.list
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
@ -30,6 +29,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.market.list.MarketsListModel
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.market.list.MarketsList
@ -65,9 +65,8 @@ internal class DefaultMarketsTokenListComponent(
.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
endIntensity = .2f,
preferPerformance = true,
easing = EaseOut,
)
},
startContent = {
@ -129,12 +128,13 @@ internal class DefaultMarketsTokenListComponent(
@Serializable
data class Params(
val preselectedSortType: SortByTypeUM,
val preselectedInterval: MarketsListUM.TrendInterval,
val shouldAlwaysShowSearchBar: Boolean,
)
data class ClickIntents(
val onBackClicked: () -> Unit,
val onSearchClicked: () -> Unit,
val onSearchClicked: (source: String) -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
)

View file

@ -1,6 +1,5 @@
package com.tangem.features.feed.components.news.list
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
@ -52,10 +51,10 @@ internal class DefaultNewsListComponent(
modifier = Modifier.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
endIntensity = .2f,
preferPerformance = true,
easing = EaseOut,
)
backgroundColor = background
},
title = resourceReference(R.string.common_news),
type = TangemTopBarType.BottomSheet,

View file

@ -6,15 +6,22 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.ds.field.search.TangemFieldShape
import com.tangem.core.ui.ds.field.search.TangemSearchField
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.search.SearchModel
import com.tangem.features.feed.ui.search.SearchContent
import com.tangem.features.feed.ui.search.state.SearchCallbacks
@ -27,6 +34,13 @@ internal class DefaultSearchComponent(
private val model = getOrCreateModel<SearchModel, Params>(params = params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
@ -67,6 +81,7 @@ internal class DefaultSearchComponent(
contentPadding: PaddingValues,
modifier: Modifier,
) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by model.state.collectAsStateWithLifecycle()
val searchCallbacks = remember {
SearchCallbacks(
@ -74,6 +89,7 @@ internal class DefaultSearchComponent(
onClearHintsClick = model::clearSearchHistory,
onTextHintClick = model::onTextHintClick,
onResultMarketTokenClick = model::onResultMarketTokenClick,
onHistoryTokenClick = model::onHistoryTokenClick,
)
}
SearchContent(
@ -82,9 +98,28 @@ internal class DefaultSearchComponent(
searchCallbacks = searchCallbacks,
contentPadding = contentPadding,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: SearchBottomSheetRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is SearchBottomSheetRoute.TokenSelector -> SearchTokenSelectorComponent(
context = childByContext(componentContext),
params = SearchTokenSelectorComponent.Params(
entries = config.entries,
appCurrency = config.appCurrency,
isBalanceHidden = config.isBalanceHidden,
onTokenSelected = config.onTokenSelected,
onDismiss = config.onDismiss,
),
)
}
data class Params(
val onBackClick: () -> Unit,
val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val sourceParams: String,
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.feed.components.search
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
internal sealed interface SearchBottomSheetRoute {
data class TokenSelector(
val entries: List<UserAssetSearchEntry>,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val onTokenSelected: (UserAssetSearchEntry) -> Unit,
val onDismiss: () -> Unit,
) : SearchBottomSheetRoute
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.feed.components.search
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.model.search.SearchTokenSelectorModel
import com.tangem.features.feed.ui.search.components.TokenSelectorBottomSheet
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
internal class SearchTokenSelectorComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableBottomSheetComponent {
private val model = getOrCreateModel<SearchTokenSelectorModel, Params>(params = params)
override fun dismiss() {
params.onDismiss()
}
@Composable
override fun BottomSheet() {
val state = model.state.collectAsStateWithLifecycle()
TokenSelectorBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = state.value,
),
)
}
data class Params(
val entries: List<UserAssetSearchEntry>,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val onTokenSelected: (UserAssetSearchEntry) -> Unit,
val onDismiss: () -> Unit,
)
}

View file

@ -2,20 +2,31 @@ package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.INTERVAL_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.ORDER_KEY
import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultMarketsDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
appRouter: AppRouter,
) : MarketsDeepLinkHandler {
init {
appRouter.push(AppRoute.Markets)
appRouter.push(
AppRoute.Markets(
preselectedOrder = PreselectedMarketsOrder.parse(queryParams[ORDER_KEY]),
preselectedInterval = PreselectedMarketsInterval.parse(queryParams[INTERVAL_KEY]),
),
)
}
@AssistedFactory
interface Factory : MarketsDeepLinkHandler.Factory {
override fun create(): DefaultMarketsDeepLinkHandler
override fun create(params: Map<String, String>): DefaultMarketsDeepLinkHandler
}
}

View file

@ -2,7 +2,9 @@ package com.tangem.features.feed.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.SECTION_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -11,12 +13,12 @@ import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@ -32,8 +34,15 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc
private fun handleDeepLink() {
val tokenId = queryParams[TOKEN_ID_KEY]
val section = PreselectedTokenDetailsSection.parse(queryParams[SECTION_KEY])
val rawTokenId = CryptoCurrency.RawID(tokenId.orEmpty())
if (tokenId.isNullOrEmpty()) {
TangemLogger.e("Markets token details deeplink does not contain token_id")
appRouter.push(AppRoute.Markets())
return
}
val rawTokenId = CryptoCurrency.RawID(tokenId)
scope.launch {
val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse {
@ -65,6 +74,7 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc
appCurrency = appCurrency,
shouldShowPortfolio = true,
analyticsParams = null,
preselectedSection = section,
),
)
}

View file

@ -0,0 +1,88 @@
package com.tangem.features.feed.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
internal class DefaultMarketsTokenExchangesDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@Assisted private val queryParams: Map<String, String>,
private val appRouter: AppRouter,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
) : MarketsTokenExchangesDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
val tokenId = queryParams[TOKEN_ID_KEY]
if (tokenId.isNullOrEmpty()) {
TangemLogger.e("Markets token exchanges deeplink does not contain token_id")
appRouter.push(AppRoute.Markets())
return
}
val rawTokenId = CryptoCurrency.RawID(tokenId)
scope.launch {
val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse {
AppCurrency.Default
}
val tokenInfo = getTokenMarketInfoUseCase(
appCurrency = appCurrency,
tokenId = rawTokenId,
tokenSymbol = "",
).getOrElse {
TangemLogger.e("Failed to get market token info for exchanges deeplink")
appRouter.push(AppRoute.Markets())
return@launch
}
appRouter.push(
AppRoute.MarketsTokenDetails(
token = TokenMarketParams(
id = rawTokenId,
name = tokenInfo.name,
symbol = tokenInfo.symbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = tokenInfo.quotes.currentPrice,
h24Percent = tokenInfo.quotes.h24ChangePercent,
weekPercent = tokenInfo.quotes.weekChangePercent,
monthPercent = tokenInfo.quotes.monthChangePercent,
),
imageUrl = getTokenIconUrlFromDefaultHost(rawTokenId),
),
appCurrency = appCurrency,
shouldShowPortfolio = true,
shouldOpenExchanges = true,
exchangesCount = tokenInfo.exchangesAmount,
),
)
}
}
@AssistedFactory
interface Factory : MarketsTokenExchangesDeepLinkHandler.Factory {
override fun create(
coroutineScope: CoroutineScope,
queryParams: Map<String, String>,
): DefaultMarketsTokenExchangesDeepLinkHandler
}
}

View file

@ -2,8 +2,10 @@ package com.tangem.features.feed.deeplink.di
import com.tangem.features.feed.deeplink.DefaultMarketsDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultMarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultMarketsTokenExchangesDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -23,4 +25,10 @@ internal interface MarketsDeepLinkModule {
fun bindMarketsTokenDetailDeepLinkHandlerFactory(
impl: DefaultMarketsTokenDetailDeepLinkHandler.Factory,
): MarketsTokenDetailDeepLinkHandler.Factory
@Binds
@Singleton
fun bindMarketsTokenExchangesDeepLinkHandlerFactory(
impl: DefaultMarketsTokenExchangesDeepLinkHandler.Factory,
): MarketsTokenExchangesDeepLinkHandler.Factory
}

View file

@ -1,23 +0,0 @@
package com.tangem.features.feed.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.featuretoggle.DefaultFeedFeatureToggle
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 FeedFeatureToggleModule {
@Provides
@Singleton
fun provideFeedFeatureToggle(featureTogglesManager: FeatureTogglesManager): FeedFeatureToggle {
return DefaultFeedFeatureToggle(
featureTogglesManager = featureTogglesManager,
)
}
}

View file

@ -12,6 +12,7 @@ import com.tangem.features.feed.model.market.list.MarketsListModel
import com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.model.news.list.NewsListModel
import com.tangem.features.feed.model.search.SearchModel
import com.tangem.features.feed.model.search.SearchTokenSelectorModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -71,4 +72,9 @@ internal interface ModelModule {
@IntoMap
@ClassKey(SearchModel::class)
fun provideSearchModel(model: SearchModel): Model
@Binds
@IntoMap
@ClassKey(SearchTokenSelectorModel::class)
fun provideSearchTokenSelectorModel(model: SearchTokenSelectorModel): Model
}

View file

@ -1,13 +0,0 @@
package com.tangem.features.feed.featuretoggle
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
internal class DefaultFeedFeatureToggle(
private val featureTogglesManager: FeatureTogglesManager,
) : FeedFeatureToggle {
override val isEarnBlockEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.EARN_BLOCK_ENABLED)
}

View file

@ -8,6 +8,7 @@ import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -25,7 +26,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent
import com.tangem.features.feed.components.earn.EarnTypeFilterComponent
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent
import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkConverter
import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkUMConverter
@ -325,7 +326,7 @@ internal class EarnModel @Inject constructor(
onNetworkFilterClick = ::onNetworkFilterClick,
onTypeFilterClick = ::onTypeFilterClick,
onScroll = ::onMostlyUsedScrolled,
onSearchBarClicked = params.onSearchClicked,
onSearchBarClicked = { params.onSearchClicked(AnalyticsParam.ScreensSources.Earn.value) },
),
)
}

View file

@ -50,30 +50,6 @@ internal sealed class EarnAnalyticsEvent(
),
)
data class AddTokenScreenOpened(
private val tokenSymbol: String,
private val blockchain: String,
private val source: String,
) : EarnAnalyticsEvent(
event = "Add Token Screen Opened",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to tokenSymbol,
AnalyticsParam.BLOCKCHAIN to blockchain,
AnalyticsParam.SOURCE to source,
),
)
data class TokenAdded(
private val tokenSymbol: String,
private val blockchain: String,
) : EarnAnalyticsEvent(
event = "Token Added",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to tokenSymbol,
AnalyticsParam.BLOCKCHAIN to blockchain,
),
)
data class BestOpportunitiesLoadError(
private val code: Int?,
private val message: String,

View file

@ -31,8 +31,7 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
@ -63,7 +62,6 @@ internal class FeedComponentModel @Inject constructor(
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val stateController: FeedStateController,
private val feedFeatureToggle: FeedFeatureToggle,
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase,
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase,
private val appRouter: AppRouter,
@ -147,7 +145,6 @@ internal class FeedComponentModel @Inject constructor(
}
},
analyticsEventHandler = analyticsEventHandler,
feedFeatureToggle = feedFeatureToggle,
)
val currentState = stateController.value
@ -171,7 +168,7 @@ internal class FeedComponentModel @Inject constructor(
analyticsEventHandler = analyticsEventHandler,
),
UpdateEarnStateTransformer(
isEarnEnabled = feedFeatureToggle.isEarnBlockEnabled,
isEarnEnabled = true,
onItemClick = ::handleEarnTokenClick,
onRetryClick = ::fetchEarnData,
earnResult = earnResult,
@ -206,7 +203,6 @@ internal class FeedComponentModel @Inject constructor(
}
private fun fetchEarnData() {
if (!feedFeatureToggle.isEarnBlockEnabled) return
modelScope.launch(dispatchers.default) {
stateController.update(UpdateEarnLoadingStateTransformer())
fetchTopEarnTokensUseCase()
@ -244,7 +240,7 @@ internal class FeedComponentModel @Inject constructor(
onBarClick = {
analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked())
if (designFeatureToggles.isRedesignEnabled) {
params.feedClickIntents.openSearch()
params.feedClickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value)
} else {
params.feedClickIntents.onMarketOpenClick(null)
}
@ -280,11 +276,7 @@ internal class FeedComponentModel @Inject constructor(
currentSortByType = SortByTypeUM.TopGainers,
),
globalState = GlobalFeedState.Loading,
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
EarnListUM.Empty
},
earnListUM = EarnListUM.Loading,
)
}

View file

@ -30,5 +30,5 @@ internal interface FeedModelClickIntents {
fun onOpenEarnPage()
fun openSearch()
fun openSearch(source: String)
}

View file

@ -2,7 +2,6 @@ package com.tangem.features.feed.model.feed.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.model.feed.state.transformers.FeedListUMTransformer
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
@ -16,9 +15,7 @@ import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class FeedStateController @Inject constructor(
private val feedFeatureToggle: FeedFeatureToggle,
) {
internal class FeedStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<FeedListUM> = MutableStateFlow(value = getInitialState())
@ -67,11 +64,7 @@ internal class FeedStateController @Inject constructor(
currentSortByType = SortByTypeUM.Trending,
),
globalState = GlobalFeedState.Loading,
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
EarnListUM.Empty
},
earnListUM = EarnListUM.Loading,
)
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
@ -19,16 +18,13 @@ internal class UpdateGlobalFeedStateTransformer(
private val earnResult: EarnTopToken?,
private val onRetryClicked: () -> Unit,
private val analyticsEventHandler: AnalyticsEventHandler,
private val feedFeatureToggle: FeedFeatureToggle,
) : FeedListUMTransformer {
override fun transform(prevState: FeedListUM): FeedListUM {
val blockStates = buildList {
add(getNewsState(prevState))
add(getChartsState())
if (feedFeatureToggle.isEarnBlockEnabled) {
add(getEarnState(prevState))
}
add(getEarnState(prevState))
}
val newGlobalState = when {

View file

@ -5,6 +5,7 @@ import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.TangemSiteShareUrlBuilder
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.sorted
@ -21,6 +22,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
@ -95,6 +97,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private val quotesJob = JobHolder()
private var userCountry: UserCountry? = null
private var isScrollToSectionHandled = false
private val params = paramsContainer.require<DefaultMarketsTokenDetailsComponent.Params>()
private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token)
@ -297,6 +300,15 @@ internal class MarketsTokenDetailsModel @Inject constructor(
initialLoad()
loadRelatedNews()
if (params.shouldOpenExchanges) {
modelScope.launch {
val exchangesCount = params.exchangesCount
?: currentTokenInfo.value?.exchangesAmount
?: 0
onListedOnClick(exchangesCount)
}
}
}
private fun initialLoad() {
@ -519,6 +531,14 @@ internal class MarketsTokenDetailsModel @Inject constructor(
description = descriptionConverter.convert(newInfo),
infoBlocks = infoConverter.convert(newInfo),
),
scrollToSection = if (!isScrollToSectionHandled) {
mapSectionToKey(params.preselectedSection)?.let { key ->
isScrollToSectionHandled = true
triggeredEvent(data = key, onConsume = ::consumeScrollToSection)
} ?: consumedEvent()
} else {
marketsTokenDetailsUM.scrollToSection
},
)
}
@ -526,7 +546,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
val networks = newInfo.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
networkId = network.networkId,
excludedBlockchains = excludedBlockchains,
hotExcludedBlockchains = hotWalletExcludedBlockchains,
hasOnlyHotWallets = isAllWalletsIsHot,
@ -755,6 +775,17 @@ internal class MarketsTokenDetailsModel @Inject constructor(
)
}
private fun mapSectionToKey(section: PreselectedTokenDetailsSection?): String? {
return when (section) {
PreselectedTokenDetailsSection.News -> MarketsTokenDetailsUM.RelatedNews.SECTION_KEY
null -> null
}
}
private fun consumeScrollToSection() {
state.update { it.copy(scrollToSection = consumedEvent()) }
}
private companion object {
const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L
const val RELATED_NEWS_LIMIT = 10

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -67,9 +68,10 @@ internal class MarketsListModel @Inject constructor(
onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) },
shouldAlwaysShowSearchBar = Provider { modelParams.params.shouldAlwaysShowSearchBar },
preselectedSortType = Provider { modelParams.params.preselectedSortType },
preselectedInterval = Provider { modelParams.params.preselectedInterval },
onBackClick = modelParams.clickIntents.onBackClicked,
analyticsEventHandler = analyticsEventHandler,
onSearchBarClick = modelParams.clickIntents.onSearchClicked,
onSearchBarClick = { modelParams.clickIntents.onSearchClicked(AnalyticsParam.ScreensSources.Market.value) },
)
}

View file

@ -141,6 +141,21 @@ internal class MarketsListBatchFlowManager(
initialValue = false,
)
val initialLoadingError: Flow<Throwable> = batchFlow.state
.map { it.status }
.distinctUntilChanged()
.filterIsInstance<PaginationStatus.InitialLoadingError>()
.map { it.throwable }
val totalCount: StateFlow<Int?> = batchFlow.state
.map { it.totalCount }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
val isSearchNotFoundState = batchFlow.state
.map { batchListState ->
currentSearchText().isNullOrEmpty().not() &&

View file

@ -27,6 +27,7 @@ internal class MarketsListUMStateManager(
private val shouldAlwaysShowSearchBar: Provider<Boolean>,
private val currentVisibleIds: Provider<List<CryptoCurrency.RawID>>,
private val preselectedSortType: Provider<SortByTypeUM>,
private val preselectedInterval: Provider<MarketsListUM.TrendInterval>,
private val onLoadMoreUiItems: () -> Unit,
private val visibleItemsChanged: (itemsKeys: List<CryptoCurrency.RawID>) -> Unit,
private val onRetryButtonClicked: () -> Unit,
@ -229,7 +230,7 @@ internal class MarketsListUMStateManager(
shouldAlwaysShowSearchBar = shouldAlwaysShowSearchBar(),
),
selectedSortBy = preselectedSortType(),
selectedInterval = MarketsListUM.TrendInterval.H24,
selectedInterval = preselectedInterval(),
onIntervalClick = { selectedInterval = it },
onSortByButtonClick = { isSortByBottomSheetShown = true },
sortByBottomSheet = TangemBottomSheetConfig(

View file

@ -1,65 +1,72 @@
package com.tangem.features.feed.model.search
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.GetTokenPriceChartUseCase
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.markets.*
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase
import com.tangem.domain.search.usecase.GetSearchResultsUseCase
import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase
import com.tangem.domain.search.usecase.SaveSearchQueryUseCase
import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.feed.components.search.SearchBottomSheetRoute
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager
import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecentSearchTokenConverter
import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency
import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter
import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency
import com.tangem.features.feed.model.search.analytics.SearchAnalyticsHelper
import com.tangem.features.feed.model.search.converter.*
import com.tangem.features.feed.model.search.state.SearchStateController
import com.tangem.features.feed.model.search.state.transformers.*
import com.tangem.features.feed.ui.search.state.*
import com.tangem.features.feed.ui.search.state.MarketSearchResultUM
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import com.tangem.features.feed.ui.search.state.TextHintItemUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L
private const val MARKET_SEARCH_DEBOUNCE_MS = 500L
private const val RESULTS_SHOWN_DEBOUNCE_MS = 1000L
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
@ModelScoped
internal class SearchModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getSearchResultsUseCase: GetSearchResultsUseCase,
private val saveSearchQueryUseCase: SaveSearchQueryUseCase,
private val saveRecentSearchTokenUseCase: SaveRecentSearchTokenUseCase,
private val clearSearchHistoryUseCase: ClearSearchHistoryUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val appRouter: AppRouter,
private val stateController: SearchStateController,
private val searchAnalyticsHelper: SearchAnalyticsHelper,
) : Model() {
private val params = paramsContainer.require<DefaultSearchComponent.Params>()
@ -68,7 +75,6 @@ internal class SearchModel @Inject constructor(
private val searchResultsJob = JobHolder()
private val marketSearchDebounceJob = JobHolder()
private var shouldShowAllTokensIncludingUnder100k = false
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
@ -77,6 +83,13 @@ internal class SearchModel @Inject constructor(
initialValue = AppCurrency.Default,
)
private val isBalanceHidden = getBalanceHidingSettingsUseCase.isBalanceHidden()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
private val marketsListItemToRecentSearchTokenConverter by lazy {
MarketsListItemUMToRecentSearchTokenConverter()
}
@ -102,6 +115,8 @@ internal class SearchModel @Inject constructor(
)
}
val bottomSheetNavigation: SlotNavigation<SearchBottomSheetRoute> = SlotNavigation()
val state: StateFlow<SearchUM> get() = stateController.uiState
init {
@ -110,7 +125,10 @@ internal class SearchModel @Inject constructor(
subscribeToMarketUiItems()
subscribeToQuotesPolling()
subscribeToAppCurrencyChanges()
subscribeToMarketLoadingErrors()
subscribeToResultsShown()
loadHistory()
searchAnalyticsHelper.sendSearchScreenOpened(params.sourceParams)
}
fun loadMore() {
@ -126,11 +144,14 @@ internal class SearchModel @Inject constructor(
fun clearSearchHistory() {
modelScope.launch(dispatchers.default) {
clearSearchHistoryUseCase()
searchAnalyticsHelper.sendClearButtonClicked()
}
}
fun onTextHintClick(text: String) {
stateController.update(UpdateSearchBarQueryTransformer(text))
searchAnalyticsHelper.sendHintClicked(text)
searchAnalyticsHelper.sendSearchStarted()
}
fun onResultMarketTokenClick(item: MarketsListItemUM) {
@ -143,17 +164,40 @@ internal class SearchModel @Inject constructor(
)
saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input))
saveSearchQueryUseCase(stateController.value.searchBar.query)
searchAnalyticsHelper.sendMarketItemClicked(item.currencySymbol)
withContext(dispatchers.mainImmediate) {
searchMarketsListManager.getTokenById(item.id)?.let { found ->
params.onMarketTokenClick(found.toSerializableParam(), appCurrency)
}
}
}
}
fun onHistoryTokenClick(item: MarketsListItemUM) {
val tokenMarketParams = TokenMarketParams(
id = item.id,
name = item.name,
symbol = item.currencySymbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = item.price.fiatPrice,
h24Percent = null,
weekPercent = null,
monthPercent = null,
),
imageUrl = item.iconUrl,
)
searchAnalyticsHelper.sendRecentItemClicked(item.currencySymbol)
params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value)
}
private fun initCallbacks() {
stateController.update(object : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(
onQueryChange = ::onQueryChange,
onActiveChange = ::onActiveChange,
onClearClick = ::onClearClick,
onCancelClick = params.onBackClick,
),
)
}
@ -162,16 +206,41 @@ internal class SearchModel @Inject constructor(
private fun onQueryChange(query: String) {
stateController.update(UpdateSearchBarQueryTransformer(query))
}
private fun onActiveChange(isActive: Boolean) {
if (!isActive) params.onBackClick()
searchAnalyticsHelper.sendSearchStarted()
}
private fun onClearClick() {
stateController.update(UpdateSearchBarQueryTransformer(""))
}
private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) {
searchAnalyticsHelper.sendPortfolioItemClicked(entry.currencyStatus.currency.symbol)
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = entry.userWalletId,
currency = entry.currencyStatus.currency,
),
)
}
private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) {
searchAnalyticsHelper.sendGroupClicked(grouped.tokenSymbol)
bottomSheetNavigation.activate(
SearchBottomSheetRoute.TokenSelector(
entries = grouped.entries,
appCurrency = currentAppCurrency.value,
isBalanceHidden = isBalanceHidden.value,
onTokenSelected = ::onTokenSelectedFromGroup,
onDismiss = { bottomSheetNavigation.dismiss() },
),
)
}
private fun onTokenSelectedFromGroup(entry: UserAssetSearchEntry) {
bottomSheetNavigation.dismiss()
onSingleUserAssetClick(entry)
}
private fun subscribeToQueryChanges() {
stateController.uiState
.map { it.searchBar.query.trim() }
@ -200,20 +269,21 @@ internal class SearchModel @Inject constructor(
private fun subscribeToSearchResults(query: String) {
modelScope.launch {
getSearchResultsUseCase(query = query).collectLatest { searchResult ->
val userAssets = searchResult.userAssets.map { entry ->
UserAssetItemUM(
id = "${entry.userWalletId.stringValue}_${entry.accountId.value}" +
"_${entry.currencyStatus.currency.id.value}",
tokenIconUrl = entry.currencyStatus.currency.iconUrl,
tokenName = entry.currencyStatus.currency.name,
tokenSymbol = entry.currencyStatus.currency.symbol,
accountName = entry.accountName.toDisplayString(),
onClick = {
// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task.
},
)
}.toImmutableList()
combine(
getSearchResultsUseCase(query = query),
currentAppCurrency,
isBalanceHidden,
) { searchResult, appCurrency, balanceHidden ->
val converter = UserAssetSearchItemConverter(
appCurrency = appCurrency,
isBalanceHidden = balanceHidden,
onSingleClick = ::onSingleUserAssetClick,
onGroupedClick = ::onGroupedUserAssetClick,
)
searchResult.userAssets
.map(converter::convert)
.toImmutableList()
}.collectLatest { userAssets ->
stateController.update(UpdateUserAssetsTransformer(userAssets))
}
}.saveIn(searchResultsJob)
@ -235,6 +305,50 @@ internal class SearchModel @Inject constructor(
}.launchIn(modelScope)
}
private fun subscribeToMarketLoadingErrors() {
searchMarketsListManager.initialLoadingError
.onEach { throwable ->
val (code, message) = when (throwable) {
is ApiResponseError.HttpException ->
throwable.code.numericCode to throwable.message.orEmpty()
else -> null to throwable.message.orEmpty()
}
searchAnalyticsHelper.sendErrorMarketsData(code, message)
}
.launchIn(modelScope)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
private fun subscribeToResultsShown() {
stateController.uiState
.map { it.searchBar.query.trim() }
.distinctUntilChanged()
.flatMapLatest { query ->
if (query.isEmpty()) {
emptyFlow()
} else {
combine(
searchMarketsListManager.totalCount.filterNotNull(),
stateController.uiState.map { state ->
(state.content as? SearchContentUM.Results)?.userAssets?.size ?: 0
}.distinctUntilChanged(),
) { marketCount, userAssetsCount ->
marketCount to userAssetsCount
}
.debounce(RESULTS_SHOWN_DEBOUNCE_MS)
.take(1)
}
}
.onEach { (marketCount, userAssetsCount) ->
searchAnalyticsHelper.sendResultShown(
totalResultsCount = marketCount + userAssetsCount,
marketsResultsCount = marketCount,
userTokensResultsCount = userAssetsCount,
)
}
.launchIn(modelScope)
}
private fun subscribeToMarketUiItems() {
combine(
flow = stateController.uiState.map { it.searchBar.query }.distinctUntilChanged(),
@ -256,6 +370,7 @@ internal class SearchModel @Inject constructor(
)
}
.filterNotNull()
.distinctUntilChanged()
.onEach { snapshot ->
stateController.update(ApplySearchMarketBatchTransformer(snapshot))
}
@ -334,13 +449,6 @@ internal class SearchModel @Inject constructor(
}
}
private fun AccountName.toDisplayString(): String {
return when (this) {
is AccountName.DefaultMain -> "Main" // TODO [REDACTED_TASK_KEY] localize
is AccountName.Custom -> value
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {

View file

@ -0,0 +1,38 @@
package com.tangem.features.feed.model.search
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.feed.components.search.SearchTokenSelectorComponent
import com.tangem.features.feed.model.search.state.TokenSelectorStateController
import com.tangem.features.feed.model.search.state.transformers.BuildTokenSelectorSectionsTransformer
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@Stable
@ModelScoped
internal class SearchTokenSelectorModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val stateController: TokenSelectorStateController,
) : Model() {
private val params = paramsContainer.require<SearchTokenSelectorComponent.Params>()
val state: StateFlow<TokenSelectorContentUM>
get() = stateController.uiState
init {
stateController.update(
BuildTokenSelectorSectionsTransformer(
entries = params.entries,
appCurrency = params.appCurrency,
isBalanceHidden = params.isBalanceHidden,
onTokenSelected = params.onTokenSelected,
),
)
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.features.feed.model.search.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR
internal sealed class SearchAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Search", event = event, params = params) {
data class SearchScreenOpened(
private val screensSource: String,
) : SearchAnalyticsEvent(
event = "Search Screen Opened",
params = mapOf(SOURCE to screensSource),
)
class SearchStarted : SearchAnalyticsEvent(event = "Search Started")
data class ResultsShown(
private val totalResultsCount: Int,
private val marketsResultsCount: Int,
private val userTokensResultsCount: Int,
) : SearchAnalyticsEvent(
event = "Results Shown",
params = mapOf(
"Total Results" to totalResultsCount.toString(),
"User Tokens Count" to userTokensResultsCount.toString(),
"Market Tokens Count" to marketsResultsCount.toString(),
),
)
data class ErrorMarketsData(
private val code: Int?,
private val message: String,
) : SearchAnalyticsEvent(
event = "Error - Markets Data",
params = mapOf(
ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(),
ERROR_MESSAGE to message,
),
)
data class PortfolioItemClicked(
private val tokenSymbol: String,
) : SearchAnalyticsEvent(
event = "Portfolio Item Clicked",
params = mapOf(TOKEN_PARAM to tokenSymbol),
)
data class HintClicked(
private val hint: String,
) : SearchAnalyticsEvent(
event = "Hint Clicked",
params = mapOf("Text" to hint),
)
data class RecentItemClicked(
private val tokenSymbol: String,
) : SearchAnalyticsEvent(
event = "Recent Item Clicked",
params = mapOf(TOKEN_PARAM to tokenSymbol),
)
data class MarketItemClicked(
private val tokenSymbol: String,
) : SearchAnalyticsEvent(
event = "Market Item Clicked",
params = mapOf(TOKEN_PARAM to tokenSymbol),
)
data class GroupClicked(
private val tokenSymbol: String,
) : SearchAnalyticsEvent(
event = "Group Clicked",
params = mapOf(TOKEN_PARAM to tokenSymbol),
)
class ButtonClearHistoryClick : SearchAnalyticsEvent(event = "Button - Clear History")
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.feed.model.search.analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import javax.inject.Inject
class SearchAnalyticsHelper @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
) {
private var isSearchStartedWasSent = false
fun sendSearchScreenOpened(source: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.SearchScreenOpened(source))
}
fun sendMarketItemClicked(tokenSymbol: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.MarketItemClicked(tokenSymbol))
}
fun sendClearButtonClicked() {
analyticsEventHandler.send(SearchAnalyticsEvent.ButtonClearHistoryClick())
}
fun sendHintClicked(text: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.HintClicked(text))
}
fun sendRecentItemClicked(tokenSymbol: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.RecentItemClicked(tokenSymbol))
}
fun sendSearchStarted() {
if (isSearchStartedWasSent) return
analyticsEventHandler.send(SearchAnalyticsEvent.SearchStarted())
isSearchStartedWasSent = true
}
fun sendPortfolioItemClicked(tokenSymbol: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.PortfolioItemClicked(tokenSymbol))
}
fun sendGroupClicked(tokenSymbol: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.GroupClicked(tokenSymbol))
}
fun sendResultShown(totalResultsCount: Int, marketsResultsCount: Int, userTokensResultsCount: Int) {
analyticsEventHandler.send(
SearchAnalyticsEvent.ResultsShown(
totalResultsCount = totalResultsCount,
marketsResultsCount = marketsResultsCount,
userTokensResultsCount = userTokensResultsCount,
),
)
}
fun sendErrorMarketsData(code: Int?, message: String) {
analyticsEventHandler.send(
SearchAnalyticsEvent.ErrorMarketsData(
code = code,
message = message,
),
)
}
}

View file

@ -0,0 +1,176 @@
package com.tangem.features.feed.model.search.converter
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
internal class UserAssetSearchItemConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onSingleClick: (UserAssetSearchEntry) -> Unit,
private val onGroupedClick: (UserAssetSearchItem.Grouped) -> Unit,
) : Converter<UserAssetSearchItem, UserAssetItemUM> {
override fun convert(value: UserAssetSearchItem): UserAssetItemUM {
return when (value) {
is UserAssetSearchItem.Single -> convertSingle(value.entry)
is UserAssetSearchItem.Grouped -> convertGrouped(value)
}
}
private fun convertSingle(entry: UserAssetSearchEntry): UserAssetItemUM.Single {
val currency = entry.currencyStatus.currency
val value = entry.currencyStatus.value
return UserAssetItemUM.Single(
id = "${entry.userWalletId.stringValue}_${entry.accountId.value}_${currency.id.value}",
icon = TangemIconUM.Currency(
currencyIconState = CryptoCurrencyToIconStateConverter().convert(entry.currencyStatus),
),
tokenName = currency.name,
tokenSymbol = currency.symbol,
fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) },
priceChangeState = when (value) {
is CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.NoAmount,
-> PriceChangeState.Unknown
else -> PriceChangeState.Content(
type = PriceChangeType.fromBigDecimal(value.priceChange.orZero()),
valueInPercent = value.priceChange.format { percent() },
)
},
balanceState = convertSingleBalanceState(value, currency.symbol, currency.decimals),
isBalanceHidden = isBalanceHidden,
onClick = { onSingleClick(entry) },
networkName = entry.currencyStatus.currency.network.name,
)
}
private fun convertSingleBalanceState(
value: CryptoCurrencyStatus.Value,
symbol: String,
decimals: Int,
): BalanceDisplayState {
return when {
value is CryptoCurrencyStatus.Loading && value.amount != null ->
BalanceDisplayState.Flickering(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol,
) ?: stringReference(StringsSigns.DASH_SIGN),
)
value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading
value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable
value.isError && value.amount != null ->
BalanceDisplayState.Stale(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol,
) ?: stringReference(StringsSigns.DASH_SIGN),
)
value.isError -> BalanceDisplayState.Unreachable
else -> BalanceDisplayState.Loaded(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol,
) ?: stringReference(StringsSigns.DASH_SIGN),
)
}
}
private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped {
val firstCurrency = item.entries.first().currencyStatus.currency
val entryCurrencyStatus = item.entries.first().currencyStatus
return UserAssetItemUM.Grouped(
id = "grouped_${item.tokenName}_${item.tokenSymbol}",
icon = TangemIconUM.Currency(
currencyIconState = CurrencyIconState.CoinIcon(
url = entryCurrencyStatus.currency.iconUrl,
fallbackResId = entryCurrencyStatus.currency.networkIconResId,
isGrayscale = entryCurrencyStatus.currency.network.isTestnet || entryCurrencyStatus.value.isError,
shouldShowCustomBadge = entryCurrencyStatus.currency.isCustom,
),
),
tokenName = item.tokenName,
tokenSymbol = item.tokenSymbol,
tokensCount = item.entries.size,
balanceState = convertGroupedBalanceState(item.entries, firstCurrency.symbol, firstCurrency.decimals),
isBalanceHidden = isBalanceHidden,
onClick = { onGroupedClick(item) },
)
}
private fun convertGroupedBalanceState(
entries: List<UserAssetSearchEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState {
val hasAnyLoading = entries.any { it.currencyStatus.value is CryptoCurrencyStatus.Loading }
val hasAnyError = entries.any { it.currencyStatus.value.isError }
val hasAnyAmount = entries.any { it.currencyStatus.value.amount != null }
val isAllError = entries.all { it.currencyStatus.value.isError }
val balance = when {
hasAnyLoading && !hasAnyAmount -> BalanceDisplayState.Loading
hasAnyLoading && hasAnyAmount -> computeGroupBalanceFlickering(entries, symbol, decimals)
isAllError -> BalanceDisplayState.Unreachable
hasAnyError && entries.size == 1 && !hasAnyAmount -> BalanceDisplayState.Unreachable
hasAnyError && entries.size > 1 -> computeGroupBalance(entries, symbol, decimals)
else -> computeGroupBalance(entries, symbol, decimals)
}
return balance
}
private fun computeGroupBalance(
entries: List<UserAssetSearchEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState.Loaded {
val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO }
return BalanceDisplayState.Loaded(
cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)),
fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol),
)
}
private fun computeGroupBalanceFlickering(
entries: List<UserAssetSearchEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState.Flickering {
val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO }
return BalanceDisplayState.Flickering(
cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)),
fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol),
)
}
private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String {
return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.feed.model.search.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.features.feed.model.search.state.transformers.TokenSelectorUMTransformer
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class TokenSelectorStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<TokenSelectorContentUM> = MutableStateFlow(
value = TokenSelectorContentUM(sections = persistentListOf()),
)
val uiState: StateFlow<TokenSelectorContentUM> get() = mutableUiState.asStateFlow()
fun update(transformer: TokenSelectorUMTransformer) {
mutableUiState.update(function = transformer::transform)
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.common.ui.account.toUM
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.AccountHeaderData
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
import kotlinx.collections.immutable.toImmutableList
internal class BuildTokenSelectorSectionsTransformer(
private val entries: List<UserAssetSearchEntry>,
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onTokenSelected: (UserAssetSearchEntry) -> Unit,
) : TokenSelectorUMTransformer {
private val entryConverter = TokenSelectorEntryConverter(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
onTokenSelected = onTokenSelected,
)
override fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM {
return TokenSelectorContentUM(sections = buildSections().toImmutableList())
}
private fun buildSections(): List<TokenSelectorSectionUM> {
val sections = mutableListOf<TokenSelectorSectionUM>()
val byWallet = entries.groupBy { it.userWalletId }
val shouldShowWalletHeaders = byWallet.size > 1
for ((_, walletEntries) in byWallet) {
if (shouldShowWalletHeaders) {
sections.add(
TokenSelectorSectionUM.WalletHeader(
walletName = walletEntries.first().userWalletName,
),
)
}
val byAccount = walletEntries.groupBy { it.accountId }
val shouldShowAccountHeaders = byAccount.size > 1
for ((_, accountEntries) in byAccount) {
val singles = entryConverter.convertList(accountEntries).toImmutableList()
val accountHeader = if (shouldShowAccountHeaders) {
val firstEntry = accountEntries.first()
AccountHeaderData(
accountName = firstEntry.accountName.toUM().value,
cryptoPortfolioIcon = firstEntry.accountIcon,
)
} else {
null
}
sections.add(
TokenSelectorSectionUM.TokenGroup(
accountHeader = accountHeader,
items = singles,
),
)
}
}
return sections
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
internal class TokenSelectorEntryConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onTokenSelected: (UserAssetSearchEntry) -> Unit,
) : Converter<UserAssetSearchEntry, UserAssetItemUM.Single> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: UserAssetSearchEntry): UserAssetItemUM.Single {
val currency = value.currencyStatus.currency
val currencyValue = value.currencyStatus.value
return UserAssetItemUM.Single(
id = "${value.userWalletId.stringValue}_${value.accountId.value}_${currency.id.value}",
icon = TangemIconUM.Currency(
currencyIconState = iconConverter.convert(value.currencyStatus),
),
tokenName = currency.name,
tokenSymbol = currency.symbol,
fiatRate = currencyValue.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) },
priceChangeState = when (currencyValue) {
is CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.NoAmount,
-> PriceChangeState.Unknown
else -> PriceChangeState.Content(
type = PriceChangeType.fromBigDecimal(currencyValue.priceChange.orZero()),
valueInPercent = currencyValue.priceChange.format { percent() },
)
},
balanceState = convertBalanceState(currencyValue, currency.symbol, currency.decimals),
isBalanceHidden = isBalanceHidden,
onClick = { onTokenSelected(value) },
networkName = value.currencyStatus.currency.network.name,
)
}
private fun convertBalanceState(
value: CryptoCurrencyStatus.Value,
symbol: String,
decimals: Int,
): BalanceDisplayState {
return when {
value is CryptoCurrencyStatus.Loading && value.amount != null ->
BalanceDisplayState.Flickering(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = stringReference(
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
?: StringsSigns.DASH_SIGN,
),
)
value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading
value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable
value.isError && value.amount != null ->
BalanceDisplayState.Stale(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = stringReference(
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
?: StringsSigns.DASH_SIGN,
),
)
value.isError -> BalanceDisplayState.Unreachable
else -> BalanceDisplayState.Loaded(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = stringReference(
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
?: StringsSigns.DASH_SIGN,
),
)
}
}
private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String {
return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN
}
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
internal interface TokenSelectorUMTransformer {
fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM
}

View file

@ -0,0 +1,99 @@
package com.tangem.features.feed.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
private const val MIN_STACKED_ICON_COUNT = 1
private const val MAX_STACKED_ICON_COUNT = 3
private const val FIRST_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER = 1
private const val SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER = 2
private const val MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER = 2
private const val FIRST_BACK_LAYER_ICON_ALPHA = 0.4f
private const val SECOND_BACK_LAYER_ICON_ALPHA = 0.2f
@Composable
fun LayeringIcons(
tangemIconUM: TangemIconUM,
modifier: Modifier = Modifier,
count: Int = MIN_STACKED_ICON_COUNT,
layerHorizontalShift: Dp = TangemTheme.dimens2.x1,
iconSize: Dp = TangemTheme.dimens2.x10,
) {
require(count > 0)
val stackTrailingWidth = layerHorizontalShift * SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER
Box(
modifier = modifier.size(
width = iconSize + stackTrailingWidth,
height = iconSize,
),
) {
val baseIconModifier = Modifier
.align(Alignment.TopStart)
.size(iconSize)
if (count >= MAX_STACKED_ICON_COUNT) {
TangemIcon(
tangemIconUM = tangemIconUM,
modifier = baseIconModifier
.offset(x = layerHorizontalShift * SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER)
.alpha(SECOND_BACK_LAYER_ICON_ALPHA),
)
}
if (count >= MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER) {
TangemIcon(
tangemIconUM = tangemIconUM,
modifier = baseIconModifier
.offset(x = layerHorizontalShift * FIRST_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER)
.alpha(FIRST_BACK_LAYER_ICON_ALPHA),
)
}
TangemIcon(
tangemIconUM = tangemIconUM,
modifier = baseIconModifier,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun LayeringIconsPreview() {
val previewCurrencyIcon = TangemIconUM.Currency(
currencyIconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = null,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
)
TangemThemePreview {
Column(horizontalAlignment = Alignment.End) {
LayeringIcons(count = MIN_STACKED_ICON_COUNT, tangemIconUM = previewCurrencyIcon)
LayeringIcons(
count = MAX_STACKED_ICON_COUNT,
tangemIconUM = previewCurrencyIcon,
)
LayeringIcons(
count = MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER,
tangemIconUM = previewCurrencyIcon,
)
}
}
}

View file

@ -106,6 +106,13 @@ private fun Content(
lazyListState = lazyListState,
onShouldShowPriceSubtitleChange = state.onShouldShowPriceSubtitleChange,
)
EventEffect(state.scrollToSection) { targetKey ->
val targetIndex = lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == targetKey }?.index
if (targetIndex != null) {
lazyListState.animateScrollToItem(targetIndex)
}
}
var bottomSpacing by remember { mutableStateOf(0.dp) }
Box(

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