Updated on 2026-08-14
This commit is contained in:
parent
7de4f3ab1f
commit
6badbf18b5
24 changed files with 1557 additions and 78 deletions
20
core/ui/src/main/res/drawable/ic_key_card_20.xml
Normal file
20
core/ui/src/main/res/drawable/ic_key_card_20.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M0,8.967C0,7.287 0,6.446 0.327,5.805C0.615,5.24 1.074,4.781 1.638,4.494C2.28,4.167 3.12,4.167 4.8,4.167H10.2C11.88,4.167 12.72,4.167 13.362,4.494C13.927,4.781 14.385,5.24 14.673,5.805C15,6.446 15,7.287 15,8.967V11.033C15,12.714 15,13.554 14.673,14.195C14.385,14.76 13.927,15.219 13.362,15.506C12.72,15.833 11.88,15.833 10.2,15.833H4.8C3.12,15.833 2.28,15.833 1.638,15.506C1.074,15.219 0.615,14.76 0.327,14.195C0,13.554 0,12.714 0,11.033V8.967Z"
|
||||
android:fillColor="#919191"/>
|
||||
<path
|
||||
android:pathData="M0,8.967C0,7.287 0,6.446 0.327,5.805C0.615,5.24 1.074,4.781 1.638,4.494C2.28,4.167 3.12,4.167 4.8,4.167H10.2C11.88,4.167 12.72,4.167 13.362,4.494C13.927,4.781 14.385,5.24 14.673,5.805C15,6.446 15,7.287 15,8.967V11.033C15,12.714 15,13.554 14.673,14.195C14.385,14.76 13.927,15.219 13.362,15.506C12.72,15.833 11.88,15.833 10.2,15.833H4.8C3.12,15.833 2.28,15.833 1.638,15.506C1.074,15.219 0.615,14.76 0.327,14.195C0,13.554 0,12.714 0,11.033V8.967Z"
|
||||
android:fillColor="#919191"/>
|
||||
<path
|
||||
android:pathData="M13.362,4.167C14.81,4.167 15.535,4.167 16.088,4.439C16.575,4.679 16.97,5.061 17.218,5.531C17.5,6.066 17.5,6.766 17.5,8.167V11.833C17.5,13.233 17.5,13.934 17.218,14.469C16.97,14.939 16.575,15.321 16.088,15.561C15.535,15.833 14.81,15.833 13.362,15.833H12.5C13.948,15.833 14.673,15.833 15.226,15.561C15.712,15.321 16.108,14.939 16.356,14.469C16.638,13.934 16.638,13.233 16.638,11.833V8.167C16.638,6.766 16.638,6.066 16.356,5.531C16.108,5.061 15.712,4.679 15.226,4.439C14.673,4.167 13.948,4.167 12.5,4.167H13.362Z"
|
||||
android:fillColor="#919191"/>
|
||||
<path
|
||||
android:pathData="M15.862,4.167C17.31,4.167 18.035,4.167 18.588,4.439C19.075,4.679 19.47,5.061 19.718,5.531C20,6.066 20,6.766 20,8.167V11.833C20,13.233 20,13.934 19.718,14.469C19.47,14.939 19.075,15.321 18.588,15.561C18.035,15.833 17.31,15.833 15.862,15.833H15C16.448,15.833 17.173,15.833 17.726,15.561C18.212,15.321 18.608,14.939 18.856,14.469C19.138,13.934 19.138,13.233 19.138,11.833V8.167C19.138,6.766 19.138,6.066 18.856,5.531C18.608,5.061 18.212,4.679 17.726,4.439C17.173,4.167 16.448,4.167 15,4.167H15.862Z"
|
||||
android:strokeAlpha="0.6"
|
||||
android:fillColor="#919191"
|
||||
android:fillAlpha="0.6"/>
|
||||
</vector>
|
||||
|
|
@ -9,7 +9,7 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "com.tangem.features.feed.impl"
|
||||
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
merges += "paymentrequest.proto"
|
||||
|
|
@ -17,6 +17,10 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/* Project - API */
|
||||
api(projects.features.feed.api)
|
||||
|
|
@ -102,4 +106,10 @@ dependencies {
|
|||
/** Tangem libraries */
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.blockchain)
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
}
|
||||
|
|
@ -6,10 +6,15 @@ import androidx.compose.runtime.*
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.slot.childSlot
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||
import com.tangem.core.ui.components.haze.hazeEffectTangem
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||
import com.tangem.core.ui.ds.field.search.TangemFieldShape
|
||||
import com.tangem.core.ui.ds.field.search.TangemSearchField
|
||||
|
|
@ -29,6 +34,13 @@ internal class DefaultSearchComponent(
|
|||
|
||||
private val model = getOrCreateModel<SearchModel, Params>(params = params)
|
||||
|
||||
private val bottomSheetSlot = childSlot(
|
||||
source = model.bottomSheetNavigation,
|
||||
serializer = null,
|
||||
handleBackButton = false,
|
||||
childFactory = ::bottomSheetChild,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Title(bottomSheetState: State<BottomSheetState>) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
|
|
@ -69,6 +81,7 @@ internal class DefaultSearchComponent(
|
|||
contentPadding: PaddingValues,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
val searchCallbacks = remember {
|
||||
SearchCallbacks(
|
||||
|
|
@ -85,6 +98,23 @@ internal class DefaultSearchComponent(
|
|||
searchCallbacks = searchCallbacks,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
bottomSheet.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
private fun bottomSheetChild(
|
||||
config: SearchBottomSheetRoute,
|
||||
componentContext: ComponentContext,
|
||||
): ComposableBottomSheetComponent = when (config) {
|
||||
is SearchBottomSheetRoute.TokenSelector -> SearchTokenSelectorComponent(
|
||||
context = childByContext(componentContext),
|
||||
params = SearchTokenSelectorComponent.Params(
|
||||
entries = config.entries,
|
||||
appCurrency = config.appCurrency,
|
||||
isBalanceHidden = config.isBalanceHidden,
|
||||
onTokenSelected = config.onTokenSelected,
|
||||
onDismiss = config.onDismiss,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.features.feed.components.search
|
||||
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.search.model.UserAssetSearchEntry
|
||||
|
||||
internal sealed interface SearchBottomSheetRoute {
|
||||
|
||||
data class TokenSelector(
|
||||
val entries: List<UserAssetSearchEntry>,
|
||||
val appCurrency: AppCurrency,
|
||||
val isBalanceHidden: Boolean,
|
||||
val onTokenSelected: (UserAssetSearchEntry) -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
) : SearchBottomSheetRoute
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.tangem.features.feed.components.search
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.search.model.UserAssetSearchEntry
|
||||
import com.tangem.features.feed.model.search.SearchTokenSelectorModel
|
||||
import com.tangem.features.feed.ui.search.components.TokenSelectorBottomSheet
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class SearchTokenSelectorComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted private val params: Params,
|
||||
) : AppComponentContext by context, ComposableBottomSheetComponent {
|
||||
|
||||
private val model = getOrCreateModel<SearchTokenSelectorModel, Params>(params = params)
|
||||
|
||||
override fun dismiss() {
|
||||
params.onDismiss()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
val state = model.state.collectAsStateWithLifecycle()
|
||||
TokenSelectorBottomSheet(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = ::dismiss,
|
||||
content = state.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val entries: List<UserAssetSearchEntry>,
|
||||
val appCurrency: AppCurrency,
|
||||
val isBalanceHidden: Boolean,
|
||||
val onTokenSelected: (UserAssetSearchEntry) -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import com.tangem.features.feed.model.market.list.MarketsListModel
|
|||
import com.tangem.features.feed.model.news.details.NewsDetailsModel
|
||||
import com.tangem.features.feed.model.news.list.NewsListModel
|
||||
import com.tangem.features.feed.model.search.SearchModel
|
||||
import com.tangem.features.feed.model.search.SearchTokenSelectorModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -71,4 +72,9 @@ internal interface ModelModule {
|
|||
@IntoMap
|
||||
@ClassKey(SearchModel::class)
|
||||
fun provideSearchModel(model: SearchModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(SearchTokenSelectorModel::class)
|
||||
fun provideSearchTokenSelectorModel(model: SearchTokenSelectorModel): Model
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package com.tangem.features.feed.model.search
|
||||
|
||||
import arrow.core.getOrElse
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.charts.state.MarketChartData
|
||||
|
|
@ -13,28 +16,25 @@ import com.tangem.core.decompose.model.ParamsContainer
|
|||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
|
||||
import com.tangem.domain.markets.GetTokenPriceChartUseCase
|
||||
import com.tangem.domain.markets.PriceChangeInterval
|
||||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.domain.markets.toSerializableParam
|
||||
import com.tangem.domain.markets.*
|
||||
import com.tangem.domain.search.model.UserAssetSearchEntry
|
||||
import com.tangem.domain.search.model.UserAssetSearchItem
|
||||
import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase
|
||||
import com.tangem.domain.search.usecase.GetSearchResultsUseCase
|
||||
import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase
|
||||
import com.tangem.domain.search.model.UserAssetSearchEntry
|
||||
import com.tangem.domain.search.usecase.SaveSearchQueryUseCase
|
||||
import com.tangem.features.feed.components.search.DefaultSearchComponent
|
||||
import com.tangem.features.feed.components.search.SearchBottomSheetRoute
|
||||
import com.tangem.features.feed.model.market.list.state.MarketsListUM
|
||||
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
|
||||
import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager
|
||||
import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecentSearchTokenConverter
|
||||
import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency
|
||||
import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter
|
||||
import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency
|
||||
import com.tangem.features.feed.model.search.converter.UserAssetSearchItemConverter
|
||||
import com.tangem.features.feed.model.search.converter.*
|
||||
import com.tangem.features.feed.model.search.state.SearchStateController
|
||||
import com.tangem.features.feed.model.search.state.transformers.*
|
||||
import com.tangem.features.feed.ui.search.state.*
|
||||
import com.tangem.features.feed.ui.search.state.MarketSearchResultUM
|
||||
import com.tangem.features.feed.ui.search.state.SearchContentUM
|
||||
import com.tangem.features.feed.ui.search.state.SearchUM
|
||||
import com.tangem.features.feed.ui.search.state.TextHintItemUM
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
|
|
@ -48,7 +48,7 @@ import javax.inject.Inject
|
|||
private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L
|
||||
private const val MARKET_SEARCH_DEBOUNCE_MS = 500L
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@ModelScoped
|
||||
internal class SearchModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
|
|
@ -112,6 +112,8 @@ internal class SearchModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
val bottomSheetNavigation: SlotNavigation<SearchBottomSheetRoute> = SlotNavigation()
|
||||
|
||||
val state: StateFlow<SearchUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
|
|
@ -208,6 +210,23 @@ internal class SearchModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) {
|
||||
bottomSheetNavigation.activate(
|
||||
SearchBottomSheetRoute.TokenSelector(
|
||||
entries = grouped.entries,
|
||||
appCurrency = currentAppCurrency.value,
|
||||
isBalanceHidden = isBalanceHidden.value,
|
||||
onTokenSelected = ::onTokenSelectedFromGroup,
|
||||
onDismiss = { bottomSheetNavigation.dismiss() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onTokenSelectedFromGroup(entry: UserAssetSearchEntry) {
|
||||
bottomSheetNavigation.dismiss()
|
||||
onSingleUserAssetClick(entry)
|
||||
}
|
||||
|
||||
private fun subscribeToQueryChanges() {
|
||||
stateController.uiState
|
||||
.map { it.searchBar.query.trim() }
|
||||
|
|
@ -245,6 +264,7 @@ internal class SearchModel @Inject constructor(
|
|||
appCurrency = appCurrency,
|
||||
isBalanceHidden = balanceHidden,
|
||||
onSingleClick = ::onSingleUserAssetClick,
|
||||
onGroupedClick = ::onGroupedUserAssetClick,
|
||||
)
|
||||
searchResult.userAssets
|
||||
.map(converter::convert)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tangem.features.feed.model.search
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.features.feed.components.search.SearchTokenSelectorComponent
|
||||
import com.tangem.features.feed.model.search.state.TokenSelectorStateController
|
||||
import com.tangem.features.feed.model.search.state.transformers.BuildTokenSelectorSectionsTransformer
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@Stable
|
||||
@ModelScoped
|
||||
internal class SearchTokenSelectorModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
paramsContainer: ParamsContainer,
|
||||
private val stateController: TokenSelectorStateController,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<SearchTokenSelectorComponent.Params>()
|
||||
|
||||
val state: StateFlow<TokenSelectorContentUM>
|
||||
get() = stateController.uiState
|
||||
|
||||
init {
|
||||
stateController.update(
|
||||
BuildTokenSelectorSectionsTransformer(
|
||||
entries = params.entries,
|
||||
appCurrency = params.appCurrency,
|
||||
isBalanceHidden = params.isBalanceHidden,
|
||||
onTokenSelected = params.onTokenSelected,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.features.feed.model.search.converter
|
||||
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
|
|
@ -22,13 +21,13 @@ import com.tangem.features.feed.ui.search.state.UserAssetItemUM
|
|||
import com.tangem.utils.StringsSigns
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class UserAssetSearchItemConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
private val isBalanceHidden: Boolean,
|
||||
private val onSingleClick: (UserAssetSearchEntry) -> Unit,
|
||||
private val onGroupedClick: (UserAssetSearchItem.Grouped) -> Unit,
|
||||
) : Converter<UserAssetSearchItem, UserAssetItemUM> {
|
||||
|
||||
override fun convert(value: UserAssetSearchItem): UserAssetItemUM {
|
||||
|
|
@ -64,6 +63,7 @@ internal class UserAssetSearchItemConverter(
|
|||
balanceState = convertSingleBalanceState(value, currency.symbol, currency.decimals),
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
onClick = { onSingleClick(entry) },
|
||||
networkName = entry.currencyStatus.currency.network.name,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -101,15 +101,6 @@ internal class UserAssetSearchItemConverter(
|
|||
|
||||
private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped {
|
||||
val firstCurrency = item.entries.first().currencyStatus.currency
|
||||
val children = item.entries.map { entry ->
|
||||
UserAssetItemUM.GroupedChild(
|
||||
walletName = entry.userWalletName,
|
||||
accountName = entry.accountName.toUM(),
|
||||
accountIcon = entry.accountIcon.value,
|
||||
accountColor = entry.accountIcon.color,
|
||||
currencyStatus = entry.currencyStatus,
|
||||
)
|
||||
}.toImmutableList()
|
||||
|
||||
val entryCurrencyStatus = item.entries.first().currencyStatus
|
||||
|
||||
|
|
@ -128,8 +119,7 @@ internal class UserAssetSearchItemConverter(
|
|||
tokensCount = item.entries.size,
|
||||
balanceState = convertGroupedBalanceState(item.entries, firstCurrency.symbol, firstCurrency.decimals),
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
children = children,
|
||||
onClick = {},
|
||||
onClick = { onGroupedClick(item) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -141,10 +131,12 @@ internal class UserAssetSearchItemConverter(
|
|||
val hasAnyLoading = entries.any { it.currencyStatus.value is CryptoCurrencyStatus.Loading }
|
||||
val hasAnyError = entries.any { it.currencyStatus.value.isError }
|
||||
val hasAnyAmount = entries.any { it.currencyStatus.value.amount != null }
|
||||
val isAllError = entries.all { it.currencyStatus.value.isError }
|
||||
|
||||
val balance = when {
|
||||
hasAnyLoading && !hasAnyAmount -> BalanceDisplayState.Loading
|
||||
hasAnyLoading && hasAnyAmount -> computeGroupBalanceFlickering(entries, symbol, decimals)
|
||||
isAllError -> BalanceDisplayState.Unreachable
|
||||
hasAnyError && entries.size == 1 && !hasAnyAmount -> BalanceDisplayState.Unreachable
|
||||
hasAnyError && entries.size > 1 -> computeGroupBalance(entries, symbol, decimals)
|
||||
else -> computeGroupBalance(entries, symbol, decimals)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.tangem.features.feed.model.search.state
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.features.feed.model.search.state.transformers.TokenSelectorUMTransformer
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class TokenSelectorStateController @Inject constructor() {
|
||||
|
||||
private val mutableUiState: MutableStateFlow<TokenSelectorContentUM> = MutableStateFlow(
|
||||
value = TokenSelectorContentUM(sections = persistentListOf()),
|
||||
)
|
||||
|
||||
val uiState: StateFlow<TokenSelectorContentUM> get() = mutableUiState.asStateFlow()
|
||||
|
||||
fun update(transformer: TokenSelectorUMTransformer) {
|
||||
mutableUiState.update(function = transformer::transform)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.tangem.features.feed.model.search.state.transformers
|
||||
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.search.model.UserAssetSearchEntry
|
||||
import com.tangem.features.feed.ui.search.state.AccountHeaderData
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class BuildTokenSelectorSectionsTransformer(
|
||||
private val entries: List<UserAssetSearchEntry>,
|
||||
private val appCurrency: AppCurrency,
|
||||
private val isBalanceHidden: Boolean,
|
||||
private val onTokenSelected: (UserAssetSearchEntry) -> Unit,
|
||||
) : TokenSelectorUMTransformer {
|
||||
|
||||
private val entryConverter = TokenSelectorEntryConverter(
|
||||
appCurrency = appCurrency,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
onTokenSelected = onTokenSelected,
|
||||
)
|
||||
|
||||
override fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM {
|
||||
return TokenSelectorContentUM(sections = buildSections().toImmutableList())
|
||||
}
|
||||
|
||||
private fun buildSections(): List<TokenSelectorSectionUM> {
|
||||
val sections = mutableListOf<TokenSelectorSectionUM>()
|
||||
val byWallet = entries.groupBy { it.userWalletId }
|
||||
val shouldShowWalletHeaders = byWallet.size > 1
|
||||
|
||||
for ((_, walletEntries) in byWallet) {
|
||||
if (shouldShowWalletHeaders) {
|
||||
sections.add(
|
||||
TokenSelectorSectionUM.WalletHeader(
|
||||
walletName = walletEntries.first().userWalletName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val byAccount = walletEntries.groupBy { it.accountId }
|
||||
val shouldShowAccountHeaders = byAccount.size > 1
|
||||
|
||||
for ((_, accountEntries) in byAccount) {
|
||||
val singles = entryConverter.convertList(accountEntries).toImmutableList()
|
||||
val accountHeader = if (shouldShowAccountHeaders) {
|
||||
val firstEntry = accountEntries.first()
|
||||
AccountHeaderData(
|
||||
accountName = firstEntry.accountName.toUM().value,
|
||||
cryptoPortfolioIcon = firstEntry.accountIcon,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
sections.add(
|
||||
TokenSelectorSectionUM.TokenGroup(
|
||||
accountHeader = accountHeader,
|
||||
items = singles,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.tangem.features.feed.model.search.state.transformers
|
||||
|
||||
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeState
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.search.model.UserAssetSearchEntry
|
||||
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
|
||||
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
|
||||
import com.tangem.utils.StringsSigns
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class TokenSelectorEntryConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
private val isBalanceHidden: Boolean,
|
||||
private val onTokenSelected: (UserAssetSearchEntry) -> Unit,
|
||||
) : Converter<UserAssetSearchEntry, UserAssetItemUM.Single> {
|
||||
|
||||
private val iconConverter = CryptoCurrencyToIconStateConverter()
|
||||
|
||||
override fun convert(value: UserAssetSearchEntry): UserAssetItemUM.Single {
|
||||
val currency = value.currencyStatus.currency
|
||||
val currencyValue = value.currencyStatus.value
|
||||
|
||||
return UserAssetItemUM.Single(
|
||||
id = "${value.userWalletId.stringValue}_${value.accountId.value}_${currency.id.value}",
|
||||
icon = TangemIconUM.Currency(
|
||||
currencyIconState = iconConverter.convert(value.currencyStatus),
|
||||
),
|
||||
tokenName = currency.name,
|
||||
tokenSymbol = currency.symbol,
|
||||
fiatRate = currencyValue.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) },
|
||||
priceChangeState = when (currencyValue) {
|
||||
is CryptoCurrencyStatus.Loading,
|
||||
is CryptoCurrencyStatus.Unreachable,
|
||||
is CryptoCurrencyStatus.MissedDerivation,
|
||||
is CryptoCurrencyStatus.NoAmount,
|
||||
-> PriceChangeState.Unknown
|
||||
else -> PriceChangeState.Content(
|
||||
type = PriceChangeType.fromBigDecimal(currencyValue.priceChange.orZero()),
|
||||
valueInPercent = currencyValue.priceChange.format { percent() },
|
||||
)
|
||||
},
|
||||
balanceState = convertBalanceState(currencyValue, currency.symbol, currency.decimals),
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
onClick = { onTokenSelected(value) },
|
||||
networkName = value.currencyStatus.currency.network.name,
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertBalanceState(
|
||||
value: CryptoCurrencyStatus.Value,
|
||||
symbol: String,
|
||||
decimals: Int,
|
||||
): BalanceDisplayState {
|
||||
return when {
|
||||
value is CryptoCurrencyStatus.Loading && value.amount != null ->
|
||||
BalanceDisplayState.Flickering(
|
||||
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
|
||||
fiatBalance = stringReference(
|
||||
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
?: StringsSigns.DASH_SIGN,
|
||||
),
|
||||
)
|
||||
value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading
|
||||
value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable
|
||||
value.isError && value.amount != null ->
|
||||
BalanceDisplayState.Stale(
|
||||
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
|
||||
fiatBalance = stringReference(
|
||||
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
?: StringsSigns.DASH_SIGN,
|
||||
),
|
||||
)
|
||||
value.isError -> BalanceDisplayState.Unreachable
|
||||
else -> BalanceDisplayState.Loaded(
|
||||
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
|
||||
fiatBalance = stringReference(
|
||||
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
?: StringsSigns.DASH_SIGN,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String {
|
||||
return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.tangem.features.feed.model.search.state.transformers
|
||||
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
|
||||
|
||||
internal interface TokenSelectorUMTransformer {
|
||||
fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM
|
||||
}
|
||||
|
|
@ -329,7 +329,14 @@ private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) {
|
|||
@Composable
|
||||
private fun UserAssetItem(asset: UserAssetItemUM) {
|
||||
when (asset) {
|
||||
is UserAssetItemUM.Single -> SingleUserAssetItem(item = asset)
|
||||
is UserAssetItemUM.Single -> Box(
|
||||
modifier = Modifier.background(
|
||||
color = TangemTheme.colors2.surface.level3,
|
||||
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
|
||||
),
|
||||
) {
|
||||
SingleUserAssetItem(item = asset, shouldUsePriceBlock = true)
|
||||
}
|
||||
is UserAssetItemUM.Grouped -> GroupedUserAssetItem(item = asset)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.tangem.features.feed.ui.search.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -23,48 +21,52 @@ import com.tangem.features.feed.ui.search.state.BalanceDisplayState
|
|||
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
|
||||
|
||||
@Composable
|
||||
fun SingleUserAssetItem(item: UserAssetItemUM.Single, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = TangemTheme.colors2.surface.level3,
|
||||
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
|
||||
),
|
||||
) {
|
||||
TangemRowContainer(
|
||||
modifier = Modifier.clickable(onClick = item.onClick),
|
||||
content = {
|
||||
TangemIcon(
|
||||
modifier = Modifier
|
||||
.layoutId(layoutId = TangemRowLayoutId.HEAD)
|
||||
.size(40.dp)
|
||||
.padding(end = TangemTheme.dimens2.x1),
|
||||
tangemIconUM = item.icon,
|
||||
)
|
||||
fun SingleUserAssetItem(shouldUsePriceBlock: Boolean, item: UserAssetItemUM.Single, modifier: Modifier = Modifier) {
|
||||
TangemRowContainer(
|
||||
modifier = modifier.clickable(onClick = item.onClick),
|
||||
content = {
|
||||
TangemIcon(
|
||||
modifier = Modifier
|
||||
.layoutId(layoutId = TangemRowLayoutId.HEAD)
|
||||
.size(40.dp)
|
||||
.padding(end = TangemTheme.dimens2.x1),
|
||||
tangemIconUM = item.icon,
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
|
||||
text = item.tokenName,
|
||||
style = TangemTheme.typography2.bodyMedium16,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
|
||||
text = item.tokenName,
|
||||
style = TangemTheme.typography2.bodyMedium16,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
|
||||
if (shouldUsePriceBlock) {
|
||||
PriceBlock(
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
|
||||
priceChangeState = item.priceChangeState,
|
||||
fiatRate = item.fiatRate,
|
||||
balanceState = item.balanceState,
|
||||
)
|
||||
|
||||
BalanceColumn(
|
||||
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP),
|
||||
balanceState = item.balanceState,
|
||||
isBalanceHidden = item.isBalanceHidden,
|
||||
} else {
|
||||
Text(
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
|
||||
text = item.networkName,
|
||||
style = TangemTheme.typography2.captionMedium12,
|
||||
color = TangemTheme.colors2.text.neutral.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BalanceColumn(
|
||||
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP),
|
||||
balanceState = item.balanceState,
|
||||
isBalanceHidden = item.isBalanceHidden,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
package com.tangem.features.feed.ui.search.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.Fade
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
|
||||
import com.tangem.core.ui.components.haze.hazeEffectTangem
|
||||
import com.tangem.core.ui.components.haze.hazeSourceTangem
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBarType
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.feed.ui.search.preview.TokenSelectorContentPreviewProvider
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
|
||||
@Composable
|
||||
internal fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig) {
|
||||
TangemBottomSheet<TokenSelectorContentUM>(
|
||||
config = config,
|
||||
type = TangemBottomSheetType.Modal,
|
||||
containerColor = TangemTheme.colors2.surface.level2,
|
||||
content = { content ->
|
||||
TokenSelectorContent(content, config.onDismissRequest)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenSelectorContent(content: TokenSelectorContentUM, onDismiss: () -> Unit) {
|
||||
val hazeState = rememberHazeState()
|
||||
var topBarHeight by remember { mutableStateOf(0.dp) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.hazeSourceTangem(state = hazeState, 1f),
|
||||
contentPadding = PaddingValues(
|
||||
start = TangemTheme.dimens2.x4,
|
||||
end = TangemTheme.dimens2.x4,
|
||||
top = topBarHeight,
|
||||
bottom = TangemTheme.dimens2.x10,
|
||||
),
|
||||
) {
|
||||
tokenSelectorSectionItems(content.sections)
|
||||
}
|
||||
TokenSelectorSheetTopBar(
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
onDismiss = onDismiss,
|
||||
hazeState = hazeState,
|
||||
onChangeHeight = { topBarHeight = it },
|
||||
)
|
||||
Fade(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
height = TangemTheme.dimens2.x10,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenSelectorSheetTopBar(
|
||||
hazeState: HazeState,
|
||||
onDismiss: () -> Unit,
|
||||
onChangeHeight: (Dp) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bgColor = TangemTheme.colors2.surface.level2
|
||||
val density = LocalDensity.current
|
||||
TangemTopBar(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { coordinates ->
|
||||
if (coordinates.size.height > 0) {
|
||||
with(density) {
|
||||
onChangeHeight(coordinates.size.height.toDp())
|
||||
}
|
||||
}
|
||||
}
|
||||
.hazeEffectTangem(state = hazeState) {
|
||||
backgroundColor = bgColor
|
||||
progressive = HazeProgressive.verticalGradient(
|
||||
startIntensity = .55f,
|
||||
endIntensity = 0f,
|
||||
preferPerformance = true,
|
||||
easing = EaseOut,
|
||||
)
|
||||
},
|
||||
type = TangemTopBarType.BottomSheet,
|
||||
title = resourceReference(R.string.markets_search_portfolio_header),
|
||||
endContent = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_close_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors2.graphic.neutral.primary,
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens2.x11)
|
||||
.background(
|
||||
color = TangemTheme.colors2.button.backgroundSecondary,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.clickableSingle(onClick = onDismiss)
|
||||
.padding(TangemTheme.dimens2.x2_5),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun TokenSelectorBottomSheetPreview(
|
||||
@PreviewParameter(TokenSelectorContentPreviewProvider::class) content: TokenSelectorContentUM,
|
||||
) {
|
||||
TangemThemePreviewRedesign {
|
||||
TokenSelectorBottomSheet(
|
||||
config = TangemBottomSheetConfig(
|
||||
onDismissRequest = {},
|
||||
content = content,
|
||||
isShown = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
package com.tangem.features.feed.ui.search.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.tangem.common.ui.account.getResId
|
||||
import com.tangem.common.ui.account.getUiColor
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.feed.ui.search.state.AccountHeaderData
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
internal fun LazyListScope.tokenSelectorSectionItems(sections: ImmutableList<TokenSelectorSectionUM>) {
|
||||
sections.forEachIndexed { index, section ->
|
||||
when (section) {
|
||||
is TokenSelectorSectionUM.WalletHeader -> {
|
||||
item(key = "wallet_${section.walletName}_$index") {
|
||||
WalletHeaderSection(section)
|
||||
}
|
||||
}
|
||||
is TokenSelectorSectionUM.TokenGroup -> {
|
||||
if (section.items.isEmpty()) return@forEachIndexed
|
||||
|
||||
if (index > 0 && sections[index - 1] is TokenSelectorSectionUM.TokenGroup) {
|
||||
item(key = "spacer_before_group_$index") {
|
||||
SpacerH(TangemTheme.dimens2.x2)
|
||||
}
|
||||
}
|
||||
|
||||
val lastIndex = if (section.accountHeader != null) {
|
||||
section.items.size
|
||||
} else {
|
||||
section.items.lastIndex.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
section.accountHeader?.let { header ->
|
||||
item(key = "account_header_$index") {
|
||||
TokenGroupAccountHeaderRow(
|
||||
data = header,
|
||||
modifier = Modifier.tokenGroupRowDecoration(
|
||||
currentIndex = 0,
|
||||
lastIndex = lastIndex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val indexOffset = if (section.accountHeader != null) 1 else 0
|
||||
section.items.forEachIndexed { itemIndex, single ->
|
||||
item(key = "token_${single.id}_$index") {
|
||||
SingleUserAssetItem(
|
||||
item = single,
|
||||
modifier = Modifier.tokenGroupRowDecoration(
|
||||
currentIndex = indexOffset + itemIndex,
|
||||
lastIndex = lastIndex,
|
||||
),
|
||||
shouldUsePriceBlock = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.tokenGroupRowDecoration(currentIndex: Int, lastIndex: Int): Modifier =
|
||||
this.roundedShapeItemDecoration(
|
||||
currentIndex = currentIndex,
|
||||
lastIndex = lastIndex,
|
||||
addDefaultPadding = false,
|
||||
radius = TangemTheme.dimens2.x6,
|
||||
backgroundColor = TangemTheme.colors2.surface.level3,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun WalletHeaderSection(section: TokenSelectorSectionUM.WalletHeader) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2)
|
||||
.padding(horizontal = TangemTheme.dimens2.x3),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
|
||||
) {
|
||||
Text(
|
||||
text = section.walletName,
|
||||
style = TangemTheme.typography2.subheadlineMedium14,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_key_card_20),
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x5),
|
||||
tint = TangemTheme.colors2.graphic.neutral.tertiary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenGroupAccountHeaderRow(data: AccountHeaderData, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2)
|
||||
.padding(horizontal = TangemTheme.dimens2.x4),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x4),
|
||||
imageVector = ImageVector.vectorResource(data.cryptoPortfolioIcon.value.getResId()),
|
||||
tint = data.cryptoPortfolioIcon.color.getUiColor(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = data.accountName.resolveReference(),
|
||||
style = TangemTheme.typography2.captionMedium12,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -228,6 +228,7 @@ internal object SearchContentPreviewFixtures {
|
|||
),
|
||||
isBalanceHidden = false,
|
||||
onClick = {},
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private fun textHint(text: String): TextHintItemUM = TextHintItemUM(text = text)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
package com.tangem.features.feed.ui.search.preview
|
||||
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeState
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.feed.ui.search.state.AccountHeaderData
|
||||
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
|
||||
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Suppress("StringLiteralDuplication")
|
||||
internal class TokenSelectorContentPreviewProvider :
|
||||
CollectionPreviewParameterProvider<TokenSelectorContentUM>(
|
||||
listOf(
|
||||
tokenSelectorPreviewSimple(),
|
||||
tokenSelectorPreviewWithAccountHeaders(),
|
||||
tokenSelectorPreviewMultiWallet(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun tokenSelectorPreviewSimple(): TokenSelectorContentUM {
|
||||
return TokenSelectorContentUM(
|
||||
sections = persistentListOf(
|
||||
TokenSelectorSectionUM.TokenGroup(
|
||||
accountHeader = null,
|
||||
items = persistentListOf(
|
||||
previewTokenItem(id = "eth", name = "Ethereum", symbol = "ETH"),
|
||||
previewTokenItem(id = "btc", name = "Bitcoin", symbol = "BTC"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun tokenSelectorPreviewWithAccountHeaders(): TokenSelectorContentUM {
|
||||
val accountIcon = CryptoPortfolioIcon.ofCustomAccount(
|
||||
value = CryptoPortfolioIcon.Icon.Wallet,
|
||||
color = CryptoPortfolioIcon.Color.CaribbeanBlue,
|
||||
)
|
||||
return TokenSelectorContentUM(
|
||||
sections = persistentListOf(
|
||||
TokenSelectorSectionUM.TokenGroup(
|
||||
accountHeader = AccountHeaderData(
|
||||
accountName = stringReference(value = "Main account"),
|
||||
cryptoPortfolioIcon = accountIcon,
|
||||
),
|
||||
items = persistentListOf(
|
||||
previewTokenItem(id = "eth_main", name = "Ethereum", symbol = "ETH"),
|
||||
),
|
||||
),
|
||||
TokenSelectorSectionUM.TokenGroup(
|
||||
accountHeader = AccountHeaderData(
|
||||
accountName = stringReference(value = "Trading"),
|
||||
cryptoPortfolioIcon = accountIcon,
|
||||
),
|
||||
items = persistentListOf(
|
||||
previewTokenItem(id = "sol_trade", name = "Solana", symbol = "SOL"),
|
||||
previewTokenItem(id = "avax_trade", name = "Avalanche", symbol = "AVAX"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun tokenSelectorPreviewMultiWallet(): TokenSelectorContentUM {
|
||||
return TokenSelectorContentUM(
|
||||
sections = persistentListOf(
|
||||
TokenSelectorSectionUM.WalletHeader(walletName = "Cold wallet"),
|
||||
TokenSelectorSectionUM.TokenGroup(
|
||||
accountHeader = null,
|
||||
items = persistentListOf(
|
||||
previewTokenItem(id = "btc_cold", name = "Bitcoin", symbol = "BTC"),
|
||||
),
|
||||
),
|
||||
TokenSelectorSectionUM.WalletHeader(walletName = "Hot wallet"),
|
||||
TokenSelectorSectionUM.TokenGroup(
|
||||
accountHeader = null,
|
||||
items = persistentListOf(
|
||||
previewTokenItem(id = "eth_hot", name = "Ethereum", symbol = "ETH"),
|
||||
previewTokenItem(id = "usdt_hot", name = "Tether", symbol = "USDT"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun previewTokenItem(id: String, name: String, symbol: String): UserAssetItemUM.Single {
|
||||
val cryptoRef = stringReference(value = "1.234 $symbol")
|
||||
val fiatRef = stringReference(value = "$1,234.56")
|
||||
return UserAssetItemUM.Single(
|
||||
id = id,
|
||||
icon = TangemIconUM.Currency(
|
||||
CurrencyIconState.CoinIcon(
|
||||
url = null,
|
||||
fallbackResId = R.drawable.ic_ethereumpow_22,
|
||||
isGrayscale = false,
|
||||
shouldShowCustomBadge = false,
|
||||
),
|
||||
),
|
||||
tokenName = name,
|
||||
tokenSymbol = symbol,
|
||||
fiatRate = "$98,765.43",
|
||||
priceChangeState = PriceChangeState.Content(
|
||||
valueInPercent = "+2.34%",
|
||||
type = PriceChangeType.UP,
|
||||
),
|
||||
balanceState = BalanceDisplayState.Loaded(
|
||||
cryptoBalance = cryptoRef,
|
||||
fiatBalance = fiatRef,
|
||||
),
|
||||
isBalanceHidden = false,
|
||||
onClick = {},
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -22,7 +23,6 @@ import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem
|
|||
import com.tangem.features.feed.ui.search.components.SingleUserAssetItem
|
||||
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
|
||||
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/** Labeled UI state for [SingleUserAssetItem] previews (dropdown label in Studio). */
|
||||
internal data class SingleUserAssetItemPreviewScenario(
|
||||
|
|
@ -146,6 +146,7 @@ internal object UserAssetItemPreviewFixtures {
|
|||
balanceState = balanceState,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
onClick = {},
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private fun grouped(balanceState: BalanceDisplayState, isBalanceHidden: Boolean): UserAssetItemUM.Grouped =
|
||||
|
|
@ -157,7 +158,6 @@ internal object UserAssetItemPreviewFixtures {
|
|||
tokensCount = 3,
|
||||
balanceState = balanceState,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
children = persistentListOf(),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -185,7 +185,14 @@ private fun SingleUserAssetItemPreviewHost(
|
|||
.background(TangemTheme.colors2.surface.level1)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
SingleUserAssetItem(item = scenario.item)
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
color = TangemTheme.colors2.surface.level3,
|
||||
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
|
||||
),
|
||||
) {
|
||||
SingleUserAssetItem(item = scenario.item, shouldUsePriceBlock = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
package com.tangem.features.feed.ui.search.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.account.AccountNameUM
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.ui.components.fields.entity.SearchBarUM
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeState
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class SearchUM(
|
||||
|
|
@ -87,6 +84,7 @@ sealed interface UserAssetItemUM {
|
|||
val priceChangeState: PriceChangeState,
|
||||
val balanceState: BalanceDisplayState,
|
||||
val isBalanceHidden: Boolean,
|
||||
val networkName: String,
|
||||
override val onClick: () -> Unit,
|
||||
) : UserAssetItemUM
|
||||
|
||||
|
|
@ -98,15 +96,6 @@ sealed interface UserAssetItemUM {
|
|||
val tokensCount: Int,
|
||||
val balanceState: BalanceDisplayState,
|
||||
val isBalanceHidden: Boolean,
|
||||
val children: ImmutableList<GroupedChild>,
|
||||
override val onClick: () -> Unit,
|
||||
) : UserAssetItemUM
|
||||
|
||||
data class GroupedChild(
|
||||
val walletName: String,
|
||||
val accountName: AccountNameUM,
|
||||
val accountIcon: CryptoPortfolioIcon.Icon,
|
||||
val accountColor: CryptoPortfolioIcon.Color,
|
||||
val currencyStatus: CryptoCurrencyStatus,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.tangem.features.feed.ui.search.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal data class TokenSelectorContentUM(
|
||||
val sections: ImmutableList<TokenSelectorSectionUM>,
|
||||
) : TangemBottomSheetConfigContent
|
||||
|
||||
@Immutable
|
||||
internal data class AccountHeaderData(
|
||||
val accountName: TextReference,
|
||||
val cryptoPortfolioIcon: CryptoPortfolioIcon,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
internal sealed interface TokenSelectorSectionUM {
|
||||
|
||||
data class WalletHeader(val walletName: String) : TokenSelectorSectionUM
|
||||
|
||||
data class TokenGroup(
|
||||
val accountHeader: AccountHeaderData?,
|
||||
val items: ImmutableList<UserAssetItemUM.Single>,
|
||||
) : TokenSelectorSectionUM
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
package com.tangem.features.feed.model.search.state.transformers
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.AccountName
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.search.model.UserAssetSearchEntry
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
|
||||
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
|
||||
import io.mockk.*
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class BuildTokenSelectorSectionsTransformerTest {
|
||||
|
||||
private val appCurrency: AppCurrency = AppCurrency.Default
|
||||
private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true)
|
||||
private val prevState = TokenSelectorContentUM(sections = persistentListOf())
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(onTokenSelected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return empty sections when entries list is empty`() {
|
||||
val transformer = createTransformer(entries = emptyList())
|
||||
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
Truth.assertThat(result.sections).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create single TokenGroup without wallet header for single wallet`() {
|
||||
val walletId = createMockUserWalletId("wallet1")
|
||||
val accountId = createMockAccountId("account1")
|
||||
val entries = listOf(
|
||||
createMockEntry(walletId, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"),
|
||||
createMockEntry(walletId, "Wallet 1", accountId, "eth", "Ethereum", "ETH"),
|
||||
)
|
||||
|
||||
val transformer = createTransformer(entries)
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
Truth.assertThat(result.sections).hasSize(1)
|
||||
Truth.assertThat(result.sections[0]).isInstanceOf(TokenSelectorSectionUM.TokenGroup::class.java)
|
||||
|
||||
val group = result.sections[0] as TokenSelectorSectionUM.TokenGroup
|
||||
Truth.assertThat(group.items).hasSize(2)
|
||||
Truth.assertThat(group.accountHeader).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should add wallet headers when multiple wallets present`() {
|
||||
val walletId1 = createMockUserWalletId("wallet1")
|
||||
val walletId2 = createMockUserWalletId("wallet2")
|
||||
val accountId = createMockAccountId("account1")
|
||||
val entries = listOf(
|
||||
createMockEntry(walletId1, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"),
|
||||
createMockEntry(walletId2, "Wallet 2", accountId, "eth", "Ethereum", "ETH"),
|
||||
)
|
||||
|
||||
val transformer = createTransformer(entries)
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
val walletHeaders = result.sections.filterIsInstance<TokenSelectorSectionUM.WalletHeader>()
|
||||
val tokenGroups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
|
||||
|
||||
Truth.assertThat(walletHeaders).hasSize(2)
|
||||
Truth.assertThat(walletHeaders[0].walletName).isEqualTo("Wallet 1")
|
||||
Truth.assertThat(walletHeaders[1].walletName).isEqualTo("Wallet 2")
|
||||
Truth.assertThat(tokenGroups).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not show account headers when single account per wallet`() {
|
||||
val walletId = createMockUserWalletId("wallet1")
|
||||
val accountId = createMockAccountId("account1")
|
||||
val entries = listOf(
|
||||
createMockEntry(walletId, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"),
|
||||
createMockEntry(walletId, "Wallet 1", accountId, "eth", "Ethereum", "ETH"),
|
||||
)
|
||||
|
||||
val transformer = createTransformer(entries)
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
|
||||
Truth.assertThat(groups).hasSize(1)
|
||||
Truth.assertThat(groups[0].accountHeader).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show account headers when multiple accounts in same wallet`() {
|
||||
val walletId = createMockUserWalletId("wallet1")
|
||||
val accountId1 = createMockAccountId("account1")
|
||||
val accountId2 = createMockAccountId("account2")
|
||||
val entries = listOf(
|
||||
createMockEntry(walletId, "Wallet 1", accountId1, "btc", "Bitcoin", "BTC"),
|
||||
createMockEntry(walletId, "Wallet 1", accountId2, "eth", "Ethereum", "ETH"),
|
||||
)
|
||||
|
||||
val transformer = createTransformer(entries)
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
|
||||
Truth.assertThat(groups).hasSize(2)
|
||||
Truth.assertThat(groups[0].accountHeader).isNotNull()
|
||||
Truth.assertThat(groups[1].accountHeader).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should group entries by wallet and then by account`() {
|
||||
val walletId1 = createMockUserWalletId("wallet1")
|
||||
val walletId2 = createMockUserWalletId("wallet2")
|
||||
val account1 = createMockAccountId("acc1")
|
||||
val account2 = createMockAccountId("acc2")
|
||||
|
||||
val entries = listOf(
|
||||
createMockEntry(walletId1, "W1", account1, "btc", "Bitcoin", "BTC"),
|
||||
createMockEntry(walletId1, "W1", account2, "eth", "Ethereum", "ETH"),
|
||||
createMockEntry(walletId2, "W2", account1, "sol", "Solana", "SOL"),
|
||||
)
|
||||
|
||||
val transformer = createTransformer(entries)
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
// 2 wallet headers + 2 token groups for wallet1 (2 accounts) + 1 token group for wallet2
|
||||
val walletHeaders = result.sections.filterIsInstance<TokenSelectorSectionUM.WalletHeader>()
|
||||
val tokenGroups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
|
||||
|
||||
Truth.assertThat(walletHeaders).hasSize(2)
|
||||
Truth.assertThat(tokenGroups).hasSize(3)
|
||||
|
||||
// Wallet1 has 2 accounts so headers should be present
|
||||
Truth.assertThat(tokenGroups[0].accountHeader).isNotNull()
|
||||
Truth.assertThat(tokenGroups[1].accountHeader).isNotNull()
|
||||
// Wallet2 has 1 account so no account header
|
||||
Truth.assertThat(tokenGroups[2].accountHeader).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should correctly place multiple tokens in same account group`() {
|
||||
val walletId = createMockUserWalletId("wallet1")
|
||||
val accountId = createMockAccountId("account1")
|
||||
val entries = listOf(
|
||||
createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"),
|
||||
createMockEntry(walletId, "W1", accountId, "eth", "Ethereum", "ETH"),
|
||||
createMockEntry(walletId, "W1", accountId, "sol", "Solana", "SOL"),
|
||||
)
|
||||
|
||||
val transformer = createTransformer(entries)
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
|
||||
Truth.assertThat(groups).hasSize(1)
|
||||
Truth.assertThat(groups[0].items).hasSize(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should preserve order of wallets and accounts`() {
|
||||
val walletId1 = createMockUserWalletId("wallet1")
|
||||
val walletId2 = createMockUserWalletId("wallet2")
|
||||
val account1 = createMockAccountId("acc1")
|
||||
val account2 = createMockAccountId("acc2")
|
||||
|
||||
val entries = listOf(
|
||||
createMockEntry(walletId1, "First Wallet", account1, "btc", "Bitcoin", "BTC"),
|
||||
createMockEntry(walletId1, "First Wallet", account2, "eth", "Ethereum", "ETH"),
|
||||
createMockEntry(walletId2, "Second Wallet", account1, "sol", "Solana", "SOL"),
|
||||
)
|
||||
|
||||
val transformer = createTransformer(entries)
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
val headers = result.sections.filterIsInstance<TokenSelectorSectionUM.WalletHeader>()
|
||||
Truth.assertThat(headers[0].walletName).isEqualTo("First Wallet")
|
||||
Truth.assertThat(headers[1].walletName).isEqualTo("Second Wallet")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should convert entries to UserAssetItemUM Single`() {
|
||||
val walletId = createMockUserWalletId("wallet1")
|
||||
val accountId = createMockAccountId("account1")
|
||||
val entries = listOf(
|
||||
createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"),
|
||||
)
|
||||
|
||||
val transformer = createTransformer(entries)
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
|
||||
Truth.assertThat(groups).hasSize(1)
|
||||
|
||||
val item = groups[0].items[0]
|
||||
Truth.assertThat(item.tokenName).isEqualTo("Bitcoin")
|
||||
Truth.assertThat(item.tokenSymbol).isEqualTo("BTC")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should ignore previous state and build from scratch`() {
|
||||
val walletId = createMockUserWalletId("wallet1")
|
||||
val accountId = createMockAccountId("account1")
|
||||
val entries = listOf(
|
||||
createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"),
|
||||
)
|
||||
|
||||
val prevStateWithSections = TokenSelectorContentUM(
|
||||
sections = persistentListOf(
|
||||
TokenSelectorSectionUM.WalletHeader(walletName = "Old Wallet"),
|
||||
),
|
||||
)
|
||||
|
||||
val transformer = createTransformer(entries)
|
||||
val result = transformer.transform(prevStateWithSections)
|
||||
|
||||
val headers = result.sections.filterIsInstance<TokenSelectorSectionUM.WalletHeader>()
|
||||
Truth.assertThat(headers).isEmpty()
|
||||
|
||||
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
|
||||
Truth.assertThat(groups).hasSize(1)
|
||||
}
|
||||
|
||||
// region Helpers
|
||||
|
||||
private fun createTransformer(entries: List<UserAssetSearchEntry>): BuildTokenSelectorSectionsTransformer {
|
||||
return BuildTokenSelectorSectionsTransformer(
|
||||
entries = entries,
|
||||
appCurrency = appCurrency,
|
||||
isBalanceHidden = false,
|
||||
onTokenSelected = onTokenSelected,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMockUserWalletId(id: String): UserWalletId {
|
||||
return mockk<UserWalletId> {
|
||||
every { stringValue } returns id
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMockAccountId(id: String): AccountId {
|
||||
return mockk<AccountId> {
|
||||
every { value } returns id
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMockEntry(
|
||||
walletId: UserWalletId = createMockUserWalletId("wallet1"),
|
||||
walletName: String = "Wallet 1",
|
||||
accountId: AccountId = createMockAccountId("account1"),
|
||||
currencyId: String = "btc",
|
||||
currencyName: String = "Bitcoin",
|
||||
currencySymbol: String = "BTC",
|
||||
): UserAssetSearchEntry {
|
||||
val network = mockk<Network>(relaxed = true) {
|
||||
every { name } returns "Network"
|
||||
}
|
||||
val currencyIdObj = mockk<CryptoCurrency.ID> {
|
||||
every { value } returns currencyId
|
||||
}
|
||||
val currency = mockk<CryptoCurrency.Coin> {
|
||||
every { id } returns currencyIdObj
|
||||
every { name } returns currencyName
|
||||
every { symbol } returns currencySymbol
|
||||
every { this@mockk.network } returns network
|
||||
every { decimals } returns 8
|
||||
every { iconUrl } returns null
|
||||
every { isCustom } returns false
|
||||
}
|
||||
val currencyStatus = mockk<CryptoCurrencyStatus> {
|
||||
every { this@mockk.currency } returns currency
|
||||
every { value } returns CryptoCurrencyStatus.Loaded(
|
||||
amount = BigDecimal("1.0"),
|
||||
fiatAmount = BigDecimal("30000.0"),
|
||||
fiatRate = BigDecimal("30000.0"),
|
||||
priceChange = BigDecimal("1.0"),
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = mockk(relaxed = true),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
)
|
||||
}
|
||||
return mockk<UserAssetSearchEntry> {
|
||||
every { userWalletId } returns walletId
|
||||
every { userWalletName } returns walletName
|
||||
every { this@mockk.accountId } returns accountId
|
||||
every { accountName } returns AccountName.DefaultMain
|
||||
every { accountIcon } returns mockk(relaxed = true)
|
||||
every { this@mockk.currencyStatus } returns currencyStatus
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
package com.tangem.features.feed.model.search.state.transformers
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeState
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.search.model.UserAssetSearchEntry
|
||||
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class TokenSelectorEntryConverterTest {
|
||||
|
||||
private val appCurrency: AppCurrency = AppCurrency.Default
|
||||
private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true)
|
||||
|
||||
private lateinit var converter: TokenSelectorEntryConverter
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(onTokenSelected)
|
||||
converter = TokenSelectorEntryConverter(
|
||||
appCurrency = appCurrency,
|
||||
isBalanceHidden = false,
|
||||
onTokenSelected = onTokenSelected,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should convert entry with Loaded status to Single with Loaded balance`() {
|
||||
val entry = createMockEntry(
|
||||
walletId = "wallet1",
|
||||
accountId = "account1",
|
||||
currencyId = "btc",
|
||||
currencyName = "Bitcoin",
|
||||
currencySymbol = "BTC",
|
||||
networkName = "Bitcoin",
|
||||
decimals = 8,
|
||||
value = createLoadedValue(
|
||||
amount = BigDecimal("1.5"),
|
||||
fiatAmount = BigDecimal("45000.0"),
|
||||
fiatRate = BigDecimal("30000.0"),
|
||||
priceChange = BigDecimal("2.5"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.id).isEqualTo("wallet1_account1_btc")
|
||||
Truth.assertThat(result.tokenName).isEqualTo("Bitcoin")
|
||||
Truth.assertThat(result.tokenSymbol).isEqualTo("BTC")
|
||||
Truth.assertThat(result.networkName).isEqualTo("Bitcoin")
|
||||
Truth.assertThat(result.isBalanceHidden).isFalse()
|
||||
Truth.assertThat(result.balanceState).isInstanceOf(BalanceDisplayState.Loaded::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set balance hidden when isBalanceHidden is true`() {
|
||||
converter = TokenSelectorEntryConverter(
|
||||
appCurrency = appCurrency,
|
||||
isBalanceHidden = true,
|
||||
onTokenSelected = onTokenSelected,
|
||||
)
|
||||
|
||||
val entry = createMockEntry(
|
||||
value = createLoadedValue(
|
||||
amount = BigDecimal("1.0"),
|
||||
fiatAmount = BigDecimal("30000.0"),
|
||||
fiatRate = BigDecimal("30000.0"),
|
||||
priceChange = BigDecimal("1.0"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.isBalanceHidden).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Loading balance state for Loading value without amount`() {
|
||||
val entry = createMockEntry(
|
||||
value = CryptoCurrencyStatus.Loading,
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.balanceState).isEqualTo(BalanceDisplayState.Loading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Unreachable balance state for Unreachable value without amount`() {
|
||||
val entry = createMockEntry(
|
||||
value = createUnreachableValue(),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.balanceState).isEqualTo(BalanceDisplayState.Unreachable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Unknown price change state for Loading value`() {
|
||||
val entry = createMockEntry(
|
||||
value = CryptoCurrencyStatus.Loading,
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Unknown price change state for Unreachable value`() {
|
||||
val entry = createMockEntry(
|
||||
value = createUnreachableValue(),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Unknown price change state for MissedDerivation value`() {
|
||||
val entry = createMockEntry(
|
||||
value = createMissedDerivationValue(),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Unknown price change state for NoAmount value`() {
|
||||
val entry = createMockEntry(
|
||||
value = createNoAmountValue(),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Content price change state with UP type for positive change`() {
|
||||
val entry = createMockEntry(
|
||||
value = createLoadedValue(
|
||||
amount = BigDecimal("1.0"),
|
||||
fiatAmount = BigDecimal("30000.0"),
|
||||
fiatRate = BigDecimal("30000.0"),
|
||||
priceChange = BigDecimal("5.0"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.priceChangeState).isInstanceOf(PriceChangeState.Content::class.java)
|
||||
val content = result.priceChangeState as PriceChangeState.Content
|
||||
Truth.assertThat(content.type).isEqualTo(PriceChangeType.UP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Content price change state with DOWN type for negative change`() {
|
||||
val entry = createMockEntry(
|
||||
value = createLoadedValue(
|
||||
amount = BigDecimal("1.0"),
|
||||
fiatAmount = BigDecimal("30000.0"),
|
||||
fiatRate = BigDecimal("30000.0"),
|
||||
priceChange = BigDecimal("-3.0"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.priceChangeState).isInstanceOf(PriceChangeState.Content::class.java)
|
||||
val content = result.priceChangeState as PriceChangeState.Content
|
||||
Truth.assertThat(content.type).isEqualTo(PriceChangeType.DOWN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should invoke onTokenSelected callback when onClick is called`() {
|
||||
val entry = createMockEntry(
|
||||
value = createLoadedValue(
|
||||
amount = BigDecimal("1.0"),
|
||||
fiatAmount = BigDecimal("30000.0"),
|
||||
fiatRate = BigDecimal("30000.0"),
|
||||
priceChange = BigDecimal("1.0"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
result.onClick()
|
||||
|
||||
verify(exactly = 1) { onTokenSelected(entry) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should generate correct composite id from wallet account and currency`() {
|
||||
val entry = createMockEntry(
|
||||
walletId = "myWallet",
|
||||
accountId = "myAccount",
|
||||
currencyId = "eth",
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.id).isEqualTo("myWallet_myAccount_eth")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should convert list of entries`() {
|
||||
val entries = listOf(
|
||||
createMockEntry(currencyId = "btc", currencyName = "Bitcoin", currencySymbol = "BTC"),
|
||||
createMockEntry(currencyId = "eth", currencyName = "Ethereum", currencySymbol = "ETH"),
|
||||
)
|
||||
|
||||
val results = converter.convertList(entries)
|
||||
|
||||
Truth.assertThat(results).hasSize(2)
|
||||
Truth.assertThat(results[0].tokenName).isEqualTo("Bitcoin")
|
||||
Truth.assertThat(results[1].tokenName).isEqualTo("Ethereum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return fiatRate formatted string for Loaded value`() {
|
||||
val entry = createMockEntry(
|
||||
value = createLoadedValue(
|
||||
amount = BigDecimal("1.0"),
|
||||
fiatAmount = BigDecimal("30000.0"),
|
||||
fiatRate = BigDecimal("30000.0"),
|
||||
priceChange = BigDecimal("1.0"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.fiatRate).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return null fiatRate when value has no fiatRate`() {
|
||||
val entry = createMockEntry(
|
||||
value = CryptoCurrencyStatus.Loading,
|
||||
)
|
||||
|
||||
val result = converter.convert(entry)
|
||||
|
||||
Truth.assertThat(result.fiatRate).isNull()
|
||||
}
|
||||
|
||||
// region Helpers
|
||||
|
||||
private fun createMockEntry(
|
||||
walletId: String = "wallet1",
|
||||
accountId: String = "account1",
|
||||
currencyId: String = "btc",
|
||||
currencyName: String = "Bitcoin",
|
||||
currencySymbol: String = "BTC",
|
||||
networkName: String = "Bitcoin",
|
||||
decimals: Int = 8,
|
||||
value: CryptoCurrencyStatus.Value = createLoadedValue(
|
||||
amount = BigDecimal("1.0"),
|
||||
fiatAmount = BigDecimal("30000.0"),
|
||||
fiatRate = BigDecimal("30000.0"),
|
||||
priceChange = BigDecimal("1.0"),
|
||||
),
|
||||
): UserAssetSearchEntry {
|
||||
val userWalletId = mockk<UserWalletId> {
|
||||
every { stringValue } returns walletId
|
||||
}
|
||||
val accountIdMock = mockk<com.tangem.domain.models.account.AccountId> {
|
||||
every { this@mockk.value } returns accountId
|
||||
}
|
||||
val network = mockk<Network>(relaxed = true) {
|
||||
every { name } returns networkName
|
||||
}
|
||||
val currencyIdObj = mockk<CryptoCurrency.ID> {
|
||||
every { this@mockk.value } returns currencyId
|
||||
}
|
||||
val currency = mockk<CryptoCurrency.Coin> {
|
||||
every { id } returns currencyIdObj
|
||||
every { name } returns currencyName
|
||||
every { symbol } returns currencySymbol
|
||||
every { this@mockk.network } returns network
|
||||
every { this@mockk.decimals } returns decimals
|
||||
every { iconUrl } returns null
|
||||
every { isCustom } returns false
|
||||
}
|
||||
val currencyStatus = mockk<CryptoCurrencyStatus> {
|
||||
every { this@mockk.currency } returns currency
|
||||
every { this@mockk.value } returns value
|
||||
}
|
||||
return mockk<UserAssetSearchEntry> {
|
||||
every { this@mockk.userWalletId } returns userWalletId
|
||||
every { this@mockk.userWalletName } returns "Wallet"
|
||||
every { this@mockk.accountId } returns accountIdMock
|
||||
every { this@mockk.accountName } returns mockk(relaxed = true)
|
||||
every { this@mockk.accountIcon } returns mockk(relaxed = true)
|
||||
every { this@mockk.currencyStatus } returns currencyStatus
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLoadedValue(
|
||||
amount: BigDecimal,
|
||||
fiatAmount: BigDecimal,
|
||||
fiatRate: BigDecimal,
|
||||
priceChange: BigDecimal,
|
||||
): CryptoCurrencyStatus.Loaded {
|
||||
return CryptoCurrencyStatus.Loaded(
|
||||
amount = amount,
|
||||
fiatAmount = fiatAmount,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = priceChange,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = mockk(relaxed = true),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createUnreachableValue(): CryptoCurrencyStatus.Unreachable {
|
||||
return CryptoCurrencyStatus.Unreachable(
|
||||
priceChange = null,
|
||||
fiatRate = null,
|
||||
networkAddress = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMissedDerivationValue(): CryptoCurrencyStatus.MissedDerivation {
|
||||
return CryptoCurrencyStatus.MissedDerivation(
|
||||
priceChange = null,
|
||||
fiatRate = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNoAmountValue(): CryptoCurrencyStatus.NoAmount {
|
||||
return CryptoCurrencyStatus.NoAmount(
|
||||
priceChange = null,
|
||||
fiatRate = null,
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue