Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-09 16:09:24 +03:00
commit ff09b9f79b
361 changed files with 10298 additions and 8064 deletions

View file

@ -15,4 +15,7 @@ dependencies {
/* Project - Core */
implementation(projects.core.ui)
implementation(projects.core.decompose)
/* Compose */
implementation(deps.compose.runtime)
}

View file

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

View file

@ -0,0 +1,18 @@
package com.tangem.features.managetokens.component
import androidx.compose.runtime.Composable
import com.tangem.domain.wallets.models.UserWalletId
interface AddCustomTokenComponent {
@Composable
fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit)
data class Params(
val userWalletId: UserWalletId,
)
interface Factory {
fun create(params: Params): AddCustomTokenComponent
}
}

View file

@ -2,13 +2,11 @@ package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
interface ManageTokensComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
)
data class Params(val mode: Mode)
enum class Mode { READ_ONLY, MANAGE, }
interface Factory : ComponentFactory<Params, ManageTokensComponent>
}

View file

@ -19,6 +19,11 @@ dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.common.routing)
implementation(projects.core.featuretoggles)
/* Project - Domain */
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)
/* AndroidX */
implementation(deps.androidx.activity.compose)
@ -28,6 +33,7 @@ dependencies {
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.foundation)
implementation(deps.compose.material) // For button colors
implementation(deps.compose.material3)
implementation(deps.compose.shimmer)

View file

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

View file

@ -0,0 +1,19 @@
package com.tangem.features.managetokens.component
import androidx.compose.foundation.lazy.LazyListScope
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
internal interface CustomTokenFormComponent {
fun content(scope: LazyListScope)
data class Params(
val userWalletId: UserWalletId,
val networkId: Network.ID,
)
interface Factory {
fun create(params: Params): CustomTokenFormComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.managetokens.component
import androidx.compose.foundation.lazy.LazyListScope
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.entity.SelectedNetworkUM
internal interface CustomTokenNetworkSelectorComponent {
fun content(scope: LazyListScope)
data class Params(
val userWalletId: UserWalletId,
val selectedNetwork: SelectedNetworkUM?,
val onNetworkSelected: (SelectedNetworkUM) -> Unit,
)
interface Factory {
fun create(params: Params): CustomTokenNetworkSelectorComponent
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.managetokens.component.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.model.ManageTokensModel
import com.tangem.features.managetokens.ui.ManageTokensScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultManageTokensComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: ManageTokensComponent.Params,
) : ManageTokensComponent, AppComponentContext by context {
private val model: ManageTokensModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
ManageTokensScreen(
modifier = modifier,
state = state,
)
}
@AssistedFactory
interface Factory : ManageTokensComponent.Factory {
override fun create(
context: AppComponentContext,
params: ManageTokensComponent.Params,
): DefaultManageTokensComponent
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent
import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM
import com.tangem.features.managetokens.entity.AddCustomTokenUM
import com.tangem.features.managetokens.entity.ClickableFieldUM
import com.tangem.features.managetokens.entity.SelectedNetworkUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
internal class PreviewAddCustomTokenComponent(
initialState: AddCustomTokenUM = AddCustomTokenUM.NetworkSelector(popBack = {}),
) : AddCustomTokenComponent {
private val userWalletId = UserWalletId(stringValue = "321")
private val previewState: MutableStateFlow<AddCustomTokenUM> = MutableStateFlow(initialState)
@Composable
override fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit) {
val state by previewState.collectAsStateWithLifecycle()
val config = TangemBottomSheetConfig(
isShow = isVisible,
onDismissRequest = onDismiss,
content = state,
)
AddCustomTokenBottomSheet(
config = config,
content = {
when (val s = state) {
is AddCustomTokenUM.Form -> {
PreviewCustomTokenFormComponent(
networkName = ClickableFieldUM(
label = resourceReference(R.string.custom_token_network_input_title),
value = stringReference(s.selectedNetwork.name),
onClick = { showNetworkSelector(s.selectedNetwork) },
),
).content(this)
}
is AddCustomTokenUM.NetworkSelector -> {
PreviewCustomTokenNetworkSelectorComponent(
params = CustomTokenNetworkSelectorComponent.Params(
userWalletId = userWalletId,
selectedNetwork = s.selectedNetwork,
onNetworkSelected = ::showForm,
),
networksSize = 20,
).content(this)
}
}
},
)
}
private fun showNetworkSelector(selectedNetwork: SelectedNetworkUM) {
previewState.update {
AddCustomTokenUM.NetworkSelector(selectedNetwork, popBack = { showForm(selectedNetwork) })
}
}
private fun showForm(network: SelectedNetworkUM) {
previewState.update {
AddCustomTokenUM.Form(
popBack = {},
selectedNetwork = network,
addTokenButton = AddCustomTokenButtonUM.Visible(
isEnabled = false,
onClick = {},
),
)
}
}
}

View file

@ -0,0 +1,81 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.foundation.lazy.LazyListScope
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.managetokens.component.CustomTokenFormComponent
import com.tangem.features.managetokens.entity.ClickableFieldUM
import com.tangem.features.managetokens.entity.CustomTokenFormUM
import com.tangem.features.managetokens.entity.TextInputFieldUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.customTokenFormContent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal class PreviewCustomTokenFormComponent(
networkName: ClickableFieldUM = ClickableFieldUM(
label = resourceReference(R.string.custom_token_network_input_title),
value = stringReference(value = "Ethereum"),
onClick = {},
),
derivationPath: ClickableFieldUM = ClickableFieldUM(
label = resourceReference(R.string.custom_token_derivation_path),
value = stringReference(value = "Default"),
onClick = {},
),
canAddToken: Boolean = false,
contractAddress: TextInputFieldUM = TextInputFieldUM(
label = resourceReference(R.string.custom_token_contract_address_input_title),
placeholder = stringReference(value = "0x000000000000000000000000000"),
value = "",
onValueChange = {},
),
tokenName: TextInputFieldUM = TextInputFieldUM(
label = resourceReference(R.string.custom_token_name_input_title),
placeholder = stringReference(value = "E.g. USD Coin"),
value = "",
onValueChange = {},
),
tokenSymbol: TextInputFieldUM = TextInputFieldUM(
label = resourceReference(R.string.custom_token_token_symbol_input_title),
placeholder = stringReference(value = "E.g. USDC"),
value = "",
onValueChange = {},
),
tokenDecimals: TextInputFieldUM = TextInputFieldUM(
label = resourceReference(R.string.custom_token_decimals_input_title),
placeholder = stringReference(value = "8"),
value = "",
onValueChange = {},
),
notifications: ImmutableList<CustomTokenFormUM.NotificationUM> = persistentListOf(
CustomTokenFormUM.NotificationUM(
id = "1",
config = NotificationConfig(
title = stringReference(value = "Note that tokens can be created by anyone"),
subtitle = stringReference(value = "Be aware of adding scam tokens, they can cost nothing"),
iconResId = R.drawable.img_attention_20,
),
),
),
) : CustomTokenFormComponent {
private val previewState = CustomTokenFormUM(
networkName = networkName,
contractAddress = contractAddress,
tokenName = tokenName,
tokenSymbol = tokenSymbol,
tokenDecimals = tokenDecimals,
derivationPath = derivationPath,
notifications = notifications,
canAddToken = canAddToken,
onDerivationPathClick = {},
onNetworkClick = {},
onAddClick = {},
)
override fun content(scope: LazyListScope) {
scope.customTokenFormContent(model = previewState)
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.foundation.lazy.LazyListScope
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent
import com.tangem.features.managetokens.entity.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM
import com.tangem.features.managetokens.entity.SelectedNetworkUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.customTokenNetworkSelectorContent
import kotlinx.collections.immutable.toImmutableList
internal class PreviewCustomTokenNetworkSelectorComponent(
private val params: CustomTokenNetworkSelectorComponent.Params = CustomTokenNetworkSelectorComponent.Params(
userWalletId = UserWalletId(stringValue = "321"),
selectedNetwork = null,
onNetworkSelected = {},
),
networksSize: Int = 5,
) : CustomTokenNetworkSelectorComponent {
private val previewNetworks = List(size = networksSize) { networkIndex ->
val n = SelectedNetworkUM(
id = Network.ID(networkIndex.toString()),
name = "Network $networkIndex",
)
CurrencyNetworkUM(
id = n.id,
name = n.name,
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = false,
isSelected = n.id == params.selectedNetwork?.id,
onSelectedStateChange = { params.onNetworkSelected(n) },
)
}.toImmutableList()
private val previewState = CustomTokenNetworkSelectorUM(
showTitle = params.selectedNetwork == null,
networks = previewNetworks,
)
override fun content(scope: LazyListScope) {
scope.customTokenNetworkSelectorContent(
model = previewState,
)
}
}

View file

@ -6,15 +6,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.entity.CurrencyItemUM
import com.tangem.features.managetokens.entity.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.ManageTokensUM
import com.tangem.features.managetokens.entity.*
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.ManageTokensScreen
import kotlinx.collections.immutable.mutate
@ -28,11 +27,18 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
private val changedItemsIds: MutableSet<String> = mutableSetOf()
private var items = initItems()
private val previewState = MutableStateFlow(
value = ManageTokensUM(
value = ManageTokensUM.ManageContent(
popBack = {},
items = items,
topBar = ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = {},
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = {},
),
),
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
@ -41,8 +47,8 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
onActiveChange = ::toggleSearchBar,
),
hasChanges = false,
isLoading = false,
onSaveClick = {},
onAddCustomToken = {},
),
)
@ -129,14 +135,11 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex ->
CurrencyNetworkUM(
id = networkIndex.toString(),
model = BlockchainRowUM(
name = "NETWORK$networkIndex",
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = networkIndex == 0,
isSelected = false,
),
id = Network.ID(networkIndex.toString()),
name = "NETWORK$networkIndex",
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = networkIndex == 0,
isSelected = false,
onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) },
)
@ -171,14 +174,11 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
it.fastForEachIndexed { index, network ->
if (index == networkIndex) {
it[index] = network.copy(
model = network.model.copy(
iconResId = if (isSelected) {
R.drawable.img_eth_22
} else {
R.drawable.ic_eth_16
},
isSelected = isSelected,
),
iconResId = if (isSelected) {
R.drawable.img_eth_22
} else {
R.drawable.ic_eth_16
},
isSelected = isSelected,
)
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.managetokens.di
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.impl.DefaultManageTokensComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindManageTokensComponentFactory(factory: DefaultManageTokensComponent.Factory): ManageTokensComponent.Factory
}

View file

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

View file

@ -0,0 +1,20 @@
package com.tangem.features.managetokens.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.managetokens.model.ManageTokensModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(DecomposeComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(ManageTokensModel::class)
fun provideManageTokensModel(model: ManageTokensModel): Model
}

View file

@ -0,0 +1,54 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.domain.tokens.model.Network
@Immutable
internal sealed class AddCustomTokenUM : TangemBottomSheetConfigContent {
abstract val selectedNetwork: SelectedNetworkUM?
abstract val addTokenButton: AddCustomTokenButtonUM
abstract val popBack: () -> Unit
data class NetworkSelector(
override val selectedNetwork: SelectedNetworkUM? = null,
override val popBack: () -> Unit,
) : AddCustomTokenUM() {
override val addTokenButton: AddCustomTokenButtonUM = AddCustomTokenButtonUM.Hidden
}
data class Form(
override val selectedNetwork: SelectedNetworkUM,
override val addTokenButton: AddCustomTokenButtonUM.Visible,
override val popBack: () -> Unit,
) : AddCustomTokenUM()
}
@Immutable
internal data class SelectedNetworkUM(
val id: Network.ID,
val name: String,
)
@Immutable
internal sealed class AddCustomTokenButtonUM {
open val onClick: () -> Unit = {}
open val isEnabled: Boolean = false
val isVisible: Boolean
get() = this is Visible
data object Hidden : AddCustomTokenButtonUM() {
override val onClick: () -> Unit = {}
}
data class Visible(
override val isEnabled: Boolean,
override val onClick: () -> Unit,
) : AddCustomTokenButtonUM()
}

View file

@ -1,12 +1,15 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.tokens.model.Network
@Immutable
internal data class CurrencyNetworkUM(
val id: String,
val model: BlockchainRowUM,
val id: Network.ID,
val name: String,
val type: String,
val iconResId: Int,
val isMainNetwork: Boolean,
val isSelected: Boolean,
val onSelectedStateChange: (Boolean) -> Unit,
)

View file

@ -0,0 +1,44 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class CustomTokenFormUM(
val networkName: ClickableFieldUM,
val contractAddress: TextInputFieldUM,
val tokenName: TextInputFieldUM,
val tokenSymbol: TextInputFieldUM,
val tokenDecimals: TextInputFieldUM,
val derivationPath: ClickableFieldUM,
val notifications: ImmutableList<NotificationUM>,
val canAddToken: Boolean,
val onNetworkClick: () -> Unit,
val onDerivationPathClick: () -> Unit,
val onAddClick: () -> Unit,
) {
@Immutable
data class NotificationUM(
val id: String,
val config: NotificationConfig,
)
}
@Immutable
internal data class TextInputFieldUM(
val label: TextReference,
val placeholder: TextReference,
val value: String,
val onValueChange: (String) -> Unit,
val error: TextReference? = null,
)
@Immutable
internal data class ClickableFieldUM(
val label: TextReference,
val value: TextReference,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,10 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class CustomTokenNetworkSelectorUM(
val showTitle: Boolean,
val networks: ImmutableList<CurrencyNetworkUM>,
)

View file

@ -0,0 +1,23 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class ManageTokensTopBarUM {
abstract val title: TextReference
abstract val onBackButtonClick: () -> Unit
data class ReadContent(
override val title: TextReference,
override val onBackButtonClick: () -> Unit,
) : ManageTokensTopBarUM()
data class ManageContent(
override val title: TextReference,
override val onBackButtonClick: () -> Unit,
val endButton: TopAppBarButtonUM,
) : ManageTokensTopBarUM()
}

View file

@ -5,11 +5,40 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class ManageTokensUM(
val popBack: () -> Unit,
val items: ImmutableList<CurrencyItemUM>,
val search: SearchBarUM,
val hasChanges: Boolean,
val onAddCustomToken: () -> Unit,
val onSaveClick: () -> Unit,
)
internal sealed class ManageTokensUM {
abstract val popBack: () -> Unit
abstract val isLoading: Boolean
abstract val items: ImmutableList<CurrencyItemUM>
abstract val topBar: ManageTokensTopBarUM
abstract val search: SearchBarUM
data class ReadContent(
override val popBack: () -> Unit,
override val isLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val search: SearchBarUM,
) : ManageTokensUM()
data class ManageContent(
override val popBack: () -> Unit,
override val isLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val search: SearchBarUM,
val onSaveClick: () -> Unit,
val hasChanges: Boolean,
) : ManageTokensUM()
fun copySealed(
search: SearchBarUM = this.search,
items: ImmutableList<CurrencyItemUM> = this.items,
hasChanges: Boolean = this is ManageContent && this.hasChanges,
): ManageTokensUM {
return when (this) {
is ManageContent -> copy(search = search, items = items, hasChanges = hasChanges)
is ReadContent -> copy(search = search, items = items)
}
}
}

View file

@ -0,0 +1,243 @@
package com.tangem.features.managetokens.model
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.entity.*
import com.tangem.features.managetokens.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ComponentScoped
internal class ManageTokensModel @Inject constructor(
paramsContainer: ParamsContainer,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params: ManageTokensComponent.Params = paramsContainer.require()
private val changedItemsIds: MutableSet<String> = mutableSetOf()
private var items = initItems()
val state: MutableStateFlow<ManageTokensUM> = MutableStateFlow(value = getInitialState(mode = params.mode))
private fun getInitialState(mode: ManageTokensComponent.Mode): ManageTokensUM {
return when (mode) {
ManageTokensComponent.Mode.READ_ONLY -> createReadContentModel()
ManageTokensComponent.Mode.MANAGE -> createManageContentModel()
}
}
private fun createReadContentModel(): ManageTokensUM.ReadContent {
return ManageTokensUM.ReadContent(
popBack = router::pop,
isLoading = false,
items = initItems(),
topBar = ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_search_tokens),
onBackButtonClick = router::pop,
),
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = ::searchCurrencies,
isActive = false,
onActiveChange = ::toggleSearchBar,
),
)
}
private fun createManageContentModel(): ManageTokensUM.ManageContent {
return ManageTokensUM.ManageContent(
popBack = router::pop,
isLoading = false,
items = initItems(),
topBar = ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = router::pop,
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = ::onAddCustomToken,
),
),
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = ::searchCurrencies,
isActive = false,
onActiveChange = ::toggleSearchBar,
),
onSaveClick = ::onSaveClick,
hasChanges = false,
)
}
private fun onAddCustomToken() {
// TODO: [REDACTED_JIRA]
}
private fun onSaveClick() {
// TODO: [REDACTED_JIRA]
}
@Suppress("UnusedPrivateMember")
private fun searchCurrencies(query: String) {
// TODO: [REDACTED_JIRA]
val newItems = if (query.isBlank()) {
initItems()
} else {
state.value.items.filter { currency ->
currency.model.name.contains(query, ignoreCase = true)
}.toPersistentList()
}
state.update { state ->
state.copySealed(search = state.search.copy(query = query), items = newItems)
}
}
private fun toggleSearchBar(isActive: Boolean) {
state.update { state ->
state.copySealed(
search = state.search.copy(isActive = isActive),
)
}
}
private fun initItems() = List(size = 30) { index ->
if (index < 2) {
getCustomItem(index)
} else {
getBasicItem(index)
}
}.toPersistentList()
private fun getCustomItem(index: Int) = CurrencyItemUM.Custom(
id = index.toString(),
model = ChainRowUM(
name = "Custom token $index",
type = "CT$index",
icon = CurrencyIconState.CustomTokenIcon(
tint = Color.White,
background = Color.Black,
topBadgeIconResId = R.drawable.img_eth_22,
isGrayscale = false,
showCustomBadge = true,
),
showCustom = true,
),
onRemoveClick = {},
)
private fun getBasicItem(index: Int) = CurrencyItemUM.Basic(
id = index.toString(),
model = ChainRowUM(
name = "Currency $index",
type = "C$index",
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_btc_22,
isGrayscale = false,
showCustomBadge = false,
),
showCustom = false,
),
networks = if (index == 2) {
CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index))
} else {
CurrencyItemUM.Basic.NetworksUM.Collapsed
},
onExpandClick = { toggleCurrency(index) },
)
private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex ->
CurrencyNetworkUM(
id = Network.ID(networkIndex.toString()),
name = "NETWORK$networkIndex",
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = networkIndex == 0,
isSelected = false,
onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) },
)
}.toImmutableList()
private fun toggleCurrency(index: Int) {
val updatedItem = when (val item = items[index]) {
is CurrencyItemUM.Basic -> item.copy(
networks = if (item.networks is CurrencyItemUM.Basic.NetworksUM.Collapsed) {
CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index))
} else {
CurrencyItemUM.Basic.NetworksUM.Collapsed
},
)
is CurrencyItemUM.Custom -> return
}
state.update { state ->
items = items.mutate {
it[index] = updatedItem
}
state.copySealed(items = items)
}
}
private fun toggleNetwork(currencyIndex: Int, networkIndex: Int, isSelected: Boolean) {
val updatedItem = when (val item = items[currencyIndex]) {
is CurrencyItemUM.Basic -> {
val updatedNetworks = (item.networks as? CurrencyItemUM.Basic.NetworksUM.Expanded)
?.copy(
networks = item.networks.networks.toPersistentList().mutate {
it.fastForEachIndexed { index, network ->
if (index == networkIndex) {
it[index] = network.copy(
iconResId = if (isSelected) {
R.drawable.img_eth_22
} else {
R.drawable.ic_eth_16
},
isSelected = isSelected,
)
}
}
},
)
?: return
item.copy(networks = updatedNetworks)
}
is CurrencyItemUM.Custom -> return
}
val id = "${currencyIndex}_$networkIndex"
if (changedItemsIds.contains(id)) {
changedItemsIds.remove(id)
} else {
changedItemsIds.add(id)
}
state.update { state ->
items = items.mutate {
it[currencyIndex] = updatedItem
}
state.copySealed(
items = items,
hasChanges = changedItemsIds.isNotEmpty(),
)
}
}
}

View file

@ -0,0 +1,188 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle
import com.tangem.core.ui.components.isOpened
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.preview.PreviewAddCustomTokenComponent
import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM
import com.tangem.features.managetokens.entity.AddCustomTokenUM
import com.tangem.features.managetokens.entity.SelectedNetworkUM
import com.tangem.features.managetokens.impl.R
@Composable
internal fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig, content: LazyListScope.() -> Unit) {
TangemBottomSheet<AddCustomTokenUM>(
config = config,
title = { model ->
Title(model)
},
containerColor = TangemTheme.colors.background.secondary,
content = { model ->
Content(
model = model,
content = content,
)
},
)
}
@Composable
private fun Title(model: AddCustomTokenUM, modifier: Modifier = Modifier) {
val showTokenNetworkTitle = model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null
if (showTokenNetworkTitle) {
TangemTopAppBar(
modifier = modifier,
title = resourceReference(R.string.custom_token_network_selector_title),
titleAlignment = Alignment.CenterHorizontally,
startButton = TopAppBarButtonUM.Back(model.popBack),
height = TangemTopAppBarHeight.BOTTOM_SHEET,
)
} else {
TangemBottomSheetTitle(
modifier = modifier,
title = resourceReference(R.string.add_custom_token_title),
)
}
}
@Composable
private fun Content(model: AddCustomTokenUM, content: LazyListScope.() -> Unit, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val keyboardState by keyboardAsState()
var fabHeight by remember { mutableStateOf(0.dp) }
Scaffold(
modifier = modifier.imePadding(),
containerColor = TangemTheme.colors.background.secondary,
floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
AnimatedVisibility(
modifier = Modifier.onSizeChanged {
fabHeight = with(density) { it.height.toDp() }
},
visible = model.addTokenButton.isVisible && !keyboardState.isOpened,
enter = fadeIn(),
exit = fadeOut(),
label = "Add button visibility",
) {
PrimaryButton(
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing16)
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResource(id = R.string.custom_token_add_token),
enabled = model.addTokenButton.isEnabled,
onClick = model.addTokenButton.onClick,
)
}
},
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues),
contentPadding = PaddingValues(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing32 + fabHeight,
),
) {
item {
if (model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null) {
Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing12))
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = TangemTheme.dimens.spacing16),
contentAlignment = Alignment.Center,
) {
Text(
modifier = Modifier.fillMaxWidth(fraction = 0.7f),
text = stringResource(id = R.string.custom_token_subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
}
}
content()
}
}
}
// region Preview
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_AddCustomTokenBottomSheet(
@PreviewParameter(AddCustomTokenComponentPreviewProvider::class) component: AddCustomTokenComponent,
) {
TangemThemePreview {
component.BottomSheet(isVisible = true, onDismiss = {})
}
}
private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider<AddCustomTokenComponent> {
override val values: Sequence<AddCustomTokenComponent>
get() = sequenceOf(
PreviewAddCustomTokenComponent(),
PreviewAddCustomTokenComponent(
initialState = AddCustomTokenUM.NetworkSelector(
popBack = {},
selectedNetwork = SelectedNetworkUM(
id = Network.ID(value = "0"),
name = "Ethereum",
),
),
),
PreviewAddCustomTokenComponent(
initialState = AddCustomTokenUM.Form(
popBack = {},
selectedNetwork = SelectedNetworkUM(
id = Network.ID(value = "1"),
name = "Ethereum",
),
addTokenButton = AddCustomTokenButtonUM.Visible(
isEnabled = false,
onClick = {},
),
),
),
)
}
// endregion Preview

View file

@ -0,0 +1,196 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.managetokens.component.preview.PreviewCustomTokenFormComponent
import com.tangem.features.managetokens.entity.ClickableFieldUM
import com.tangem.features.managetokens.entity.CustomTokenFormUM
import com.tangem.features.managetokens.entity.TextInputFieldUM
internal fun LazyListScope.customTokenFormContent(model: CustomTokenFormUM) {
item {
ClickableField(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
model = model.networkName,
)
}
item {
Column(
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing12)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
) {
TextField(
model = model.contractAddress,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
),
)
TextField(
model = model.tokenName,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
),
)
TextField(
model = model.tokenSymbol,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
),
)
TextField(
model = model.tokenDecimals,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next,
),
)
}
}
item {
ClickableField(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
model = model.derivationPath,
)
}
items(
items = model.notifications,
key = { it.id },
) { notification ->
Notification(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
config = notification.config,
containerColor = TangemTheme.colors.button.disabled,
)
}
}
@Composable
private fun TextField(
model: TextInputFieldUM,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
InformationBlock(
modifier = modifier,
title = {
val color by animateColorAsState(
targetValue = if (model.error != null) {
TangemTheme.colors.text.warning
} else {
TangemTheme.colors.text.tertiary
},
label = "Field label color",
)
Text(
text = (model.error ?: model.label).resolveReference(),
style = TangemTheme.typography.subtitle2,
color = color,
)
},
content = {
SimpleTextField(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
value = model.value,
onValueChange = model.onValueChange,
readOnly = false,
placeholder = model.placeholder,
singleLine = true,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
},
)
}
@Composable
private fun ClickableField(model: ClickableFieldUM, modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable(onClick = model.onClick),
title = {
Text(
text = model.label.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
content = {
Text(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
text = model.value.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
},
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_CustomTokenFormContent(
@PreviewParameter(PreviewCustomTokenFormComponentProvider::class)
component: PreviewCustomTokenFormComponent,
) {
TangemThemePreview {
LazyColumn(
modifier = Modifier.background(color = TangemTheme.colors.background.secondary),
) { component.content(scope = this) }
}
}
private class PreviewCustomTokenFormComponentProvider :
PreviewParameterProvider<PreviewCustomTokenFormComponent> {
override val values: Sequence<PreviewCustomTokenFormComponent>
get() = sequenceOf(
PreviewCustomTokenFormComponent(),
PreviewCustomTokenFormComponent(
contractAddress = TextInputFieldUM(
label = stringReference("Contract address"),
value = "0x1234567890",
error = stringReference("Contract address is invalid"),
placeholder = stringReference("0x1234567890"),
onValueChange = {},
),
),
)
}
// endregion Preview

View file

@ -0,0 +1,158 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.rows.ChainRow
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent
import com.tangem.features.managetokens.component.preview.PreviewCustomTokenNetworkSelectorComponent
import com.tangem.features.managetokens.entity.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM
import com.tangem.features.managetokens.entity.SelectedNetworkUM
import com.tangem.features.managetokens.impl.R
internal fun LazyListScope.customTokenNetworkSelectorContent(model: CustomTokenNetworkSelectorUM) {
val lastIndex = model.networks.lastIndex
if (model.showTitle) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size36)
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.bottomSheet,
),
) {
Text(
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing6,
)
.padding(horizontal = TangemTheme.dimens.spacing12),
text = stringResource(R.string.add_custom_token_choose_network),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
itemsIndexed(
items = model.networks,
key = { _, item -> item.id.value },
) { index, item ->
NetworkItem(
modifier = Modifier
.fillMaxWidth()
.clip(
shape = when {
!model.showTitle && index == 0 -> RoundedCornerShape(
topStart = TangemTheme.dimens.radius16,
topEnd = TangemTheme.dimens.radius16,
)
index == lastIndex -> RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius16,
bottomEnd = TangemTheme.dimens.radius16,
)
else -> RectangleShape
},
)
.background(color = TangemTheme.colors.background.primary)
.clickable(onClick = { item.onSelectedStateChange(true) })
.padding(horizontal = TangemTheme.dimens.spacing4),
model = item,
)
}
}
@Composable
private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier) {
ChainRow(
modifier = modifier,
model = with(model) {
ChainRowUM(
name = name,
type = type,
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = model.iconResId,
isGrayscale = false,
showCustomBadge = false,
),
showCustom = false,
)
},
action = {
AnimatedVisibility(
modifier = Modifier.size(TangemTheme.dimens.size24),
visible = model.isSelected,
) {
Icon(
painter = painterResource(id = R.drawable.ic_check_24),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
}
},
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_CustomTokenNetworkSelectorContent(
@PreviewParameter(CustomTokenNetworkSelectorComponentPreviewProvider::class)
component: CustomTokenNetworkSelectorComponent,
) {
TangemThemePreview {
LazyColumn {
component.content(this)
}
}
}
private class CustomTokenNetworkSelectorComponentPreviewProvider :
PreviewParameterProvider<CustomTokenNetworkSelectorComponent> {
override val values: Sequence<CustomTokenNetworkSelectorComponent>
get() = sequenceOf(
PreviewCustomTokenNetworkSelectorComponent(),
PreviewCustomTokenNetworkSelectorComponent(
params = CustomTokenNetworkSelectorComponent.Params(
userWalletId = UserWalletId(stringValue = "321"),
selectedNetwork = SelectedNetworkUM(
id = Network.ID(value = "0"),
name = "",
),
onNetworkSelected = {},
),
),
)
}
// endregion Preview

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
@ -35,12 +36,15 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.rows.ArrowRow
import com.tangem.core.ui.components.rows.BlockchainRow
import com.tangem.core.ui.components.rows.ChainRow
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.resolveReference
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.managetokens.component.preview.PreviewManageTokensComponent
import com.tangem.features.managetokens.entity.CurrencyItemUM
import com.tangem.features.managetokens.entity.CurrencyItemUM.Basic.NetworksUM
import com.tangem.features.managetokens.entity.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.ManageTokensUM
import com.tangem.features.managetokens.impl.R
import kotlinx.collections.immutable.ImmutableList
@ -56,14 +60,9 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi
modifier = modifier,
containerColor = TangemTheme.colors.background.primary,
topBar = {
TangemTopAppBar(
ManageTokensTopBar(
modifier = Modifier.statusBarsPadding(),
title = stringResource(id = R.string.main_manage_tokens),
startButton = TopAppBarButtonUM.Back(state.popBack),
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = state.onAddCustomToken,
),
topBar = state.topBar,
)
},
content = { innerPadding ->
@ -71,18 +70,36 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
state = state,
search = state.search,
items = state.items,
isLoading = state.isLoading,
hasChanges = state is ManageTokensUM.ManageContent && state.hasChanges,
)
},
floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
SaveChangesButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
isVisible = state.hasChanges,
onClick = state.onSaveClick,
)
if (state is ManageTokensUM.ManageContent) {
SaveChangesButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
isVisible = state.hasChanges,
onClick = state.onSaveClick,
)
}
},
)
}
@Composable
private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, modifier: Modifier = Modifier) {
TangemTopAppBar(
modifier = modifier,
title = topBar.title.resolveReference(),
startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick),
endButton = when (topBar) {
is ManageTokensTopBarUM.ManageContent -> topBar.endButton
is ManageTokensTopBarUM.ReadContent -> null
},
)
}
@ -105,24 +122,48 @@ private fun SaveChangesButton(isVisible: Boolean, onClick: () -> Unit, modifier:
}
@Composable
private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) {
private fun LoadingContent() {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = TangemTheme.colors.background.primary),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = TangemTheme.colors.icon.accent)
}
}
@Composable
private fun Content(
search: SearchBarUM,
items: ImmutableList<CurrencyItemUM>,
isLoading: Boolean,
hasChanges: Boolean,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
Currencies(
modifier = Modifier.fillMaxSize(),
items = state.items,
search = state.search,
items = items,
search = search,
)
AnimatedVisibility(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
visible = state.hasChanges,
visible = hasChanges,
label = "bottom_fade_visibility",
) {
BottomFade()
}
}
Crossfade(targetState = isLoading, label = "ManageTokensLoadingContent") {
if (it) {
LoadingContent()
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@ -245,7 +286,15 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod
isLastItem = index == currentItems.lastIndex,
content = {
BlockchainRow(
model = network.model,
model = with(network) {
BlockchainRowUM(
name = name,
type = type,
iconResId = iconResId,
isMainNetwork = isMainNetwork,
isSelected = isSelected,
)
},
action = {
TangemSwitch(
checked = network.isSelected,

View file

@ -8,7 +8,7 @@ import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
@Stable
interface MarketsListComponent {
interface MarketsEntryComponent {
@Composable
fun BottomSheetContent(
@ -18,6 +18,6 @@ interface MarketsListComponent {
)
interface Factory {
fun create(context: AppComponentContext): MarketsListComponent
fun create(context: AppComponentContext): MarketsEntryComponent
}
}

View file

@ -14,6 +14,7 @@ android {
dependencies {
/* Project - API */
api(projects.features.markets.api)
implementation(projects.core.navigation)
/* Domain */
implementation(projects.domain.markets)
@ -23,6 +24,7 @@ dependencies {
/* Compose */
implementation(deps.compose.coil)
implementation(deps.compose.foundation)
implementation(deps.compose.material)
implementation(deps.compose.material3)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
@ -37,6 +39,7 @@ dependencies {
/* Other */
implementation(deps.kotlin.immutable.collections)
implementation(deps.timber)
implementation(deps.decompose.ext.compose)
/* Core */
implementation(projects.core.decompose)

View file

@ -0,0 +1,162 @@
package com.tangem.features.markets
import androidx.compose.animation.Animatable
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children
import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.*
import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState
import com.arkivanov.decompose.router.stack.*
import com.arkivanov.decompose.value.Value
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.component.BottomSheetState
import com.tangem.features.markets.component.MarketsEntryComponent
import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.api.toSerializable
import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Stable
internal class DefaultMarketsEntryComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
private val marketsEntryChildFactory: MarketsEntryChildFactory,
) : MarketsEntryComponent, AppComponentContext by context {
private val stackNavigation = StackNavigation<MarketsEntryChildFactory.Child>()
val stack: Value<ChildStack<MarketsEntryChildFactory.Child, Any>> = childStack(
key = "main",
source = stackNavigation,
serializer = MarketsEntryChildFactory.Child.serializer(),
initialConfiguration = MarketsEntryChildFactory.Child.TokenList,
handleBackButton = true,
childFactory = { configuration, componentContext ->
marketsEntryChildFactory.createChild(
child = configuration,
appComponentContext = childByContext(componentContext),
onTokenSelected = ::marketsListTokenSelected,
onDetailsBack = ::onDetailsBack,
)
},
)
@Suppress("LongMethod")
@Composable
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
val primary = TangemTheme.colors.background.primary
val secondary = TangemTheme.colors.background.secondary
val backgroundColor = remember { Animatable(primary) }
val stackState = stack.subscribeAsState()
LocalMainBottomSheetColor.current.value = backgroundColor.value
Children(
stack = stackState.value,
animation = stackAnimation(slide()),
) {
when (it.configuration) {
is MarketsEntryChildFactory.Child.TokenDetails -> {
(it.instance as MarketsTokenDetailsComponent).BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = onHeaderSizeChange,
modifier = modifier,
)
}
MarketsEntryChildFactory.Child.TokenList -> {
(it.instance as MarketsTokenListComponent).BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = onHeaderSizeChange,
modifier = modifier,
)
}
}
}
val activeChild = stackState.value.active.configuration
LaunchedEffect(bottomSheetState.value) {
if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) {
when (bottomSheetState.value) {
BottomSheetState.EXPANDED -> {
backgroundColor.animateTo(
secondary,
animationSpec = tween(durationMillis = 100),
)
}
BottomSheetState.COLLAPSED -> {
backgroundColor.animateTo(
primary,
animationSpec = tween(durationMillis = 100),
)
}
}
}
}
LaunchedEffect(activeChild) {
when (activeChild) {
is MarketsEntryChildFactory.Child.TokenDetails -> {
backgroundColor.animateTo(
secondary,
animationSpec = tween(durationMillis = 500),
)
}
MarketsEntryChildFactory.Child.TokenList -> {
backgroundColor.animateTo(
primary,
animationSpec = tween(durationMillis = 500),
)
}
}
}
LaunchedEffect(primary, secondary) {
if (backgroundColor.isRunning) return@LaunchedEffect
when (activeChild) {
is MarketsEntryChildFactory.Child.TokenDetails -> {
backgroundColor.snapTo(secondary)
}
MarketsEntryChildFactory.Child.TokenList -> {
backgroundColor.snapTo(primary)
}
}
}
}
@OptIn(ExperimentalDecomposeApi::class)
private fun marketsListTokenSelected(token: TokenMarket, appCurrency: AppCurrency) {
stackNavigation.pushNew(
configuration = MarketsEntryChildFactory.Child.TokenDetails(
params = MarketsTokenDetailsComponent.Params(
token = token.toSerializable(),
appCurrency = appCurrency,
),
),
)
}
private fun onDetailsBack() {
stackNavigation.popWhile { it != MarketsEntryChildFactory.Child.TokenList }
}
@AssistedFactory
interface Factory : MarketsEntryComponent.Factory {
override fun create(context: AppComponentContext): DefaultMarketsEntryComponent
}
}

View file

@ -0,0 +1,52 @@
package com.tangem.features.markets
import androidx.compose.runtime.Immutable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent
import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent
import kotlinx.serialization.Serializable
import javax.inject.Inject
internal class MarketsEntryChildFactory @Inject constructor(
private val tokenListComponentFactory: MarketsTokenListComponent.Factory,
private val tokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory,
) {
@Serializable
@Immutable
sealed interface Child {
@Serializable
@Immutable
data object TokenList : Child
@Serializable
@Immutable
data class TokenDetails(val params: MarketsTokenDetailsComponent.Params) : Child
}
fun createChild(
child: Child,
appComponentContext: AppComponentContext,
onTokenSelected: (TokenMarket, AppCurrency) -> Unit,
onDetailsBack: () -> Unit,
): Any {
return when (child) {
is Child.TokenDetails -> {
tokenDetailsComponentFactory.create(
context = appComponentContext,
params = child.params,
onBack = onDetailsBack,
)
}
is Child.TokenList -> {
tokenListComponentFactory.create(
context = appComponentContext,
onTokenSelected = onTokenSelected,
)
}
}
}
}

View file

@ -1,51 +0,0 @@
package com.tangem.features.markets.component.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.markets.component.BottomSheetState
import com.tangem.features.markets.component.MarketsListComponent
import com.tangem.features.markets.model.MarketsListModel
import com.tangem.features.markets.ui.MarketsList
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultMarketsListComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
) : MarketsListComponent, AppComponentContext by context {
private val model: MarketsListModel = getOrCreateModel()
@Composable
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
val state by model.state.collectAsStateWithLifecycle()
val bsState by bottomSheetState
LaunchedEffect(bsState) {
model.containerBottomSheetState.value = bsState
}
MarketsList(
modifier = modifier,
state = state,
onHeaderSizeChange = onHeaderSizeChange,
bottomSheetState = bsState,
)
}
@AssistedFactory
interface Factory : MarketsListComponent.Factory {
override fun create(context: AppComponentContext): DefaultMarketsListComponent
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.markets.details.api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.markets.component.BottomSheetState
import kotlinx.serialization.Serializable
@Stable
interface MarketsTokenDetailsComponent {
@Serializable
data class Params(
val token: TokenMarketSerializable,
val appCurrency: AppCurrency,
)
@Composable
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
)
interface Factory {
fun create(context: AppComponentContext, params: Params, onBack: () -> Unit): MarketsTokenDetailsComponent
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.markets.details.api
import com.tangem.domain.core.serialization.SerializedBigDecimal
import com.tangem.domain.markets.TokenMarket
import kotlinx.serialization.Serializable
@Serializable
data class TokenMarketSerializable(
val id: String,
val name: String,
val symbol: String,
val marketCap: SerializedBigDecimal?,
val tokenQuotes: Quotes,
val imageUrl: String,
) {
@Serializable
data class Quotes(
val currentPrice: SerializedBigDecimal,
val h24Percent: SerializedBigDecimal,
val weekPercent: SerializedBigDecimal,
val monthPercent: SerializedBigDecimal,
)
}
fun TokenMarket.toSerializable(): TokenMarketSerializable {
return TokenMarketSerializable(
id = id,
name = name,
symbol = symbol,
marketCap = marketCap,
tokenQuotes = TokenMarketSerializable.Quotes(
currentPrice = tokenQuotes.currentPrice,
h24Percent = tokenQuotes.h24Percent(),
weekPercent = tokenQuotes.weekPercent(),
monthPercent = tokenQuotes.monthPercent(),
),
imageUrl = imageUrlLarge,
)
}

View file

@ -0,0 +1,53 @@
package com.tangem.features.markets.details.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.markets.component.BottomSheetState
import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel
import com.tangem.features.markets.details.impl.ui.MarketsTokenDetailsContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Stable
internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: MarketsTokenDetailsComponent.Params,
@Assisted private val onBack: () -> Unit,
) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent {
private val model: MarketsTokenDetailsModel = getOrCreateModel(params)
@Composable
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
val state by model.state.collectAsStateWithLifecycle()
MarketsTokenDetailsContent(
state = state,
onBackClick = { onBack() },
onHeaderSizeChange = onHeaderSizeChange,
modifier = modifier,
)
}
@AssistedFactory
interface Factory : MarketsTokenDetailsComponent.Factory {
override fun create(
context: AppComponentContext,
params: MarketsTokenDetailsComponent.Params,
onBack: () -> Unit,
): DefaultMarketsTokenDetailsComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.markets.details.impl.di
import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.DefaultMarketsTokenDetailsComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindMarketsTokenDetailsComponent(
factory: DefaultMarketsTokenDetailsComponent.Factory,
): MarketsTokenDetailsComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.markets.details.impl.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(DecomposeComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(MarketsTokenDetailsModel::class)
fun provideMarketsTokenDetailsModel(model: MarketsTokenDetailsModel): Model
}

View file

@ -0,0 +1,384 @@
package com.tangem.features.markets.details.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.charts.state.*
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
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.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.GetTokenPriceChartUseCase
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter
import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
import 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.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
@Suppress("LargeClass")
@Stable
internal class MarketsTokenDetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val urlOpener: UrlOpener,
) : Model() {
val params = paramsContainer.require<MarketsTokenDetailsComponent.Params>()
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = params.appCurrency,
)
private val infoConverter = TokenMarketInfoConverter(
appCurrency = Provider { currentAppCurrency.value },
onInfoClick = {
showInfoBottomSheet(it)
},
onLinkClick = {
urlOpener.openUrl(it.url)
},
)
private val descriptionConverter = DescriptionConverter(
onReadModeClicked = {
showInfoBottomSheet(it)
},
)
private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) {
chartData = MarketChartData.NoData.Loading
updateLook {
it.copy(
type = getChartTypeByPercent(params.token.tokenQuotes.h24Percent),
xAxisFormatter = { value ->
value.toLong().toTimeFormat(DateTimeFormatters.timeFormatter)
},
yAxisFormatter = { value ->
BigDecimalFormatter.formatFiatAmountUncapped(
fiatAmount = value,
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = "",
)
},
)
}
}
val state = MutableStateFlow(
MarketsTokenDetailsUM(
tokenName = params.token.name,
priceText = BigDecimalFormatter.formatFiatAmountUncapped(
fiatAmount = params.token.tokenQuotes.currentPrice,
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
),
dateTimeText = resourceReference(R.string.common_today),
priceChangePercentText = BigDecimalFormatter.formatPercent(
percent = params.token.tokenQuotes.h24Percent,
useAbsoluteValue = true,
),
priceChangeType = if (params.token.tokenQuotes.h24Percent < BigDecimal.ZERO) {
PriceChangeType.DOWN
} else {
PriceChangeType.UP
},
iconUrl = params.token.imageUrl,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = chartDataProducer,
chartLook = MarketChartLook(),
onLoadRetryClick = ::onLoadRetryClicked,
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = ::onMarkerPointSelected,
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = ::onSelectedIntervalChange,
body = MarketsTokenDetailsUM.Body.Loading,
infoBottomSheet = TangemBottomSheetConfig(
isShow = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
),
)
private val loadChartJobHolder = JobHolder()
init {
// reload screen if currency changed
modelScope.launch {
currentAppCurrency
.filter { it != params.appCurrency }
.collectLatest {
initialLoad()
}
}
initialLoad()
}
private fun initialLoad() {
loadChart(state.value.selectedInterval)
loadInfo()
}
private fun onSelectedIntervalChange(interval: PriceChangeInterval) {
if (state.value.selectedInterval == interval) return
state.update {
it.copy(
selectedInterval = interval,
priceChangeType = PriceChangeType.UP,
)
}
loadChart(interval)
}
private fun loadChart(interval: PriceChangeInterval) {
modelScope.launch {
state.update {
it.copy(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
),
)
}
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.NoData.Loading
}
val chart = getTokenPriceChartUseCase.invoke(
appCurrency = currentAppCurrency.value,
interval = interval,
tokenId = params.token.id,
)
state.update {
it.copy(
selectedInterval = interval,
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
),
)
}
val xAxisFormatter = getFormatterByInterval(state.value.selectedInterval)
chart.onRight {
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
y = it.priceY.toImmutableList(),
)
updateLook {
it.copy(
xAxisFormatter = xAxisFormatter,
)
}
}
state.update {
it.copy(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.DATA,
),
)
}
}.onLeft {
state.update {
it.copy(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.ERROR,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Error) {
MarketsTokenDetailsUM.Body.Nothing
} else {
it.body
},
)
}
}
}.saveIn(loadChartJobHolder)
}
private fun loadInfo() {
state.update {
it.copy(
body = MarketsTokenDetailsUM.Body.Loading,
)
}
modelScope.launch {
val tokenMarketInfo = getTokenMarketInfoUseCase(
appCurrency = currentAppCurrency.value,
tokenId = params.token.id,
)
tokenMarketInfo.fold(
ifRight = { result ->
state.update {
it.copy(
body = MarketsTokenDetailsUM.Body.Content(
description = descriptionConverter.convert(result),
infoBlocks = infoConverter.convert(result),
),
)
}
},
ifLeft = {
state.update {
if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) {
it.copy(
body = MarketsTokenDetailsUM.Body.Error(
onLoadRetryClick = ::onLoadRetryClicked,
),
)
} else {
it.copy(
body = MarketsTokenDetailsUM.Body.Nothing,
)
}
}
},
)
}
}
private fun getFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String {
return when (interval) {
PriceChangeInterval.H24 -> { value: BigDecimal ->
value.toLong().toTimeFormat(DateTimeFormatters.timeFormatter)
}
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
PriceChangeInterval.MONTH6,
-> { value ->
value.toLong().toTimeFormat(DateTimeFormatters.dateMMMMd)
}
PriceChangeInterval.YEAR -> { value ->
value.toLong().toTimeFormat(DateTimeFormatters.dateMMMMd)
}
PriceChangeInterval.ALL_TIME -> { value ->
value.toLong().toTimeFormat(DateTimeFormatters.dateYYYY)
}
}
}
@Suppress("MagicNumber")
private fun onMarkerPointSelected(time: BigDecimal?, price: BigDecimal?) {
val timeText = time?.toLong()?.toTimeFormat(DateTimeFormatters.dateTimeFormatter)?.let {
resourceReference(R.string.common_range, wrappedList(it, resourceReference(R.string.common_now)))
} ?: resourceReference(R.string.common_today)
val percent = price?.subtract(params.token.tokenQuotes.currentPrice)
?.divide(params.token.tokenQuotes.currentPrice, 4, RoundingMode.HALF_UP)
?.multiply(BigDecimal(-100))
?: params.token.tokenQuotes.h24Percent
val percentText = BigDecimalFormatter.formatPercent(
percent = percent,
useAbsoluteValue = true,
)
state.update {
it.copy(
dateTimeText = timeText,
priceText = BigDecimalFormatter.formatFiatAmountUncapped(
fiatAmount = price ?: params.token.tokenQuotes.currentPrice,
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
),
priceChangePercentText = percentText,
priceChangeType = when {
percent < BigDecimal.ZERO -> PriceChangeType.DOWN
percent > BigDecimal.ZERO -> PriceChangeType.UP
else -> PriceChangeType.NEUTRAL
},
)
}
chartDataProducer.runTransaction {
updateLook {
it.copy(
type = getChartTypeByPercent(percent),
)
}
}
}
private fun getChartTypeByPercent(percent: BigDecimal): MarketChartLook.Type {
return if (percent >= BigDecimal.ZERO) {
MarketChartLook.Type.Growing
} else {
MarketChartLook.Type.Falling
}
}
private fun showInfoBottomSheet(content: InfoBottomSheetContent) {
state.update { stateToUpdate ->
stateToUpdate.copy(
infoBottomSheet = stateToUpdate.infoBottomSheet.copy(
isShow = true,
onDismissRequest = ::hideInfoBottomSheet,
content = content,
),
)
}
}
private fun hideInfoBottomSheet() {
state.update { stateToUpdate ->
stateToUpdate.copy(
infoBottomSheet = stateToUpdate.infoBottomSheet.copy(
isShow = false,
),
)
}
}
private fun onLoadRetryClicked() {
val currentState = state.value
if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.ERROR) {
loadChart(currentState.selectedInterval)
}
if (currentState.body is MarketsTokenDetailsUM.Body.Error ||
currentState.body is MarketsTokenDetailsUM.Body.Nothing
) {
loadInfo()
}
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
@Stable
internal class DescriptionConverter(
val onReadModeClicked: (InfoBottomSheetContent) -> Unit,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.Description?> {
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? {
return value.shortDescription?.let { desc ->
MarketsTokenDetailsUM.Description(
shortDescription = stringReference(desc),
fullDescription = value.fullDescription?.let { fullDescription ->
stringReference(fullDescription)
},
onReadMoreClick = {
onReadModeClicked(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_about_token_title,
wrappedList(
value.name,
),
),
body = stringReference(value.fullDescription ?: ""),
),
)
},
)
}
}
}

