Updated on 2026-08-14

This commit is contained in:
Tangem 2024-12-10 11:10:35 +03:00
commit cdd662f90b
11 changed files with 171 additions and 114 deletions

View file

@ -140,9 +140,10 @@ internal object OnrampDomainModule {
@Singleton
fun provideGetOnrampPaymentMethodsUseCase(
onrampRepository: OnrampRepository,
settingsRepository: SettingsRepository,
onrampErrorResolver: OnrampErrorResolver,
): GetOnrampPaymentMethodsUseCase {
return GetOnrampPaymentMethodsUseCase(onrampRepository, onrampErrorResolver)
return GetOnrampPaymentMethodsUseCase(onrampRepository, settingsRepository, onrampErrorResolver)
}
@Provides

View file

@ -343,9 +343,7 @@ internal class DefaultOnrampRepository(
override suspend fun getAvailablePaymentMethods(): Set<OnrampPaymentMethod> {
val quotes = requireNotNull(quotesStore.getSyncOrNull(QUOTES_KEY)) { "Quotes must not be null" }
return quotes
.filterIsInstance<OnrampQuote.Data>()
.mapTo(hashSetOf(), OnrampQuote.Data::paymentMethod)
return quotes.mapTo(hashSetOf(), OnrampQuote::paymentMethod)
}
override suspend fun saveSelectedPaymentMethod(paymentMethod: OnrampPaymentMethod) {

View file

@ -5,14 +5,22 @@ import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.settings.repositories.SettingsRepository
class GetOnrampPaymentMethodsUseCase(
private val repository: OnrampRepository,
private val settingsRepository: SettingsRepository,
private val errorResolver: OnrampErrorResolver,
) {
suspend operator fun invoke(): Either<OnrampError, Set<OnrampPaymentMethod>> {
return Either.catch { repository.getAvailablePaymentMethods() }
.mapLeft(errorResolver::resolve)
return Either.catch {
val isGooglePayAvailable = settingsRepository.isGooglePayAvailability()
repository.getAvailablePaymentMethods()
.toList()
.sortedBy { it.type.getPriority(isGooglePayAvailable) }
.toSet()
}.mapLeft(errorResolver::resolve)
}
}

View file

@ -31,34 +31,23 @@ class GetOnrampProviderWithQuoteUseCase(
private fun List<OnrampQuote>.quoteWithProvider(
provider: OnrampProvider,
selectedPaymentMethod: OnrampPaymentMethod,
): OnrampProviderWithQuote? {
val quoteData = this.filterIsInstance<OnrampQuote.Data>()
val matchedPaymentMethodQuote = quoteData.firstOrNull { it.paymentMethod == selectedPaymentMethod }
val paymentMethodNotSupported = quoteData.filterNot { it.paymentMethod == selectedPaymentMethod }
val amountError =
filterIsInstance<OnrampQuote.Error>().firstOrNull { it.paymentMethod == selectedPaymentMethod }
return when {
matchedPaymentMethodQuote != null -> {
OnrampProviderWithQuote.Data(
provider = matchedPaymentMethodQuote.provider,
paymentMethod = matchedPaymentMethodQuote.paymentMethod,
toAmount = matchedPaymentMethodQuote.toAmount,
fromAmount = matchedPaymentMethodQuote.fromAmount,
)
}
paymentMethodNotSupported.isNotEmpty() -> {
Unavailable.NotSupportedPaymentMethod(
provider = provider,
availablePaymentMethods = paymentMethodNotSupported.map(OnrampQuote.Data::paymentMethod),
)
}
amountError != null -> {
Unavailable.Error(
amountError.provider,
amountError,
)
}
else -> null
): OnrampProviderWithQuote {
val matchedQuote = firstOrNull { it.paymentMethod == selectedPaymentMethod }
return when (matchedQuote) {
is OnrampQuote.Data -> OnrampProviderWithQuote.Data(
provider = matchedQuote.provider,
paymentMethod = matchedQuote.paymentMethod,
toAmount = matchedQuote.toAmount,
fromAmount = matchedQuote.fromAmount,
)
is OnrampQuote.Error -> Unavailable.Error(
provider = matchedQuote.provider,
quoteError = matchedQuote,
)
null -> Unavailable.NotSupportedPaymentMethod(
provider = provider,
availablePaymentMethods = provider.paymentMethods,
)
}
}
}

View file

@ -38,6 +38,7 @@ 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.extensions.isSingleItem
import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -196,8 +197,17 @@ internal class OnrampMainComponentModel @Inject constructor(
quotes.filterIsInstance<OnrampQuote.Error>().forEach { errorState ->
sendOnrampErrorAnalytic(errorState.error)
}
val isNoQuotes = quotes.none { it is OnrampQuote.Data }
val quote = quotes.firstOrNull() ?: return@onEach
val bestProvider = quote as? OnrampQuote.Data
val isMultipleQuotes = !quotes.isSingleItem()
val isOtherQuotesHasData = quotes
.filterNot { it == bestProvider }
.any { it is OnrampQuote.Data }
val hasBestProvider = isMultipleQuotes && isOtherQuotesHasData
val isBestProvider = quote == bestProvider && hasBestProvider
if (quote is OnrampQuote.Data && lastAmount.value != quote.fromAmount.value) {
lastAmount.value = quote.fromAmount.value
analyticsEventHandler.send(
@ -208,7 +218,12 @@ internal class OnrampMainComponentModel @Inject constructor(
),
)
}
_state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote, isNoQuotes) }
_state.update {
amountStateFactory.getAmountSecondaryUpdatedState(
quote = quote,
isBestRate = isBestProvider,
)
}
},
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.onramp.providers.entity
import com.tangem.features.onramp.paymentmethod.entity.PaymentMethodUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.serialization.Serializable
@Serializable
@ -9,6 +10,6 @@ internal sealed interface ProviderListBottomSheetConfig {
@Serializable
data class PaymentMethods(
val selectedMethodId: String,
val paymentMethodsUM: List<PaymentMethodUM>,
val paymentMethodsUM: ImmutableList<PaymentMethodUM>,
) : ProviderListBottomSheetConfig
}

View file

@ -13,7 +13,7 @@ internal sealed interface ProviderListItemUM {
val imageUrl: String,
val rate: String,
val isBestRate: Boolean,
val diffRate: TextReference,
val diffRate: TextReference?,
val isSelected: Boolean,
val onClick: () -> Unit,
) : ProviderListItemUM

View file

@ -1,8 +1,16 @@
package com.tangem.features.onramp.providers.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import kotlinx.collections.immutable.ImmutableList
internal data class ProviderListUM(
val paymentMethod: ProviderListPaymentMethodUM,
internal data class SelectPaymentAndProviderUM(
val paymentMethods: ImmutableList<OnrampPaymentMethod>,
val selectedPaymentMethod: SelectProviderUM,
val isPaymentMethodClickEnabled: Boolean,
val onPaymentMethodClick: () -> Unit,
)
internal data class SelectProviderUM(
val paymentMethod: OnrampPaymentMethod,
val providers: ImmutableList<ProviderListItemUM>,
)

View file

@ -28,8 +28,11 @@ import com.tangem.features.onramp.providers.entity.*
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
import com.tangem.utils.StringsSigns.MINUS
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.isSingleItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
@ -41,24 +44,41 @@ import javax.inject.Inject
internal class SelectProviderModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getSelectedPaymentMethodsUseCase: GetOnrampPaymentMethodsUseCase,
private val getOnrampPaymentMethodsUseCase: GetOnrampPaymentMethodsUseCase,
private val getOnrampSelectedPaymentMethodUseCase: GetOnrampSelectedPaymentMethodUseCase,
private val getOnrampProviderWithQuoteUseCase: GetOnrampProviderWithQuoteUseCase,
private val saveSelectedPaymentMethod: OnrampSaveSelectedPaymentMethod,
paramsContainer: ParamsContainer,
) : Model() {
val state: StateFlow<ProviderListUM> get() = _state.asStateFlow()
val state: StateFlow<SelectPaymentAndProviderUM> get() = _state.asStateFlow()
val bottomSheetNavigation: SlotNavigation<ProviderListBottomSheetConfig> = SlotNavigation()
private val params: SelectProviderComponent.Params = paramsContainer.require()
private val _state = MutableStateFlow(getInitialState())
init {
analyticsEventHandler.send(OnrampAnalyticsEvent.ProvidersScreenOpened)
getPaymentMethods()
getProviders(params.selectedPaymentMethod)
subscribeToPaymentMethodUpdates()
}
private fun getPaymentMethods() {
modelScope.launch {
val methods = getOnrampPaymentMethodsUseCase().fold(
ifLeft = { error ->
sendOnrampErrorEvent(error)
emptySet()
},
ifRight = { it },
)
_state.value = state.value.copy(
paymentMethods = methods.toPersistentList(),
isPaymentMethodClickEnabled = methods.isNotEmpty(),
)
}
}
private fun subscribeToPaymentMethodUpdates() {
getOnrampSelectedPaymentMethodUseCase.invoke()
.onEach { maybePaymentMethod ->
@ -74,11 +94,11 @@ internal class SelectProviderModel @Inject constructor(
modelScope.launch {
getOnrampProviderWithQuoteUseCase.invoke(paymentMethod)
.onRight { quotes ->
val hasQuotesData = quotes.any { it is OnrampProviderWithQuote.Data }
_state.update { state ->
state.copy(
paymentMethod = state.paymentMethod.copy(enabled = hasQuotesData),
providers = quotes.toProvidersListItems(),
selectedPaymentMethod = state.selectedPaymentMethod.copy(
providers = quotes.toProvidersListItems(),
),
)
}
}
@ -89,58 +109,47 @@ internal class SelectProviderModel @Inject constructor(
}
}
private fun getInitialState(): ProviderListUM {
return ProviderListUM(
paymentMethod = ProviderListPaymentMethodUM(
id = params.selectedPaymentMethod.id,
name = params.selectedPaymentMethod.name,
imageUrl = params.selectedPaymentMethod.imageUrl,
enabled = false,
onClick = ::openPaymentMethods,
private fun getInitialState(): SelectPaymentAndProviderUM {
return SelectPaymentAndProviderUM(
paymentMethods = persistentListOf(params.selectedPaymentMethod),
isPaymentMethodClickEnabled = false,
onPaymentMethodClick = ::openPaymentMethods,
selectedPaymentMethod = SelectProviderUM(
paymentMethod = params.selectedPaymentMethod,
providers = emptyList<ProviderListItemUM>().toImmutableList(),
),
providers = emptyList<ProviderListItemUM>().toImmutableList(),
)
}
private fun openPaymentMethods() {
analyticsEventHandler.send(OnrampAnalyticsEvent.PaymentMethodsScreenOpened)
modelScope.launch {
val methods = getSelectedPaymentMethodsUseCase().fold(
ifLeft = { error ->
sendOnrampErrorEvent(error)
emptySet()
},
ifRight = { it },
)
bottomSheetNavigation.activate(
ProviderListBottomSheetConfig.PaymentMethods(
selectedMethodId = _state.value.paymentMethod.id,
paymentMethodsUM = methods.toPaymentUMList(),
),
)
}
bottomSheetNavigation.activate(
ProviderListBottomSheetConfig.PaymentMethods(
selectedMethodId = state.value.selectedPaymentMethod.paymentMethod.id,
paymentMethodsUM = state.value.paymentMethods.toPaymentUMList(),
),
)
}
private fun Set<OnrampPaymentMethod>.toPaymentUMList(): List<PaymentMethodUM> = map { method ->
private fun ImmutableList<OnrampPaymentMethod>.toPaymentUMList(): ImmutableList<PaymentMethodUM> = map { method ->
PaymentMethodUM(
id = method.id,
imageUrl = method.imageUrl,
name = method.name,
onSelect = { onPaymentMethodSelected(method) },
)
}
}.toPersistentList()
private fun onPaymentMethodSelected(paymentMethod: OnrampPaymentMethod) {
analyticsEventHandler.send(OnrampAnalyticsEvent.OnPaymentMethodChosen(paymentMethod = paymentMethod.name))
modelScope.launch {
saveSelectedPaymentMethod.invoke(paymentMethod)
_state.update { state ->
val paymentMethodUM = state.paymentMethod.copy(
id = paymentMethod.id,
name = paymentMethod.name,
imageUrl = paymentMethod.imageUrl,
state.copy(
selectedPaymentMethod = state.selectedPaymentMethod.copy(
paymentMethod = paymentMethod,
),
)
state.copy(paymentMethod = paymentMethodUM)
}
bottomSheetNavigation.dismiss()
}
@ -151,7 +160,11 @@ internal class SelectProviderModel @Inject constructor(
val sorted = sortByRate()
val bestProvider = sorted.firstOrNull() as? OnrampProviderWithQuote.Data
return sorted.mapIndexed { index, quote ->
val isMultipleQuotes = !sorted.isSingleItem()
val isOtherQuotesHasData = sorted.filterNot { it == bestProvider }.any { it is OnrampProviderWithQuote.Data }
val hasBestProvider = isMultipleQuotes && isOtherQuotesHasData
return sorted.map { quote ->
when (quote) {
is OnrampProviderWithQuote.Data -> {
val rate = quote.toAmount.value.format {
@ -162,14 +175,16 @@ internal class SelectProviderModel @Inject constructor(
}
val isSelectedProvider = quote.provider.id == params.selectedProviderId
val isSelectedPayment = quote.paymentMethod.id == params.selectedPaymentMethod.id
val isBestProvider = quote == bestProvider && hasBestProvider
ProviderListItemUM.Available(
providerId = quote.provider.id,
imageUrl = quote.provider.info.imageLarge,
name = quote.provider.info.name,
rate = rate,
isSelected = isSelectedProvider && isSelectedPayment,
isBestRate = quote == bestProvider,
diffRate = stringReference("$MINUS${rateDiff.format { percent() }}"),
isBestRate = isBestProvider,
diffRate = stringReference("$MINUS${rateDiff.format { percent() }}")
.takeIf { hasBestProvider },
onClick = {
onProviderSelected(
result = SelectProviderResult.ProviderWithQuote(
@ -178,7 +193,7 @@ internal class SelectProviderModel @Inject constructor(
fromAmount = quote.fromAmount,
toAmount = quote.toAmount,
),
isBestRate = bestProvider == quote,
isBestRate = isBestProvider,
)
},
)
@ -237,7 +252,7 @@ internal class SelectProviderModel @Inject constructor(
}
private fun sendOnrampErrorEvent(error: OnrampError) {
val selectedProvider = state.value.providers.firstOrNull {
val selectedProvider = state.value.selectedPaymentMethod.providers.firstOrNull {
it.providerId == params.selectedProviderId
}
analyticsEventHandler.sendOnrampErrorEvent(

View file

@ -2,21 +2,26 @@ package com.tangem.features.onramp.providers.model.previewData
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.providers.entity.ProviderListItemUM
import com.tangem.features.onramp.providers.entity.ProviderListPaymentMethodUM
import com.tangem.features.onramp.providers.entity.ProviderListUM
import com.tangem.features.onramp.providers.entity.SelectPaymentAndProviderUM
import com.tangem.features.onramp.providers.entity.SelectProviderUM
import kotlinx.collections.immutable.persistentListOf
internal object SelectProviderPreviewData {
val state = ProviderListUM(
paymentMethod = ProviderListPaymentMethodUM(
private val paymentMethods = persistentListOf(
OnrampPaymentMethod(
id = "card",
name = "Card",
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png",
onClick = {},
enabled = false,
type = PaymentMethodType.CARD,
),
)
private val providerListUM = SelectProviderUM(
paymentMethod = paymentMethods.first(),
providers = persistentListOf(
ProviderListItemUM.Available(
providerId = "mercuryo",
@ -46,4 +51,11 @@ internal object SelectProviderPreviewData {
),
),
)
val state = SelectPaymentAndProviderUM(
paymentMethods = paymentMethods,
selectedPaymentMethod = providerListUM,
isPaymentMethodClickEnabled = false,
onPaymentMethodClick = {},
)
}

View file

@ -32,11 +32,11 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.paymentmethod.ui.PaymentMethodIcon
import com.tangem.features.onramp.providers.entity.ProviderListItemUM
import com.tangem.features.onramp.providers.entity.ProviderListPaymentMethodUM
import com.tangem.features.onramp.providers.entity.ProviderListUM
import com.tangem.features.onramp.providers.entity.SelectPaymentAndProviderUM
import com.tangem.features.onramp.providers.model.previewData.SelectProviderPreviewData
import com.tangem.features.onramp.utils.selectedBorder
@ -50,7 +50,7 @@ internal fun SelectProviderBottomSheet(config: TangemBottomSheetConfig, content:
}
@Composable
internal fun SelectProviderBottomSheetContent(state: ProviderListUM, modifier: Modifier = Modifier) {
internal fun SelectProviderBottomSheetContent(state: SelectPaymentAndProviderUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.primary)
@ -65,16 +65,23 @@ internal fun SelectProviderBottomSheetContent(state: ProviderListUM, modifier: M
)
PaymentMethodBlock(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing20, bottom = TangemTheme.dimens.spacing16),
state = state.paymentMethod,
state = state.selectedPaymentMethod.paymentMethod,
isClickEnabled = state.isPaymentMethodClickEnabled,
onPaymentMethodClick = state.onPaymentMethodClick,
)
state.providers.fastForEach { provider ->
state.selectedPaymentMethod.providers.fastForEach { provider ->
key(provider.providerId) { ProviderItem(state = provider) }
}
}
}
@Composable
private fun PaymentMethodBlock(state: ProviderListPaymentMethodUM, modifier: Modifier = Modifier) {
private fun PaymentMethodBlock(
state: OnrampPaymentMethod,
isClickEnabled: Boolean,
onPaymentMethodClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
@ -83,7 +90,7 @@ private fun PaymentMethodBlock(state: ProviderListPaymentMethodUM, modifier: Mod
color = TangemTheme.colors.stroke.secondary,
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
)
.clickable(enabled = state.enabled, onClick = state.onClick)
.clickable(enabled = isClickEnabled, onClick = onPaymentMethodClick)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
@ -177,23 +184,26 @@ private fun AvailableProviderItem(state: ProviderListItemUM.Available, modifier:
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
if (state.isBestRate) {
Text(
text = stringResource(R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.constantWhite,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(TangemTheme.colors.icon.accent)
.padding(vertical = 1.dp, horizontal = 6.dp),
)
} else {
Text(
text = state.diffRate.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.warning,
modifier = Modifier.padding(vertical = 1.dp),
)
when {
state.isBestRate -> {
Text(
text = stringResource(R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.constantWhite,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(TangemTheme.colors.icon.accent)
.padding(vertical = 1.dp, horizontal = 6.dp),
)
}
state.diffRate != null -> {
Text(
text = state.diffRate.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.warning,
modifier = Modifier.padding(vertical = 1.dp),
)
}
}
}
}
@ -247,7 +257,7 @@ private fun UnavailableProviderItem(
@Composable
private fun SelectProviderBottomSheetContent_Preview(
@PreviewParameter(SelectProviderBottomSheetContentPreviewProvider::class)
data: ProviderListUM,
data: SelectPaymentAndProviderUM,
) {
TangemThemePreview {
SelectProviderBottomSheetContent(data)
@ -255,8 +265,8 @@ private fun SelectProviderBottomSheetContent_Preview(
}
private class SelectProviderBottomSheetContentPreviewProvider :
PreviewParameterProvider<ProviderListUM> {
override val values: Sequence<ProviderListUM>
PreviewParameterProvider<SelectPaymentAndProviderUM> {
override val values: Sequence<SelectPaymentAndProviderUM>
get() = sequenceOf(
SelectProviderPreviewData.state,
)