Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-19 18:23:22 +03:00
commit ae15bcfc55
211 changed files with 4715 additions and 3641 deletions

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.domain.feedback.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.markets)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens)
@ -56,12 +57,6 @@ dependencies {
implementation(projects.domain.yieldSupply)
implementation(projects.domain.earn)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.
// Instead, create some kind of interface for such cases.
/* Redux -_- */
implementation(projects.domain.legacy)
implementation(deps.reKotlin)
/* Compose */
implementation(deps.compose.coil)

View file

@ -2,9 +2,12 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
@ -12,9 +15,8 @@ import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
@ -30,7 +32,9 @@ internal class TokenActionsHandler @AssistedInject constructor(
private val router: Router,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
@ -104,12 +108,13 @@ internal class TokenActionsHandler @AssistedInject constructor(
}
private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
reduxStateHolder.dispatch(
TradeCryptoAction.Sell(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
),
)
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {

View file

@ -32,12 +32,10 @@ import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkUMConv
import com.tangem.features.feed.model.earn.filters.state.EarnFilterTypeConverter
import com.tangem.features.feed.model.earn.filters.state.EarnFilterTypeUMConverter
import com.tangem.features.feed.model.earn.state.EarnStateController
import com.tangem.features.feed.model.earn.state.transformers.EarnFilterSelectedStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.UpdateBestOpportunitiesStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.UpdateEarnUMInitialStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.UpdateMostlyUsedStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.*
import com.tangem.features.feed.model.earn.statemanager.EarnListBatchFlowManager
import com.tangem.features.feed.model.earn.statemanager.EarnListStateManager
import com.tangem.features.feed.ui.earn.state.EarnBestOpportunitiesUM
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnUM
@ -69,8 +67,8 @@ internal class EarnModel @Inject constructor(
private val earnNetworks = MutableStateFlow<EarnNetworks>(Either.Right(emptyList()))
private val earnListConfigProvider = Provider {
createEarnTokensListConfig(
selectedTypeFilter = stateController.value.selectedTypeFilter,
selectedNetworkFilter = stateController.value.selectedNetworkFilter,
selectedTypeFilter = stateController.value.earnFilterUM.selectedTypeFilter,
selectedNetworkFilter = stateController.value.earnFilterUM.selectedNetworkFilter,
earnNetworks = earnNetworks.value,
)
}
@ -104,7 +102,6 @@ internal class EarnModel @Inject constructor(
init {
updateInitialState()
fetchEarnNetworks()
fetchTopEarnTokens()
subscribeOnStoredFilters()
subscribeOnNetworks()
subscribeOnBatchFlow()
@ -117,20 +114,23 @@ internal class EarnModel @Inject constructor(
batchFlowManager.initialLoadingError,
batchFlowManager.paginationStatus,
) { items, error, paginationStatus ->
val hasActiveFilters = state.value.selectedTypeFilter != EarnFilterTypeUM.All ||
state.value.selectedNetworkFilter !is EarnFilterNetworkUM.AllNetworks
error?.let(::handleBestOpportunitiesErrorAnalytics)
val hasActiveFilters = state.value.earnFilterUM.selectedTypeFilter != EarnFilterTypeUM.All ||
state.value.earnFilterUM.selectedNetworkFilter !is EarnFilterNetworkUM.AllNetworks
EarnListStateManager.calculateState(
items = items,
error = error,
paginationStatus = paginationStatus,
hasActiveFilters = hasActiveFilters,
onRetryClick = { batchFlowManager.reload() },
onRetryClick = {
batchFlowManager.reload()
reloadEarnNetworks()
},
onLoadMore = { batchFlowManager.loadMore() },
onClearFiltersClick = ::onClearFiltersClick,
)
}.onEach { bestOpportunitiesState ->
) to error
}.onEach { (bestOpportunitiesState, error) ->
stateController.update(UpdateBestOpportunitiesStateTransformer(bestOpportunitiesState))
error?.let(::handleBestOpportunitiesErrorAnalytics)
}.launchIn(modelScope)
}
@ -156,23 +156,27 @@ internal class EarnModel @Inject constructor(
private fun subscribeOnStoredFilters() {
modelScope.launch(dispatchers.default) {
getEarnFilterUseCase()
.collect { filter ->
val typeFilterUM = EarnFilterTypeConverter().convert(filter.earnFilterType)
val networkFilterUM = EarnFilterNetworkConverter().convert(filter.earnFilterNetwork)
stateController.update(
EarnFilterSelectedStateTransformer(
filterType = typeFilterUM,
filterNetwork = networkFilterUM,
),
)
batchFlowManager.reload()
}
combine(
getEarnFilterUseCase(),
earnNetworks,
) { filter, networks ->
val typeFilterUM = EarnFilterTypeConverter().convert(filter.earnFilterType)
val networkFilterUM = EarnFilterNetworkConverter().convert(filter.earnFilterNetwork)
stateController.update(
EarnFilterSelectedStateTransformer(
filterType = typeFilterUM,
filterNetwork = networkFilterUM,
earnNetworks = networks,
),
)
batchFlowManager.reload()
}.collect()
}
}
private fun fetchTopEarnTokens() {
modelScope.launch(dispatchers.default) {
stateController.update(UpdateMostlyUsedStateLoadingTransformer())
fetchTopEarnTokensUseCase()
}
}
@ -183,13 +187,21 @@ internal class EarnModel @Inject constructor(
}
}
private fun reloadEarnNetworks() {
modelScope.launch(dispatchers.default) {
if (earnNetworks.value.isLeft()) {
fetchEarnNetworks()
}
}
}
/* start of clicks area */
private fun onTypeFilterClick() {
val currentState = state.value
bottomSheetNavigation.activate(
FeedBottomSheetRoute.TypeFilter(
params = EarnTypeFilterComponent.Params(
selectedFilter = EarnFilterTypeUMConverter().convert(currentState.selectedTypeFilter),
selectedFilter = EarnFilterTypeUMConverter().convert(currentState.earnFilterUM.selectedTypeFilter),
onFilterSelected = ::onTypeFilterOptionSelected,
onDismiss = { bottomSheetNavigation.dismiss() },
),
@ -210,7 +222,7 @@ internal class EarnModel @Inject constructor(
}
private fun createNetworkFilters(): List<EarnFilterNetwork> {
val selectedFilter = state.value.selectedNetworkFilter
val selectedFilter = state.value.earnFilterUM.selectedNetworkFilter
return buildList {
add(
EarnFilterNetwork.AllNetworks(
@ -277,11 +289,14 @@ internal class EarnModel @Inject constructor(
modelScope.launch(dispatchers.default) {
setEarnFilterUseCase(
EarnFilter(
earnFilterNetwork = EarnFilterNetworkUMConverter().convert(state.value.selectedNetworkFilter),
earnFilterNetwork = EarnFilterNetworkUMConverter().convert(
value = state.value.earnFilterUM.selectedNetworkFilter,
),
earnFilterType = type,
),
)
bottomSheetNavigation.dismiss()
reloadEarnNetworks()
}
}
@ -290,7 +305,7 @@ internal class EarnModel @Inject constructor(
setEarnFilterUseCase(
EarnFilter(
earnFilterNetwork = filter,
earnFilterType = EarnFilterTypeUMConverter().convert(state.value.selectedTypeFilter),
earnFilterType = EarnFilterTypeUMConverter().convert(state.value.earnFilterUM.selectedTypeFilter),
),
)
}
@ -319,11 +334,13 @@ internal class EarnModel @Inject constructor(
is ApiResponseError.HttpException -> error.code.numericCode to error.message.orEmpty()
else -> null to ""
}
analyticsEventHandler.send(
EarnAnalyticsEvent.BestOpportunitiesLoadError(
code = code,
message = message,
),
)
if (state.value.bestOpportunities !is EarnBestOpportunitiesUM.Error) {
analyticsEventHandler.send(
EarnAnalyticsEvent.BestOpportunitiesLoadError(
code = code,
message = message,
),
)
}
}
}

View file

@ -23,7 +23,9 @@ internal fun createEarnTokensListConfig(
earnNetworks.fold(
ifLeft = { null },
ifRight = { networks ->
networks.filter(EarnNetwork::isAdded).map(EarnNetwork::networkId)
networks.filter(EarnNetwork::isAdded)
.map(EarnNetwork::networkId)
.ifEmpty { listOf(NO_ONE_NETWORK) }
},
)
}
@ -34,4 +36,9 @@ internal fun createEarnTokensListConfig(
networks = networks,
isForEarn = isForEarn,
)
}
}
/**
* This id means that backend has to return empty result
*/
private const val NO_ONE_NETWORK = "-1"

View file

@ -26,8 +26,10 @@ internal class EarnStateController @Inject constructor() {
return EarnUM(
mostlyUsed = EarnListUM.Loading,
bestOpportunities = EarnBestOpportunitiesUM.Loading,
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
earnFilterUM = EarnFilterUM(
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},

View file

@ -1,18 +1,24 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.domain.models.earn.EarnNetworks
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnFilterSelectedStateTransformer(
private val earnNetworks: EarnNetworks,
private val filterType: EarnFilterTypeUM,
private val filterNetwork: EarnFilterNetworkUM,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(
selectedTypeFilter = filterType,
selectedNetworkFilter = filterNetwork,
earnFilterUM = prevState.earnFilterUM.copy(
selectedTypeFilter = filterType,
selectedNetworkFilter = filterNetwork,
isNetworkFilterEnabled = earnNetworks.isRight(),
isTypeFilterEnabled = true,
),
)
}
}

View file

@ -1,13 +0,0 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnNetworkFilterSelectedStateTransformer(
private val filter: EarnFilterNetworkUM,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(selectedNetworkFilter = filter)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class UpdateMostlyUsedStateLoadingTransformer : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(mostlyUsed = EarnListUM.Loading)
}
}

View file

@ -91,12 +91,7 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
SpacerH(12.dp)
BestOpportunitiesFilters(
state = state.bestOpportunities,
selectedNetworkFilterText = when (state.selectedNetworkFilter) {
is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks)
is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks)
is EarnFilterNetworkUM.Network -> TextReference.Str(state.selectedNetworkFilter.text)
},
selectedTypeFilterText = state.selectedTypeFilterText,
earnFilterUM = state.earnFilterUM,
onNetworkFilterClick = state.onNetworkFilterClick,
onTypeFilterClick = state.onTypeFilterClick,
)
@ -151,10 +146,12 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 12.dp,
),
.padding(horizontal = 16.dp, vertical = 12.dp)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(vertical = 32.dp, horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = st.onRetryClicked)
@ -218,17 +215,14 @@ private fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier:
@Composable
private fun BestOpportunitiesFilters(
state: EarnBestOpportunitiesUM,
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
earnFilterUM: EarnFilterUM,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
) {
when (state) {
is EarnBestOpportunitiesUM.Loading -> FilterButtonsShimmer()
else -> FilterButtons(
selectedNetworkFilterText = selectedNetworkFilterText,
selectedTypeFilterText = selectedTypeFilterText,
isEnabled = state is EarnBestOpportunitiesUM.Content || state is EarnBestOpportunitiesUM.EmptyFiltered,
earnFilterUM = earnFilterUM,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
@ -307,9 +301,7 @@ private fun LazyListScope.bestOpportunitiesItems(state: EarnBestOpportunitiesUM)
@Composable
private fun FilterButtons(
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
isEnabled: Boolean,
earnFilterUM: EarnFilterUM,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
modifier: Modifier = Modifier,
@ -319,10 +311,14 @@ private fun FilterButtons(
) {
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedNetworkFilterText,
text = when (earnFilterUM.selectedNetworkFilter) {
is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks)
is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks)
is EarnFilterNetworkUM.Network -> TextReference.Str(earnFilterUM.selectedNetworkFilter.text)
},
onClick = onNetworkFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
isEnabled = earnFilterUM.isNetworkFilterEnabled,
),
)
@ -330,10 +326,10 @@ private fun FilterButtons(
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedTypeFilterText,
text = earnFilterUM.selectedTypeFilter.text,
onClick = onTypeFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
isEnabled = earnFilterUM.isTypeFilterEnabled,
),
)
}
@ -496,7 +492,7 @@ private fun EarnContentLoadingPreview() {
) {
EarnContent(
state = previewEarnUM(
mostlyUsed = EarnListUM.Loading,
mostlyUsed = EarnListUM.Error(onRetryClicked = {}),
bestOpportunities = EarnBestOpportunitiesUM.Loading,
),
)
@ -588,8 +584,12 @@ private fun previewEarnUM(
): EarnUM = EarnUM(
mostlyUsed = mostlyUsed,
bestOpportunities = bestOpportunities,
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
earnFilterUM = EarnFilterUM(
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
isTypeFilterEnabled = true,
isNetworkFilterEnabled = true,
),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
@Immutable
internal data class EarnFilterUM(
val selectedTypeFilter: EarnFilterTypeUM,
val selectedNetworkFilter: EarnFilterNetworkUM,
val isTypeFilterEnabled: Boolean = true,
val isNetworkFilterEnabled: Boolean = true,
)

View file

@ -1,20 +1,14 @@
package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal data class EarnUM(
val mostlyUsed: EarnListUM,
val bestOpportunities: EarnBestOpportunitiesUM,
val selectedTypeFilter: EarnFilterTypeUM,
val selectedNetworkFilter: EarnFilterNetworkUM,
val earnFilterUM: EarnFilterUM,
val onBackClick: () -> Unit,
val onNetworkFilterClick: () -> Unit,
val onTypeFilterClick: () -> Unit,
val onSliderScroll: () -> Unit,
) {
val selectedTypeFilterText: TextReference
get() = selectedTypeFilter.text
}
)

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -8,12 +9,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.extensions.TextReference
@Composable
internal fun Header(onSeeAllClick: () -> Unit, isLoading: Boolean = false, title: @Composable () -> Unit) {
internal fun Header(
onSeeAllClick: () -> Unit,
isLoading: Boolean,
shouldShowSeeAll: Boolean,
title: @Composable () -> Unit,
) {
AnimatedContent(isLoading) { animatedState ->
Row(
modifier = Modifier
@ -25,13 +32,18 @@ internal fun Header(onSeeAllClick: () -> Unit, isLoading: Boolean = false, title
if (animatedState) {
RectangleShimmer(modifier = Modifier.size(width = 104.dp, height = 18.dp))
} else {
title()
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.common_see_all),
onClick = onSeeAllClick,
),
)
Box(modifier = Modifier.weight(1f)) {
title()
}
SpacerW(8.dp)
AnimatedVisibility(shouldShowSeeAll) {
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.common_see_all),
onClick = onSeeAllClick,
),
)
}
}
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
@ -33,10 +34,13 @@ internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM?, modif
text = stringResourceSafe(R.string.markets_earn_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = onSeeAllClick,
isLoading = earnListUM is EarnListUM.Loading,
shouldShowSeeAll = earnListUM is EarnListUM.Content,
)
SpacerH(12.dp)

View file

@ -4,12 +4,7 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -19,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.MarketsListItem
@ -55,9 +51,13 @@ internal fun MarketBlock(marketChart: MarketChartUM?, feedListCallbacks: FeedLis
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
shouldShowSeeAll = currentChart is MarketChartUM.Content,
isLoading = currentChart is MarketChartUM.Loading,
)
SpacerH(12.dp)
@ -88,9 +88,13 @@ internal fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCall
text = stringResourceSafe(R.string.markets_pulse_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = { onSeeAllClick() },
shouldShowSeeAll = true,
isLoading = marketChartConfig.marketCharts[marketChartConfig.currentSortByType] is MarketChartUM.Loading,
)
LazyRow(

View file

@ -8,8 +8,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -19,6 +17,7 @@ import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleCard
@ -65,12 +64,6 @@ internal fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trend
@Composable
private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
val listState = rememberLazyListState()
val articlesReadStatus = remember(news.content) {
news.content.map { it.isViewed }
}
LaunchedEffect(articlesReadStatus) {
listState.requestScrollToItem(0)
}
Column {
Header(
title = {
@ -104,10 +97,14 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
}
},
style = TangemTheme.typography.subtitle1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
onSeeAllClick = { feedListCallbacks.onOpenAllNews(false) },
isLoading = news.newsUMState == NewsUMState.LOADING,
shouldShowSeeAll = news.newsUMState == NewsUMState.CONTENT,
)
SpacerH(12.dp)
@ -133,7 +130,7 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
) {
itemsIndexed(
items = news.content,
key = { _, article -> article.id },
key = { index, _ -> index },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
@ -181,10 +178,14 @@ private fun NewsErrorBlock(onRetryClick: () -> Unit) {
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
onSeeAllClick = {},
shouldShowSeeAll = false,
isLoading = false,
)
SpacerH(12.dp)
BlockCard(

View file

@ -9,7 +9,6 @@ import dagger.assisted.AssistedInject
/**
* Mocking it for release/external builds to exclude SumSub dependency
* This will never be called if the FT [isTangemPayEnabled] is off
*/
@Suppress("UnusedPrivateProperty")
internal class MockKycComponent @AssistedInject constructor(

View file

@ -36,6 +36,7 @@ dependencies {
implementation(projects.domain.feedback.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.markets)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.staking)
@ -49,12 +50,6 @@ dependencies {
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.yieldSupply)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.
// Instead, create some kind of interface for such cases.
/* Redux -_- */
implementation(projects.domain.legacy)
implementation(deps.reKotlin)
/* Compose */
implementation(deps.compose.coil)

View file

@ -2,9 +2,12 @@ package com.tangem.features.markets.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
@ -12,9 +15,8 @@ import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
@ -30,7 +32,9 @@ internal class TokenActionsHandler @AssistedInject constructor(
private val router: Router,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
@ -104,12 +108,13 @@ internal class TokenActionsHandler @AssistedInject constructor(
}
private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
reduxStateHolder.dispatch(
TradeCryptoAction.Sell(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
),
)
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {

View file

@ -42,8 +42,8 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)

View file

@ -1,7 +1,7 @@
package com.tangem.features.onramp.alloffers.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
internal interface AllOffersIntents {

View file

@ -10,9 +10,9 @@ import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.*
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.MINUS
import kotlinx.collections.immutable.toImmutableList

View file

@ -4,7 +4,7 @@ import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import kotlinx.collections.immutable.ImmutableList
internal sealed interface AllOffersStateUM {

View file

@ -12,7 +12,7 @@ import com.tangem.features.onramp.alloffers.entity.AllOffersIntents
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateFactory
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.Job

View file

@ -32,10 +32,10 @@ import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.mainv2.ui.Offer
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.features.onramp.main.ui.Offer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList

View file

@ -33,10 +33,10 @@ import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.mainv2.ui.TimingBlock
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.features.onramp.main.ui.TimingBlock
import com.tangem.features.onramp.paymentmethod.ui.PaymentMethodIcon
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -8,15 +8,16 @@ import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
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.decompose.ComposableBottomSheetComponent
import com.tangem.features.onramp.alloffers.AllOffersComponent
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
import com.tangem.features.onramp.main.model.OnrampMainComponentModel
import com.tangem.features.onramp.main.ui.OnrampMainComponentContent
import com.tangem.features.onramp.providers.SelectProviderComponent
import com.tangem.features.onramp.main.ui.OnrampMainScreen
import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -27,10 +28,15 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
@Assisted private val params: OnrampMainComponent.Params,
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val selectProviderComponentFactory: SelectProviderComponent.Factory,
private val allOffersComponentFactory: AllOffersComponent.Factory,
) : OnrampMainComponent, AppComponentContext by appComponentContext {
private val model: OnrampMainComponentModel = getOrCreateModel(params)
init {
lifecycle.subscribe(onStop = model::onStop)
}
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
@ -43,7 +49,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
val state by model.state.collectAsState()
val bottomSheet by bottomSheetSlot.subscribeAsState()
OnrampMainComponentContent(modifier = modifier, state = state)
OnrampMainScreen(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
@ -57,7 +63,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
country = config.country,
isLaunchSepa = params.isLaunchSepa,
isLaunchSepa = false,
onDismiss = {
model.bottomSheetNavigation.dismiss()
model.handleOnrampAvailable()
@ -72,14 +78,14 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
is OnrampMainBottomSheetConfig.ProvidersList -> selectProviderComponentFactory.create(
is OnrampMainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create(
context = childByContext(componentContext),
params = SelectProviderComponent.Params(
onProviderClick = model::onProviderSelected,
onDismiss = model.bottomSheetNavigation::dismiss,
selectedProviderId = config.selectedProviderId,
selectedPaymentMethod = config.selectedPaymentMethod,
params = AllOffersComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
openRedirectPage = params.openRedirectPage,
amountCurrencyCode = config.amountCurrencyCode,
),
)
}

View file

@ -15,7 +15,6 @@ internal interface OnrampMainComponent : ComposableContentComponent {
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val isLaunchSepa: Boolean,
)
interface Factory : ComponentFactory<Params, OnrampMainComponent>

View file

@ -1,20 +0,0 @@
package com.tangem.features.onramp.main.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.main.model.OnrampMainComponentModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface OnrampMainComponentModelModule {
@Binds
@IntoMap
@ClassKey(OnrampMainComponentModel::class)
fun bindOnrampSelectCountryModel(model: OnrampMainComponentModel): Model
}

View file

@ -1,11 +1,15 @@
package com.tangem.features.onramp.main.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.main.DefaultOnrampMainComponent
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.model.OnrampMainComponentModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@ -15,4 +19,9 @@ internal interface OnrampMainComponentModule {
@Binds
@Singleton
fun bindOnrampMainComponentFactory(factory: DefaultOnrampMainComponent.Factory): OnrampMainComponent.Factory
@Binds
@IntoMap
@ClassKey(OnrampMainComponentModel::class)
fun bindOnrampMainComponentModel(model: OnrampMainComponentModel): Model
}

View file

@ -1,25 +0,0 @@
package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.extensions.TextReference
internal data class OnrampAmountBlockUM(
val currencyUM: OnrampCurrencyUM,
val amountFieldModel: AmountFieldModel,
val secondaryFieldModel: OnrampAmountSecondaryFieldUM,
)
internal data class OnrampCurrencyUM(
val code: String,
val iconUrl: String?,
val precision: Int,
val onClick: () -> Unit,
)
@Immutable
internal sealed interface OnrampAmountSecondaryFieldUM {
data object Loading : OnrampAmountSecondaryFieldUM
data class Content(val amount: TextReference) : OnrampAmountSecondaryFieldUM
data class Error(val error: TextReference) : OnrampAmountSecondaryFieldUM
}

View file

@ -1,17 +1,17 @@
package com.tangem.features.onramp.mainv2.entity
package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class OnrampNewAmountBlockUM(
val currencyUM: OnrampNewCurrencyUM,
internal data class OnrampAmountBlockUM(
val currencyUM: OnrampCurrencyUM,
val amountFieldModel: AmountFieldModel,
val secondaryFieldModel: OnrampSecondaryFieldErrorUM,
)
internal data class OnrampNewCurrencyUM(
internal data class OnrampCurrencyUM(
val unit: String,
val code: String,
val iconUrl: String?,
@ -25,9 +25,9 @@ internal sealed interface OnrampSecondaryFieldErrorUM {
data class Error(val error: TextReference) : OnrampSecondaryFieldErrorUM
}
internal sealed interface OnrampV2AmountButtonUMState {
data class Loaded(val amountButtons: ImmutableList<OnrampAmountButtonUM>) : OnrampV2AmountButtonUMState
data object None : OnrampV2AmountButtonUMState
internal sealed interface OnrampAmountButtonUMState {
data class Loaded(val amountButtons: ImmutableList<OnrampAmountButtonUM>) : OnrampAmountButtonUMState
data object None : OnrampAmountButtonUMState
}
internal data class OnrampAmountButtonUM(

View file

@ -2,12 +2,15 @@ package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
interface OnrampIntents {
fun onAmountValueChanged(value: String, isValuePasted: Boolean)
internal interface OnrampIntents {
fun onAmountValueChanged(value: String)
fun openSettings()
fun openCurrenciesList()
fun onBuyClick(quote: OnrampProviderWithQuote.Data)
fun onBuyClick(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
)
fun openProviders()
fun onRefresh()
fun onLinkClick(link: String)
}

View file

@ -1,10 +0,0 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampAmount
import com.tangem.domain.onramp.model.OnrampPaymentMethod
data class OnrampLastUpdate(
val fromAmount: OnrampAmount,
val countryCode: String,
val paymentMethod: OnrampPaymentMethod,
)

View file

@ -1,11 +1,10 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import kotlinx.serialization.Serializable
@Serializable
internal sealed interface OnrampMainBottomSheetConfig {
sealed interface OnrampMainBottomSheetConfig {
@Serializable
data class ConfirmResidency(val country: OnrampCountry) : OnrampMainBottomSheetConfig
@ -13,8 +12,5 @@ internal sealed interface OnrampMainBottomSheetConfig {
data object CurrenciesList : OnrampMainBottomSheetConfig
@Serializable
data class ProvidersList(
val selectedProviderId: String,
val selectedPaymentMethod: OnrampPaymentMethod,
) : OnrampMainBottomSheetConfig
data class AllOffers(val amountCurrencyCode: String) : OnrampMainBottomSheetConfig
}

View file

@ -4,55 +4,29 @@ import androidx.compose.runtime.Immutable
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.onramp.impl.R
@Immutable
internal sealed interface OnrampMainComponentUM {
val topBarConfig: OnrampMainTopBarUM
val buyButtonConfig: BuyButtonConfig
val errorNotification: NotificationUM?
data class InitialLoading(
val currency: String,
val onClose: () -> Unit,
val openSettings: () -> Unit,
override val errorNotification: NotificationUM? = null,
) : OnrampMainComponentUM {
override val topBarConfig: OnrampMainTopBarUM = OnrampMainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
startButtonUM = TopAppBarButtonUM.Back(
onBackClicked = onClose,
enabled = true,
),
endButtonUM = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_more_vertical_24,
onClicked = openSettings,
isEnabled = false,
),
)
override val buyButtonConfig: BuyButtonConfig = BuyButtonConfig(
text = resourceReference(R.string.common_buy),
onClick = {},
isEnabled = false,
)
}
override val topBarConfig: OnrampMainTopBarUM,
override val errorNotification: NotificationUM?,
) : OnrampMainComponentUM
data class Content(
override val topBarConfig: OnrampMainTopBarUM,
override val buyButtonConfig: BuyButtonConfig,
override val errorNotification: NotificationUM?,
val amountBlockState: OnrampAmountBlockUM,
val providerBlockState: OnrampProviderBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampAmountButtonUMState,
) : OnrampMainComponentUM
}
internal data class BuyButtonConfig(
val text: TextReference,
val onClick: () -> Unit,
val isEnabled: Boolean,
internal data class OnrampMainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,
)

View file

@ -1,10 +0,0 @@
package com.tangem.features.onramp.main.entity
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
internal data class OnrampMainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,
)

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.mainv2.entity
package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference

View file

@ -1,18 +0,0 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
sealed class OnrampProviderBlockUM {
data object Empty : OnrampProviderBlockUM()
data object Loading : OnrampProviderBlockUM()
data class Content(
val providerId: String,
val paymentMethod: OnrampPaymentMethod,
val providerName: String,
val termsOfUseLink: String?,
val privacyPolicyLink: String?,
val isBestRate: Boolean,
val onLinkClick: (String) -> Unit,
val onClick: () -> Unit,
) : OnrampProviderBlockUM()
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
sealed interface OnrampProvidersUM {
data object Empty : OnrampProvidersUM
data object Loading : OnrampProvidersUM
data class Content(
val providerId: String,
val paymentMethod: OnrampPaymentMethod,
) : OnrampProvidersUM
}

View file

@ -1,24 +1,24 @@
package com.tangem.features.onramp.mainv2.entity.converter
package com.tangem.features.onramp.main.entity.converter
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class OnrampV2AmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
internal class OnrampAmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val onrampIntents: OnrampV2Intents,
) : Converter<String, OnrampV2MainComponentUM> {
private val onrampIntents: OnrampIntents,
) : Converter<String, OnrampMainComponentUM> {
override fun convert(value: String): OnrampV2MainComponentUM {
override fun convert(value: String): OnrampMainComponentUM {
val state = currentStateProvider()
if (state !is OnrampV2MainComponentUM.Content) return state
if (state !is OnrampMainComponentUM.Content) return state
if (value.isEmpty()) return state.emptyState()
@ -36,13 +36,13 @@ internal class OnrampV2AmountFieldChangeConverter(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Loading,
errorNotification = null,
)
}
private fun OnrampV2MainComponentUM.Content.emptyState(): OnrampV2MainComponentUM.Content {
private fun OnrampMainComponentUM.Content.emptyState(): OnrampMainComponentUM.Content {
val amountFieldModel = amountBlockState.amountFieldModel.copy(
value = "",
fiatValue = "",

View file

@ -1,7 +1,7 @@
package com.tangem.features.onramp.mainv2.entity.factory
package com.tangem.features.onramp.main.entity.factory
import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUMState
import kotlinx.collections.immutable.toPersistentList
internal class OnrampAmountButtonUMStateFactory {
@ -12,7 +12,7 @@ internal class OnrampAmountButtonUMStateFactory {
currencyCode: String,
currencySymbol: String,
onAmountValueChanged: (String) -> Unit,
): OnrampV2AmountButtonUMState {
): OnrampAmountButtonUMState {
return when (currencyCode) {
USD_CODE, EUR_CODE -> {
val buttons = defaultPreselectedAmount.map { value ->
@ -22,9 +22,9 @@ internal class OnrampAmountButtonUMStateFactory {
onClick = { onAmountValueChanged(value.toString()) },
)
}.toPersistentList()
OnrampV2AmountButtonUMState.Loaded(buttons)
OnrampAmountButtonUMState.Loaded(buttons)
}
else -> OnrampV2AmountButtonUMState.None
else -> OnrampAmountButtonUMState.None
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.mainv2.entity.factory
package com.tangem.features.onramp.main.entity.factory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.resourceReference
@ -11,34 +11,34 @@ import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.AmountType
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.converter.OnrampV2AmountFieldChangeConverter
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.converter.OnrampAmountFieldChangeConverter
import com.tangem.utils.Provider
internal class OnrampV2AmountStateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
internal class OnrampAmountStateFactory(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onrampIntents: OnrampV2Intents,
private val onrampIntents: OnrampIntents,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
) {
private val onrampAmountFieldChangeConverter: OnrampV2AmountFieldChangeConverter by lazy(
private val onrampAmountFieldChangeConverter: OnrampAmountFieldChangeConverter by lazy(
mode = LazyThreadSafetyMode.NONE,
) {
OnrampV2AmountFieldChangeConverter(
OnrampAmountFieldChangeConverter(
currentStateProvider = currentStateProvider,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
onrampIntents = onrampIntents,
)
}
fun getOnAmountValueChange(value: String): OnrampV2MainComponentUM {
fun getOnAmountValueChange(value: String): OnrampMainComponentUM {
return onrampAmountFieldChangeConverter.convert(value)
}
fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampV2MainComponentUM {
fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
@ -72,9 +72,9 @@ internal class OnrampV2AmountStateFactory(
)
}
fun getSecondaryFieldAmountErrorState(quotes: List<OnrampQuote>): OnrampV2MainComponentUM {
fun getSecondaryFieldAmountErrorState(quotes: List<OnrampQuote>): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.amountFieldModel.fiatValue.isEmpty()) return currentState
@ -91,23 +91,23 @@ internal class OnrampV2AmountStateFactory(
)
}
fun getAmountSecondaryFieldResetState(): OnrampV2MainComponentUM {
fun getAmountSecondaryFieldResetState(): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Empty) return currentState
return currentState.copy(
amountBlockState = amountState.copy(secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
errorNotification = null,
offersBlockState = currentState.offersBlockState,
)
}
private fun OnrampQuote.AmountError.toSecondaryFieldUiModel(
amountState: OnrampNewAmountBlockUM,
amountState: OnrampAmountBlockUM,
): OnrampSecondaryFieldErrorUM.Error {
val amount = error.requiredAmount.format {
fiat(

View file

@ -1,24 +1,23 @@
package com.tangem.features.onramp.mainv2.entity.factory
package com.tangem.features.onramp.main.entity.factory
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.onramp.model.*
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.main.entity.*
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toPersistentList
internal class OnrampOffersStateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val onrampIntents: OnrampV2Intents,
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val onrampIntents: OnrampIntents,
) {
fun getOffersState(offers: List<OnrampOffersBlock>): OnrampV2MainComponentUM {
val currentState = currentStateProvider.invoke()
return when (currentState) {
is OnrampV2MainComponentUM.InitialLoading -> currentState
is OnrampV2MainComponentUM.Content -> {
fun getOffersState(offers: List<OnrampOffersBlock>): OnrampMainComponentUM {
return when (val currentState = currentStateProvider.invoke()) {
is OnrampMainComponentUM.InitialLoading -> currentState
is OnrampMainComponentUM.Content -> {
if (currentState.offersBlockState is OnrampOffersBlockUM.Loading) {
return currentState
}

View file

@ -8,7 +8,9 @@ import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
@ -22,16 +24,30 @@ import java.math.BigDecimal
internal class OnrampStateFactory(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val cryptoCurrency: CryptoCurrency,
private val onrampIntents: OnrampIntents,
) {
fun getInitialState(currency: String, onClose: () -> Unit): OnrampMainComponentUM.InitialLoading {
fun getInitialState(
currency: String,
onClose: () -> Unit,
openSettings: () -> Unit,
): OnrampMainComponentUM.InitialLoading {
return OnrampMainComponentUM.InitialLoading(
currency = currency,
onClose = onClose,
openSettings = onrampIntents::openSettings,
errorNotification = null,
topBarConfig = OnrampMainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
startButtonUM = TopAppBarButtonUM.Close(
onCloseClick = onClose,
enabled = true,
),
endButtonUM = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_more_vertical_24,
onClicked = openSettings,
isEnabled = false,
),
),
)
}
@ -42,12 +58,19 @@ internal class OnrampStateFactory(
is TopAppBarButtonUM.Icon -> button.copy(isEnabled = true)
is TopAppBarButtonUM.Text -> button.copy(isEnabled = true)
}
val initialAmountBlockState = getInitialAmountBlockState(currency)
return OnrampMainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
buyButtonConfig = state.buyButtonConfig,
amountBlockState = getInitialAmountBlockState(currency),
providerBlockState = OnrampProviderBlockUM.Empty,
amountBlockState = initialAmountBlockState,
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = null,
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencyCode = currency.code,
currencySymbol = currency.unit,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
)
}
@ -68,21 +91,6 @@ internal class OnrampStateFactory(
}
}
private fun getNoPairsErrorState(): OnrampMainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampMainComponentUM.Content ?: return state
return contentState.copy(
buyButtonConfig = contentState.buyButtonConfig.copy(isEnabled = false),
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
)
}
fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampMainComponentUM {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
@ -93,15 +101,15 @@ internal class OnrampStateFactory(
return when (state) {
is OnrampMainComponentUM.Content -> state.copy(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
buyButtonConfig = state.buyButtonConfig.copy(isEnabled = false),
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(TextReference.EMPTY),
),
providerBlockState = OnrampProviderBlockUM.Empty,
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
)
is OnrampMainComponentUM.InitialLoading -> state.copy(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
@ -112,6 +120,22 @@ internal class OnrampStateFactory(
}
}
private fun getNoPairsErrorState(): OnrampMainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampMainComponentUM.Content ?: return state
return contentState.copy(
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}
private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampAmountBlockUM {
return OnrampAmountBlockUM(
currencyUM = OnrampCurrencyUM(
@ -119,11 +143,12 @@ internal class OnrampStateFactory(
iconUrl = currency.image,
precision = currency.precision,
onClick = onrampIntents::openCurrenciesList,
unit = currency.unit,
),
amountFieldModel = AmountFieldModel(
value = "",
fiatValue = "",
onValueChange = { onrampIntents.onAmountValueChanged(value = it, isValuePasted = false) },
onValueChange = onrampIntents::onAmountValueChanged,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,
@ -139,7 +164,7 @@ internal class OnrampStateFactory(
isValuePasted = false,
onValuePastedTriggerDismiss = {},
),
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(TextReference.EMPTY),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
)
}
@ -149,8 +174,4 @@ internal class OnrampStateFactory(
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
)
companion object {
const val PREDEFINED_SEPA_AMOUNT = "100"
}
}

View file

@ -1,75 +0,0 @@
package com.tangem.features.onramp.main.entity.factory.amount
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.features.onramp.main.entity.OnrampAmountSecondaryFieldUM
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampProviderBlockUM
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import java.math.BigDecimal
internal class OnrampAmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
) : Converter<OnrampAmountFieldChangeConverter.Input, OnrampMainComponentUM> {
override fun convert(input: Input): OnrampMainComponentUM {
val value = input.value
val isValuePasted = input.isValuePasted
val state = currentStateProvider()
if (state !is OnrampMainComponentUM.Content) return state
if (value.isEmpty()) return state.emptyState()
val amountState = state.amountBlockState
val amountTextField = amountState.amountFieldModel
val fiatDecimal = value.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val isDoneActionEnabled = !fiatDecimal.isNullOrZero()
val amountFieldModel = amountState.amountFieldModel.copy(
fiatValue = value,
fiatAmount = amountTextField.fiatAmount.copy(value = fiatDecimal),
keyboardOptions = KeyboardOptions(
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
isValuePasted = isValuePasted,
)
return state.copy(
amountBlockState = amountState.copy(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading,
),
providerBlockState = OnrampProviderBlockUM.Loading,
)
}
private fun OnrampMainComponentUM.Content.emptyState(): OnrampMainComponentUM.Content {
val amountFieldModel = amountBlockState.amountFieldModel.copy(
value = "",
fiatValue = "",
cryptoAmount = amountBlockState.amountFieldModel.cryptoAmount.copy(value = BigDecimal.ZERO),
fiatAmount = amountBlockState.amountFieldModel.fiatAmount.copy(value = BigDecimal.ZERO),
isError = false,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,
),
)
return copy(
amountBlockState = amountBlockState.copy(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(TextReference.EMPTY),
),
buyButtonConfig = buyButtonConfig.copy(isEnabled = false),
providerBlockState = OnrampProviderBlockUM.Empty,
)
}
data class Input(val value: String, val isValuePasted: Boolean)
}

View file

@ -1,259 +0,0 @@
package com.tangem.features.onramp.main.entity.factory.amount
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.AmountType
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.providers.entity.SelectProviderResult
import com.tangem.utils.Provider
import com.tangem.utils.extensions.isSingleItem
internal class OnrampAmountStateFactory(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onrampIntents: OnrampIntents,
private val cryptoCurrency: CryptoCurrency,
private val needApplyFCARestrictions: Provider<Boolean>,
) {
private val onrampAmountFieldChangeConverter = OnrampAmountFieldChangeConverter(
currentStateProvider = currentStateProvider,
)
fun getOnAmountValueChange(value: String, isValuePasted: Boolean) =
onrampAmountFieldChangeConverter.convert(OnrampAmountFieldChangeConverter.Input(value, isValuePasted))
fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
return currentState.copy(
amountBlockState = amountState.copy(
currencyUM = amountState.currencyUM.copy(
code = currency.code,
iconUrl = currency.image,
precision = currency.precision,
),
amountFieldModel = amountState.amountFieldModel.copy(
isError = false,
fiatAmount = amountState.amountFieldModel.fiatAmount.copy(
currencySymbol = currency.unit,
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
),
),
),
)
}
fun getAmountSecondaryLoadingState(): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
return currentState.copy(
amountBlockState = amountState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading),
providerBlockState = OnrampProviderBlockUM.Loading,
buyButtonConfig = currentState.buyButtonConfig.copy(isEnabled = false),
errorNotification = null,
)
}
fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.amountFieldModel.fiatValue.isEmpty()) return currentState
return currentState.copy(
amountBlockState = amountState.copy(
amountFieldModel = amountState.amountFieldModel.copy(isError = false),
secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel,
),
buyButtonConfig = currentState.buyButtonConfig.copy(
isEnabled = quote is OnrampQuote.Data,
onClick = {
if (quote is OnrampQuote.Data) {
onrampIntents.onBuyClick(
OnrampProviderWithQuote.Data(
provider = quote.provider,
paymentMethod = quote.paymentMethod,
toAmount = quote.toAmount,
fromAmount = quote.fromAmount,
),
)
}
},
),
errorNotification = null,
)
}
fun getUpdatedProviderState(selectedQuote: OnrampQuote, quotes: List<OnrampQuote>): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
analyticsEventHandler.send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = selectedQuote.provider.info.name,
tokenSymbol = cryptoCurrency.symbol,
paymentMethod = selectedQuote.paymentMethod.name,
),
)
val bestProvider = quotes.firstOrNull()
val isMultipleQuotes = !quotes.isSingleItem()
val isOtherQuotesHasData = quotes
.filter { it.paymentMethod == selectedQuote.paymentMethod }
.filterNot { it == bestProvider }
.any { it is OnrampQuote.Data }
val isBestProvider = selectedQuote == bestProvider &&
isMultipleQuotes &&
isOtherQuotesHasData &&
!needApplyFCARestrictions()
return currentState.copy(
providerBlockState = selectedQuote.toProviderBlockState(isBestProvider),
)
}
fun getAmountSecondaryUpdatedState(
providerResult: SelectProviderResult,
isBestRate: Boolean,
): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
val secondaryField = when (providerResult) {
is SelectProviderResult.ProviderWithError -> {
providerResult.quoteError.toSecondaryFieldUiModel(amountState)
}
is SelectProviderResult.ProviderWithQuote -> {
val amount = providerResult.toAmount.value.format {
crypto(symbol = providerResult.toAmount.symbol, decimals = providerResult.toAmount.decimals)
}
OnrampAmountSecondaryFieldUM.Content(stringReference(amount))
}
}
return currentState.copy(
amountBlockState = amountState.copy(secondaryFieldModel = secondaryField),
providerBlockState = OnrampProviderBlockUM.Content(
paymentMethod = providerResult.paymentMethod,
providerId = providerResult.provider.id,
providerName = providerResult.provider.info.name,
isBestRate = isBestRate && !needApplyFCARestrictions(),
onClick = onrampIntents::openProviders,
termsOfUseLink = providerResult.provider.info.termsOfUseLink,
privacyPolicyLink = providerResult.provider.info.privacyPolicyLink,
onLinkClick = onrampIntents::onLinkClick,
),
buyButtonConfig = currentState.buyButtonConfig.copy(
isEnabled = providerResult is SelectProviderResult.ProviderWithQuote,
onClick = {
if (providerResult is SelectProviderResult.ProviderWithQuote) {
onrampIntents.onBuyClick(
OnrampProviderWithQuote.Data(
provider = providerResult.provider,
paymentMethod = providerResult.paymentMethod,
toAmount = providerResult.toAmount,
fromAmount = providerResult.fromAmount,
),
)
}
},
),
errorNotification = null,
)
}
fun getAmountSecondaryResetState(): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.secondaryFieldModel is OnrampAmountSecondaryFieldUM.Content) return currentState
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(
amount = TextReference.EMPTY,
),
),
errorNotification = null,
)
}
private fun OnrampQuote.toProviderBlockState(isBestRate: Boolean): OnrampProviderBlockUM {
return OnrampProviderBlockUM.Content(
paymentMethod = paymentMethod,
providerId = provider.id,
providerName = provider.info.name,
isBestRate = isBestRate,
onClick = onrampIntents::openProviders,
termsOfUseLink = provider.info.termsOfUseLink,
privacyPolicyLink = provider.info.privacyPolicyLink,
onLinkClick = onrampIntents::onLinkClick,
)
}
private fun OnrampQuote.toSecondaryFieldUiModel(amountState: OnrampAmountBlockUM): OnrampAmountSecondaryFieldUM? {
return when (this) {
is OnrampQuote.Error -> null
is OnrampQuote.Data -> {
val amount = toAmount.value.format {
crypto(symbol = toAmount.symbol, decimals = toAmount.decimals)
}
OnrampAmountSecondaryFieldUM.Content(stringReference(amount))
}
is OnrampQuote.AmountError -> this.toSecondaryFieldUiModel(amountState)
}
}
private fun OnrampQuote.AmountError.toSecondaryFieldUiModel(
amountState: OnrampAmountBlockUM,
): OnrampAmountSecondaryFieldUM.Error {
val amount = error.requiredAmount.format {
fiat(
fiatCurrencyCode = amountState.amountFieldModel.fiatAmount.currencySymbol,
fiatCurrencySymbol = amountState.amountFieldModel.fiatAmount.currencySymbol,
)
}
val errorTextRes = when (error) {
is OnrampError.AmountError.TooBigError -> {
analyticsEventHandler.send(OnrampAnalyticsEvent.MaxAmountError())
R.string.onramp_max_amount_restriction
}
is OnrampError.AmountError.TooSmallError -> {
analyticsEventHandler.send(OnrampAnalyticsEvent.MinAmountError())
R.string.onramp_min_amount_restriction
}
}
return OnrampAmountSecondaryFieldUM.Error(
resourceReference(
errorTextRes,
wrappedList(amount),
),
)
}
}

View file

@ -1,38 +1,25 @@
package com.tangem.features.onramp.main.model
import androidx.compose.runtime.mutableStateOf
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampAmountStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampOffersStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory.Companion.PREDEFINED_SEPA_AMOUNT
import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory
import com.tangem.features.onramp.providers.entity.SelectProviderResult
import com.tangem.features.onramp.utils.model.EUR_CURRENCY
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -43,7 +30,6 @@ import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -51,85 +37,141 @@ internal class OnrampMainComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val router: Router,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase,
private val getOnrampCountryUseCase: GetOnrampCountryUseCase,
private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase,
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
private val onrampSaveDefaultCurrencyUseCase: OnrampSaveDefaultCurrencyUseCase,
private val onrampGetDefaultCurrencyUseCase: OnrampGetDefaultCurrencyUseCase,
private val amountInputManager: InputManager,
private val messageSender: UiMessageSender,
private val urlOpener: UrlOpener,
getWalletsUseCase: GetWalletsUseCase,
getUserCountryUseCase: GetUserCountryUseCase,
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampIntents {
private val params: OnrampMainComponent.Params = paramsContainer.require()
val params = paramsContainer.require<OnrampMainComponent.Params>()
private var shouldForceChooseSepa = params.isLaunchSepa
private var currencyToRestore: OnrampCurrency? = null
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
val bottomSheetNavigation: SlotNavigation<OnrampMainBottomSheetConfig> = SlotNavigation()
private val lastUpdateState = mutableStateOf<OnrampLastUpdate?>(null)
private var userCountry: UserCountry? = null
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountButtonUMStateFactory()
}
@Suppress("PropertyUsedBeforeDeclaration")
private val stateFactory = OnrampStateFactory(
currentStateProvider = Provider { state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
)
private val stateFactory: OnrampStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampStateFactory(
currentStateProvider = Provider { state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
val state: StateFlow<OnrampMainComponentUM>
field = MutableStateFlow<OnrampMainComponentUM>(
value = stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = ::onCloseClick,
openSettings = ::openSettings,
),
)
private val amountStateFactory = OnrampAmountStateFactory(
currentStateProvider = Provider { state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
cryptoCurrency = params.cryptoCurrency,
needApplyFCARestrictions = Provider { userCountry.needApplyFCARestrictions() },
)
private val amountStateFactory: OnrampAmountStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountStateFactory(
currentStateProvider = Provider { state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
private val onrampOffersStateFactory: OnrampOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampOffersStateFactory(
currentStateProvider = Provider { state.value },
onrampIntents = this,
)
}
private val quotesTaskScheduler = SingleTaskScheduler<Unit>()
init {
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
val bottomSheetNavigation: SlotNavigation<OnrampMainBottomSheetConfig> = SlotNavigation()
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
init {
modelScope.launch {
clearOnrampCacheUseCase()
if (params.isLaunchSepa) {
currencyToRestore = onrampGetDefaultCurrencyUseCase.invoke().getOrNull()
onrampSaveDefaultCurrencyUseCase.invoke(EUR_CURRENCY)
}
}
startLoadingQuotes()
sendScreenOpenAnalytics()
checkResidenceCountry()
subscribeToAmountChanges()
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
subscribeOnOffers()
}
private fun sendScreenOpenAnalytics() {
override fun onDestroy() {
modelScope.launch { clearOnrampCacheUseCase.invoke() }
quotesTaskScheduler.cancelTask()
super.onDestroy()
}
override fun onAmountValueChanged(value: String) {
state.update { amountStateFactory.getOnAmountValueChange(value) }
modelScope.launch { amountInputManager.update(value) }
}
override fun openSettings() {
params.openSettings.invoke()
}
override fun openCurrenciesList() {
analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened())
bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.CurrenciesList)
}
override fun onBuyClick(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
) {
val currentContentState = state.value as? OnrampMainComponentUM.Content ?: return
analyticsEventHandler.send(
OnrampAnalyticsEvent.ScreenOpened(
source = params.source,
OnrampAnalyticsEvent.OnBuyClick(
providerName = quote.provider.info.name,
currency = currentContentState.amountBlockState.currencyUM.code,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
sendOfferClickEvent(
quote = quote,
onrampOfferAdvantagesUM = onrampOfferAdvantagesUM,
categoryUM = categoryUM,
)
params.openRedirectPage(quote)
}
override fun openProviders() {
val currentContentState = state.value as? OnrampMainComponentUM.Content ?: return
val amountCurrentCode = currentContentState.amountBlockState.currencyUM.code
bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.AllOffers(amountCurrentCode))
}
override fun onRefresh() {
state.update {
stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = router::pop,
openSettings = ::openSettings,
)
}
modelScope.launch {
clearOnrampCacheUseCase.invoke()
checkResidenceCountry()
handleOnrampAvailable()
}
}
fun onStop() {
quotesTaskScheduler.cancelTask()
}
fun handleOnrampAvailable() {
@ -137,92 +179,6 @@ internal class OnrampMainComponentModel @Inject constructor(
subscribeToQuotesUpdate()
}
fun onProviderSelected(result: SelectProviderResult, isBestRate: Boolean) {
state.update { amountStateFactory.getAmountSecondaryUpdatedState(result, isBestRate) }
if (result.paymentMethod.id != SEPA_METHOD_ID) {
shouldForceChooseSepa = false
}
}
private fun checkResidenceCountry() {
modelScope.launch {
checkOnrampAvailabilityUseCase(userWallet)
.onRight(::handleOnrampAvailability)
.onLeft(::handleOnrampError)
}
}
private fun handleOnrampAvailability(availability: OnrampAvailability) {
when (availability) {
is OnrampAvailability.Available -> handleOnrampAvailable()
is OnrampAvailability.ConfirmResidency,
is OnrampAvailability.NotSupported,
-> bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.ConfirmResidency(availability.country))
}
}
private fun subscribeToCountryAndCurrencyUpdates() {
getOnrampCountryUseCase.invoke()
.onEach { maybeCountry ->
maybeCountry.fold(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
val wasInitialLoading = state.value is OnrampMainComponentUM.InitialLoading
state.update { prevState ->
if (prevState is OnrampMainComponentUM.InitialLoading) {
stateFactory.getReadyState(country.defaultCurrency)
} else {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
}
updatePairsAndQuotes()
if (wasInitialLoading && params.isLaunchSepa) {
onAmountValueChanged(value = PREDEFINED_SEPA_AMOUNT, isValuePasted = true)
}
},
)
}
.launchIn(modelScope)
}
private fun subscribeToAmountChanges() = modelScope.launch {
amountInputManager.query
.filter(String::isNotEmpty)
.collectLatest { _ ->
state.update { amountStateFactory.getAmountSecondaryLoadingState() }
startLoadingQuotes()
}
}
private suspend fun updatePairsAndQuotes() {
state.update { prevState ->
val contentState = state.value as? OnrampMainComponentUM.Content ?: return@update prevState
if (contentState.amountBlockState.amountFieldModel.fiatValue.isNotEmpty()) {
amountStateFactory.getAmountSecondaryLoadingState()
} else {
prevState
}
}
fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError,
ifRight = { state.update { amountStateFactory.getAmountSecondaryResetState() } },
)
startLoadingQuotes()
}
private fun handleOnrampError(onrampError: OnrampError) {
Timber.e(onrampError.toString())
sendOnrampErrorAnalytic(onrampError)
state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun startLoadingQuotes() {
quotesTaskScheduler.cancelTask()
quotesTaskScheduler.scheduleTask(scope = modelScope, task = loadQuotesTask())
@ -233,11 +189,12 @@ internal class OnrampMainComponentModel @Inject constructor(
delay = UPDATE_DELAY,
task = {
runSuspendCatching {
val content = state.value as? OnrampMainComponentUM.Content ?: return@runSuspendCatching
val amountBlockState = content.amountBlockState
if (amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero()) {
return@runSuspendCatching
}
val amountBlockState = (state.value as? OnrampMainComponentUM.Content)?.amountBlockState
?: return@runSuspendCatching
val fiatAmount = amountBlockState.amountFieldModel.fiatAmount
if (fiatAmount.value.isNullOrZero()) return@runSuspendCatching
fetchQuotesUseCase.invoke(
userWallet = userWallet,
amount = amountBlockState.amountFieldModel.fiatAmount,
@ -250,6 +207,84 @@ internal class OnrampMainComponentModel @Inject constructor(
)
}
private fun checkResidenceCountry() {
modelScope.launch {
checkOnrampAvailabilityUseCase(userWallet)
.onRight(::handleOnrampAvailability)
.onLeft(::handleOnrampError)
}
}
private fun handleOnrampAvailability(availability: OnrampAvailability) {
when (availability) {
is OnrampAvailability.Available -> Unit
is OnrampAvailability.ConfirmResidency,
is OnrampAvailability.NotSupported,
-> bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.ConfirmResidency(availability.country))
}
}
private fun onCloseClick() {
analyticsEventHandler.send(OnrampAnalyticsEvent.CloseOnramp())
router.pop()
}
private fun subscribeOnOffers() = modelScope.launch {
getOnrampOffersUseCase
.invoke()
.collectLatest { maybeOffers ->
maybeOffers.fold(
ifLeft = ::handleOnrampError,
ifRight = { offers ->
val currentState = state.value
if (currentState is OnrampMainComponentUM.Content) {
if (currentState.amountBlockState.amountFieldModel.fiatValue.isEmpty()) {
state.update {
currentState.copy(offersBlockState = OnrampOffersBlockUM.Empty)
}
return@fold
}
state.update {
onrampOffersStateFactory.getOffersState(offers)
}
}
},
)
}
}
private fun subscribeToAmountChanges() = modelScope.launch {
amountInputManager.query
.filter(String::isNotEmpty)
.collectLatest { _ ->
startLoadingQuotes()
}
}
private fun subscribeToCountryAndCurrencyUpdates() {
getOnrampCountryUseCase.invoke()
.onEach { maybeCountry ->
maybeCountry.fold(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
state.update { prevState ->
when (prevState) {
is OnrampMainComponentUM.Content -> {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampMainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency)
}
}
}
updatePairsAndQuotes()
},
)
}
.launchIn(modelScope)
}
private fun subscribeToQuotesUpdate() {
getOnrampQuotesUseCase.invoke()
.conflate()
@ -262,152 +297,31 @@ internal class OnrampMainComponentModel @Inject constructor(
.launchIn(modelScope)
}
override fun onAmountValueChanged(value: String, isValuePasted: Boolean) {
state.update { amountStateFactory.getOnAmountValueChange(value, isValuePasted) }
modelScope.launch { amountInputManager.update(value) }
}
override fun openSettings() {
params.openSettings()
}
override fun onBuyClick(quote: OnrampProviderWithQuote.Data) {
if (userWallet is UserWallet.Cold && isDemoCardUseCase.invoke(userWallet.cardId)) {
showDemoWarning()
} else {
val currentContentState = state.value as? OnrampMainComponentUM.Content ?: return
analyticsEventHandler.send(
OnrampAnalyticsEvent.OnBuyClick(
providerName = quote.provider.info.name,
currency = currentContentState.amountBlockState.currencyUM.code,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
params.openRedirectPage(quote)
}
}
override fun openCurrenciesList() {
analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened())
bottomSheetNavigation.activate(OnrampMainBottomSheetConfig.CurrenciesList)
}
override fun openProviders() {
val providerState = (state.value as? OnrampMainComponentUM.Content)?.providerBlockState ?: return
val providerContentState = providerState as? OnrampProviderBlockUM.Content ?: return
bottomSheetNavigation.activate(
OnrampMainBottomSheetConfig.ProvidersList(
selectedPaymentMethod = providerContentState.paymentMethod,
selectedProviderId = providerContentState.providerId,
),
)
}
override fun onRefresh() {
state.update {
stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = router::pop,
)
}
quotesTaskScheduler.cancelTask()
modelScope.launch {
clearOnrampCacheUseCase.invoke()
checkResidenceCountry()
}
}
override fun onLinkClick(link: String) = urlOpener.openUrl(link)
override fun onDestroy() {
modelScope.launch { clearOnrampCacheUseCase.invoke() }
quotesTaskScheduler.cancelTask()
modelScope.launch {
if (params.isLaunchSepa) {
currencyToRestore?.let { onrampSaveDefaultCurrencyUseCase.invoke(it) }
}
}
super.onDestroy()
}
private fun onCloseClick() {
analyticsEventHandler.send(OnrampAnalyticsEvent.CloseOnramp())
router.pop()
}
private fun handleQuoteResult(quotes: List<OnrampQuote>) {
sendOnrampQuotesErrorAnalytic(quotes)
val quote = selectOrUpdateQuote(quotes)
if (quote == null) {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
lastUpdateState.value = null
return
}
state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) }
}
/**
* !!! Important quote selection logic !!!
* Selects or updated quote based on input data (amount, country, currency).
* If input data has changed select new best quote, otherwise last selected quote.
* If last selected quote on same input data is in an error state, select next best quote
* If new best quote or next best quote does not exist (i.e. Error state) select nothing.
*/
private fun selectOrUpdateQuote(quotes: List<OnrampQuote>): OnrampQuote? {
val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error }
val bestSepaQuote = if (params.isLaunchSepa && shouldForceChooseSepa) {
quotes.filterIsInstance<OnrampQuote.Data>()
.filter { it.paymentMethod.id == SEPA_METHOD_ID }
.maxByOrNull { it.toAmount.value }
} else {
null
}
// Check if amount, country or currency has changed
val newQuote = bestSepaQuote ?: if (isAmountOrCountryChanged(quoteToCheck)) {
quoteToCheck
} else {
val state = state.value as? OnrampMainComponentUM.Content
val providerState = state?.providerBlockState as? OnrampProviderBlockUM.Content
// Get current selected quote to update
val lastSelectedQuote = quotes.firstOrNull { quote ->
quote.provider.id == providerState?.providerId &&
quote.paymentMethod.id == providerState.paymentMethod.id
when {
quotes.isEmpty() -> {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
// Check if selected updated quote is not error
if (lastSelectedQuote is OnrampQuote.Error) {
quoteToCheck
} else {
lastSelectedQuote
quotes.all { it is OnrampQuote.AmountError } -> {
state.update { amountStateFactory.getSecondaryFieldAmountErrorState(quotes) }
}
quotes.none { it is OnrampQuote.Data } -> {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
else -> {
state.update { prevState ->
val resetState = amountStateFactory.getAmountSecondaryFieldResetState()
if (prevState is OnrampMainComponentUM.Content &&
resetState is OnrampMainComponentUM.Content &&
prevState.offersBlockState is OnrampOffersBlockUM.Loading
) {
resetState.copy(offersBlockState = OnrampOffersBlockUM.Empty)
} else {
resetState
}
}
}
}
if (newQuote != null) {
updateProvider(newQuote, quotes)
}
return newQuote
}
private fun updateProvider(quote: OnrampQuote, quotes: List<OnrampQuote>) {
lastUpdateState.value = OnrampLastUpdate(
quote.fromAmount,
quote.countryCode,
quote.paymentMethod,
)
if (quote.paymentMethod.id != SEPA_METHOD_ID) {
shouldForceChooseSepa = false
}
state.update {
amountStateFactory.getUpdatedProviderState(selectedQuote = quote, quotes = quotes)
}
}
@ -415,41 +329,30 @@ internal class OnrampMainComponentModel @Inject constructor(
state.update { prevState ->
(prevState as? OnrampMainComponentUM.Content)?.copy(
errorNotification = null,
providerBlockState = OnrampProviderBlockUM.Loading,
offersBlockState = OnrampOffersBlockUM.Loading,
amountBlockState = prevState.amountBlockState.copy(
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading,
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
) ?: prevState
}
startLoadingQuotes()
}
private fun showDemoWarning() {
val alertUM = AlertDemoModeUM(onConfirmClick = {})
val message = DialogMessage(
title = alertUM.title,
message = alertUM.message,
firstActionBuilder = {
EventMessageAction(
title = alertUM.confirmButtonText,
onClick = alertUM.onConfirmClick,
)
private suspend fun updatePairsAndQuotes() {
fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError,
ifRight = {
state.update {
amountStateFactory.getAmountSecondaryFieldResetState()
}
startLoadingQuotes()
},
secondActionBuilder = { cancelAction() },
)
messageSender.send(message)
}
private fun sendOnrampErrorAnalytic(error: OnrampError) {
val content = state.value as? OnrampMainComponentUM.Content
val providerContent = content?.providerBlockState as? OnrampProviderBlockUM.Content
analyticsEventHandler.sendOnrampErrorEvent(
error = error,
tokenSymbol = params.cryptoCurrency.symbol,
providerName = providerContent?.providerName,
paymentMethod = providerContent?.paymentMethod?.name,
)
private fun handleOnrampError(onrampError: OnrampError) {
Timber.e(onrampError.toString())
state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
@ -467,20 +370,48 @@ internal class OnrampMainComponentModel @Inject constructor(
providerName = errorState.provider.info.name,
paymentMethod = errorState.paymentMethod.name,
)
else -> { /* no-op */
}
else -> Unit
}
}
}
private fun isAmountOrCountryChanged(quote: OnrampQuote?): Boolean {
return lastUpdateState.value?.fromAmount != quote?.fromAmount ||
lastUpdateState.value?.countryCode != quote?.countryCode
private fun sendScreenOpenAnalytics() {
analyticsEventHandler.send(
OnrampAnalyticsEvent.ScreenOpened(
source = params.source,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
}
private fun sendOfferClickEvent(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
) {
val event = when (categoryUM) {
OnrampOfferCategoryUM.RecentlyUsed -> {
OnrampAnalyticsEvent.RecentlyBuyClicked(
tokenSymbol = params.cryptoCurrency.symbol,
providerName = quote.provider.info.name,
paymentMethod = quote.paymentMethod.name,
)
}
OnrampOfferCategoryUM.Recommended -> {
onrampOfferAdvantagesUM.toAnalyticsEvent(
cryptoCurrencySymbol = params.cryptoCurrency.symbol,
providerName = quote.provider.info.name,
paymentMethodName = quote.paymentMethod.name,
)
}
}
if (event != null) {
analyticsEventHandler.send(event)
}
}
private companion object {
const val UPDATE_DELAY = 10_000L
const val SEPA_METHOD_ID = "sepa"
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.features.onramp.main.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -17,61 +19,89 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.BuyTokenDetailsScreenTestTags
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampAmountBlockUM
import com.tangem.features.onramp.main.entity.OnrampAmountSecondaryFieldUM
import com.tangem.features.onramp.main.entity.OnrampCurrencyUM
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampSecondaryFieldErrorUM
@Composable
internal fun OnrampAmountContent(state: OnrampAmountBlockUM, modifier: Modifier = Modifier) {
internal fun OnrampAmountContent(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.padding(vertical = TangemTheme.dimens.spacing28),
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(size = TangemTheme.dimens.radius16),
)
.padding(vertical = 24.dp, horizontal = 16.dp)
.animateContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OnrampCurrencyIcon(currencyUM = state.currencyUM)
OnrampAmountField(amountField = state.amountFieldModel)
OnrampAmountSecondary(state = state.secondaryFieldModel)
OnrampHeaderTitle()
OnrampAmountField(
amountField = state.amountBlockState.amountFieldModel,
currencyCode = state.amountBlockState.currencyUM.code,
)
AnimatedVisibility(
visible = state.amountBlockState.secondaryFieldModel !is OnrampSecondaryFieldErrorUM.Empty,
) {
if (state.amountBlockState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Error) {
OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel)
}
}
SpacerH(20.dp)
OnrampCurrencyIcon(currencyUM = state.amountBlockState.currencyUM)
}
}
@Composable
private fun OnrampAmountField(amountField: AmountFieldModel) {
private fun OnrampHeaderTitle() {
Text(
text = stringResourceSafe(R.string.onramp_you_will_pay_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: String) {
val decimalFormat = rememberDecimalFormat()
val requester = remember { FocusRequester() }
val symbolColor = if (amountField.fiatValue.isBlank()) {
TangemTheme.colors.text.disabled
} else {
TangemTheme.colors.text.primary1
}
AmountTextField(
value = amountField.fiatValue,
decimals = amountField.fiatAmount.decimals,
visualTransformation = AmountVisualTransformation(
decimals = amountField.fiatAmount.decimals,
symbol = amountField.fiatAmount.currencySymbol,
currencyCode = amountField.fiatAmount.currencySymbol,
symbol = currencyCode,
currencyCode = currencyCode,
decimalFormat = decimalFormat,
symbolColor = symbolColor,
symbolColor = if (amountField.fiatValue.isBlank()) {
TangemTheme.colors.text.disabled
} else {
TangemTheme.colors.text.primary1
},
),
onValueChange = amountField.onValueChange,
keyboardOptions = amountField.keyboardOptions,
keyboardActions = amountField.keyboardActions,
textStyle = TangemTheme.typography.h2.copy(
textStyle = TangemTheme.typography.head.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
@ -82,7 +112,8 @@ private fun OnrampAmountField(amountField: AmountFieldModel) {
modifier = Modifier
.focusRequester(requester)
.padding(
top = TangemTheme.dimens.spacing24,
top = TangemTheme.dimens.spacing8,
bottom = TangemTheme.dimens.spacing4,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)
@ -96,7 +127,7 @@ private fun OnrampAmountField(amountField: AmountFieldModel) {
}
@Composable
private fun OnrampAmountSecondary(state: OnrampAmountSecondaryFieldUM) {
private fun OnrampAmountSecondary(state: OnrampSecondaryFieldErrorUM.Error) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -107,24 +138,12 @@ private fun OnrampAmountSecondary(state: OnrampAmountSecondaryFieldUM) {
),
contentAlignment = Alignment.Center,
) {
when (state) {
is OnrampAmountSecondaryFieldUM.Content -> Text(
text = state.amount.resolveReference(),
style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
is OnrampAmountSecondaryFieldUM.Error -> Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
is OnrampAmountSecondaryFieldUM.Loading -> TextShimmer(
style = TangemTheme.typography.caption2,
modifier = Modifier.width(TangemTheme.dimens.size62),
)
}
Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
}
}
@ -132,20 +151,27 @@ private fun OnrampAmountSecondary(state: OnrampAmountSecondaryFieldUM) {
private fun OnrampCurrencyIcon(currencyUM: OnrampCurrencyUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(14.dp))
.background(TangemTheme.colors.button.secondary)
.clickable(onClick = currencyUM.onClick)
.padding(start = TangemTheme.dimens.spacing24),
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AsyncImage(
modifier = Modifier
.size(TangemTheme.dimens.size40)
.size(20.dp)
.clip(CircleShape)
.testTag(BuyTokenDetailsScreenTestTags.FIAT_CURRENCY_ICON),
model = currencyUM.iconUrl,
contentDescription = null,
)
Text(
text = currencyUM.code,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
)
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size16)

View file

@ -1,104 +0,0 @@
package com.tangem.features.onramp.main.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.extensions.appendColored
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampProviderBlockUM
private const val TERMS_OF_USE_KEY = "termsOfUse"
private const val PRIVACY_POLICY_KEY = "privacyPolicy"
@Composable
internal fun OnrampButtonComponent(state: OnrampMainComponentUM) {
val content = state as? OnrampMainComponentUM.Content
val providerState = content?.providerBlockState as? OnrampProviderBlockUM.Content
Column(
modifier = Modifier
.navigationBarsPadding()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OnrampTosText(providerState)
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(id = R.string.common_buy),
onClick = state.buyButtonConfig.onClick,
enabled = state.buyButtonConfig.isEnabled,
)
}
}
@Composable
private fun OnrampTosText(provider: OnrampProviderBlockUM.Content?) {
val termsOfUse = stringResourceSafe(R.string.common_terms_of_use)
val privacyPolicy = stringResourceSafe(R.string.common_privacy_policy)
val tosText = stringResourceSafe(R.string.onramp_legal, termsOfUse, privacyPolicy)
val clickableAnnotation = buildAnnotatedString {
append(tosText.substringBefore(termsOfUse))
pushStringAnnotation(TERMS_OF_USE_KEY, "")
appendColored(termsOfUse, TangemTheme.colors.text.accent)
pop()
append(tosText.substringAfter(termsOfUse).substringBefore(privacyPolicy))
pushStringAnnotation(PRIVACY_POLICY_KEY, "")
appendColored(privacyPolicy, TangemTheme.colors.text.accent)
pop()
}
AnimatedContent(
targetState = provider,
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
label = "Onramp Legal Info Animation",
) { state ->
val termsOfUseLink = provider?.termsOfUseLink
val privacyPolicyLink = provider?.privacyPolicyLink
if (state != null && termsOfUseLink != null && privacyPolicyLink != null) {
ClickableText(
text = clickableAnnotation,
style = TangemTheme.typography.caption2.copy(
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
),
onClick = { offset ->
val tosAnnotations = clickableAnnotation.getStringAnnotations(
tag = TERMS_OF_USE_KEY,
start = offset,
end = offset,
)
if (tosAnnotations.any()) {
state.onLinkClick(termsOfUseLink)
}
val privacyPolicyAnnotations = clickableAnnotation.getStringAnnotations(
tag = PRIVACY_POLICY_KEY,
start = offset,
end = offset,
)
if (privacyPolicyAnnotations.any()) {
state.onLinkClick(privacyPolicyLink)
}
},
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.mainv2.ui
package com.tangem.features.onramp.main.ui
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
@ -20,13 +20,13 @@ import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUMState
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampOffersBlockUM
@Composable
internal fun BoxScope.OnrampFooterContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
internal fun BoxScope.OnrampFooterContent(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) {
AnimatedVisibility(
modifier = modifier
.imePadding()
@ -53,16 +53,16 @@ internal fun BoxScope.OnrampFooterContent(state: OnrampV2MainComponentUM.Content
}
@Composable
private fun OnrampAmountButtons(state: OnrampV2AmountButtonUMState) {
private fun OnrampAmountButtons(state: OnrampAmountButtonUMState) {
val keyboard by keyboardAsState()
AnimatedVisibility(
visible = state is OnrampV2AmountButtonUMState.Loaded,
visible = state is OnrampAmountButtonUMState.Loaded,
enter = fadeIn(),
exit = fadeOut(),
) {
when (state) {
is OnrampV2AmountButtonUMState.Loaded -> {
is OnrampAmountButtonUMState.Loaded -> {
if (keyboard is Keyboard.Opened) {
LazyRow(
modifier = Modifier.background(color = TangemTheme.colors.button.secondary),
@ -82,7 +82,7 @@ private fun OnrampAmountButtons(state: OnrampV2AmountButtonUMState) {
}
}
}
OnrampV2AmountButtonUMState.None -> Unit
OnrampAmountButtonUMState.None -> Unit
}
}
}

View file

@ -5,13 +5,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import com.tangem.core.ui.components.CircleShimmer
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.notifications.Notification
@ -21,41 +20,58 @@ import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
@Composable
internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.imePadding(),
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
modifier = modifier.systemBarsPadding(),
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
startButton = state.topBarConfig.startButtonUM,
endButton = state.topBarConfig.endButtonUM,
title = state.topBarConfig.title.resolveReference(),
)
},
content = { innerPadding ->
val contentModifier = Modifier
.padding(innerPadding)
.padding(horizontal = TangemTheme.dimens.spacing16)
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
) { scaffoldPaddings ->
OnrampMainComponentContent(
state = state,
modifier = Modifier.padding(scaffoldPaddings),
)
}
}
@Composable
internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is OnrampMainComponentUM.InitialLoading -> InitialLoading(modifier = contentModifier, state = state)
is OnrampMainComponentUM.Content -> Content(modifier = contentModifier, state = state)
is OnrampMainComponentUM.InitialLoading -> InitialLoading(state = state)
is OnrampMainComponentUM.Content -> Content(state = state)
}
},
floatingActionButton = {
OnrampButtonComponent(state)
},
floatingActionButtonPosition = FabPosition.Center,
)
}
if (state is OnrampMainComponentUM.Content) {
OnrampFooterContent(state = state)
}
}
}
@Composable
private fun InitialLoading(state: OnrampMainComponentUM.InitialLoading, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContentLoading()
@ -64,27 +80,38 @@ private fun InitialLoading(state: OnrampMainComponentUM.InitialLoading, modifier
}
@Composable
private fun OnrampAmountContentLoading(modifier: Modifier = Modifier) {
private fun OnrampAmountContentLoading() {
Column(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.padding(vertical = TangemTheme.dimens.spacing28),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircleShimmer(modifier = Modifier.size(TangemTheme.dimens.size40))
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.size(width = TangemTheme.dimens.size96, height = TangemTheme.dimens.size24),
radius = TangemTheme.dimens.radius3,
.size(width = 76.dp, height = 20.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.size(width = TangemTheme.dimens.size72, height = TangemTheme.dimens.size12),
radius = TangemTheme.dimens.radius3,
.padding(top = TangemTheme.dimens.spacing12)
.size(width = 136.dp, height = 44.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8)
.size(width = 52.dp, height = 16.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing20)
.size(width = 84.dp, height = 28.dp),
radius = TangemTheme.dimens.radius14,
)
}
}
@ -93,13 +120,20 @@ private fun OnrampAmountContentLoading(modifier: Modifier = Modifier) {
private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.wrapContentHeight()
.navigationBarsPadding()
.padding(bottom = TangemTheme.dimens.spacing76),
.padding(
bottom = 76.dp,
start = 16.dp,
end = 16.dp,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContent(state = state.amountBlockState)
OnrampProviderContent(state = state.providerBlockState, modifier = Modifier.fillMaxWidth())
OnrampAmountContent(state = state)
OnrampOffersContent(state = state.offersBlockState)
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.mainv2.ui
package com.tangem.features.onramp.main.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
@ -33,7 +33,7 @@ import com.tangem.core.ui.test.OnrampOffersBlockTestTags
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.main.entity.*
import kotlinx.collections.immutable.persistentListOf
@Composable

View file

@ -1,128 +0,0 @@
package com.tangem.features.onramp.main.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.CircularProgressIndicator
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.clip
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.tangem.core.ui.extensions.appendSpace
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampProviderBlockUM
import com.tangem.features.onramp.paymentmethod.ui.PaymentMethodIcon
@Composable
internal fun OnrampProviderContent(state: OnrampProviderBlockUM, modifier: Modifier = Modifier) {
when (state) {
is OnrampProviderBlockUM.Empty -> Unit
is OnrampProviderBlockUM.Loading -> OnrampProviderLoading(modifier)
is OnrampProviderBlockUM.Content -> OnrampProviderBlock(modifier = modifier, state = state)
}
}
@Composable
private fun OnrampProviderBlock(state: OnrampProviderBlockUM.Content, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.clickable(onClick = state.onClick)
.padding(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
PaymentMethodIcon(imageUrl = state.paymentMethod.imageUrl)
Column(modifier = Modifier.weight(1F)) {
Text(
text = buildAnnotatedString {
append(stringResourceSafe(id = R.string.onramp_pay_with))
appendSpace()
withStyle(
style = SpanStyle(
fontWeight = TangemTheme.typography.subtitle2.fontWeight,
color = TangemTheme.colors.text.primary1,
),
) {
append(state.paymentMethod.name)
}
},
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
)
Text(
text = buildAnnotatedString {
append(stringResourceSafe(id = R.string.onramp_via))
appendSpace()
append(state.providerName)
},
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
AnimatedVisibility(
visible = state.isBestRate,
enter = fadeIn(),
exit = fadeOut(),
label = "Best Rate visibility animation",
) {
Text(
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent,
shape = RoundedCornerShape(TangemTheme.dimens.radius4),
)
.padding(
horizontal = TangemTheme.dimens.spacing6,
vertical = TangemTheme.dimens.spacing1,
),
text = stringResourceSafe(id = R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary2,
)
}
}
}
@Composable
private fun OnrampProviderLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
Text(
text = stringResourceSafe(id = R.string.express_provider),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
CircularProgressIndicator(
color = TangemTheme.colors.icon.informative,
strokeWidth = TangemTheme.dimens.size2,
modifier = Modifier.size(TangemTheme.dimens.size16),
)
Text(
text = stringResourceSafe(id = R.string.express_fetch_best_rates),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}

View file

@ -1,100 +0,0 @@
package com.tangem.features.onramp.mainv2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
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.decompose.ComposableBottomSheetComponent
import com.tangem.features.onramp.alloffers.AllOffersComponent
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainBottomSheetConfig
import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel
import com.tangem.features.onramp.mainv2.ui.OnrampNewMainScreen
import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnrampV2MainComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: OnrampV2MainComponent.Params,
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val allOffersComponentFactory: AllOffersComponent.Factory,
) : OnrampV2MainComponent, AppComponentContext by appComponentContext {
private val model: OnrampV2MainComponentModel = getOrCreateModel(params)
init {
lifecycle.subscribe(onStop = model::onStop)
}
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsState()
val bottomSheet by bottomSheetSlot.subscribeAsState()
OnrampNewMainScreen(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: OnrampV2MainBottomSheetConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is OnrampV2MainBottomSheetConfig.ConfirmResidency -> confirmResidencyComponentFactory.create(
context = childByContext(componentContext),
params = ConfirmResidencyComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
country = config.country,
isLaunchSepa = false,
onDismiss = {
model.bottomSheetNavigation.dismiss()
model.handleOnrampAvailable()
},
),
)
is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create(
context = childByContext(componentContext),
params = SelectCurrencyComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
is OnrampV2MainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create(
context = childByContext(componentContext),
params = AllOffersComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
openRedirectPage = params.openRedirectPage,
amountCurrencyCode = config.amountCurrencyCode,
),
)
}
@AssistedFactory
interface Factory : OnrampV2MainComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnrampV2MainComponent.Params,
): DefaultOnrampV2MainComponent
}
}

View file

@ -1,10 +0,0 @@
package com.tangem.features.onramp.mainv2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
class DefaultOnrampV2MainFeatureToggle(
private val featureTogglesManager: FeatureTogglesManager,
) : OnrampV2MainFeatureToggle {
override val isOnrampNewMainEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_MAIN_ENABLED")
}

View file

@ -1,21 +0,0 @@
package com.tangem.features.onramp.mainv2
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampSource
internal interface OnrampV2MainComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
)
interface Factory : ComponentFactory<Params, OnrampV2MainComponent>
}

View file

@ -1,5 +0,0 @@
package com.tangem.features.onramp.mainv2
internal interface OnrampV2MainFeatureToggle {
val isOnrampNewMainEnabled: Boolean
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.onramp.mainv2.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface OnrampMainV2ComponentModelModule {
@Binds
@IntoMap
@ClassKey(OnrampV2MainComponentModel::class)
fun bindOnrampV2MainComponentModel(model: OnrampV2MainComponentModel): Model
}

View file

@ -1,33 +0,0 @@
package com.tangem.features.onramp.mainv2.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainComponent
import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainFeatureToggle
import com.tangem.features.onramp.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.OnrampV2MainFeatureToggle
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface OnrampNewMainComponentModule {
@Binds
@Singleton
fun bindOnrampV2MainComponentFactory(factory: DefaultOnrampV2MainComponent.Factory): OnrampV2MainComponent.Factory
}
@Module
@InstallIn(SingletonComponent::class)
internal object FeatureToggleModule {
@Provides
@Singleton
fun provideOnrampV2MainFeatureToggle(featureTogglesManager: FeatureTogglesManager): OnrampV2MainFeatureToggle {
return DefaultOnrampV2MainFeatureToggle(featureTogglesManager = featureTogglesManager)
}
}

View file

@ -1,16 +0,0 @@
package com.tangem.features.onramp.mainv2.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
internal interface OnrampV2Intents {
fun onAmountValueChanged(value: String)
fun openSettings()
fun openCurrenciesList()
fun onBuyClick(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
)
fun openProviders()
fun onRefresh()
}

View file

@ -1,16 +0,0 @@
package com.tangem.features.onramp.mainv2.entity
import com.tangem.domain.onramp.model.OnrampCountry
import kotlinx.serialization.Serializable
@Serializable
sealed interface OnrampV2MainBottomSheetConfig {
@Serializable
data class ConfirmResidency(val country: OnrampCountry) : OnrampV2MainBottomSheetConfig
@Serializable
data object CurrenciesList : OnrampV2MainBottomSheetConfig
@Serializable
data class AllOffers(val amountCurrencyCode: String) : OnrampV2MainBottomSheetConfig
}

View file

@ -1,32 +0,0 @@
package com.tangem.features.onramp.mainv2.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed interface OnrampV2MainComponentUM {
val topBarConfig: OnrampV2MainTopBarUM
val errorNotification: NotificationUM?
data class InitialLoading(
override val topBarConfig: OnrampV2MainTopBarUM,
override val errorNotification: NotificationUM?,
) : OnrampV2MainComponentUM
data class Content(
override val topBarConfig: OnrampV2MainTopBarUM,
override val errorNotification: NotificationUM?,
val amountBlockState: OnrampNewAmountBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampV2AmountButtonUMState,
) : OnrampV2MainComponentUM
}
internal data class OnrampV2MainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,
)

View file

@ -1,15 +0,0 @@
package com.tangem.features.onramp.mainv2.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
sealed interface OnrampV2ProvidersUM {
data object Empty : OnrampV2ProvidersUM
data object Loading : OnrampV2ProvidersUM
data class Content(
val providerId: String,
val paymentMethod: OnrampPaymentMethod,
) : OnrampV2ProvidersUM
}

View file

@ -1,177 +0,0 @@
package com.tangem.features.onramp.mainv2.entity.factory
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.tokens.model.convertToAmount
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.utils.Provider
import java.math.BigDecimal
internal class OnrampV2StateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val cryptoCurrency: CryptoCurrency,
private val onrampIntents: OnrampV2Intents,
) {
fun getInitialState(
currency: String,
onClose: () -> Unit,
openSettings: () -> Unit,
): OnrampV2MainComponentUM.InitialLoading {
return OnrampV2MainComponentUM.InitialLoading(
errorNotification = null,
topBarConfig = OnrampV2MainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
startButtonUM = TopAppBarButtonUM.Close(
onCloseClick = onClose,
enabled = true,
),
endButtonUM = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_more_vertical_24,
onClicked = openSettings,
isEnabled = false,
),
),
)
}
fun getReadyState(currency: OnrampCurrency): OnrampV2MainComponentUM.Content {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
is TopAppBarButtonUM.Icon -> button.copy(isEnabled = true)
is TopAppBarButtonUM.Text -> button.copy(isEnabled = true)
}
val initialAmountBlockState = getInitialAmountBlockState(currency)
return OnrampV2MainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
amountBlockState = initialAmountBlockState,
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = null,
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencyCode = currency.code,
currencySymbol = currency.unit,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
)
}
fun getOnrampErrorState(onrampError: OnrampError): OnrampV2MainComponentUM {
return when (onrampError) {
OnrampError.PairsNotFound -> getNoPairsErrorState()
is OnrampError.DataError -> getErrorState(
errorCode = onrampError.code,
onRefresh = onrampIntents::onRefresh,
)
is OnrampError.DomainError -> getErrorState(onRefresh = onrampIntents::onRefresh)
is OnrampError.AmountError.TooBigError,
is OnrampError.AmountError.TooSmallError,
OnrampError.RedirectError.VerificationFailed,
OnrampError.RedirectError.WrongRequestId,
OnrampError.AlreadyHandledTransaction,
-> currentStateProvider() // ignore error state
}
}
fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampV2MainComponentUM {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
is TopAppBarButtonUM.Icon -> button.copy(isEnabled = true)
is TopAppBarButtonUM.Text -> button.copy(isEnabled = true)
}
return when (state) {
is OnrampV2MainComponentUM.Content -> state.copy(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
)
is OnrampV2MainComponentUM.InitialLoading -> state.copy(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
)
}
}
private fun getNoPairsErrorState(): OnrampV2MainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampV2MainComponentUM.Content ?: return state
return contentState.copy(
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}
private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampNewAmountBlockUM {
return OnrampNewAmountBlockUM(
currencyUM = OnrampNewCurrencyUM(
code = currency.code,
iconUrl = currency.image,
precision = currency.precision,
onClick = onrampIntents::openCurrenciesList,
unit = currency.unit,
),
amountFieldModel = AmountFieldModel(
value = "",
fiatValue = "",
onValueChange = onrampIntents::onAmountValueChanged,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
isFiatValue = true,
cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency),
fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency),
isError = false,
isWarning = false,
error = TextReference.EMPTY,
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
)
}
private fun BigDecimal.convertToFiatAmount(currency: OnrampCurrency): Amount = Amount(
currencySymbol = currency.unit,
value = this,
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
)
}

View file

@ -1,417 +0,0 @@
package com.tangem.features.onramp.mainv2.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.fields.InputManager
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.features.onramp.mainv2.entity.factory.OnrampOffersStateFactory
import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2AmountStateFactory
import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2StateFactory
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.PeriodicTask
import com.tangem.utils.coroutines.SingleTaskScheduler
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
internal class OnrampV2MainComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val router: Router,
private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase,
private val getOnrampCountryUseCase: GetOnrampCountryUseCase,
private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase,
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
private val amountInputManager: InputManager,
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampV2Intents {
val params = paramsContainer.require<OnrampV2MainComponent.Params>()
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountButtonUMStateFactory()
}
@Suppress("PropertyUsedBeforeDeclaration")
private val stateFactory: OnrampV2StateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampV2StateFactory(
currentStateProvider = Provider { state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
val state: StateFlow<OnrampV2MainComponentUM>
field = MutableStateFlow<OnrampV2MainComponentUM>(
value = stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = ::onCloseClick,
openSettings = ::openSettings,
),
)
private val amountStateFactory: OnrampV2AmountStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampV2AmountStateFactory(
currentStateProvider = Provider { state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
}
private val onrampOffersStateFactory: OnrampOffersStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampOffersStateFactory(
currentStateProvider = Provider { state.value },
onrampIntents = this,
)
}
private val quotesTaskScheduler = SingleTaskScheduler<Unit>()
val bottomSheetNavigation: SlotNavigation<OnrampV2MainBottomSheetConfig> = SlotNavigation()
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
init {
modelScope.launch {
clearOnrampCacheUseCase()
}
startLoadingQuotes()
sendScreenOpenAnalytics()
checkResidenceCountry()
subscribeToAmountChanges()
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
subscribeOnOffers()
}
override fun onDestroy() {
modelScope.launch { clearOnrampCacheUseCase.invoke() }
quotesTaskScheduler.cancelTask()
super.onDestroy()
}
override fun onAmountValueChanged(value: String) {
state.update { amountStateFactory.getOnAmountValueChange(value) }
modelScope.launch { amountInputManager.update(value) }
}
override fun openSettings() {
params.openSettings.invoke()
}
override fun openCurrenciesList() {
analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened())
bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.CurrenciesList)
}
override fun onBuyClick(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
) {
val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return
analyticsEventHandler.send(
OnrampAnalyticsEvent.OnBuyClick(
providerName = quote.provider.info.name,
currency = currentContentState.amountBlockState.currencyUM.code,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
sendOfferClickEvent(
quote = quote,
onrampOfferAdvantagesUM = onrampOfferAdvantagesUM,
categoryUM = categoryUM,
)
params.openRedirectPage(quote)
}
override fun openProviders() {
val currentContentState = state.value as? OnrampV2MainComponentUM.Content ?: return
val amountCurrentCode = currentContentState.amountBlockState.currencyUM.code
bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.AllOffers(amountCurrentCode))
}
override fun onRefresh() {
state.update {
stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = router::pop,
openSettings = ::openSettings,
)
}
modelScope.launch {
clearOnrampCacheUseCase.invoke()
checkResidenceCountry()
handleOnrampAvailable()
}
}
fun onStop() {
quotesTaskScheduler.cancelTask()
}
fun handleOnrampAvailable() {
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
}
private fun startLoadingQuotes() {
quotesTaskScheduler.cancelTask()
quotesTaskScheduler.scheduleTask(scope = modelScope, task = loadQuotesTask())
}
private fun loadQuotesTask(): PeriodicTask<Unit> {
return PeriodicTask(
delay = UPDATE_DELAY,
task = {
runSuspendCatching {
val amountBlockState = (state.value as? OnrampV2MainComponentUM.Content)?.amountBlockState
?: return@runSuspendCatching
val fiatAmount = amountBlockState.amountFieldModel.fiatAmount
if (fiatAmount.value.isNullOrZero()) return@runSuspendCatching
fetchQuotesUseCase.invoke(
userWallet = userWallet,
amount = amountBlockState.amountFieldModel.fiatAmount,
cryptoCurrency = params.cryptoCurrency,
).onLeft(::handleOnrampError)
}
},
onSuccess = {},
onError = {},
)
}
private fun checkResidenceCountry() {
modelScope.launch {
checkOnrampAvailabilityUseCase(userWallet)
.onRight(::handleOnrampAvailability)
.onLeft(::handleOnrampError)
}
}
private fun handleOnrampAvailability(availability: OnrampAvailability) {
when (availability) {
is OnrampAvailability.Available -> Unit
is OnrampAvailability.ConfirmResidency,
is OnrampAvailability.NotSupported,
-> bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.ConfirmResidency(availability.country))
}
}
private fun onCloseClick() {
analyticsEventHandler.send(OnrampAnalyticsEvent.CloseOnramp())
router.pop()
}
private fun subscribeOnOffers() = modelScope.launch {
getOnrampOffersUseCase
.invoke()
.collectLatest { maybeOffers ->
maybeOffers.fold(
ifLeft = ::handleOnrampError,
ifRight = { offers ->
val currentState = state.value
if (currentState is OnrampV2MainComponentUM.Content) {
if (currentState.amountBlockState.amountFieldModel.fiatValue.isEmpty()) {
state.update {
currentState.copy(offersBlockState = OnrampOffersBlockUM.Empty)
}
return@fold
}
state.update {
onrampOffersStateFactory.getOffersState(offers)
}
}
},
)
}
}
private fun subscribeToAmountChanges() = modelScope.launch {
amountInputManager.query
.filter(String::isNotEmpty)
.collectLatest { _ ->
startLoadingQuotes()
}
}
private fun subscribeToCountryAndCurrencyUpdates() {
getOnrampCountryUseCase.invoke()
.onEach { maybeCountry ->
maybeCountry.fold(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
state.update { prevState ->
when (prevState) {
is OnrampV2MainComponentUM.Content -> {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampV2MainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency)
}
}
}
updatePairsAndQuotes()
},
)
}
.launchIn(modelScope)
}
private fun subscribeToQuotesUpdate() {
getOnrampQuotesUseCase.invoke()
.conflate()
.onEach { maybeQuotes ->
maybeQuotes.fold(
ifLeft = ::handleOnrampError,
ifRight = ::handleQuoteResult,
)
}
.launchIn(modelScope)
}
private fun handleQuoteResult(quotes: List<OnrampQuote>) {
sendOnrampQuotesErrorAnalytic(quotes)
when {
quotes.isEmpty() -> {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
quotes.all { it is OnrampQuote.AmountError } -> {
state.update { amountStateFactory.getSecondaryFieldAmountErrorState(quotes) }
}
quotes.none { it is OnrampQuote.Data } -> {
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
else -> {
state.update { prevState ->
val resetState = amountStateFactory.getAmountSecondaryFieldResetState()
if (prevState is OnrampV2MainComponentUM.Content &&
resetState is OnrampV2MainComponentUM.Content &&
prevState.offersBlockState is OnrampOffersBlockUM.Loading
) {
resetState.copy(offersBlockState = OnrampOffersBlockUM.Empty)
} else {
resetState
}
}
}
}
}
private fun onRetryQuotes() {
state.update { prevState ->
(prevState as? OnrampV2MainComponentUM.Content)?.copy(
errorNotification = null,
offersBlockState = OnrampOffersBlockUM.Loading,
amountBlockState = prevState.amountBlockState.copy(
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
) ?: prevState
}
startLoadingQuotes()
}
private suspend fun updatePairsAndQuotes() {
fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError,
ifRight = {
state.update {
amountStateFactory.getAmountSecondaryFieldResetState()
}
startLoadingQuotes()
},
)
}
private fun handleOnrampError(onrampError: OnrampError) {
Timber.e(onrampError.toString())
state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
quotes.forEach { errorState ->
when (errorState) {
is OnrampQuote.Error -> analyticsEventHandler.sendOnrampErrorEvent(
error = errorState.error,
tokenSymbol = params.cryptoCurrency.symbol,
providerName = errorState.provider.info.name,
paymentMethod = errorState.paymentMethod.name,
)
is OnrampQuote.AmountError -> analyticsEventHandler.sendOnrampErrorEvent(
error = errorState.error,
tokenSymbol = params.cryptoCurrency.symbol,
providerName = errorState.provider.info.name,
paymentMethod = errorState.paymentMethod.name,
)
else -> Unit
}
}
}
private fun sendScreenOpenAnalytics() {
analyticsEventHandler.send(
OnrampAnalyticsEvent.ScreenOpened(
source = params.source,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
}
private fun sendOfferClickEvent(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
) {
val event = when (categoryUM) {
OnrampOfferCategoryUM.RecentlyUsed -> {
OnrampAnalyticsEvent.RecentlyBuyClicked(
tokenSymbol = params.cryptoCurrency.symbol,
providerName = quote.provider.info.name,
paymentMethod = quote.paymentMethod.name,
)
}
OnrampOfferCategoryUM.Recommended -> {
onrampOfferAdvantagesUM.toAnalyticsEvent(
cryptoCurrencySymbol = params.cryptoCurrency.symbol,
providerName = quote.provider.info.name,
paymentMethodName = quote.paymentMethod.name,
)
}
}
if (event != null) {
analyticsEventHandler.send(event)
}
}
private companion object {
const val UPDATE_DELAY = 10_000L
}
}

View file

@ -1,139 +0,0 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampNewMainScreen(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.systemBarsPadding(),
topBar = {
TangemTopAppBar(
startButton = state.topBarConfig.startButtonUM,
endButton = state.topBarConfig.endButtonUM,
title = state.topBarConfig.title.resolveReference(),
)
},
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
) { scaffoldPaddings ->
OnrampNewMainComponentContent(
state = state,
modifier = Modifier.padding(scaffoldPaddings),
)
}
}
@Composable
internal fun OnrampNewMainComponentContent(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is OnrampV2MainComponentUM.InitialLoading -> InitialLoading(state = state)
is OnrampV2MainComponentUM.Content -> Content(state = state)
}
}
if (state is OnrampV2MainComponentUM.Content) {
OnrampFooterContent(state = state)
}
}
}
@Composable
private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContentLoading()
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
}
}
@Composable
private fun OnrampAmountContentLoading() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.padding(vertical = TangemTheme.dimens.spacing28),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.size(width = 76.dp, height = 20.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12)
.size(width = 136.dp, height = 44.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8)
.size(width = 52.dp, height = 16.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing20)
.size(width = 84.dp, height = 28.dp),
radius = TangemTheme.dimens.radius14,
)
}
}
@Composable
private fun Content(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.navigationBarsPadding()
.padding(
bottom = 76.dp,
start = 16.dp,
end = 16.dp,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampV2AmountContent(state = state)
OnrampOffersContent(state = state.offersBlockState)
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
}
}

View file

@ -1,184 +0,0 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.BuyTokenDetailsScreenTestTags
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampNewCurrencyUM
import com.tangem.features.onramp.mainv2.entity.OnrampSecondaryFieldErrorUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(size = TangemTheme.dimens.radius16),
)
.padding(vertical = 24.dp, horizontal = 16.dp)
.animateContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OnrampHeaderTitle()
OnrampAmountField(
amountField = state.amountBlockState.amountFieldModel,
currencyCode = state.amountBlockState.currencyUM.code,
)
AnimatedVisibility(
visible = state.amountBlockState.secondaryFieldModel !is OnrampSecondaryFieldErrorUM.Empty,
) {
if (state.amountBlockState.secondaryFieldModel is OnrampSecondaryFieldErrorUM.Error) {
OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel)
}
}
SpacerH(20.dp)
OnrampCurrencyIcon(currencyUM = state.amountBlockState.currencyUM)
}
}
@Composable
private fun OnrampHeaderTitle() {
Text(
text = stringResourceSafe(R.string.onramp_you_will_pay_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: String) {
val decimalFormat = rememberDecimalFormat()
val requester = remember { FocusRequester() }
AmountTextField(
value = amountField.fiatValue,
decimals = amountField.fiatAmount.decimals,
visualTransformation = AmountVisualTransformation(
decimals = amountField.fiatAmount.decimals,
symbol = currencyCode,
currencyCode = currencyCode,
decimalFormat = decimalFormat,
symbolColor = if (amountField.fiatValue.isBlank()) {
TangemTheme.colors.text.disabled
} else {
TangemTheme.colors.text.primary1
},
),
onValueChange = amountField.onValueChange,
keyboardOptions = amountField.keyboardOptions,
keyboardActions = amountField.keyboardActions,
textStyle = TangemTheme.typography.head.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
isEnabled = !amountField.isError,
isAutoResize = true,
isValuePasted = amountField.isValuePasted,
onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss,
modifier = Modifier
.focusRequester(requester)
.padding(
top = TangemTheme.dimens.spacing8,
bottom = TangemTheme.dimens.spacing4,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)
.requiredHeightIn(min = TangemTheme.dimens.size32)
.testTag(BuyTokenDetailsScreenTestTags.FIAT_AMOUNT_TEXT_FIELD),
)
LaunchedEffect(key1 = Unit) {
requester.requestFocus()
}
}
@Composable
private fun OnrampAmountSecondary(state: OnrampSecondaryFieldErrorUM.Error) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
contentAlignment = Alignment.Center,
) {
Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun OnrampCurrencyIcon(currencyUM: OnrampNewCurrencyUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(14.dp))
.background(TangemTheme.colors.button.secondary)
.clickable(onClick = currencyUM.onClick)
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AsyncImage(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.testTag(BuyTokenDetailsScreenTestTags.FIAT_CURRENCY_ICON),
model = currencyUM.iconUrl,
contentDescription = null,
)
Text(
text = currencyUM.code,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
)
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.testTag(BuyTokenDetailsScreenTestTags.EXPAND_FIAT_LIST_BUTTON),
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}

View file

@ -17,8 +17,6 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.onramp.component.OnrampComponent
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.OnrampV2MainFeatureToggle
import com.tangem.features.onramp.redirect.OnrampRedirectComponent
import com.tangem.features.onramp.root.entity.OnrampChild
import com.tangem.features.onramp.settings.OnrampSettingsComponent
@ -32,9 +30,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
@Assisted private val params: OnrampComponent.Params,
private val settingsComponentFactory: OnrampSettingsComponent.Factory,
private val onrampMainComponentFactory: OnrampMainComponent.Factory,
private val onrampMainV2ComponentFactory: OnrampV2MainComponent.Factory,
private val onrampRedirectComponentFactory: OnrampRedirectComponent.Factory,
private val onrampV2MainFeatureToggle: OnrampV2MainFeatureToggle,
) : OnrampComponent, AppComponentContext by context {
private val navigation = StackNavigation<OnrampChild>()
@ -71,44 +67,23 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
onBack = navigation::pop,
),
)
OnrampChild.Main -> if (onrampV2MainFeatureToggle.isOnrampNewMainEnabled) {
onrampMainV2ComponentFactory.create(
context = childByContext(componentContext),
params = OnrampV2MainComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
openSettings = { navigation.push(OnrampChild.Settings) },
source = params.source,
openRedirectPage = { quote ->
navigation.push(
OnrampChild.RedirectPage(
quote = quote,
cryptoCurrency = params.cryptoCurrency,
),
)
},
),
)
} else {
onrampMainComponentFactory.create(
context = childByContext(componentContext),
params = OnrampMainComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
openSettings = { navigation.push(OnrampChild.Settings) },
source = params.source,
openRedirectPage = { onrampProviderWithQuoteData ->
navigation.push(
OnrampChild.RedirectPage(
quote = onrampProviderWithQuoteData,
cryptoCurrency = params.cryptoCurrency,
),
)
},
isLaunchSepa = params.shouldLaunchSepa,
),
)
}
OnrampChild.Main -> onrampMainComponentFactory.create(
context = childByContext(componentContext),
params = OnrampMainComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
openSettings = { navigation.push(OnrampChild.Settings) },
source = params.source,
openRedirectPage = { quote ->
navigation.push(
OnrampChild.RedirectPage(
quote = quote,
cryptoCurrency = params.cryptoCurrency,
),
)
},
),
)
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(
context = childByContext(componentContext),
params = OnrampRedirectComponent.Params(

View file

@ -7,10 +7,12 @@ import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -19,9 +21,8 @@ import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.impl.R
@ -43,7 +44,8 @@ internal class OnrampOperationModel @Inject constructor(
private val router: AppRouter,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val reduxStateHolder: ReduxStateHolder,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
private val rampStateManager: RampStateManager,
@ -119,9 +121,13 @@ internal class OnrampOperationModel @Inject constructor(
val appCurrencyCode = getSelectedAppCurrencyUseCase.invokeSync()
.getOrElse { AppCurrency.Default }.code
reduxStateHolder.dispatch(
action = TradeCryptoAction.Sell(status, appCurrencyCode),
)
getOfframpUrlUseCase(
cryptoCurrencyStatus = status,
appCurrencyCode = appCurrencyCode,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
}
}

View file

@ -1,5 +0,0 @@
package com.tangem.features.tangempay
interface TangemPayFeatureToggles {
val isTangemPayEnabled: Boolean
}

View file

@ -1,10 +0,0 @@
package com.tangem.features.tangempay
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
internal class DefaultTangemPayFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : TangemPayFeatureToggles {
override val isTangemPayEnabled
get() = featureTogglesManager.isFeatureEnabled("TANGEM_PAY_ENABLED")
}

View file

@ -1,21 +0,0 @@
package com.tangem.features.tangempay.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.tangempay.DefaultTangemPayFeatureToggles
import com.tangem.features.tangempay.TangemPayFeatureToggles
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 TangemPayDetailsModule {
@Provides
@Singleton
fun provideTangemPayFeatureToggles(featureTogglesManager: FeatureTogglesManager): TangemPayFeatureToggles {
return DefaultTangemPayFeatureToggles(featureTogglesManager)
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.features.tangempay.deeplink
import android.net.Uri
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.tangempay.TangemPayFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -11,18 +10,13 @@ import dagger.assisted.AssistedInject
internal class DefaultOnboardVisaDeepLinkHandler @AssistedInject constructor(
@Assisted uri: Uri,
appRouter: AppRouter,
tangemPayFeatureToggles: TangemPayFeatureToggles,
) : OnboardVisaDeepLinkHandler {
init {
if (tangemPayFeatureToggles.isTangemPayEnabled) {
val mode = AppRoute.TangemPayOnboarding.Mode.Deeplink(
deeplink = uri.toString(),
)
appRouter.push(AppRoute.TangemPayOnboarding(mode))
} else {
appRouter.push(AppRoute.Home())
}
val mode = AppRoute.TangemPayOnboarding.Mode.Deeplink(
deeplink = uri.toString(),
)
appRouter.push(AppRoute.TangemPayOnboarding(mode))
}
@AssistedFactory

View file

@ -69,10 +69,10 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.legacy)
implementation(projects.domain.markets.models)
implementation(projects.domain.models)
implementation(projects.domain.notifications.models)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.promo)

View file

@ -176,7 +176,6 @@ internal object TokenDetailsPreviewData {
bottomSheetConfig = null,
isBalanceHidden = false,
isMarketPriceAvailable = false,
isYieldSupplyFeatureEnabled = false,
)
val tokenDetailsState_2 = TokenDetailsState(
@ -202,7 +201,6 @@ internal object TokenDetailsPreviewData {
bottomSheetConfig = null,
isBalanceHidden = false,
isMarketPriceAvailable = true,
isYieldSupplyFeatureEnabled = true,
)
val tokenDetailsState_3 = tokenDetailsState_2.copy(stakingBlocksState = stakingBalanceBlock)

View file

@ -18,11 +18,13 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
@ -47,15 +49,14 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.promo.ShouldShowPromoTokenUseCase
import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.staking.GetStakingAvailabilityUseCase
import com.tangem.domain.staking.GetStakingEntryInfoUseCase
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent
@ -91,7 +92,6 @@ import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokendetails.impl.R
import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter
import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
@ -130,7 +130,8 @@ internal class TokenDetailsModel @Inject constructor(
private val retryIncompleteTransactionUseCase: RetryIncompleteTransactionUseCase,
private val openTrustlineUseCase: OpenTrustlineUseCase,
private val dismissIncompleteTransactionUseCase: DismissIncompleteTransactionUseCase,
private val reduxStateHolder: ReduxStateHolder,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val vibratorHapticManager: VibratorHapticManager,
private val clipboardManager: ClipboardManager,
@ -144,7 +145,6 @@ internal class TokenDetailsModel @Inject constructor(
private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val receiveAddressesFactory: ReceiveAddressesFactory,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase,
private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase,
private val needShowYieldSupplyDepositedWarningUseCase: NeedShowYieldSupplyDepositedWarningUseCase,
@ -193,7 +193,6 @@ internal class TokenDetailsModel @Inject constructor(
networkHasDerivationUseCase = networkHasDerivationUseCase,
getUserWalletUseCase = getUserWalletUseCase,
userWalletId = userWalletId,
yieldSupplyFeatureToggles = yieldSupplyFeatureToggles,
)
private val internalUiState = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency))
@ -423,9 +422,7 @@ internal class TokenDetailsModel @Inject constructor(
}
private fun subscribeOnYieldSupplyBalanceIfActive(status: CryptoCurrencyStatus) {
if (yieldSupplyFeatureToggles.isYieldSupplyFeatureEnabled &&
status.value.yieldSupplyStatus?.isActive == true
) {
if (status.value.yieldSupplyStatus?.isActive == true) {
if (yieldSupplyBalanceJobHolder.isActive && status.value.sources.networkSource != StatusSource.ACTUAL) {
return
}
@ -709,12 +706,13 @@ internal class TokenDetailsModel @Inject constructor(
showErrorIfDemoModeOrElse {
val status = cryptoCurrencyStatus ?: return@showErrorIfDemoModeOrElse
reduxStateHolder.dispatch(
TradeCryptoAction.Sell(
cryptoCurrencyStatus = status,
appCurrencyCode = selectedAppCurrencyFlow.value.code,
),
)
getOfframpUrlUseCase(
cryptoCurrencyStatus = status,
appCurrencyCode = selectedAppCurrencyFlow.value.code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventsHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
}
@ -1235,13 +1233,11 @@ internal class TokenDetailsModel @Inject constructor(
}
private suspend fun needShowYieldSupplyWarning(): Boolean {
return yieldSupplyFeatureToggles.isYieldSupplyFeatureEnabled &&
needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)
return needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)
}
private fun isActiveYieldSupply(): Boolean {
return yieldSupplyFeatureToggles.isYieldSupplyFeatureEnabled &&
cryptoCurrencyStatus?.value?.yieldSupplyStatus?.isActive == true
return cryptoCurrencyStatus?.value?.yieldSupplyStatus?.isActive == true
}
override fun onYieldSupplyWarningAcknowledged(tokenAction: TokenAction) {

View file

@ -23,5 +23,4 @@ internal data class TokenDetailsState(
val bottomSheetConfig: TangemBottomSheetConfig?,
val isBalanceHidden: Boolean,
val isMarketPriceAvailable: Boolean,
val isYieldSupplyFeatureEnabled: Boolean,
)

View file

@ -19,7 +19,6 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsYieldSupplyState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.getBalance
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.converter.Converter
@ -31,7 +30,6 @@ internal class TokenDetailsLoadedBalanceConverter(
private val currentStateProvider: Provider<TokenDetailsState>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val clickIntents: TokenDetailsClickIntents,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
) : Converter<Either<CurrencyStatusError, CryptoCurrencyStatus>, TokenDetailsState> {
override fun convert(value: Either<CurrencyStatusError, CryptoCurrencyStatus>): TokenDetailsState {
@ -113,10 +111,7 @@ internal class TokenDetailsLoadedBalanceConverter(
selectedBalanceType = currentState.selectedBalanceType,
isBalanceSelectorEnabled = isBalanceSelectorEnabled,
isBalanceFlickering = status.value.isFlickering(),
yieldSupplyState =
if (yieldSupplyFeatureToggles.isYieldSupplyFeatureEnabled &&
status.value.yieldSupplyStatus?.isActive == true
) {
yieldSupplyState = if (status.value.yieldSupplyStatus?.isActive == true) {
TokenDetailsYieldSupplyState.Active(clickIntents::onYieldInfoClick)
} else {
TokenDetailsYieldSupplyState.Empty

View file

@ -19,7 +19,6 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.*
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton
import com.tangem.features.tokendetails.impl.R
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.lib.crypto.BlockchainUtils.isBitcoin
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
@ -31,7 +30,6 @@ internal class TokenDetailsSkeletonStateConverter(
private val networkHasDerivationUseCase: NetworkHasDerivationUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val userWalletId: UserWalletId,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
) : Converter<CryptoCurrency, TokenDetailsState> {
private val iconStateConverter by lazy { TokenDetailsIconStateConverter() }
@ -72,7 +70,6 @@ internal class TokenDetailsSkeletonStateConverter(
bottomSheetConfig = null,
isBalanceHidden = true,
isMarketPriceAvailable = value.id.rawCurrencyId != null,
isYieldSupplyFeatureEnabled = yieldSupplyFeatureToggles.isYieldSupplyFeatureEnabled,
)
}

View file

@ -4,7 +4,6 @@ import arrow.core.Either
import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig
import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig
import com.tangem.common.ui.tokens.getUnavailabilityReasonText
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.clore.CloreMigrationBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.extensions.TextReference
@ -33,8 +32,8 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBala
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.clore.CloreMigrationBottomSheetConfig
import com.tangem.features.tokendetails.impl.R
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toImmutableList
@ -48,7 +47,6 @@ internal class TokenDetailsStateFactory(
private val networkHasDerivationUseCase: NetworkHasDerivationUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val userWalletId: UserWalletId,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
) {
private val skeletonStateConverter by lazy {
@ -57,7 +55,6 @@ internal class TokenDetailsStateFactory(
networkHasDerivationUseCase = networkHasDerivationUseCase,
getUserWalletUseCase = getUserWalletUseCase,
userWalletId = userWalletId,
yieldSupplyFeatureToggles = yieldSupplyFeatureToggles,
)
}
@ -74,7 +71,6 @@ internal class TokenDetailsStateFactory(
currentStateProvider = currentStateProvider,
appCurrencyProvider = appCurrencyProvider,
clickIntents = tokenDetailsClickIntents,
yieldSupplyFeatureToggles = yieldSupplyFeatureToggles,
)
}

View file

@ -148,10 +148,8 @@ internal fun TokenDetailsScreen(
)
}
if (state.isYieldSupplyFeatureEnabled) {
item {
yieldSupplyComponent.Content(modifier = itemModifier)
}
item {
yieldSupplyComponent.Content(modifier = itemModifier)
}
expressTransactionsItems(

View file

@ -9,6 +9,12 @@ plugins {
android {
namespace = "com.tangem.feature.wallet.impl"
packaging {
resources {
// To build and run composable preview
merges += "paymentrequest.proto"
}
}
}
dependencies {
@ -44,6 +50,16 @@ dependencies {
exclude(group = "com.google.firebase", module = "protolite-well-known-types")
exclude(group = "com.google.protobuf", module = "protobuf-javalite")
}
implementation(deps.haze) {
exclude(module = "activity-compose")
exclude(module = "activity")
exclude(module = "activity-ktx")
}
implementation(deps.haze.materials) {
exclude(module = "activity-compose")
exclude(module = "activity")
exclude(module = "activity-ktx")
}
/** DI */
implementation(deps.hilt.android)
@ -84,6 +100,7 @@ dependencies {
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
implementation(projects.domain.hotWallet)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.promo)

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
@ -14,6 +15,7 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
@ -23,6 +25,7 @@ import com.tangem.feature.wallet.child.wallet.model.WalletModel
import com.tangem.feature.wallet.navigation.WalletRoute
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig
import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen
import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen2
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedComponent
import com.tangem.feature.walletsettings.component.RenameWalletComponent
import com.tangem.features.biometry.AskBiometryComponent
@ -38,6 +41,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
@OptIn(ExperimentalDecomposeApi::class)
@Suppress("LongParameterList")
internal class WalletComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@ -50,6 +54,7 @@ internal class WalletComponent @AssistedInject constructor(
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val yieldSupplyDepositedWarningComponent: YieldSupplyDepositedWarningComponent.Factory,
private val feedFeatureToggle: FeedFeatureToggle,
private val designFeatureToggles: DesignFeatureToggles,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: WalletModel = getOrCreateModel()
@ -148,18 +153,33 @@ internal class WalletComponent @AssistedInject constructor(
var headerSize by remember { mutableStateOf(0.dp) }
val dialog by dialog.subscribeAsState()
WalletScreen(
state = model.uiState.collectAsStateWithLifecycle().value,
bottomSheetContent = {
BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = { headerSize = it },
modifier = modifier,
)
},
bottomSheetHeaderHeightProvider = { headerSize },
onBottomSheetStateChange = { bottomSheetState.value = it },
)
if (designFeatureToggles.isRedesignEnabled) {
WalletScreen2(
state = model.uiState.collectAsStateWithLifecycle().value,
bottomSheetContent = {
BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = { headerSize = it },
modifier = modifier,
)
},
bottomSheetHeaderHeightProvider = { headerSize },
onBottomSheetStateChange = { bottomSheetState.value = it },
)
} else {
WalletScreen(
state = model.uiState.collectAsStateWithLifecycle().value,
bottomSheetContent = {
BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = { headerSize = it },
modifier = modifier,
)
},
bottomSheetHeaderHeightProvider = { headerSize },
onBottomSheetStateChange = { bottomSheetState.value = it },
)
}
when (val dialog = dialog.child?.instance) {
is ComposableDialogComponent -> dialog.Dialog()

View file

@ -45,9 +45,7 @@ import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvid
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import kotlinx.coroutines.*
@ -90,10 +88,8 @@ internal class WalletModel @Inject constructor(
private val setNotificationsEnabledUseCase: SetNotificationsEnabledUseCase,
private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase,
private val tangemPayOnboardingRepository: OnboardingRepository,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase,
private val getAppThemeModeUseCase: GetAppThemeModeUseCase,
@ -166,10 +162,8 @@ internal class WalletModel @Inject constructor(
}
private fun updateYieldSupplyApy() {
if (yieldSupplyFeatureToggles.isYieldSupplyFeatureEnabled) {
modelScope.launch(dispatchers.default) {
yieldSupplyApyUpdateUseCase()
}
modelScope.launch(dispatchers.default) {
yieldSupplyApyUpdateUseCase()
}
}
@ -394,7 +388,6 @@ internal class WalletModel @Inject constructor(
* Update state each time a user opens/returns to wallet screen
* and every minute while user stays on the main screen
*/
if (!tangemPayFeatureToggles.isTangemPayEnabled) return
combine(
flow = screenLifecycleProvider.isBackgroundState,

View file

@ -12,6 +12,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletType
import timber.log.Timber
import javax.inject.Inject
@ -110,7 +111,7 @@ internal class WalletsUpdateActionResolver @Inject constructor(
when (walletState) {
is WalletState.MultiCurrency -> {
val wallet = wallets.firstOrNull { it.walletId == walletState.walletCardState.id }
walletState.type == WalletState.MultiCurrency.WalletType.Hot && wallet is UserWallet.Cold
walletState.type == WalletType.Hot && wallet is UserWallet.Cold
}
else -> false
}
@ -212,7 +213,7 @@ internal class WalletsUpdateActionResolver @Inject constructor(
val previousState = state.wallets.firstOrNull { it.walletCardState.id == wallet.walletId }
?: return@filter false
wallet is UserWallet.Cold && previousState is WalletState.MultiCurrency &&
previousState.type == WalletState.MultiCurrency.WalletType.Hot
previousState.type == WalletType.Hot
}
return Action.ReinitializeWallets(selectedWallet, walletsToUpdate)
}

View file

@ -27,7 +27,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogCon
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayHideOnboardingStateTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayRefreshNeededStateTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayRefreshShowProgressTransformer
import com.tangem.features.tangempay.TangemPayFeatureToggles
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -64,7 +63,6 @@ internal interface TangemPayIntents {
@ModelScoped
internal class TangemPayClickIntentsImplementor @Inject constructor(
private val stateHolder: WalletStateController,
private val featureToggles: TangemPayFeatureToggles,
private val onboardingRepository: OnboardingRepository,
private val produceInitialDataTangemPay: ProduceTangemPayInitialDataUseCase,
private val getWalletMetainfoUseCase: GetWalletMetaInfoUseCase,
@ -77,9 +75,7 @@ internal class TangemPayClickIntentsImplementor @Inject constructor(
override suspend fun onPullToRefresh() {
val userWalletId = stateHolder.getSelectedWalletId()
if (!featureToggles.isTangemPayEnabled ||
!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)
) {
if (!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)) {
return
}
tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId)

View file

@ -11,8 +11,10 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
@ -36,13 +38,12 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource
import com.tangem.domain.tokens.model.analytics.TokenReceiveNewAnalyticsEvent
@ -63,7 +64,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState
import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
@ -136,14 +136,14 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
private val getStoryContentUseCase: GetStoryContentUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val dispatchers: CoroutineDispatcherProvider,
private val reduxStateHolder: ReduxStateHolder,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val vibratorHapticManager: VibratorHapticManager,
private val clipboardManager: ClipboardManager,
private val appRouter: AppRouter,
private val rampStateManager: RampStateManager,
private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase,
private val receiveAddressesFactory: ReceiveAddressesFactory,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val needShowYieldSupplyDepositedWarningUseCase: NeedShowYieldSupplyDepositedWarningUseCase,
private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase,
private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase,
@ -335,12 +335,13 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
showErrorIfDemoModeOrElse {
modelScope.launch(dispatchers.main) {
reduxStateHolder.dispatch(
action = TradeCryptoAction.Sell(
cryptoCurrencyStatus = cryptoCurrencyStatus,
appCurrencyCode = getSelectedAppCurrencyUseCase.unwrap().code,
),
)
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyStatus,
appCurrencyCode = getSelectedAppCurrencyUseCase.unwrap().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
}
}
@ -472,9 +473,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
override fun onMultiWalletSwapClick(userWalletId: UserWalletId) {
val selectedWallet = stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content ?: return
val tokenListState = selectedWallet.tokensListState
when (tokenListState) {
when (val tokenListState = selectedWallet.tokensListState) {
is WalletTokensListState.ContentState.Content -> checkSwapCryptoAvailability(
tokenCount = tokenListState.items.count { it is TokensListItemUM.Token },
)
@ -663,8 +662,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
private suspend fun needShowYieldSupplyWarning(cryptoCurrencyStatus: CryptoCurrencyStatus): Boolean {
return yieldSupplyFeatureToggles.isYieldSupplyFeatureEnabled &&
needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)
return needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)
}
private fun navigateToSend(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) {

View file

@ -1,6 +1,5 @@
package com.tangem.feature.wallet.presentation.common
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.event.consumedEvent
@ -186,17 +185,6 @@ internal object WalletPreviewData {
)
}
val bottomSheet by lazy {
TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = WalletBottomSheetConfig.UnlockWallets(
onUnlockClick = {},
onScanClick = {},
),
)
}
val actionsBottomSheet = ActionsBottomSheetConfig(
actions = listOf(
TokenActionButtonConfig(

View file

@ -186,7 +186,7 @@ internal object WalletScreenPreviewData {
onItemClick = { },
),
tangemPayState = TangemPayState.Empty,
type = WalletState.MultiCurrency.WalletType.Cold,
type = WalletType.Cold,
)
}
@ -218,6 +218,7 @@ internal object WalletScreenPreviewData {
singleWalletLockedState,
multiWalletState,
),
wallets2 = persistentListOf(),
onWalletChange = { _, _ -> },
event = consumedEvent(),
isHidingMode = false,

View file

@ -0,0 +1,38 @@
package com.tangem.feature.wallet.presentation.preview
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.styledStringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM
internal object WalletBalancePreview {
val content: WalletBalanceUM.Content = WalletBalanceUM.Content(
id = UserWalletId("0"),
name = "My Wallet",
balance = combinedReference(
stringReference("1,234"),
styledStringReference(
".56",
{
TangemTheme.typography2.headingRegular28.toSpanStyle()
},
),
stringReference(" $"),
),
isBalanceFlickering = false,
isZeroBalance = false,
)
val loading: WalletBalanceUM.Loading = WalletBalanceUM.Loading(
id = UserWalletId("1"),
name = "My Wallet",
)
val error: WalletBalanceUM.Error = WalletBalanceUM.Error(
id = UserWalletId("2"),
name = "My Wallet",
)
}

View file

@ -8,9 +8,8 @@ import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.*
import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen.*
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.PushBannerPromo.*
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.PushBannerPromo.PushBanner
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider
import javax.inject.Inject
@ -34,10 +33,29 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
}
}
fun send(displayedWalletUM: WalletUM?, newNotifications: List<WalletNotificationUM>) {
if (screenLifecycleProvider.isBackgroundState.value) return
if (newNotifications.isEmpty()) return
if (displayedWalletUM == null || displayedWalletUM.pullToRefreshConfig.isRefreshing) return
val totalNotifications = displayedWalletUM.notifications + displayedWalletUM.notificationsCarousel
val notificationsDiff = newNotifications.filter { it !in totalNotifications }
val eventsToSend = getEvents2(notificationsDiff)
eventsToSend.forEach { event ->
analyticsEventHandler.send(event)
}
}
private fun getEvents(warnings: List<WalletNotification>): Set<AnalyticsEvent> {
return warnings.mapNotNullTo(mutableSetOf(), ::getEvent)
}
private fun getEvents2(notifications: List<WalletNotificationUM>): Set<AnalyticsEvent> {
return notifications.mapNotNullTo(mutableSetOf(), ::getEvent2)
}
@Suppress("CyclomaticComplexMethod")
private fun getEvent(warning: WalletNotification): AnalyticsEvent? {
return when (warning) {
@ -106,4 +124,53 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.UpgradeHotWalletPromo -> null
}
}
@Suppress("CyclomaticComplexMethod")
private fun getEvent2(notificationUM: WalletNotificationUM): AnalyticsEvent? {
return when (notificationUM) {
WalletNotificationUM.DevCard -> DevelopmentCard()
WalletNotificationUM.FailedCardValidation -> ProductSampleCard()
is WalletNotificationUM.MissingBackup -> BackupYourWallet()
is WalletNotificationUM.NumberOfSignedHashesIncorrect -> CardSignedTransactions()
WalletNotificationUM.TestnetCard -> TestnetCard()
WalletNotificationUM.DemoCard -> DemoCard()
is WalletNotificationUM.MissingAddresses -> MissingAddresses()
is WalletNotificationUM.RateApp -> HowDoYouLikeTangem()
is WalletNotificationUM.BackupError -> BackupError()
is WalletNotificationUM.NoteMigration -> NotePromo()
is WalletNotificationUM.OnePlusOnePromo -> NoticePromotionBanner(
source = AnalyticsParam.ScreensSources.Main,
program = Program.OnePlusOne,
)
is WalletNotificationUM.YieldPromo -> NoticePromotionBanner(
source = AnalyticsParam.ScreensSources.Main,
program = Program.YieldPromo,
)
is WalletNotificationUM.FinishWalletActivation -> {
val activationState = if (notificationUM.isBackupExists) {
NoticeFinishActivation.ActivationState.Unfinished
} else {
NoticeFinishActivation.ActivationState.NotStarted
}
val balanceState = when (notificationUM.type) {
WalletNotificationType.Warning -> AnalyticsParam.EmptyFull.Full
else -> AnalyticsParam.EmptyFull.Empty
}
NoticeFinishActivation(
activationState = activationState,
balanceState = balanceState,
)
}
is WalletNotificationUM.SeedPhraseNotification -> NoticeSeedPhraseSupport()
is WalletNotificationUM.SeedPhraseSecondNotification -> NoticeSeedPhraseSupportSecond()
is WalletNotificationUM.PushNotifications -> PushBanner()
is WalletNotificationUM.UnlockWallets,
is WalletNotificationUM.NoAccount,
is WalletNotificationUM.LowSignatures,
WalletNotificationUM.SomeNetworksUnreachable,
is WalletNotificationUM.UsedOutdatedData,
is WalletNotificationUM.CloreMigration,
-> null
}
}
}

View file

@ -4,12 +4,8 @@ import com.tangem.common.routing.AppRoute.WalletBackup
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2
import com.tangem.core.ui.components.bottomsheets.message.icon
import com.tangem.core.ui.components.bottomsheets.message.infoBlock
import com.tangem.core.ui.components.bottomsheets.message.onClick
import com.tangem.core.ui.components.bottomsheets.message.primaryButton
import com.tangem.core.ui.components.bottomsheets.message.secondaryButton
import com.tangem.core.ui.components.bottomsheets.message.*
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.domain.models.wallet.UserWallet
@ -19,7 +15,9 @@ import com.tangem.domain.wallets.usecase.SeedPhraseNotificationUseCase
import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotificationUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@ -71,6 +69,44 @@ internal class WalletWarningsSingleEventSender @Inject constructor(
}
}
suspend fun send(
userWalletId: UserWalletId,
displayedWalletUM: WalletUM?,
newNotifications: List<WalletNotificationUM>,
) {
if (screenLifecycleProvider.isBackgroundState.value) return
if (newNotifications.isEmpty()) return
if (displayedWalletUM == null || displayedWalletUM.pullToRefreshConfig.isRefreshing) return
val totalNotifications = displayedWalletUM.notifications + displayedWalletUM.notificationsCarousel
val events = newNotifications.filter { it !in totalNotifications }
// We must show activation bs only for the first seen wallet when open the app (if need, see conditions below),
// so we keep this wallet id and use for future checks, ignore other wallets during the app session.
if (isActivationBottomSheetShown.isEmpty()) {
isActivationBottomSheetShown[userWalletId] = false
}
events.forEach { event ->
when (event) {
is WalletNotificationUM.SeedPhraseNotification -> {
seedPhraseNotificationUseCase.notified(userWalletId = userWalletId)
}
is WalletNotificationUM.FinishWalletActivation -> {
// We check that map contains the first seen wallet (will return null instead false/true otherwise)
// and for this wallet we haven't shown the activation bs yet (check that returns false, not true)
if (isActivationBottomSheetShown[userWalletId] == false) {
if (event.messageEffect == TangemMessageEffect.Warning && event.isBackupExists.not()) {
showFinishActivationBottomSheet(userWalletId)
}
isActivationBottomSheetShown[userWalletId] = true
}
}
else -> Unit
}
}
}
private fun showFinishActivationBottomSheet(userWalletId: UserWalletId) {
val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return
if (userWallet !is UserWallet.Hot) return

View file

@ -0,0 +1,131 @@
package com.tangem.feature.wallet.presentation.wallet.domain
import com.tangem.common.TangemSiteUrlBuilder
import com.tangem.common.ui.notifications.NotificationId
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.promo.ShouldShowPromoWalletUseCase
import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotificationUM
import com.tangem.utils.extensions.addIf
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
/**
* Factory for creating a list of notifications that can be shown on the wallet screen.
* These notifications are not critical and can be stacked with each other.
*/
@ModelScoped
internal class GetWalletNotificationsCarouselFactory @Inject constructor(
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
private val shouldShowPromoWalletUseCase: ShouldShowPromoWalletUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val notificationsRepository: NotificationsRepository,
) {
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotificationUM>> {
return combine(
flow = shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.YieldPromo)
.distinctUntilChanged(),
flow2 = notificationsRepository.getShouldShowNotification(
NotificationId.EnablePushesReminderNotification.key,
).distinctUntilChanged(),
flow3 = shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.OnePlusOne)
.distinctUntilChanged(),
flow4 = isReadyToShowRateAppUseCase().distinctUntilChanged(),
flow5 = getWalletsUseCase().conflate(),
) { showYieldPromo, showPushesNotification, showOnePlusOnePromo, showRateAppPromo, wallets ->
buildList {
addNoteMigrationNotification(userWallet, wallets, clickIntents)
addRateAppNotification(showRateAppPromo, clickIntents)
addOnePlusOnePromoNotification(clickIntents, showOnePlusOnePromo)
addYieldPromoNotification(clickIntents, showYieldPromo)
addPushNotification(
shouldShow = showPushesNotification,
isPushesAllowed = notificationsRepository.isUserAllowToSubscribeOnPushNotifications(),
clickIntents = clickIntents,
)
}.sortedBy { it.type.ordinal }.toImmutableList()
}
}
private fun MutableList<WalletNotificationUM>.addRateAppNotification(
isReadyToShowRating: Boolean,
clickIntents: WalletClickIntents,
) {
addIf(isReadyToShowRating) {
WalletNotificationUM.RateApp(
onLikeClick = clickIntents::onLikeAppClick,
onDislikeClick = clickIntents::onDislikeAppClick,
onCloseClick = clickIntents::onCloseRateAppWarningClick,
)
}
}
private fun MutableList<WalletNotificationUM>.addYieldPromoNotification(
clickIntents: WalletClickIntents,
shouldShowPromo: Boolean,
) {
addIf(shouldShowPromo) {
WalletNotificationUM.YieldPromo(
onCloseClick = { clickIntents.onClosePromoClick(promoId = PromoId.YieldPromo) },
onTermsAndConditionsClick = { clickIntents.onYieldPromoTermsAndConditionsClick() },
)
}
}
private fun MutableList<WalletNotificationUM>.addOnePlusOnePromoNotification(
clickIntents: WalletClickIntents,
shouldShowPromo: Boolean,
) {
addIf(shouldShowPromo) {
WalletNotificationUM.OnePlusOnePromo(
onCloseClick = { clickIntents.onClosePromoClick(promoId = PromoId.OnePlusOne) },
onClick = { clickIntents.onPromoClick(promoId = PromoId.OnePlusOne) },
)
}
}
private fun MutableList<WalletNotificationUM>.addNoteMigrationNotification(
userWallet: UserWallet,
userWallets: List<UserWallet>,
clickIntents: WalletClickIntents,
) {
val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver
val isUserHasWalletOrWallet2 = userWallets.filterIsInstance<UserWallet.Cold>().any { wallet ->
val typesResolver = wallet.scanResponse.cardTypesResolver
typesResolver.isTangemWallet() || typesResolver.isWallet2()
}
addIf(cardTypesResolver != null && cardTypesResolver.isTangemNote() && !isUserHasWalletOrWallet2) {
WalletNotificationUM.NoteMigration(
onClick = { clickIntents.onNoteMigrationButtonClick(TangemSiteUrlBuilder.NOTE_MIGRATION_URL) },
)
}
}
private fun MutableList<WalletNotificationUM>.addPushNotification(
shouldShow: Boolean,
isPushesAllowed: Boolean,
clickIntents: WalletClickIntents,
) {
addIf(shouldShow && !isPushesAllowed) {
WalletNotificationUM.PushNotifications(
onCloseClick = clickIntents::onDenyPermissions,
onEnabledClick = clickIntents::onAllowPermissions,
)
}
}
}

View file

@ -0,0 +1,326 @@
package com.tangem.feature.wallet.presentation.wallet.domain
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.hotwallet.GetAccessCodeSkippedUseCase
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus
import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase
import com.tangem.domain.wallets.usecase.SeedPhraseNotificationUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.account.AccountDependencies
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotificationUM
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.extensions.addIf
import com.tangem.utils.extensions.isPositive
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import javax.inject.Inject
/**
* Factory for creating a list of notifications that can be shown on the wallet screen.
* These notifications are critical and should be shown separately from each other.
*/
@Suppress("LongParameterList")
@ModelScoped
internal class GetWalletWarningsFactory @Inject constructor(
private val isDemoCardUseCase: IsDemoCardUseCase,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator,
private val seedPhraseNotificationUseCase: SeedPhraseNotificationUseCase,
private val accountDependencies: AccountDependencies,
private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase,
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase,
) {
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotificationUM>> {
val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver
val params = SingleAccountStatusListProducer.Params(userWallet.walletId)
val accountStatusListFlow = accountDependencies.singleAccountStatusListSupplier(params)
return combine(
flow = accountStatusListFlow,
flow2 = isNeedToBackupUseCase(userWallet.walletId).distinctUntilChanged(),
flow3 = seedPhraseNotificationUseCase(userWalletId = userWallet.walletId).distinctUntilChanged(),
flow4 = getAccessCodeSkippedUseCase(userWallet.walletId).distinctUntilChanged(),
) { accountList, isNeedToBackup, seedPhraseIssueStatus, shouldAccessCodeSkipped ->
val totalFiatBalance = accountList.totalFiatBalance
val flattenCurrencies = accountList.flattenCurrencies()
buildList {
addUsedOutdatedDataNotification(totalFiatBalance)
addCriticalNotifications(userWallet, seedPhraseIssueStatus, clickIntents)
addFinishWalletActivationNotification(
userWallet = userWallet,
totalFiatBalance = totalFiatBalance,
clickIntents = clickIntents,
shouldAccessCodeSkipped = shouldAccessCodeSkipped,
)
addInformationalNotifications(
userWallet = userWallet,
cardTypesResolver = cardTypesResolver,
flattenCurrencies = flattenCurrencies,
clickIntents = clickIntents,
)
addWarningNotifications(
userWallet = userWallet,
cardTypesResolver = cardTypesResolver,
flattenCurrencies = flattenCurrencies,
isNeedToBackup = isNeedToBackup,
clickIntents = clickIntents,
)
}.sortedBy { it.type.ordinal }.toImmutableList()
}
}
private fun MutableList<WalletNotificationUM>.addUsedOutdatedDataNotification(totalFiatBalance: TotalFiatBalance) {
addIf(
element = WalletNotificationUM.UsedOutdatedData,
condition = (totalFiatBalance as? TotalFiatBalance.Loaded)?.source == StatusSource.ONLY_CACHE,
)
}
private fun MutableList<WalletNotificationUM>.addCriticalNotifications(
userWallet: UserWallet,
seedPhraseIssueStatus: SeedPhraseNotificationsStatus,
clickIntents: WalletClickIntents,
) {
if (userWallet !is UserWallet.Cold) {
return
}
addSeedNotificationIfNeeded(userWallet, seedPhraseIssueStatus, clickIntents)
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
addIf(
element = WalletNotificationUM.BackupError { clickIntents.onSupportClick() },
condition = !backupValidator.isValidBackupStatus(userWallet.scanResponse.card) || userWallet.hasBackupError,
)
addIf(
element = WalletNotificationUM.DevCard,
condition = !cardTypesResolver.isReleaseFirmwareType(),
)
addIf(
element = WalletNotificationUM.FailedCardValidation,
condition = cardTypesResolver.isReleaseFirmwareType() && cardTypesResolver.isAttestationFailed(),
)
cardTypesResolver.getRemainingSignatures()?.let { remainingSignatures ->
addIf(
element = WalletNotificationUM.LowSignatures(count = remainingSignatures),
condition = remainingSignatures <= MAX_REMAINING_SIGNATURES_COUNT,
)
}
}
private fun MutableList<WalletNotificationUM>.addInformationalNotifications(
userWallet: UserWallet,
cardTypesResolver: CardTypesResolver?,
flattenCurrencies: List<CryptoCurrencyStatus>,
clickIntents: WalletClickIntents,
) {
addIf(
element = WalletNotificationUM.DemoCard,
condition = cardTypesResolver != null && isDemoCardUseCase(cardId = cardTypesResolver.getCardId()),
)
addMissingAddressesNotification(userWallet, flattenCurrencies, clickIntents)
}
private fun MutableList<WalletNotificationUM>.addMissingAddressesNotification(
userWallet: UserWallet,
flattenCurrencies: List<CryptoCurrencyStatus>,
clickIntents: WalletClickIntents,
) {
val currencies = flattenCurrencies.getMissingAddressCurrencies().ifEmpty { return }
addIf(
element = WalletNotificationUM.MissingAddresses(
tangemIcon = walletInterationIcon(userWallet),
missingAddressesCount = currencies.count(),
onGenerateClick = {
clickIntents.onGenerateMissedAddressesClick(missedAddressCurrencies = currencies)
},
),
condition = currencies.isNotEmpty(),
)
}
private fun List<CryptoCurrencyStatus>.getMissingAddressCurrencies(): List<CryptoCurrency> {
return this
.filter { it.value is CryptoCurrencyStatus.MissedDerivation }
.map(CryptoCurrencyStatus::currency)
}
private suspend fun MutableList<WalletNotificationUM>.addWarningNotifications(
userWallet: UserWallet,
cardTypesResolver: CardTypesResolver?,
flattenCurrencies: List<CryptoCurrencyStatus>,
isNeedToBackup: Boolean,
clickIntents: WalletClickIntents,
) {
addIf(
element = WalletNotificationUM.MissingBackup(
onClick = clickIntents::onAddBackupCardClick,
),
condition = isNeedToBackup,
)
addIf(
element = WalletNotificationUM.TestnetCard,
condition = cardTypesResolver?.isTestCard() == true,
)
addIf(
element = WalletNotificationUM.SomeNetworksUnreachable,
condition = flattenCurrencies.hasUnreachableNetworks(),
)
addCloreMigrationNotification(flattenCurrencies, clickIntents)
addNoAccountWarning(cryptoCurrencyStatus = flattenCurrencies.firstOrNull())
addIf(
element = WalletNotificationUM.NumberOfSignedHashesIncorrect(
onCloseClick = clickIntents::onCloseAlreadySignedHashesWarningClick,
),
condition = hasSignedHashes(userWallet, flattenCurrencies.firstOrNull()),
)
}
private fun MutableList<WalletNotificationUM>.addNoAccountWarning(cryptoCurrencyStatus: CryptoCurrencyStatus?) {
val noAccountStatus = cryptoCurrencyStatus?.value as? CryptoCurrencyStatus.NoAccount
if (noAccountStatus != null) {
add(
element = WalletNotificationUM.NoAccount(
network = cryptoCurrencyStatus.currency.name,
amount = noAccountStatus.amountToCreateAccount.toString(),
symbol = cryptoCurrencyStatus.currency.symbol,
),
)
}
}
private fun MutableList<WalletNotificationUM>.addCloreMigrationNotification(
flattenCurrencies: List<CryptoCurrencyStatus>,
clickIntents: WalletClickIntents,
) {
val cloreCurrency = flattenCurrencies.findCloreCurrency() ?: return
add(
WalletNotificationUM.CloreMigration(
onStartMigrationClick = { clickIntents.onCloreMigrationClick(cloreCurrency) },
),
)
}
private fun List<CryptoCurrencyStatus>.findCloreCurrency(): CryptoCurrencyStatus? {
return find { currencyStatus ->
BlockchainUtils.isClore(currencyStatus.currency.network.rawId)
}
}
private fun List<CryptoCurrencyStatus>.hasUnreachableNetworks(): Boolean {
return any { it.value is CryptoCurrencyStatus.Unreachable }
}
private fun MutableList<WalletNotificationUM>.addFinishWalletActivationNotification(
userWallet: UserWallet,
totalFiatBalance: TotalFiatBalance,
clickIntents: WalletClickIntents,
shouldAccessCodeSkipped: Boolean,
) {
if (userWallet !is UserWallet.Hot) return
val isBackupExists = userWallet.backedUp
val isAccessCodeRequired = userWallet.hotWalletId.authType == HotWalletId.AuthType.NoPassword &&
!shouldAccessCodeSkipped
val shouldShowFinishActivation = !isBackupExists || isAccessCodeRequired
val messageEffect = when (totalFiatBalance) {
TotalFiatBalance.Failed,
TotalFiatBalance.Loading,
-> TangemMessageEffect.None
is TotalFiatBalance.Loaded -> if (totalFiatBalance.amount.orZero().isPositive()) {
TangemMessageEffect.Warning
} else {
TangemMessageEffect.None
}
}
addIf(
element = WalletNotificationUM.FinishWalletActivation(
messageEffect = messageEffect,
onClick = { clickIntents.onFinishWalletActivationClick(isBackupExists) },
isBackupExists = isBackupExists,
),
condition = shouldShowFinishActivation,
)
}
private fun MutableList<WalletNotificationUM>.addSeedNotificationIfNeeded(
userWallet: UserWallet.Cold,
seedPhraseIssueStatus: SeedPhraseNotificationsStatus,
clickIntents: WalletClickIntents,
) {
val isNotificationAvailable = with(userWallet) {
val isDemo = isDemoCardUseCase(cardId = userWallet.cardId)
val isWalletWithSeedPhrase = scanResponse.cardTypesResolver.isWallet2() && userWallet.isImported
!isDemo && isWalletWithSeedPhrase
}
when (seedPhraseIssueStatus) {
SeedPhraseNotificationsStatus.SHOW_FIRST -> addIf(
element = WalletNotificationUM.SeedPhraseNotification(
onDeclineClick = clickIntents::onSeedPhraseNotificationDecline,
onConfirmClick = clickIntents::onSeedPhraseNotificationConfirm,
),
condition = isNotificationAvailable,
)
SeedPhraseNotificationsStatus.SHOW_SECOND -> addIf(
element = WalletNotificationUM.SeedPhraseSecondNotification(
onDeclineClick = clickIntents::onSeedPhraseSecondNotificationReject,
onConfirmClick = clickIntents::onSeedPhraseSecondNotificationAccept,
),
condition = isNotificationAvailable,
)
SeedPhraseNotificationsStatus.NOT_NEEDED -> Unit
}
}
private suspend fun hasSignedHashes(
selectedWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus?,
): Boolean {
if (selectedWallet !is UserWallet.Cold || !selectedWallet.isMultiCurrency) return false
val network = cryptoCurrencyStatus?.currency?.network ?: return false
return hasSingleWalletSignedHashesUseCase(userWallet = selectedWallet, network = network)
.conflate()
.distinctUntilChanged()
.firstOrNull() == true
}
private companion object {
const val MAX_REMAINING_SIGNATURES_COUNT = 10
}
}

View file

@ -20,7 +20,6 @@ import com.tangem.feature.wallet.presentation.wallet.domain.MultiWalletTokenList
import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.subscribers.*
import com.tangem.features.tangempay.TangemPayFeatureToggles
@Suppress("LongParameterList")
@Deprecated("Use MultiWalletContentLoaderV2 instead")
@ -44,7 +43,6 @@ internal class MultiWalletContentLoader(
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory,
) : WalletContentLoader(id = userWallet.walletId) {
@ -88,9 +86,7 @@ internal class MultiWalletContentLoader(
getStoryContentUseCase = getStoryContentUseCase,
).let(::add)
if (tangemPayFeatureToggles.isTangemPayEnabled) {
add(tangemPayMainSubscriberFactory.create(userWallet))
}
add(tangemPayMainSubscriberFactory.create(userWallet))
}
}
}

View file

@ -20,7 +20,6 @@ import com.tangem.feature.wallet.presentation.wallet.domain.MultiWalletTokenList
import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.subscribers.TangemPayMainSubscriber
import com.tangem.features.tangempay.TangemPayFeatureToggles
import javax.inject.Inject
@Suppress("LongParameterList")
@ -43,7 +42,6 @@ internal class MultiWalletContentLoaderFactory @Inject constructor(
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory,
) {
@ -66,7 +64,6 @@ internal class MultiWalletContentLoaderFactory @Inject constructor(
currenciesRepository = currenciesRepository,
yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase,
stakingAvailabilityListUseCase = stakingAvailabilityListUseCase,
tangemPayFeatureToggles = tangemPayFeatureToggles,
tangemPayMainSubscriberFactory = tangemPayMainSubscriberFactory,
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
)

View file

@ -8,7 +8,6 @@ import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarni
import com.tangem.feature.wallet.presentation.wallet.domain.GetMultiWalletWarningsFactory
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.subscribers.*
import com.tangem.features.tangempay.TangemPayFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -25,11 +24,10 @@ internal class MultiWalletContentLoaderV2 @AssistedInject constructor(
private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory,
private val getStoryContentUseCase: GetStoryContentUseCase,
private val checkWalletWithFundsSubscriberFactory: CheckWalletWithFundsSubscriber.Factory,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory,
) : WalletContentLoader(id = userWallet.walletId) {
override fun create(): List<WalletSubscriber> = listOfNotNull(
override fun create(): List<WalletSubscriber> = listOf(
accountListSubscriberFactory.create(userWallet = userWallet),
walletNFTListSubscriberV2Factory.create(userWallet = userWallet),
checkWalletWithFundsSubscriberFactory.create(userWallet = userWallet),
@ -46,12 +44,7 @@ internal class MultiWalletContentLoaderV2 @AssistedInject constructor(
stateHolder = stateController,
getStoryContentUseCase = getStoryContentUseCase,
),
if (tangemPayFeatureToggles.isTangemPayEnabled) {
tangemPayMainSubscriberFactory.create(userWallet)
} else {
null
},
tangemPayMainSubscriberFactory.create(userWallet),
)
@AssistedFactory

View file

@ -1,12 +1,10 @@
package com.tangem.feature.wallet.presentation.wallet.state
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.model.NOT_INITIALIZED_WALLET_INDEX
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTopBarConfig
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.WalletScreenStateTransformer
@ -25,7 +23,9 @@ import javax.inject.Singleton
[REDACTED_AUTHOR]
*/
@Singleton
internal class WalletStateController @Inject constructor() {
internal class WalletStateController @Inject constructor(
private val designFeatureToggles: DesignFeatureToggles,
) {
val uiState: StateFlow<WalletScreenState> get() = mutableUiState
@ -53,6 +53,10 @@ internal class WalletStateController @Inject constructor() {
return value.wallets.firstOrNull { it.walletCardState.id == userWalletId }
}
fun getWalletUM(userWalletId: UserWalletId): WalletUM? {
return value.wallets2.firstOrNull { it.walletsBalanceUM.id == userWalletId }
}
fun getWalletStateIfSelected(walletId: UserWalletId): WalletState? {
val selectedWalletId = getSelectedWalletId()
@ -61,16 +65,40 @@ internal class WalletStateController @Inject constructor() {
}
}
fun getWalletUMIfSelected(walletId: UserWalletId): WalletUM? {
val selectedWalletId = getSelectedWalletId()
return value.wallets2.firstOrNull {
it.walletsBalanceUM.id == walletId && it.walletsBalanceUM.id == selectedWalletId
}
}
fun getSelectedWallet(): WalletState {
return with(value) { wallets[selectedWalletIndex] }
}
fun getSelectedWalletUM(): WalletUM {
return with(value) { wallets2[selectedWalletIndex] }
}
fun getSelectedWalletId(): UserWalletId {
return with(value) { wallets[selectedWalletIndex].walletCardState.id }
return with(value) {
if (designFeatureToggles.isRedesignEnabled) {
wallets2[selectedWalletIndex].walletsBalanceUM.id
} else {
wallets[selectedWalletIndex].walletCardState.id
}
}
}
fun getWalletIndexByWalletId(userWalletId: UserWalletId): Int? {
return with(value) { wallets.indexOfFirstOrNull { it.walletCardState.id == userWalletId } }
return with(value) {
if (designFeatureToggles.isRedesignEnabled) {
wallets2.indexOfFirstOrNull { it.walletsBalanceUM.id == userWalletId }
} else {
wallets.indexOfFirstOrNull { it.walletCardState.id == userWalletId }
}
}
}
fun showBottomSheet(
@ -105,6 +133,7 @@ internal class WalletStateController @Inject constructor() {
topBarConfig = WalletTopBarConfig(onDetailsClick = {}),
selectedWalletIndex = NOT_INITIALIZED_WALLET_INDEX,
wallets = persistentListOf(),
wallets2 = persistentListOf(),
onWalletChange = { _, _ -> },
event = consumedEvent(),
isHidingMode = false,

View file

@ -0,0 +1,71 @@
package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId
/**
* Represents the state of the wallet balance in the UI.
*
* The sealed interface has three implementations:
* - [Content]: Represents the state when the wallet balance is successfully loaded.
* - [Error]: Represents the state when there was an error loading the wallet balance.
* - [Loading]: Represents the state when the wallet balance is currently being loaded.
*
* @property id The unique identifier of the wallet.
* @property name The name of the wallet.
*/
@Immutable
internal sealed interface WalletBalanceUM {
/** Wallet Id */
val id: UserWalletId
/** Wallet Name */
val name: String
/**
* Wallet card content state
*
* @property id wallet id
* @property name wallet name
* @property balance wallet balance
*/
data class Content(
override val id: UserWalletId,
override val name: String,
val balance: TextReference,
val isBalanceFlickering: Boolean,
val isZeroBalance: Boolean?,
) : WalletBalanceUM
/**
* Wallet card error state
*
* @property id wallet id
* @property name wallet name
*/
data class Error(
override val id: UserWalletId,
override val name: String,
) : WalletBalanceUM
/**
* Wallet card loading state
*
* @property id wallet id
* @property name wallet name
*/
data class Loading(
override val id: UserWalletId,
override val name: String,
) : WalletBalanceUM
fun copySealed(name: String): WalletBalanceUM {
return when (this) {
is Content -> copy(name = name)
is Error -> copy(name = name)
is Loading -> copy(name = name)
}
}
}

View file

@ -1,51 +0,0 @@
package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.annotation.DrawableRes
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.feature.wallet.impl.R
/**
* Wallet bottom sheet config
*
[REDACTED_AUTHOR]
*/
sealed class WalletBottomSheetConfig(
open val title: TextReference,
open val subtitle: TextReference,
@DrawableRes open val iconResId: Int,
val primaryButtonConfig: ButtonConfig,
val secondaryButtonConfig: ButtonConfig,
) : TangemBottomSheetConfigContent {
data class ButtonConfig(
val text: TextReference,
val onClick: () -> Unit,
@DrawableRes val iconResId: Int? = null,
)
data class UnlockWallets(val onUnlockClick: () -> Unit, val onScanClick: () -> Unit) : WalletBottomSheetConfig(
title = resourceReference(id = R.string.common_access_denied),
subtitle = resourceReference(
id = R.string.unlock_wallet_description_full,
formatArgs = wrappedList(
resourceReference(R.string.common_biometrics),
),
),
iconResId = R.drawable.ic_locked_24,
primaryButtonConfig = ButtonConfig(
text = resourceReference(
id = R.string.user_wallet_list_unlock_all_with,
formatArgs = wrappedList(resourceReference(R.string.common_biometrics)),
),
onClick = onUnlockClick,
),
secondaryButtonConfig = ButtonConfig(
text = resourceReference(id = R.string.welcome_unlock_card),
onClick = onScanClick,
iconResId = R.drawable.ic_tangem_24,
),
)
}

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