View file

@ -0,0 +1,131 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.InsightsUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Stable
internal class InsightsConverter(
private val appCurrency: Provider<AppCurrency>,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
) : Converter<TokenMarketInfo.Insights, InsightsUM> {
override fun convert(value: TokenMarketInfo.Insights): InsightsUM {
return with(value) {
InsightsUM(
h24Info = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.day,
holdersChange = holdersChange?.day,
liquidityChange = liquidityChange?.day,
buyPressureChange = buyPressureChange?.day,
),
weekInfo = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.week,
holdersChange = holdersChange?.week,
liquidityChange = liquidityChange?.week,
buyPressureChange = buyPressureChange?.week,
),
monthInfo = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.month,
holdersChange = holdersChange?.month,
liquidityChange = liquidityChange?.month,
buyPressureChange = buyPressureChange?.month,
),
)
}
}
private fun createInfoPointList(
experiencedBuyerChange: BigDecimal?,
holdersChange: BigDecimal?,
liquidityChange: BigDecimal?,
buyPressureChange: BigDecimal?,
): ImmutableList<InfoPointUM> {
return persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = experiencedBuyerChange.convertChange(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
body = resourceReference(R.string.markets_token_details_experienced_buyers_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = buyPressureChange.convertChange(isFiatValue = true),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_buy_pressure),
body = resourceReference(R.string.markets_token_details_buy_pressure_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = holdersChange.convertChange(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_holders),
body = resourceReference(R.string.markets_token_details_holders_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = liquidityChange.convertChange(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_liquidity),
body = resourceReference(R.string.markets_token_details_liquidity_description),
),
)
},
),
)
}
private fun BigDecimal?.convertChange(isFiatValue: Boolean = false): String {
if (this == null) return StringsSigns.DASH_SIGN
val value = if (isFiatValue) {
val currency = appCurrency()
BigDecimalFormatter.formatCompactFiatAmount(
amount = this.abs(),
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
)
} else {
BigDecimalFormatter.formatCompactAmount(amount = this.abs())
}
val spacing = if (isFiatValue) " " else ""
return when {
this > BigDecimal.ZERO -> StringsSigns.PLUS + spacing + value
this < BigDecimal.ZERO -> StringsSigns.MINUS + spacing + value
this == BigDecimal.ZERO -> value
else -> StringsSigns.DASH_SIGN
}
}
}

View file

@ -0,0 +1,49 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
@Stable
internal class LinksConverter(
private val onLinkClick: (LinksUM.Link) -> Unit,
) : Converter<TokenMarketInfo.Links, LinksUM> {
override fun convert(value: TokenMarketInfo.Links): LinksUM {
return LinksUM(
officialLinks = value.officialLinks?.map { it.convert() }.orEmpty().toImmutableList(),
social = value.social?.map { it.convert() }.orEmpty().toImmutableList(),
repository = value.repository?.map { it.convert() }.orEmpty().toImmutableList(),
blockchainSite = value.blockchainSite?.map { it.convert() }.orEmpty().toImmutableList(),
onLinkClick = onLinkClick,
)
}
private fun TokenMarketInfo.Link.convert(): LinksUM.Link {
return LinksUM.Link(
title = stringReference(title),
iconRes = getIconById(id),
url = link,
)
}
private fun getIconById(id: String?): Int {
return when (id) {
"linkedin" -> R.drawable.ic_linkedin_24
"discord" -> R.drawable.ic_discord_24
"youtube" -> R.drawable.ic_youtube_24
"telegram" -> R.drawable.ic_telegram_24
"github" -> R.drawable.ic_github_24
"twitter" -> R.drawable.ic_twitter_24
"facebook" -> R.drawable.ic_facebook_24
"reddit" -> R.drawable.ic_reddit_24
"instagram" -> R.drawable.ic_instagram_24
"whitepaper" -> R.drawable.ic_doc_24
else -> R.drawable.ic_arrow_top_right_24
}
}
}

View file

@ -0,0 +1,135 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.MetricsUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale
@Stable
internal class MetricsConverter(
private val appCurrency: Provider<AppCurrency>,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
) : Converter<TokenMarketInfo.Metrics, MetricsUM> {
@Suppress("LongMethod")
override fun convert(value: TokenMarketInfo.Metrics): MetricsUM {
return with(value) {
MetricsUM(
metrics = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_capitalization),
value = marketCap.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_market_capitalization),
body = resourceReference(
R.string.markets_token_details_market_capitalization_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_rating),
value = marketRating?.toString() ?: StringsSigns.DASH_SIGN,
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_market_rating),
body = resourceReference(R.string.markets_token_details_market_rating_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_trading_volume),
value = volume24h.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_trading_volume),
body = resourceReference(
R.string.markets_token_details_trading_volume_24h_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
value = fullyDilutedValuation.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
body = resourceReference(
R.string.markets_token_details_fully_diluted_valuation_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_circulating_supply),
value = circulatingSupply.formatAmount(crypto = true),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_circulating_supply),
body = resourceReference(
R.string.markets_token_details_circulating_supply_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_total_supply),
value = totalSupply.formatAmount(crypto = true),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_total_supply),
body = resourceReference(R.string.markets_token_details_total_supply_description),
),
)
},
),
),
)
}
}
private fun BigDecimal?.formatAmount(crypto: Boolean = false): String {
return if (crypto) {
val formatter = NumberFormat.getNumberInstance(Locale.getDefault()).apply {
maximumFractionDigits = 0
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
formatter.format(this)
} else {
val currency = appCurrency()
BigDecimalFormatter.formatFiatAmount(
fiatAmount = this,
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
decimals = 0,
)
}
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
import java.math.RoundingMode
@Stable
internal class PricePerformanceConverter(
private val appCurrency: Provider<AppCurrency>,
) : Converter<TokenMarketInfo.PricePerformance, PricePerformanceUM> {
override fun convert(value: TokenMarketInfo.PricePerformance): PricePerformanceUM {
return PricePerformanceUM(
h24 = value.day.convert(),
month = value.month.convert(),
all = value.allTime.convert(),
)
}
private fun TokenMarketInfo.Range?.convert(): PricePerformanceUM.Value {
if (this == null) {
return PricePerformanceUM.Value(
low = StringsSigns.DASH_SIGN,
high = StringsSigns.DASH_SIGN,
indicatorFraction = 0f,
)
}
return PricePerformanceUM.Value(
low = low.convert(),
high = high.convert(),
indicatorFraction = calculateFraction(),
)
}
private fun BigDecimal?.convert(): String {
val currency = appCurrency()
return BigDecimalFormatter.formatCompactFiatAmount(
amount = this,
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
)
}
private fun TokenMarketInfo.Range.calculateFraction(): Float {
if (low == null || high == null || low == BigDecimal.ZERO) return 0f
return (high!! - low!!).divide(low!!, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP)
.toFloat().coerceAtMost(1f)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
// TODO implement when backend is ready
@Stable
internal class SecurityScoreConverter(
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
) : Converter<Unit, SecurityScoreUM> {
override fun convert(value: Unit): SecurityScoreUM {
return with(value) {
SecurityScoreUM(
score = 4.7f,
description = "Based on 3 ratings",
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_security_score),
body = stringReference("markets_token_details_security_score_description"),
// FIXME
// resourceReference(R.string.markets_token_details_security_score_description)
),
)
},
)
}
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@Stable
internal class TokenMarketInfoConverter(
appCurrency: Provider<AppCurrency>,
onInfoClick: (InfoBottomSheetContent) -> Unit,
onLinkClick: (LinksUM.Link) -> Unit,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.InformationBlocks> {
private val insightsConverter = InsightsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick)
private val securityScoreConverter = SecurityScoreConverter(onInfoClick = onInfoClick)
private val metricsConverter = MetricsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick)
private val pricePerformanceConverter = PricePerformanceConverter(appCurrency = appCurrency)
private val linksConverter = LinksConverter(onLinkClick = onLinkClick)
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks {
return MarketsTokenDetailsUM.InformationBlocks(
insights = value.insights?.let { insightsConverter.convert(it) },
securityScore = securityScoreConverter.convert(Unit),
metrics = value.metrics?.let { metricsConverter.convert(it) },
pricePerformance = value.pricePerformance?.let { pricePerformanceConverter.convert(it) },
links = value.links?.let { linksConverter.convert(it) },
)
}
}

View file

@ -0,0 +1,252 @@
package com.tangem.features.markets.details.impl.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.disableNestedScroll
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.components.InfoBottomSheet
import com.tangem.features.markets.details.impl.ui.components.MarketTokenDetailsChart
import com.tangem.features.markets.details.impl.ui.components.tokenMarketDetailsBody
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
@Suppress("UnusedPrivateMember")
@Composable
internal fun MarketsTokenDetailsContent(
state: MarketsTokenDetailsUM,
onBackClick: () -> Unit,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier = Modifier,
) {
Content(
modifier = modifier,
state = state,
onBackClick = onBackClick,
onHeaderSizeChange = onHeaderSizeChange,
)
InfoBottomSheet(config = state.infoBottomSheet)
}
@Suppress("UnusedPrivateMember")
@Composable
private fun Content(
state: MarketsTokenDetailsUM,
onBackClick: () -> Unit,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier = Modifier,
) {
val backgroundColor = LocalMainBottomSheetColor.current.value
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
Column(
modifier = modifier
.drawBehind { drawRect(backgroundColor) }
.fillMaxSize(),
) {
TangemTopAppBar(
modifier = Modifier.onGloballyPositioned {
if (it.size.height > 0) {
with(density) {
onHeaderSizeChange(it.size.height.toDp())
}
}
},
title = state.tokenName,
startButton = TopAppBarButtonUM.Back(onBackClick),
)
SpacerH4()
LazyColumn(
modifier = Modifier.disableNestedScroll(),
contentPadding = PaddingValues(bottom = bottomBarHeight),
) {
item("header") {
Header(
state = state,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
item { SpacerH16() }
item("intervalSelector") {
IntervalSelector(
trendInterval = state.selectedInterval,
onIntervalClick = state.onSelectedIntervalChange,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
item { SpacerH32() }
item(
contentType = "chart",
) {
MarketTokenDetailsChart(
modifier = Modifier.fillMaxWidth(),
state = state.chartState,
)
}
item { SpacerH16() }
tokenMarketDetailsBody(
state = state.body,
)
}
}
}
@Composable
private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = state.priceText,
style = TangemTheme.typography.head,
color = TangemTheme.colors.text.primary1,
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
Text(
text = state.dateTimeText.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
PriceChangeInPercent(
valueInPercent = state.priceChangePercentText,
type = state.priceChangeType,
textStyle = TangemTheme.typography.caption2,
)
}
}
SpacerW4()
CoinIcon(
modifier = Modifier.size(TangemTheme.dimens.size48),
url = state.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
}
}
@Composable
private fun IntervalSelector(
trendInterval: PriceChangeInterval,
onIntervalClick: (PriceChangeInterval) -> Unit,
modifier: Modifier = Modifier,
) {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
PriceChangeInterval.ALL_TIME,
),
color = TangemTheme.colors.button.secondary,
initialSelectedItem = trendInterval,
onClick = onIntervalClick,
modifier = modifier,
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
}
@Composable
fun PriceChangeInterval.getText(): TextReference {
return when (this) {
PriceChangeInterval.H24 -> resourceReference(R.string.markets_selector_interval_24h_title)
PriceChangeInterval.WEEK -> resourceReference(R.string.markets_selector_interval_7d_title)
PriceChangeInterval.MONTH -> resourceReference(R.string.markets_selector_interval_1m_title)
PriceChangeInterval.MONTH3 -> resourceReference(R.string.markets_selector_interval_3m_title)
PriceChangeInterval.MONTH6 -> resourceReference(R.string.markets_selector_interval_6m_title)
PriceChangeInterval.YEAR -> resourceReference(R.string.markets_selector_interval_1y_title)
PriceChangeInterval.ALL_TIME -> resourceReference(R.string.markets_selector_interval_all_title)
}
}
@Preview
@Composable
private fun Preview() {
TangemThemePreview {
Content(
modifier = Modifier.background(TangemTheme.colors.background.tertiary),
state = MarketsTokenDetailsUM(
tokenName = "Token Name",
priceText = "Price",
dateTimeText = stringReference("Date Time"),
priceChangePercentText = "Price Change",
iconUrl = "",
priceChangeType = PriceChangeType.UP,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = MarketChartDataProducer.build { },
chartLook = MarketChartLook(),
onLoadRetryClick = {},
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = { _, _ -> },
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = { },
body = MarketsTokenDetailsUM.Body.Loading,
infoBottomSheet = TangemBottomSheetConfig(
isShow = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
),
onHeaderSizeChange = {},
onBackClick = {},
)
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.impl.R
@Composable
internal fun Description(
description: TextReference,
hasFullDescription: Boolean,
onReadMoreClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (hasFullDescription) {
val text = buildAnnotatedString {
withStyle(SpanStyle(color = TangemTheme.colors.text.secondary)) {
append(description.resolveReference())
}
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
append(" " + stringResource(R.string.common_read_more))
}
}
ClickableText(
modifier = modifier,
text = text,
style = TangemTheme.typography.body2,
) {
text.spanStyles.getOrNull(1)?.let { spanStyle ->
if (it in spanStyle.start..spanStyle.end) {
onReadMoreClick()
}
}
}
} else {
Text(
modifier = modifier,
text = description.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
@Composable
internal fun DescriptionPlaceholder(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.8f),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
}
@Preview
@Composable
private fun ContentPreview() {
TangemThemePreview {
Description(
description = stringReference(
"XRP (XRP) is a cryptocurrency launched in January 2009, where the first " +
"genesis block was mined on 9th January 2009",
),
hasFullDescription = true,
onReadMoreClick = {},
)
}
}
@Preview
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
actualContent = {
ContentPreview()
},
shimmerContent = {
DescriptionPlaceholder()
},
)
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
@Composable
internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<InfoBottomSheetContent>(
config = config,
skipPartiallyExpanded = false,
addBottomInsets = false,
title = {
TangemBottomSheetTitle(title = it.title)
},
content = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens.spacing28),
) {
Text(
text = it.body.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
SpacerH(bottomBarHeight)
}
},
)
}

View file

@ -0,0 +1,136 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
@Composable
internal fun InfoPoint(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8),
horizontalAlignment = Alignment.Start,
) {
if (infoPointUM.onInfoClick != null) {
TooltipText(
text = infoPointUM.title,
onInfoClick = infoPointUM.onInfoClick,
textStyle = TangemTheme.typography.caption2,
)
} else {
Text(
text = infoPointUM.title.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = infoPointUM.value,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
}
}
@Composable
internal fun InfoPointShimmer(modifier: Modifier = Modifier, withTooltip: Boolean = false) {
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8),
horizontalAlignment = Alignment.Start,
) {
if (withTooltip) {
Box(
modifier = Modifier
.requiredHeight(TangemTheme.dimens.size16)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.caption2,
textSizeHeight = false,
)
}
} else {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.caption2,
textSizeHeight = true,
)
}
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
style = TangemTheme.typography.body1,
textSizeHeight = true,
)
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
Column(
modifier = Modifier
.width(150.dp)
.background(TangemTheme.colors.background.tertiary),
) {
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000,000",
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000,000",
onInfoClick = { },
),
)
}
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewShimmer() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
Column(
modifier = Modifier
.width(150.dp)
.background(TangemTheme.colors.background.tertiary),
) {
InfoPointShimmer(modifier = Modifier.fillMaxWidth())
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
}
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -0,0 +1,198 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.block.information.GridItems
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.InsightsUM
import com.tangem.features.markets.details.impl.ui.getText
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) {
var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) }
InformationBlock(
modifier = modifier,
title = {
Text(
modifier = Modifier,
text = stringResource(R.string.markets_token_details_insights),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
action = {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
),
initialSelectedItem = PriceChangeInterval.H24,
onClick = { currentInterval = it },
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(vertical = TangemTheme.dimens.spacing4),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
},
content = {
val infoPoints = when (currentInterval) {
PriceChangeInterval.H24 -> state.h24Info
PriceChangeInterval.WEEK -> state.weekInfo
PriceChangeInterval.MONTH -> state.monthInfo
else -> state.h24Info
}
GridItems(
items = infoPoints,
itemContent = {
InfoPoint(
modifier = Modifier.align(Alignment.CenterStart),
infoPointUM = it,
)
},
)
},
)
}
@Composable
internal fun InsightsBlockPlaceholder(modifier: Modifier = Modifier) {
val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() }
val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() }
val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4
InformationBlock(
modifier = modifier,
title = {
RectangleShimmer(
modifier = Modifier
.height(headerHeight)
.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
)
},
content = {
GridItems(
items = List(size = 4) { it }.toImmutableList(),
horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
itemContent = {
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
)
},
)
},
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
InsightsBlock(
state = InsightsUM(
h24Info = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000 000 000",
),
),
weekInfo = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000 000",
),
),
monthInfo = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000",
),
),
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
actualContent = { ContentPreview() },
shimmerContent = { InsightsBlockPlaceholder() },
)
}
}

View file

@ -0,0 +1,221 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SmallButtonShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResource(id = R.string.markets_token_details_links),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
content = {
Column {
SubBlock(
title = stringResource(id = R.string.markets_token_details_official_links),
links = state.officialLinks,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResource(id = R.string.markets_token_details_social),
links = state.social,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResource(id = R.string.markets_token_details_repository),
links = state.repository,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResource(id = R.string.markets_token_details_blockchain_site),
links = state.blockchainSite,
onLinkClick = state.onLinkClick,
)
}
},
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SubBlock(
links: ImmutableList<LinksUM.Link>,
onLinkClick: (LinksUM.Link) -> Unit,
modifier: Modifier = Modifier,
lastBlock: Boolean = false,
title: String = "Official links",
) {
if (links.isEmpty()) return
DividerContainer(
modifier = modifier,
showDivider = !lastBlock,
) {
Column(
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Text(
text = title,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
links.fastForEach {
SecondarySmallButton(
config = SmallButtonConfig(
text = it.title,
onClick = { onLinkClick(it) },
icon = TangemButtonIconPosition.Start(iconResId = it.iconRes),
),
)
}
}
}
}
}
@Composable
fun LinksBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
title = {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.subtitle2,
)
},
content = {
Column {
SubBlockPlaceholder()
SubBlockPlaceholder()
SubBlockPlaceholder(lastBlock = true)
}
},
)
}
@Composable
private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolean = false) {
DividerContainer(
modifier = modifier,
showDivider = !lastBlock,
) {
Column(
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
TextShimmer(
modifier = Modifier.width(78.dp),
style = TangemTheme.typography.caption2,
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
repeat(times = 3) {
SmallButtonShimmer(
modifier = Modifier.weight(1f),
withIcon = true,
)
}
}
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
LinksBlock(
state = LinksUM(
officialLinks = persistentListOf(
LinksUM.Link(
title = stringReference("Website"),
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = stringReference("Website"),
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = stringReference("Website"),
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
social = persistentListOf(
LinksUM.Link(
title = stringReference("Twitter"),
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = stringReference("Facebook"),
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
repository = persistentListOf(
LinksUM.Link(
title = stringReference("Github"),
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
blockchainSite = persistentListOf(),
onLinkClick = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaceholderPreview() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = { LinksBlockPlaceholder() },
actualContent = { ContentPreview() },
)
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import com.tangem.common.ui.charts.MarketChart
import com.tangem.common.ui.charts.getMarketChartBottomAxisHeight
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.rememberMarketChartState
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData
@Composable
internal fun MarketTokenDetailsChart(state: MarketsTokenDetailsUM.ChartState, modifier: Modifier = Modifier) {
val growingColor = TangemTheme.colors.icon.accent
val fallingColor = TangemTheme.colors.icon.warning
val chartState = rememberMarketChartState(
dataProducer = state.dataProducer,
colorMapper = {
when (it) {
MarketChartLook.Type.Growing -> growingColor
MarketChartLook.Type.Falling -> fallingColor
}
},
onMarkerShown = state.onMarkerPointSelected,
)
val backgroundColor = LocalMainBottomSheetColor.current.value
val bottomChartAxisHeight = getMarketChartBottomAxisHeight()
Box(modifier) {
MarketChart(
modifier = Modifier.fillMaxWidth(),
state = chartState,
)
if (state.status != MarketsTokenDetailsUM.ChartState.Status.DATA) {
Box(
Modifier
.drawBehind { drawRect(backgroundColor) }
.matchParentSize()
.padding(bottom = bottomChartAxisHeight),
) {
when (state.status) {
MarketsTokenDetailsUM.ChartState.Status.LOADING -> {
CircularProgressIndicator(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.align(Alignment.Center),
color = TangemTheme.colors.text.accent,
strokeWidth = TangemTheme.dimens.size2,
)
}
MarketsTokenDetailsUM.ChartState.Status.ERROR -> {
UnableToLoadData(
modifier = Modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
)
.align(Alignment.Center),
onRetryClick = state.onLoadRetryClick,
)
}
else -> {}
}
}
}
}
}

View file

@ -0,0 +1,166 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.TextButton
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.GridItems
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.MetricsUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
const val MAX_METRICS_COUNT = 6
@Composable
internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResource(id = R.string.markets_token_details_metrics),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
action = {
if (state.metrics.size > MAX_METRICS_COUNT) {
ShowLessMoreButton(expanded = expanded, onClick = { expanded = !expanded })
}
},
content = {
val metrics = if (expanded) {
state.metrics
} else {
state.metrics.take(MAX_METRICS_COUNT).toImmutableList()
}
GridItems(
items = metrics,
itemContent = {
InfoPoint(infoPointUM = it)
},
)
},
)
}
// TODO make TextButton clickable area smaller and remove paddings for an action in InformationBlock
@Composable
private fun ShowLessMoreButton(expanded: Boolean, onClick: () -> Unit) {
// FIXME add string resources
val text = if (expanded) {
"See less"
} else {
"See more"
}
TextButton(
text = text,
onClick = onClick,
colors = TangemButtonsDefaults.positiveButtonColors,
textStyle = TangemTheme.typography.body2,
)
}
@Composable
internal fun MetricsBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
title = {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
style = TangemTheme.typography.subtitle2,
)
},
action = {
Box(Modifier)
},
content = {
GridItems(
items = List(size = 6) { it }.toImmutableList(),
horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
itemContent = {
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
},
)
},
)
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun BlockPreview() {
TangemThemePreview {
MetricsBlock(
state = MetricsUM(
metrics = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_capitalization),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_rating),
value = "A",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_trading_volume),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_circulating_supply),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_total_supply),
value = "1.2T",
onInfoClick = {},
),
),
),
)
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
actualContent = { BlockPreview() },
shimmerContent = { MetricsBlockPlaceholder() },
)
}
}

View file

@ -0,0 +1,248 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemAnimations
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM
import com.tangem.features.markets.details.impl.ui.getText
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier = Modifier) {
var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) }
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResource(id = R.string.markets_token_details_price_performance),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
action = {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.MONTH,
PriceChangeInterval.ALL_TIME,
),
initialSelectedItem = PriceChangeInterval.H24,
onClick = { currentInterval = it },
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(vertical = TangemTheme.dimens.spacing4),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
},
content = {
val value = when (currentInterval) {
PriceChangeInterval.H24 -> state.h24
PriceChangeInterval.MONTH -> state.month
PriceChangeInterval.ALL_TIME -> state.all
else -> error("")
}
Content(
modifier = Modifier.fillMaxWidth(),
state = value,
)
},
)
}
@Composable
private fun Content(state: PricePerformanceUM.Value, modifier: Modifier = Modifier) {
val animatedIndicatorFraction by TangemAnimations.horizontalIndicatorAsState(
targetFraction = state.indicatorFraction,
)
Column(
modifier = modifier
.padding(vertical = TangemTheme.dimens.spacing8),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.markets_token_details_low),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
SpacerW8()
Text(
text = stringResource(R.string.markets_token_details_high),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
LinearProgressIndicator(
modifier = Modifier
.height(TangemTheme.dimens.size6)
.fillMaxWidth(),
progress = { animatedIndicatorFraction },
color = TangemTheme.colors.text.accent,
trackColor = TangemTheme.colors.background.tertiary,
strokeCap = StrokeCap.Round,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = state.low,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
SpacerW8()
Text(
text = state.high,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.End,
)
}
}
}
@Composable
internal fun PricePerformanceBlockPlaceholder(modifier: Modifier = Modifier) {
val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() }
val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() }
val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4
InformationBlock(
modifier = modifier,
title = {
RectangleShimmer(
modifier = Modifier
.height(headerHeight)
.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
)
},
content = {
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.width(35.dp),
style = TangemTheme.typography.caption2,
)
SpacerW8()
TextShimmer(
modifier = Modifier.width(35.dp),
style = TangemTheme.typography.caption2,
)
}
RectangleShimmer(
modifier = Modifier
.height(TangemTheme.dimens.size6)
.fillMaxWidth(),
radius = 27.dp,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.width(TangemTheme.dimens.size56),
style = TangemTheme.typography.body1,
)
SpacerW8()
TextShimmer(
modifier = Modifier.width(TangemTheme.dimens.size56),
style = TangemTheme.typography.body1,
)
}
}
},
)
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
PricePerformanceBlock(
modifier = Modifier,
state = PricePerformanceUM(
h24 = PricePerformanceUM.Value(
low = "\$38,5K",
high = "\$58,5K",
indicatorFraction = 0.5f,
),
month = PricePerformanceUM.Value(
low = "\$500,5K",
high = "\$5800,5K",
indicatorFraction = 0.8f,
),
all = PricePerformanceUM.Value(
low = "\$58,52",
high = "\$580,5M",
indicatorFraction = 0.2f,
),
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaceholderPreview() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
PricePerformanceBlockPlaceholder()
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -0,0 +1,203 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.annotation.FloatRange
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM
import com.tangem.features.markets.impl.R
import kotlin.math.round
private const val STARS_COUNT = 5
@Composable
internal fun SecurityScoreBlock(state: SecurityScoreUM, modifier: Modifier = Modifier) {
val rounded = state.score.roundTo1decimal()
val percentage = rounded / STARS_COUNT
InformationBlock(
modifier = modifier,
title = {
Column(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
TooltipText(
text = resourceReference(R.string.markets_token_details_security_score),
onInfoClick = state.onInfoClick,
textStyle = TangemTheme.typography.subtitle2,
)
Text(
text = state.description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
action = {
Row(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = rounded.toString(),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
Stars(fraction = percentage)
}
},
)
}
@Suppress("MagicNumber")
@Composable
private fun Stars(@FloatRange(0.0, 1.0) fraction: Float = 0f) {
val grayColor = TangemTheme.colors.icon.inactive
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(times = 5) { i ->
Box(
modifier = Modifier.size(TangemTheme.dimens.size16),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier
.requiredSize(13.dp)
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithCache {
onDrawWithContent {
val starFraction = ((fraction - i * 0.2) / 0.2).coerceIn(0.0, 1.0)
val starFractionFloat = starFraction
.toFloat()
.roundTo1decimal()
drawContent()
drawRect(
color = grayColor,
topLeft = Offset(x = size.width * starFractionFloat, y = 0f),
size = Size(size.width * (1 - starFractionFloat), size.height),
blendMode = BlendMode.SrcIn,
)
}
},
imageVector = ImageVector.vectorResource(R.drawable.ic_star_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
}
}
@Suppress("MagicNumber")
private fun Float.roundTo1decimal(): Float {
return round(this * 10) / 10
}
@Composable
internal fun SecurityScorePlaceHolder(modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
title = {
Column(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.subtitle2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
},
action = {
Box(
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing24,
bottom = TangemTheme.dimens.spacing6,
),
contentAlignment = Alignment.CenterEnd,
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
RectangleShimmer(
modifier = Modifier
.height(TangemTheme.dimens.size16)
.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
)
}
},
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
SecurityScoreBlock(
state = SecurityScoreUM(
score = 3.5f,
description = "Based on 3 ratings",
onInfoClick = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
SecurityScorePlaceHolder(
modifier = Modifier.fillMaxWidth(),
)
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -0,0 +1,142 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData
internal fun LazyListScope.tokenMarketDetailsBody(state: MarketsTokenDetailsUM.Body) {
when (state) {
MarketsTokenDetailsUM.Body.Loading -> {
loading()
}
is MarketsTokenDetailsUM.Body.Content -> {
if (state.description != null) {
description(state.description)
}
infoBlocksList(state.infoBlocks)
}
is MarketsTokenDetailsUM.Body.Error -> {
error(state)
}
MarketsTokenDetailsUM.Body.Nothing -> {
// Do nothing
}
}
}
private fun LazyListScope.error(state: MarketsTokenDetailsUM.Body.Error) {
item("body-error") {
Box(Modifier.fillMaxWidth()) {
UnableToLoadData(
modifier = Modifier
.align(Alignment.Center)
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing40,
),
onRetryClick = state.onLoadRetryClick,
)
}
}
}
private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) {
item("description") {
Description(
modifier = Modifier.blockPaddings(),
description = description.shortDescription,
hasFullDescription = description.fullDescription != null,
onReadMoreClick = description.onReadMoreClick,
)
}
}
internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks) {
if (state.insights != null) {
item("insights") {
InsightsBlock(
modifier = Modifier.blockPaddings(),
state = state.insights,
)
}
}
if (state.securityScore != null) {
item("securityScore") {
SecurityScoreBlock(
modifier = Modifier.blockPaddings(),
state = state.securityScore,
)
}
}
if (state.metrics != null) {
item("metrics") {
MetricsBlock(
modifier = Modifier.blockPaddings(),
state = state.metrics,
)
}
}
if (state.pricePerformance != null) {
item("pricePerformance") {
PricePerformanceBlock(
modifier = Modifier.blockPaddings(),
state = state.pricePerformance,
)
}
}
if (state.links != null) {
item("links") {
LinksBlock(
modifier = Modifier.blockPaddings(),
state = state.links,
)
}
}
}
private fun LazyListScope.loading() {
item("description-loading") {
DescriptionPlaceholder(modifier = Modifier.blockPaddings())
}
item("insights-loading") {
InsightsBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("securityScore-loading") {
SecurityScorePlaceHolder(modifier = Modifier.blockPaddings())
}
item("metrics-loading") {
MetricsBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("pricePerformance-loading") {
PricePerformanceBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("links-loading") {
LinksBlockPlaceholder(modifier = Modifier.blockPaddings())
}
}
@Composable
private fun Modifier.blockPaddings(): Modifier {
return this.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing12,
)
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
internal data class InfoBottomSheetContent(
val title: TextReference,
val body: TextReference,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,9 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.core.ui.extensions.TextReference
internal data class InfoPointUM(
val title: TextReference,
val value: String,
val onInfoClick: (() -> Unit)? = null,
)

View file

@ -0,0 +1,9 @@
package com.tangem.features.markets.details.impl.ui.state
import kotlinx.collections.immutable.ImmutableList
internal data class InsightsUM(
val h24Info: ImmutableList<InfoPointUM>,
val weekInfo: ImmutableList<InfoPointUM>,
val monthInfo: ImmutableList<InfoPointUM>,
)

View file

@ -0,0 +1,19 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.annotation.DrawableRes
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class LinksUM(
val officialLinks: ImmutableList<Link>,
val social: ImmutableList<Link>,
val repository: ImmutableList<Link>,
val blockchainSite: ImmutableList<Link>,
val onLinkClick: (Link) -> Unit,
) {
data class Link(
@DrawableRes val iconRes: Int,
val title: TextReference,
val url: String,
)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.markets.PriceChangeInterval
import java.math.BigDecimal
internal data class MarketsTokenDetailsUM(
val tokenName: String,
val priceText: String,
val iconUrl: String,
val dateTimeText: TextReference,
val priceChangePercentText: String,
val priceChangeType: PriceChangeType,
val selectedInterval: PriceChangeInterval,
val chartState: ChartState,
val onSelectedIntervalChange: (PriceChangeInterval) -> Unit,
val infoBottomSheet: TangemBottomSheetConfig,
val body: Body,
) {
data class ChartState(
val status: Status,
val dataProducer: MarketChartDataProducer,
val chartLook: MarketChartLook,
val onLoadRetryClick: () -> Unit,
val onMarkerPointSelected: (time: BigDecimal?, price: BigDecimal?) -> Unit,
) {
enum class Status {
LOADING, ERROR, DATA
}
}
data class InformationBlocks(
val insights: InsightsUM?,
val securityScore: SecurityScoreUM?,
val metrics: MetricsUM?,
val pricePerformance: PricePerformanceUM?,
val links: LinksUM?,
)
@Immutable
sealed interface Body {
data class Error(
val onLoadRetryClick: () -> Unit,
) : Body
data object Loading : Body
data class Content(
val description: Description?,
val infoBlocks: InformationBlocks,
) : Body
data object Nothing : Body
}
data class Description(
val shortDescription: TextReference,
val fullDescription: TextReference?,
val onReadMoreClick: () -> Unit,
)
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.markets.details.impl.ui.state
import kotlinx.collections.immutable.PersistentList
internal data class MetricsUM(
val metrics: PersistentList<InfoPointUM>,
)

View file

@ -0,0 +1,15 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.annotation.FloatRange
internal data class PricePerformanceUM(
val h24: Value,
val month: Value,
val all: Value,
) {
data class Value(
val low: String,
val high: String,
@FloatRange(from = 0.0, to = 1.0) val indicatorFraction: Float,
)
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.annotation.FloatRange
internal data class SecurityScoreUM(
@FloatRange(from = 0.0, to = 5.0) val score: Float,
val description: String,
val onInfoClick: () -> Unit,
)

View file

@ -1,7 +1,7 @@
package com.tangem.features.markets.di
import com.tangem.features.markets.component.MarketsListComponent
import com.tangem.features.markets.component.impl.DefaultMarketsListComponent
import com.tangem.features.markets.component.MarketsEntryComponent
import com.tangem.features.markets.DefaultMarketsEntryComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -14,5 +14,5 @@ internal interface ComponentModule {
@Binds
@Singleton
fun bindMarketsListComponent(factory: DefaultMarketsListComponent.Factory): MarketsListComponent.Factory
fun bindMarketsListComponent(factory: DefaultMarketsEntryComponent.Factory): MarketsEntryComponent.Factory
}

View file

@ -0,0 +1,29 @@
package com.tangem.features.markets.tokenlist.api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.component.BottomSheetState
@Stable
interface MarketsTokenListComponent {
@Composable
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
)
interface Factory {
fun create(
context: AppComponentContext,
onTokenSelected: (TokenMarket, AppCurrency) -> Unit,
): MarketsTokenListComponent
}
}

View file

@ -0,0 +1,70 @@
package com.tangem.features.markets.tokenlist.impl
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.component.BottomSheetState
import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent
import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel
import com.tangem.features.markets.tokenlist.impl.ui.MarketsList
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class DefaultMarketsTokenListComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val onTokenSelected: (TokenMarket, AppCurrency) -> Unit,
) : AppComponentContext by appComponentContext, MarketsTokenListComponent {
private val model: MarketsListModel = getOrCreateModel()
init {
model.tokenSelected
.onEach { onTokenSelected(it.first, it.second) }
.launchIn(componentScope)
}
@Composable
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
val bsState by bottomSheetState
LaunchedEffect(bsState) {
model.containerBottomSheetState.value = bsState
}
MarketsList(
modifier = modifier,
state = state,
onHeaderSizeChange = onHeaderSizeChange,
bottomSheetState = bsState,
)
}
@AssistedFactory
interface Factory : MarketsTokenListComponent.Factory {
override fun create(
context: AppComponentContext,
onTokenSelected: (TokenMarket, AppCurrency) -> Unit,
): DefaultMarketsTokenListComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.markets.tokenlist.impl.di
import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent
import com.tangem.features.markets.tokenlist.impl.DefaultMarketsTokenListComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindMarketsTokenListComponent(
factory: DefaultMarketsTokenListComponent.Factory,
): MarketsTokenListComponent.Factory
}

View file

@ -1,8 +1,8 @@
package com.tangem.features.markets.di
package com.tangem.features.markets.tokenlist.impl.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.markets.model.MarketsListModel
import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.model
package com.tangem.features.markets.tokenlist.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
@ -7,11 +7,13 @@ import com.tangem.core.decompose.model.Model
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.component.BottomSheetState
import com.tangem.features.markets.model.statemanager.MarketsListUMStateManager
import com.tangem.features.markets.model.statemanager.MarketsListBatchFlowManager
import com.tangem.features.markets.ui.entity.ListUM
import com.tangem.features.markets.ui.entity.SortByTypeUM
import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListUMStateManager
import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListBatchFlowManager
import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
@ -32,6 +34,8 @@ internal class MarketsListModel @Inject constructor(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
) : Model() {
private var updateQuotesJob = JobHolder()
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
@ -47,8 +51,8 @@ internal class MarketsListModel @Inject constructor(
onLoadMoreUiItems = { activeListManager.loadMore() },
visibleItemsChanged = { visibleItemIds.value = it },
onRetryButtonClicked = { activeListManager.reload() },
onTokenClick = { onTokenUIClicked(it) },
)
private val mainMarketsListManager = MarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
@ -59,6 +63,7 @@ internal class MarketsListModel @Inject constructor(
modelScope = modelScope,
dispatchers = dispatchers,
)
private val searchMarketsListManager = MarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
@ -72,7 +77,12 @@ internal class MarketsListModel @Inject constructor(
private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager
private val _tokenSelected = MutableSharedFlow<Pair<TokenMarket, AppCurrency>>()
val tokenSelected = _tokenSelected.asSharedFlow()
val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED)
val isVisibleOnScreen = MutableStateFlow(false)
val state = marketsListUMStateManager.state.asStateFlow()
@ -169,6 +179,7 @@ internal class MarketsListModel @Inject constructor(
}
.distinctUntilChanged()
.collectLatest { visibleBatchKeys ->
// TODO load batch on scroll heat area
activeListManager.loadCharts(visibleBatchKeys, marketsListUMStateManager.selectedInterval)
}
}
@ -189,9 +200,12 @@ internal class MarketsListModel @Inject constructor(
modelScope.launch {
marketsListUMStateManager.searchQueryFlow
.filter { it.isNotEmpty() }
.debounce(timeoutMillis = SEARCH_QUERY_DEBOUNCE_MILLIS)
.filter { activeListManager == searchMarketsListManager }
.distinctUntilChanged()
.onEach {
if (it.isEmpty()) searchMarketsListManager.clearStateAndStopAllActions()
}
.filter { it.isNotEmpty() && activeListManager == searchMarketsListManager }
.collectLatest {
searchMarketsListManager.reload(searchText = it)
}
@ -210,14 +224,24 @@ internal class MarketsListModel @Inject constructor(
mainMarketsListManager.reload()
}
private var updateQuotesJob = JobHolder()
private fun onTokenUIClicked(token: MarketsListItemUM) {
modelScope.launch {
activeListManager.getTokenById(token.id)?.let { found ->
_tokenSelected.emit(found to currentAppCurrency.value)
}
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {
delay(timeMillis)
// Update quotes only when the container bottom sheet is in the expanded state
containerBottomSheetState.first { it == BottomSheetState.EXPANDED }
activeListManager.updateQuotes() // TODO update a batch that is currently on screen
// and is visible on the screen
isVisibleOnScreen.first { it }
activeListManager.updateQuotes()
}
}.saveIn(updateQuotesJob)
}

View file

@ -1,15 +1,16 @@
package com.tangem.features.markets.model.converters
package com.tangem.features.markets.tokenlist.impl.model.converters
import com.tangem.common.ui.charts.state.DefaultPointValuesConverter
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.ui.entity.MarketsListItemUM
import com.tangem.features.markets.ui.entity.MarketsListUM.TrendInterval
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
@ -18,6 +19,8 @@ internal class MarketsTokenItemConverter(
private val appCurrency: AppCurrency,
) : Converter<TokenMarket, MarketsListItemUM> {
private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = false)
override fun convert(value: TokenMarket): MarketsListItemUM {
return MarketsListItemUM(
id = value.id,
@ -30,7 +33,7 @@ internal class MarketsTokenItemConverter(
trendPercentText = value.getTrendPercent(),
trendType = value.getTrendType(),
chardData = value.getChartData(),
showUnder100kMarketCap = value.isUnder100kMarketCap(),
isUnder100kMarketCap = value.isUnder100kMarketCap(),
)
}
@ -67,10 +70,11 @@ internal class MarketsTokenItemConverter(
private fun TokenMarket.getMarketCap(): String? {
val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null
return BigDecimalFormatter.formatCompactAmount(
value,
return BigDecimalFormatter.formatCompactFiatAmount(
amount = value,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
threeDigitsMethod = true,
)
}
@ -107,10 +111,10 @@ internal class MarketsTokenItemConverter(
}
return chart?.let { ct ->
DefaultPointValuesConverter.convert(
priceAndTimePointValuesConverter.convert(
MarketChartData.Data(
y = ct.priceY,
x = ct.timeStamp.map { it.toBigDecimal() },
y = ct.priceY.toImmutableList(),
x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
),
)
}
@ -144,7 +148,7 @@ internal class MarketsTokenItemConverter(
}
private fun TokenMarket.isUnder100kMarketCap(): Boolean {
return tokenQuotes.currentPrice.compareTo(decimal100k) == -1
return marketCap?.let { it < decimal100k } ?: true
}
private companion object {

View file

@ -1,23 +1,27 @@
package com.tangem.features.markets.model.statemanager
package com.tangem.features.markets.tokenlist.impl.model.statemanager
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.features.markets.model.converters.MarketsTokenItemConverter
import com.tangem.features.markets.model.utils.logAction
import com.tangem.features.markets.model.utils.logStatus
import com.tangem.features.markets.model.utils.logUpdateResults
import com.tangem.features.markets.ui.entity.MarketsListItemUM
import com.tangem.features.markets.ui.entity.MarketsListUM.TrendInterval
import com.tangem.features.markets.ui.entity.SortByTypeUM
import com.tangem.pagination.*
import com.tangem.features.markets.tokenlist.impl.model.converters.MarketsTokenItemConverter
import com.tangem.features.markets.tokenlist.impl.model.utils.logAction
import com.tangem.features.markets.tokenlist.impl.model.utils.logStatus
import com.tangem.features.markets.tokenlist.impl.model.utils.logUpdateResults
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
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.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
private const val LOG_EVENTS = true
@ -33,6 +37,7 @@ internal class MarketsListBatchFlowManager(
private val dispatchers: CoroutineDispatcherProvider,
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
private val updateStateJob = JobHolder()
private val batchFlow = getMarketsTokenListFlowUseCase(
batchingContext = TokenListBatchingContext(
@ -42,6 +47,9 @@ internal class MarketsListBatchFlowManager(
batchFlowType = batchFlowType,
)
private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = resultBatches.map { it.uiBatches }
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
get() = uiBatches
.map { batches ->
@ -97,16 +105,20 @@ internal class MarketsListBatchFlowManager(
initialValue = false,
)
private val uiBatches = MutableStateFlow<List<Batch<Int, List<MarketsListItemUM>>>>(emptyList())
init {
batchFlow.state
.map { it.data }
.distinctUntilChanged { a, b ->
a.size == b.size && a.map { it.data }.flatten() == b.map { it.data }.flatten()
a.size == b.size &&
a.map { it.key } == b.map { it.key } &&
a.map { it.data }.flatten() == b.map { it.data }.flatten()
}
.onEachWithPrevious { prev, list ->
updateState(prev, list)
.onEach {
coroutineScope {
launch {
updateState(it)
}.saveIn(updateStateJob)
}
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
@ -127,58 +139,75 @@ internal class MarketsListBatchFlowManager(
}
}
private fun updateState(
previousList: List<Batch<Int, List<TokenMarket>>>?,
list: List<Batch<Int, List<TokenMarket>>>,
forceUpdate: Boolean = false,
) = uiBatches.update { items ->
val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency())
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
withContext(dispatchers.default) {
resultBatches.update { resultBatches ->
val items = resultBatches.uiBatches
val previousList = resultBatches.processedItems
if (previousList == null || list.size < previousList.size || forceUpdate) {
list.map {
Batch(
key = it.key,
data = converter.convertList(it.data),
val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency())
if (newList.isEmpty()) {
return@update ResultBatches(processedItems = emptyList())
}
val isInitialLoading =
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
val outItems = if (isInitialLoading) {
newList.map {
Batch(
key = it.key,
data = converter.convertList(it.data),
)
}
} else {
previousList!!
if (previousList.size != newList.size) {
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = newList.filter { keysToAdd.contains(it.key) }
items + newBatches.map {
Batch(
key = it.key,
data = converter.convertList(it.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = newList[batchIndex]
if (previousList == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data[index]
val newItem = newBatch.data[index]
converter.update(
prevItem,
marketsListItemUM,
newItem,
)
},
)
}
}
}
currentCoroutineContext().ensureActive()
ResultBatches(
uiBatches = outItems,
processedItems = newList,
)
}
} else {
if (previousList.size != list.size) {
val keysToAdd = list.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = list.filter { keysToAdd.contains(it.key) }
items + newBatches.map {
Batch(
key = it.key,
data = converter.convertList(it.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = list[batchIndex]
if (previousList == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data[index]
val newItem = newBatch.data[index]
converter.update(
prevItem,
marketsListItemUM,
newItem,
)
},
)
}
}
}
}
fun reload(searchText: String? = null) {
modelScope.launch {
uiBatches.value = emptyList()
resultBatches.value = ResultBatches()
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
@ -188,7 +217,6 @@ internal class MarketsListBatchFlowManager(
} else {
searchText ?: currentSearchText()
},
showUnder100kMarketCapTokens = false, // TODO
priceChangeInterval = currentTrendInterval().toBatchRequestInterval(),
order = currentSortByType().toRequestOrder(),
),
@ -206,12 +234,14 @@ internal class MarketsListBatchFlowManager(
fun updateUIWithSameState() {
modelScope.launch(dispatchers.default) {
val current = batchFlow.state.value.data
updateState(current, current, forceUpdate = true)
}
updateState(current, forceUpdate = true)
}.saveIn(updateStateJob)
}
fun loadCharts(batchKeys: Set<Int>, interval: TrendInterval) {
modelScope.launch(dispatchers.default) {
if (batchKeys.isEmpty()) return
modelScope.launch {
val currentData = batchFlow.state.value.data
val alreadyLoadedChartsBatchKeys = currentData
.filter {
@ -233,7 +263,7 @@ internal class MarketsListBatchFlowManager(
BatchAction.UpdateBatches(
keys = batchesKeysToLoad,
updateRequest = TokenMarketUpdateRequest.UpdateChart(
interval = interval.toRequestInterval(),
interval = interval.toBatchRequestInterval(),
currency = currentAppCurrency().code,
),
async = true,
@ -266,7 +296,7 @@ internal class MarketsListBatchFlowManager(
}
fun clearStateAndStopAllActions() {
uiBatches.value = emptyList()
resultBatches.value = ResultBatches()
modelScope.launch {
actionsFlow.emit(BatchAction.Reset)
}
@ -281,6 +311,10 @@ internal class MarketsListBatchFlowManager(
.toSet()
}
fun getTokenById(id: String): TokenMarket? {
return batchFlow.state.value.data.map { it.data }.flatten().find { it.id == id }
}
private fun SortByTypeUM.toRequestOrder(): TokenMarketListConfig.Order {
return when (this) {
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating
@ -299,20 +333,8 @@ internal class MarketsListBatchFlowManager(
}
}
private fun TrendInterval.toRequestInterval(): PriceChangeInterval {
return when (this) {
TrendInterval.H24 -> PriceChangeInterval.H24
TrendInterval.D7 -> PriceChangeInterval.WEEK
TrendInterval.M1 -> PriceChangeInterval.MONTH
}
}
private fun <T> Flow<T>.onEachWithPrevious(operation: suspend (prev: T?, value: T) -> Unit): Flow<T> = flow {
var prev: T? = null
collect { value ->
operation(prev, value)
prev = value
emit(value)
}
}
private data class ResultBatches(
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
)
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.model.statemanager
package com.tangem.features.markets.tokenlist.impl.model.statemanager
import androidx.compose.runtime.Stable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
@ -7,12 +7,14 @@ import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM
import com.tangem.features.markets.ui.entity.ListUM
import com.tangem.features.markets.ui.entity.MarketsListItemUM
import com.tangem.features.markets.ui.entity.MarketsListUM
import com.tangem.features.markets.ui.entity.SortByTypeUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM
import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
@Stable
@ -20,6 +22,7 @@ internal class MarketsListUMStateManager(
private val onLoadMoreUiItems: () -> Unit,
private val visibleItemsChanged: (itemsKeys: List<String>) -> Unit,
private val onRetryButtonClicked: () -> Unit,
private val onTokenClick: (MarketsListItemUM) -> Unit,
) {
private var sortByBottomSheetIsShown
@ -98,21 +101,74 @@ internal class MarketsListUMStateManager(
it.copy(list = ListUM.Loading)
}
else -> {
it.copy(
list = ListUM.Content(
items = uiItems,
loadMore = onLoadMoreUiItems,
visibleIdsChanged = visibleItemsChanged,
showUnder100kTokens = true,
onShowTokensUnder100kClicked = { },
triggerScrollReset = consumedEvent(),
),
)
it.updateItems(newItems = uiItems)
}
}
}
}
private fun MarketsListUM.updateItems(newItems: ImmutableList<MarketsListItemUM>): MarketsListUM {
val currentState = this
val isNextPageInSearch = isInSearchState && (this.list as? ListUM.Content)?.showUnder100kTokens == true
var searchUiItemsCached: ImmutableList<MarketsListItemUM> = persistentListOf()
val items = when {
isInSearchState && isNextPageInSearch.not() -> {
searchUiItemsCached = newItems
val filtered = newItems.filter { item -> item.isUnder100kMarketCap.not() }.toImmutableList()
if (filtered.size == newItems.size) {
return currentState.copy(list = generalContentState(newItems))
} else {
filtered
}
}
else -> {
searchUiItemsCached = persistentListOf()
newItems
}
}
return currentState.copy(
list = ListUM.Content(
items = items,
loadMore = onLoadMoreUiItems,
visibleIdsChanged = visibleItemsChanged,
showUnder100kTokens = isInSearchState.not() || isNextPageInSearch,
onShowTokensUnder100kClicked = {
if (searchUiItemsCached.isNotEmpty()) {
state.update { s ->
if (s.list is ListUM.Content) {
s.copy(
list = s.list.copy(
items = searchUiItemsCached,
showUnder100kTokens = true,
),
)
} else {
s
}
}
}
},
triggerScrollReset = consumedEvent(),
onItemClick = onTokenClick,
),
)
}
private fun generalContentState(newItems: ImmutableList<MarketsListItemUM>): ListUM.Content {
return ListUM.Content(
items = newItems,
loadMore = onLoadMoreUiItems,
visibleIdsChanged = visibleItemsChanged,
showUnder100kTokens = true,
onShowTokensUnder100kClicked = {},
triggerScrollReset = consumedEvent(),
onItemClick = onTokenClick,
)
}
private fun state(): MarketsListUM = MarketsListUM(
list = ListUM.Loading,
searchBar = SearchBarUM(

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.model.utils
package com.tangem.features.markets.tokenlist.impl.model.utils
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.markets.TokenMarketListConfig

View file

@ -1,16 +1,15 @@
package com.tangem.features.markets.ui
package com.tangem.features.markets.tokenlist.impl.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
@ -32,17 +31,18 @@ import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.component.BottomSheetState
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.ui.components.MarketsListLazyColumn
import com.tangem.features.markets.ui.components.MarketsListSortByBottomSheet
import com.tangem.features.markets.ui.entity.ListUM
import com.tangem.features.markets.ui.entity.MarketsListUM
import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM
import com.tangem.features.markets.ui.entity.SortByTypeUM
import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider
import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListLazyColumn
import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListSortByBottomSheet
import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM
import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -68,23 +68,28 @@ internal fun MarketsList(
@Composable
private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.background(color = TangemTheme.colors.background.primary),
.drawBehind { drawRect(background) },
) {
SearchBar(
modifier = Modifier
.background(color = TangemTheme.colors.background.primary)
.drawBehind { drawRect(background) }
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing4,
)
.onGloballyPositioned {
with(density) { onHeaderSizeChange(it.size.height.toDp()) }
if (it.size.height > 0) {
with(density) {
onHeaderSizeChange(it.size.height.toDp())
}
}
},
state = state.searchBar,
)
@ -225,44 +230,52 @@ private fun KeyboardEvents(isSortByBottomSheetShown: Boolean, bottomSheetState:
//region: Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
MarketsList(
state = MarketsListUM(
list = ListUM.Content(
items = MarketChartListItemPreviewDataProvider().values
.flatMap { item -> List(size = 10) { item } }
.mapIndexed { index, item ->
item.copy(id = index.toString())
}
.toImmutableList(),
showUnder100kTokens = false,
loadMore = {},
visibleIdsChanged = {},
onShowTokensUnder100kClicked = {},
triggerScrollReset = consumedEvent(),
TangemThemePreview(alwaysShowBottomSheets = false) {
val primaryBackground = TangemTheme.colors.background.primary
CompositionLocalProvider(
LocalMainBottomSheetColor provides remember { mutableStateOf(primaryBackground) },
) {
MarketsList(
state = MarketsListUM(
list = ListUM.Content(
items = MarketChartListItemPreviewDataProvider().values
.flatMap { item -> List(size = 10) { item } }
.mapIndexed { index, item ->
item.copy(id = index.toString())
}
.toImmutableList(),
showUnder100kTokens = false,
loadMore = {},
visibleIdsChanged = {},
onShowTokensUnder100kClicked = {},
triggerScrollReset = consumedEvent(),
onItemClick = {},
),
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
),
selectedSortBy = SortByTypeUM.Rating,
selectedInterval = MarketsListUM.TrendInterval.H24,
onIntervalClick = {},
onSortByButtonClick = {},
sortByBottomSheet = TangemBottomSheetConfig(
isShow = false,
onDismissRequest = {},
content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {},
),
),
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
),
selectedSortBy = SortByTypeUM.Rating,
selectedInterval = MarketsListUM.TrendInterval.H24,
onIntervalClick = {},
onSortByButtonClick = {},
sortByBottomSheet = TangemBottomSheetConfig(
isShow = false,
onDismissRequest = {},
content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {},
),
),
onHeaderSizeChange = {},
bottomSheetState = BottomSheetState.EXPANDED,
)
onHeaderSizeChange = {},
bottomSheetState = BottomSheetState.EXPANDED,
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.ui.components
package com.tangem.features.markets.tokenlist.impl.ui.components
import android.content.res.Configuration
import androidx.compose.animation.Animatable
@ -39,14 +39,15 @@ import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.windowsize.WindowSizeType
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.ui.entity.MarketsListItemUM
import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider
import com.tangem.utils.StringsSigns.MINUS
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@ -97,14 +98,14 @@ fun MarketsListItem(
.collect {
val border = -actionWidthPx * SWIPE_THRESHOLD_PERCENT
if (it < border && actionPerformed.not()) {
hapticManager.vibrateLong()
hapticManager.perform(TangemHapticEffect.View.GestureThresholdActivate)
actionPerformed = true
releasePerformed = false
}
if (it > border) {
if (releasePerformed.not()) {
hapticManager.vibrateShort()
hapticManager.perform(TangemHapticEffect.View.GestureThresholdDeactivate)
releasePerformed = true
}
actionPerformed = false

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.ui.components
package com.tangem.features.markets.tokenlist.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.ui.components
package com.tangem.features.markets.tokenlist.impl.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -9,10 +9,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.buttons.SecondarySmallButton
@ -20,11 +16,13 @@ import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.disableNestedScroll
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.ui.entity.ListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM
import kotlinx.coroutines.launch
private const val LOAD_NEXT_PAGE_ON_END_INDEX = 50
private const val TOKEN_LAZY_LIST_ID_SEPARATOR = "***"
@Composable
@Suppress("LongMethod")
@ -53,7 +51,7 @@ internal fun MarketsListLazyColumn(
if (state is ListUM.Loading) {
LazyColumn(
modifier = Modifier.nestedScroll(DisableParentConnection),
modifier = Modifier.disableNestedScroll(),
state = rememberLazyListState(),
contentPadding = PaddingValues(bottom = bottomBarHeight),
userScrollEnabled = false,
@ -64,7 +62,7 @@ internal fun MarketsListLazyColumn(
}
} else {
LazyColumn(
modifier = modifier.nestedScroll(DisableParentConnection),
modifier = modifier.disableNestedScroll(),
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight),
userScrollEnabled = true,
@ -89,9 +87,12 @@ internal fun MarketsListLazyColumn(
is ListUM.Content -> {
items(
items = state.items,
key = { it.id },
key = { it.id + TOKEN_LAZY_LIST_ID_SEPARATOR + it.marketCap.toString() },
) { item ->
MarketsListItem(model = item)
MarketsListItem(
model = item,
onClick = { state.onItemClick(item) },
)
}
if (isInSearchMode && state.showUnder100kTokens.not()) {
@ -114,8 +115,11 @@ internal fun MarketsListLazyColumn(
buffer = LOAD_NEXT_PAGE_ON_END_INDEX,
onLoadMore = remember(state) {
{
if (state is ListUM.Content) {
if (state is ListUM.Content && state.showUnder100kTokens) {
state.loadMore()
true
} else {
false
}
}
},
@ -181,7 +185,9 @@ private fun SearchNothingFoundText(modifier: Modifier = Modifier) {
private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) {
val visibleItems by remember {
derivedStateOf {
listState.layoutInfo.visibleItemsInfo.mapNotNull { it.key as? String }
listState.layoutInfo.visibleItemsInfo.mapNotNull {
(it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first()
}
}
}
@ -193,7 +199,7 @@ private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) {
}
@Composable
fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer: Int = 2) {
fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Boolean, buffer: Int = 2) {
val loadMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
@ -209,14 +215,7 @@ fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer
LaunchedEffect(loadMore) {
if (loadMore && !emitted) {
emitted = true
onLoadMore()
emitted = onLoadMore()
}
}
}
private object DisableParentConnection : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return available.copy(x = 0f)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.ui.components
package com.tangem.features.markets.tokenlist.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
@ -19,8 +19,8 @@ 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.markets.impl.R
import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM
import com.tangem.features.markets.ui.entity.SortByTypeUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM
@Composable
fun MarketsListSortByBottomSheet(config: TangemBottomSheetConfig) {

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.ui.components
package com.tangem.features.markets.tokenlist.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement

View file

@ -1,10 +1,11 @@
@file:Suppress("MagicNumber")
package com.tangem.features.markets.ui.preview
package com.tangem.features.markets.tokenlist.impl.ui.preview
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.features.markets.ui.entity.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import kotlinx.collections.immutable.persistentListOf
internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider<MarketsListItemUM>(
collection = listOf(
@ -19,7 +20,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
),
MarketsListItemUM(
@ -45,7 +46,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet
trendPercentText = "12.43%",
trendType = PriceChangeType.DOWN,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
),
MarketsListItemUM(
@ -59,7 +60,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
),
MarketsListItemUM(
@ -73,7 +74,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
),
MarketsListItemUM(
@ -87,7 +88,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chardData = MarketChartRawData(
y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f),
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
),
),

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.ui.entity
package com.tangem.features.markets.tokenlist.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartLook
@ -17,7 +17,7 @@ data class MarketsListItemUM(
val trendPercentText: String,
val trendType: PriceChangeType,
val chardData: MarketChartRawData?,
val showUnder100kMarketCap: Boolean = false,
val isUnder100kMarketCap: Boolean = false,
) {
val chartType: MarketChartLook.Type = when (trendType) {
PriceChangeType.UP,

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.ui.entity
package com.tangem.features.markets.tokenlist.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
@ -46,6 +46,7 @@ sealed class ListUM {
val visibleIdsChanged: (List<String>) -> Unit,
val onShowTokensUnder100kClicked: () -> Unit,
val triggerScrollReset: StateEvent<Unit>,
val onItemClick: (MarketsListItemUM) -> Unit,
) : ListUM()
data object Loading : ListUM()

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.ui.entity
package com.tangem.features.markets.tokenlist.impl.ui.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent

View file

@ -1,13 +0,0 @@
package com.tangem.features.send.api.featuretoggles
/**
* Send feature toggles
*/
interface SendFeatureToggles {
/** Availability of redesigned send screen */
val isRedesignedSendEnabled: Boolean
/** Updates remote toggle */
suspend fun fetchNewSendEnabled()
}

View file

@ -1,34 +0,0 @@
package com.tangem.features.send.impl.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.features.send.impl.featuretoggles.DefaultSendFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* DI module provides implementation of [SendFeatureToggles]
*/
@Module
@InstallIn(SingletonComponent::class)
internal object SendFeatureTogglesModule {
@Provides
@Singleton
fun provideSendFeatureToggles(
featureTogglesManager: FeatureTogglesManager,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): SendFeatureToggles {
return DefaultSendFeatureToggles(
featureTogglesManager = featureTogglesManager,
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
)
}
}

View file

@ -1,41 +0,0 @@
package com.tangem.features.send.impl.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import timber.log.Timber
/**
* Default implementation of Send feature toggles
*
* @property featureTogglesManager manager for getting information about the availability of feature toggles
* @property tangemTechApi api to get remote feature toggle for send
* @property dispatchers coroutine dispatchers
*/
internal class DefaultSendFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) : SendFeatureToggles {
private val remoteSendEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true)
override val isRedesignedSendEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_SEND_SCREEN_ENABLED") &&
remoteSendEnabled.value
override suspend fun fetchNewSendEnabled() {
runCatching(dispatchers.io) {
tangemTechApi.getFeatures().getOrThrow()
}.onSuccess { response ->
remoteSendEnabled.update { response.isNewSendEnabled }
}.onFailure {
Timber.e(it.localizedMessage, "Unable to fetch new send toggle")
}
}
}

View file

@ -141,7 +141,7 @@ private fun LazyListScope.memoField(memoField: SendTextField.RecipientMemo?, onM
onValueChange = memoField.onValueChange,
onPasteClick = onMemoChange,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing20),
labelStyle = TangemTheme.typography.caption2,
labelStyle = TangemTheme.typography.subtitle2,
isError = memoField.isError,
error = memoField.error,
isReadOnly = !memoField.isEnabled,

View file

@ -52,7 +52,7 @@ internal fun RecipientBlock(
private fun AddressBlock(address: SendTextField.RecipientAddress) {
Text(
text = address.label.resolveReference(),
style = TangemTheme.typography.caption2,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.secondary,
)
Row(

View file

@ -1,9 +1,11 @@
package com.tangem.features.staking.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.appcurrency.model.AppCurrency
import java.math.BigDecimal
@Immutable
sealed class FeeState {
data class Content(

View file

@ -1,6 +1,8 @@
package com.tangem.features.staking.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.Yield
import kotlinx.collections.immutable.ImmutableList
@ -11,19 +13,23 @@ sealed class InnerYieldBalanceState {
val rewardsCrypto: String,
val rewardsFiat: String,
val isRewardsToClaim: Boolean,
val balance: List<BalanceGroupedState>,
val balance: ImmutableList<BalanceGroupedState>,
) : InnerYieldBalanceState()
data object Empty : InnerYieldBalanceState()
}
// TODO staking get rid of unstable types
@Immutable
data class BalanceGroupedState(
val items: ImmutableList<BalanceState>,
val footer: TextReference?,
val title: TextReference,
val type: BalanceGroupType,
val type: BalanceType,
val isClickable: Boolean,
)
@Immutable
data class BalanceState(
val validator: Yield.Validator,
val cryptoValue: String,
@ -33,10 +39,4 @@ data class BalanceState(
val rawCurrencyId: String?,
val unbondingPeriod: TextReference,
val pendingActions: ImmutableList<PendingAction>,
)
enum class BalanceGroupType {
ACTIVE,
UNSTAKED,
UNKNOWN,
}
)

View file

@ -3,6 +3,7 @@ package com.tangem.features.staking.impl.presentation.state
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.staking.impl.R
@ -23,7 +24,15 @@ internal sealed class StakingNotification(val config: NotificationConfig) {
onCloseClick = onCloseClick,
),
) {
// TODO staking
data class StakedPositionNotFoundError(val message: String) : Error(
title = stringReference(message),
subtitle = stringReference(message),
)
data class Common(val subtitle: TextReference) : Error(
title = resourceReference(R.string.common_error),
subtitle = subtitle,
)
}
sealed class Warning(

View file

@ -1,8 +1,12 @@
package com.tangem.features.staking.impl.presentation.state
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub
import com.tangem.features.staking.impl.presentation.state.transformers.SetButtonsStateTransformer
import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -20,20 +24,26 @@ internal class StakingStateController @Inject constructor() {
val uiState: StateFlow<StakingUiState> get() = mutableUiState.asStateFlow()
private val buttonsTransformer = SetButtonsStateTransformer()
fun update(function: (StakingUiState) -> StakingUiState) {
mutableUiState.update(function = function)
mutableUiState.update(function = buttonsTransformer::transform)
}
fun update(transformer: Transformer<StakingUiState>) {
mutableUiState.update(function = transformer::transform)
mutableUiState.update(function = buttonsTransformer::transform)
}
fun clear() {
mutableUiState.update { getInitialState() }
mutableUiState.update(function = buttonsTransformer::transform)
}
private fun getInitialState(): StakingUiState {
return StakingUiState(
title = TextReference.EMPTY,
clickIntents = StakingClickIntentsStub,
cryptoCurrencyName = "",
currentStep = StakingStep.InitialInfo,
@ -44,7 +54,8 @@ internal class StakingStateController @Inject constructor() {
isBalanceHidden = false,
event = consumedEvent(),
bottomSheetConfig = null,
routeType = RouteType.STAKE,
actionType = StakingActionCommonType.ENTER,
buttonsState = NavigationButtonsState.Empty,
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.staking.impl.presentation.state
import com.tangem.common.routing.AppRouter
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
internal class StakingStateRouter(
private val appRouter: AppRouter,
@ -14,12 +15,12 @@ internal class StakingStateRouter(
fun onNextClick() {
when (stateController.value.currentStep) {
StakingStep.InitialInfo -> when (stateController.value.routeType) {
RouteType.STAKE -> showAmount()
RouteType.OTHER,
RouteType.UNSTAKE,
StakingStep.InitialInfo -> when (stateController.value.actionType) {
StakingActionCommonType.ENTER -> showAmount()
StakingActionCommonType.PENDING_OTHER,
StakingActionCommonType.EXIT,
-> showConfirmation()
RouteType.CLAIM -> showRewardsValidators()
StakingActionCommonType.PENDING_REWARDS -> showRewardsValidators()
}
StakingStep.RewardsValidators,
StakingStep.Validators,
@ -32,10 +33,17 @@ internal class StakingStateRouter(
}
fun onPrevClick() {
when (stateController.uiState.value.currentStep) {
val uiState = stateController.uiState.value
when (uiState.currentStep) {
StakingStep.InitialInfo -> onBackClick()
StakingStep.Amount -> showInitial()
StakingStep.Confirmation -> showAmount()
StakingStep.Confirmation -> {
if (uiState.actionType != StakingActionCommonType.ENTER) {
showInitial()
} else {
showAmount()
}
}
StakingStep.Validators -> showConfirmation()
StakingStep.RewardsValidators -> showInitial()
}

View file

@ -2,11 +2,13 @@ package com.tangem.features.staking.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.list.RoundedListWithDividersItemData
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.presentation.state.transformers.InfoType
import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents
import kotlinx.collections.immutable.ImmutableList
@ -16,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList
*/
@Immutable
internal data class StakingUiState(
val title: TextReference,
val clickIntents: StakingClickIntents,
val cryptoCurrencyName: String,
val currentStep: StakingStep,
@ -25,7 +28,8 @@ internal data class StakingUiState(
val confirmationState: StakingStates.ConfirmationState,
val isBalanceHidden: Boolean,
val bottomSheetConfig: TangemBottomSheetConfig?,
val routeType: RouteType,
val actionType: StakingActionCommonType,
val buttonsState: NavigationButtonsState,
val event: StateEvent<StakingEvent>,
) {
@ -55,17 +59,6 @@ internal sealed class StakingStates {
val isStakeMoreAvailable: Boolean,
) : InitialInfoState()
data class InitialInfoItems(
val available: String,
val onStake: String,
val aprRange: TextReference,
val unbondingPeriod: String,
val minimumRequirement: String,
val rewardClaiming: String,
val warmupPeriod: String,
val rewardSchedule: String,
)
data class Empty(
override val isPrimaryButtonEnabled: Boolean = false,
) : InitialInfoState()
@ -94,6 +87,7 @@ internal sealed class StakingStates {
val notifications: ImmutableList<StakingNotification>,
val footerText: String,
val transactionDoneState: TransactionDoneState,
val pendingActionInProgress: PendingAction? = null,
) : ConfirmationState()
data class Empty(
@ -108,11 +102,4 @@ enum class StakingStep {
Amount,
Confirmation,
Validators,
}
enum class RouteType {
STAKE,
UNSTAKE,
CLAIM,
OTHER,
}

View file

@ -10,7 +10,6 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.*
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.BalanceGroupType
import com.tangem.features.staking.impl.presentation.state.BalanceGroupedState
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState
@ -59,15 +58,18 @@ internal class YieldBalancesConverter(
.groupBy { it.type.toGroup() }
.mapNotNull { item ->
val (title, footer) = getGroupTitle(item.key)
val isClickable = getClickableType(item.key)
title?.let {
BalanceGroupedState(
items = item.value.mapBalances().toPersistentList(),
footer = footer,
title = it,
type = item.key,
isClickable = isClickable,
)
}
}
.toPersistentList()
private fun List<BalanceItem>.mapBalances(): List<BalanceState> {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
@ -111,31 +113,37 @@ internal class YieldBalancesConverter(
}
private fun BalanceType.toGroup() = when (this) {
BalanceType.PREPARING,
BalanceType.STAKED,
BalanceType.REWARDS,
BalanceType.AVAILABLE,
BalanceType.LOCKED,
-> BalanceGroupType.ACTIVE
BalanceType.UNSTAKING,
BalanceType.UNLOCKING,
BalanceType.UNSTAKED,
-> BalanceGroupType.UNSTAKED
BalanceType.UNKNOWN,
-> BalanceGroupType.UNKNOWN
-> BalanceType.UNKNOWN
else -> this
}
private fun getGroupTitle(type: BalanceGroupType) = when (type) {
BalanceGroupType.ACTIVE -> resourceReference(
R.string.staking_active,
) to resourceReference(
R.string.staking_active_footer,
)
BalanceGroupType.UNSTAKED -> resourceReference(
R.string.staking_unstaked,
) to resourceReference(
R.string.staking_unstaked_footer,
)
BalanceGroupType.UNKNOWN -> null to null
private fun getGroupTitle(type: BalanceType) = when (type) {
BalanceType.STAKED -> resourceReference(R.string.staking_active) to
resourceReference(R.string.staking_active_footer)
BalanceType.UNSTAKED -> resourceReference(R.string.staking_unstaked) to
resourceReference(R.string.staking_unstaked_footer)
BalanceType.UNSTAKING -> resourceReference(R.string.staking_unstaking) to null
BalanceType.AVAILABLE -> null to null
BalanceType.PREPARING -> null to null
BalanceType.REWARDS -> null to null
BalanceType.LOCKED -> null to null
BalanceType.UNLOCKING -> null to null
BalanceType.UNKNOWN -> null to null
}
private fun getClickableType(type: BalanceType) = when (type) {
BalanceType.STAKED,
BalanceType.UNSTAKED,
-> true
BalanceType.AVAILABLE,
BalanceType.UNSTAKING,
BalanceType.PREPARING,
BalanceType.REWARDS,
BalanceType.LOCKED,
BalanceType.UNLOCKING,
BalanceType.UNKNOWN,
-> false
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.staking.impl.presentation.state.previewdata
import com.tangem.core.ui.components.list.RoundedListWithDividersItemData
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.*
@ -59,11 +60,12 @@ internal object InitialStakingStatePreview {
rewardsFiat = "100 $",
rewardsCrypto = "100 SOL",
isRewardsToClaim = false,
balance = listOf(
balance = persistentListOf(
BalanceGroupedState(
title = stringReference("Staked"),
footer = null,
type = BalanceGroupType.ACTIVE,
type = BalanceType.STAKED,
isClickable = true,
items = persistentListOf(
BalanceState(
cryptoValue = "100",

View file

@ -2,6 +2,7 @@ package com.tangem.features.staking.impl.presentation.state.stub
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.features.staking.impl.presentation.state.transformers.InfoType
import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents
@ -11,10 +12,14 @@ object StakingClickIntentsStub : StakingClickIntents {
override fun onBackClick() {}
override fun onNextClick(pendingActions: ImmutableList<PendingAction>) {}
override fun onNextClick(actionType: StakingActionCommonType?, pendingActions: ImmutableList<PendingAction>) {}
override fun onActionClick(pendingAction: PendingAction?) {}
override fun onPrevClick() {}
override fun onInitialInfoBannerClick() {}
override fun onInfoClick(infoType: InfoType) {}
override fun onAmountValueChange(value: String) {}
@ -33,8 +38,6 @@ object StakingClickIntentsStub : StakingClickIntents {
override fun openRewardsValidators() {}
override fun selectRewardValidator(rewardValue: String) {}
override fun onExploreClick() {}
override fun onShareClick() {}

View file

@ -0,0 +1,36 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList
internal class AddStakingErrorTransformer(
private val error: StakingError,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val confirmationState =
prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState
return prevState.copy(
confirmationState = confirmationState.copy(
notifications = (confirmationState.notifications + convertToNotification(error)).toPersistentList(),
feeState = FeeState.Error,
),
)
}
private fun convertToNotification(error: StakingError): StakingNotification {
return when (error) {
is StakingError.StakedPositionNotFoundError -> StakingNotification.Error.StakedPositionNotFoundError(
message = error.toString(),
)
// TODO staking
else -> StakingNotification.Error.Common(
subtitle = stringReference(error.toString()),
)
}
}
}

View file

@ -0,0 +1,232 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal class SetButtonsStateTransformer : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val buttonsState = if (prevState.isButtonsVisible()) {
NavigationButtonsState.Data(
primaryButton = getPrimaryButton(prevState),
prevButton = getPrevButton(prevState),
secondaryButton = getSecondaryButton(prevState),
extraButtons = getExtraButtons(prevState),
txUrl = (confirmState?.transactionDoneState as? TransactionDoneState.Content)?.txUrl,
)
} else {
NavigationButtonsState.Empty
}
return prevState.copy(buttonsState = buttonsState)
}
private fun getPrimaryButton(prevState: StakingUiState): NavigationButton {
val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val innerConfirmState = confirmState?.innerState
val isPrimaryInProgress =
confirmState?.pendingActions?.getPrimaryAction() == confirmState?.pendingActionInProgress
val isConfirmation = prevState.currentStep == StakingStep.Confirmation
val isInProgress = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS
val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED
val isIconVisible = isConfirmation && !isCompleted
val isShowProgress = isInProgress && isPrimaryInProgress
return NavigationButton(
textReference = prevState.getButtonText(),
iconRes = R.drawable.ic_tangem_24,
isSecondary = false,
isIconVisible = isIconVisible,
showProgress = isShowProgress,
isEnabled = prevState.isButtonEnabled(),
onClick = { prevState.onPrimaryClick() },
)
}
private fun getSecondaryButton(prevState: StakingUiState): NavigationButton? {
val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val innerConfirmState = confirmState?.innerState
val isConfirmation = prevState.currentStep == StakingStep.Confirmation
val isInProgress = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS
val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED
return confirmState?.pendingActions?.getSecondaryAction()?.let { pendingAction ->
val isSecondaryInProgress = pendingAction == confirmState.pendingActionInProgress
val isShowProgress = isInProgress && isSecondaryInProgress
NavigationButton(
textReference = getPendingActionTitle(pendingAction.type),
iconRes = R.drawable.ic_tangem_24,
isSecondary = true,
isIconVisible = true,
showProgress = isShowProgress,
isEnabled = prevState.isButtonEnabled(),
onClick = { prevState.clickIntents.onActionClick(pendingAction) },
).takeIf { isConfirmation && !isCompleted }
}
}
private fun getPrevButton(prevState: StakingUiState): NavigationButton? {
return NavigationButton(
textReference = TextReference.EMPTY,
iconRes = R.drawable.ic_back_24,
isSecondary = true,
isIconVisible = true,
showProgress = false,
isEnabled = true,
onClick = prevState.clickIntents::onPrevClick,
).takeIf { prevState.currentStep.isPrevButtonVisible() }
}
private fun getExtraButtons(prevState: StakingUiState): ImmutableList<NavigationButton> {
return persistentListOf(
NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
isSecondary = true,
isIconVisible = true,
showProgress = false,
isEnabled = true,
onClick = prevState.clickIntents::onExploreClick,
),
NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
isSecondary = true,
isIconVisible = true,
showProgress = false,
isEnabled = true,
onClick = prevState.clickIntents::onShareClick,
),
)
}
private fun List<PendingAction>.getPrimaryAction(): PendingAction? = getOrNull(0)
private fun List<PendingAction>.getSecondaryAction(): PendingAction? = getOrNull(1)
private fun StakingUiState.isButtonsVisible(): Boolean = when (currentStep) {
StakingStep.InitialInfo -> isStakeMoreAvailable()
StakingStep.RewardsValidators -> false
else -> true
}
private fun StakingUiState.getButtonText(): TextReference {
return when (currentStep) {
StakingStep.InitialInfo -> {
val initialState = initialInfoState as? StakingStates.InitialInfoState.Data
if (initialState?.yieldBalance is InnerYieldBalanceState.Data) {
resourceReference(R.string.staking_stake_more)
} else {
resourceReference(R.string.common_next)
}
}
StakingStep.Confirmation -> {
if (confirmationState is StakingStates.ConfirmationState.Data) {
if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) {
resourceReference(R.string.common_close)
} else {
when (actionType) {
StakingActionCommonType.ENTER -> resourceReference(R.string.common_stake)
StakingActionCommonType.EXIT -> resourceReference(R.string.common_unstake)
StakingActionCommonType.PENDING_OTHER,
StakingActionCommonType.PENDING_REWARDS,
-> getPendingActionTitle(confirmationState.pendingActions.firstOrNull()?.type)
}
}
} else {
resourceReference(R.string.common_close)
}
}
StakingStep.Validators -> resourceReference(R.string.common_continue)
StakingStep.Amount,
StakingStep.RewardsValidators,
-> resourceReference(R.string.common_next)
}
}
private fun StakingUiState.onPrimaryClick() {
when (currentStep) {
StakingStep.InitialInfo -> {
val actionType = StakingActionCommonType.ENTER.takeIf { isStakeMoreAvailable() }
clickIntents.onAmountValueChange("") // reset amount state
clickIntents.onNextClick(actionType)
}
StakingStep.Validators,
StakingStep.Amount,
-> clickIntents.onNextClick()
StakingStep.Confirmation -> {
if (confirmationState is StakingStates.ConfirmationState.Data) {
if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) {
clickIntents.onBackClick()
} else {
clickIntents.onActionClick(confirmationState.pendingActions.firstOrNull())
}
} else {
clickIntents.onBackClick()
}
}
StakingStep.RewardsValidators -> Unit
}
}
private fun StakingStep.isPrevButtonVisible(): Boolean = when (this) {
StakingStep.InitialInfo,
StakingStep.RewardsValidators,
StakingStep.Confirmation,
StakingStep.Validators,
-> false
StakingStep.Amount,
-> true
}
private fun StakingUiState.isButtonEnabled(): Boolean {
return when (currentStep) {
StakingStep.InitialInfo -> initialInfoState.isPrimaryButtonEnabled
StakingStep.Amount -> amountState.isPrimaryButtonEnabled
StakingStep.Confirmation -> confirmationState.isPrimaryButtonEnabled
StakingStep.RewardsValidators -> rewardsValidatorsState.isPrimaryButtonEnabled
StakingStep.Validators -> true
}
}
@Suppress("CyclomaticComplexMethod")
private fun getPendingActionTitle(type: StakingActionType?): TextReference = when (type) {
StakingActionType.CLAIM_REWARDS -> resourceReference(R.string.common_claim_rewards)
StakingActionType.RESTAKE_REWARDS -> resourceReference(R.string.staking_restake_rewards)
StakingActionType.WITHDRAW -> resourceReference(R.string.staking_withdraw)
StakingActionType.RESTAKE -> resourceReference(R.string.staking_restake)
StakingActionType.CLAIM_UNSTAKED -> resourceReference(R.string.staking_claim_unstaked)
StakingActionType.UNLOCK_LOCKED -> resourceReference(R.string.staking_unlocked_locked)
StakingActionType.STAKE_LOCKED -> resourceReference(R.string.staking_stake_locked)
StakingActionType.VOTE -> resourceReference(R.string.staking_vote)
StakingActionType.REVOKE -> resourceReference(R.string.staking_revoke)
StakingActionType.VOTE_LOCKED -> resourceReference(R.string.staking_vote_locked)
StakingActionType.REVOTE -> resourceReference(R.string.staking_revote)
StakingActionType.REBOND -> resourceReference(R.string.staking_rebond)
StakingActionType.MIGRATE -> resourceReference(R.string.staking_migrate)
StakingActionType.STAKE -> resourceReference(R.string.common_stake)
StakingActionType.UNSTAKE -> resourceReference(R.string.common_unstake)
StakingActionType.UNKNOWN -> TextReference.EMPTY
null -> TextReference.EMPTY
}
private fun StakingUiState.isStakeMoreAvailable(): Boolean {
val initialState = initialInfoState as? StakingStates.InitialInfoState.Data
return initialState?.isStakeMoreAvailable == true || initialState?.yieldBalance is InnerYieldBalanceState.Empty
}
}

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