Updated on 2026-08-14

This commit is contained in:
Tangem 2024-11-28 18:35:01 +05:00
parent 38276b01ea
commit 9378e4fa31
63 changed files with 1434 additions and 75 deletions

View file

@ -16,6 +16,7 @@ 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.selectcurrency.SelectCurrencyComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -26,6 +27,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
@Assisted params: OnrampMainComponent.Params,
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val selectProviderComponentFactory: SelectProviderComponent.Factory,
) : OnrampMainComponent, AppComponentContext by appComponentContext {
private val model: OnrampMainComponentModel = getOrCreateModel(params)
@ -60,6 +62,14 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
context = childByContext(componentContext),
params = SelectCurrencyComponent.Params(model.bottomSheetNavigation::dismiss),
)
is OnrampMainBottomSheetConfig.ProvidersList -> selectProviderComponentFactory.create(
context = childByContext(componentContext),
params = SelectProviderComponent.Params(
onProviderClick = model::onProviderSelected,
onDismiss = model.bottomSheetNavigation::dismiss,
selectedPaymentMethod = config.selectedPaymentMethod,
),
)
}
@AssistedFactory

View file

@ -2,11 +2,16 @@ package com.tangem.features.onramp.main
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.tokens.model.CryptoCurrency
internal interface OnrampMainComponent : ComposableContentComponent {
data class Params(val cryptoCurrency: CryptoCurrency, val openSettings: () -> Unit)
data class Params(
val cryptoCurrency: CryptoCurrency,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
)
interface Factory : ComponentFactory<Params, OnrampMainComponent>
}

View file

@ -1,9 +1,11 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
interface OnrampIntents {
fun onAmountValueChanged(value: String)
fun openSettings()
fun openCurrenciesList()
fun onBuyClick()
fun onBuyClick(quote: OnrampProviderWithQuote.Data)
fun openProviders()
}

View file

@ -1,6 +1,7 @@
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
@ -10,4 +11,7 @@ internal sealed interface OnrampMainBottomSheetConfig {
@Serializable
data object CurrenciesList : OnrampMainBottomSheetConfig
@Serializable
data class ProvidersList(val selectedPaymentMethod: OnrampPaymentMethod) : OnrampMainBottomSheetConfig
}

View file

@ -18,7 +18,6 @@ internal sealed interface OnrampMainComponentUM {
val currency: String,
val onClose: () -> Unit,
val openSettings: () -> Unit,
val onBuyClick: () -> Unit,
) : OnrampMainComponentUM {
override val topBarConfig: OnrampMainTopBarUM = OnrampMainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
@ -36,7 +35,7 @@ internal sealed interface OnrampMainComponentUM {
override val buyButtonConfig: BuyButtonConfig = BuyButtonConfig(
text = resourceReference(R.string.common_buy),
onClick = onBuyClick,
onClick = {},
enabled = false,
)
}

View file

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

View file

@ -1,11 +1,12 @@
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 paymentMethodIconUrl: String,
val paymentMethodName: String,
val paymentMethod: OnrampPaymentMethod,
val providerName: String,
val isBestRate: Boolean,
val onClick: () -> Unit,

View file

@ -30,7 +30,6 @@ internal class OnrampStateFactory(
currency = currency,
onClose = onClose,
openSettings = onrampIntents::openSettings,
onBuyClick = onrampIntents::onBuyClick,
)
}

View file

@ -7,6 +7,7 @@ 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.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.tokens.model.AmountType
import com.tangem.features.onramp.impl.R
@ -55,6 +56,7 @@ internal class OnrampAmountStateFactory(
return currentState.copy(
amountBlockState = amountState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading),
buyButtonConfig = currentState.buyButtonConfig.copy(enabled = false),
)
}
@ -68,13 +70,52 @@ internal class OnrampAmountStateFactory(
secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState),
),
providerBlockState = quote.toProviderBlockState(),
buyButtonConfig = currentState.buyButtonConfig.copy(
enabled = 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,
),
)
}
},
),
)
}
fun getAmountSecondaryUpdatedState(quoteWithProvider: OnrampProviderWithQuote.Data): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
val amount = quoteWithProvider.toAmount.value.format {
crypto(symbol = quoteWithProvider.toAmount.symbol, decimals = quoteWithProvider.toAmount.decimals)
}
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampAmountSecondaryFieldUM.Content(stringReference(amount)),
),
providerBlockState = OnrampProviderBlockUM.Content(
paymentMethod = quoteWithProvider.paymentMethod,
providerName = quoteWithProvider.provider.info.name,
isBestRate = true,
onClick = onrampIntents::openProviders,
),
buyButtonConfig = currentState.buyButtonConfig.copy(
enabled = true,
onClick = { onrampIntents.onBuyClick(quoteWithProvider) },
),
)
}
private fun OnrampQuote.toProviderBlockState(): OnrampProviderBlockUM {
return OnrampProviderBlockUM.Content(
paymentMethodIconUrl = paymentMethod.imageUrl,
paymentMethodName = paymentMethod.name,
paymentMethod = paymentMethod,
providerName = provider.info.name,
isBestRate = true,
onClick = onrampIntents::openProviders,
@ -84,11 +125,8 @@ internal class OnrampAmountStateFactory(
private fun OnrampQuote.toSecondaryFieldUiModel(amountState: OnrampAmountBlockUM): OnrampAmountSecondaryFieldUM {
return when (this) {
is OnrampQuote.Data -> {
val amount = toAmount.format {
crypto(
symbol = amountState.amountFieldModel.cryptoAmount.currencySymbol,
decimals = amountState.amountFieldModel.cryptoAmount.decimals,
)
val amount = toAmount.value.format {
crypto(symbol = toAmount.symbol, decimals = toAmount.decimals)
}
OnrampAmountSecondaryFieldUM.Content(stringReference(amount))
}

View file

@ -8,10 +8,12 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.OnrampIntents
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampProviderBlockUM
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory
import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory
import com.tangem.features.onramp.utils.InputManager
@ -60,6 +62,10 @@ internal class OnrampMainComponentModel @Inject constructor(
subscribeToAmountChanges()
}
fun onProviderSelected(providerWithQuote: OnrampProviderWithQuote.Data) {
_state.update { amountStateFactory.getAmountSecondaryUpdatedState(providerWithQuote) }
}
private fun checkResidenceCountry() {
modelScope.launch {
checkOnrampAvailabilityUseCase.invoke(params.cryptoCurrency)
@ -125,8 +131,8 @@ internal class OnrampMainComponentModel @Inject constructor(
params.openSettings()
}
override fun onBuyClick() {
TODO("Not yet implemented")
override fun onBuyClick(quote: OnrampProviderWithQuote.Data) {
params.openRedirectPage(quote)
}
override fun openCurrenciesList() {
@ -134,6 +140,13 @@ internal class OnrampMainComponentModel @Inject constructor(
}
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,
),
)
}
override fun onDestroy() {

View file

@ -49,7 +49,7 @@ private fun OnrampProviderBlock(state: OnrampProviderBlockUM.Content, modifier:
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.paymentMethodIconUrl)
.data(state.paymentMethod.imageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
@ -67,7 +67,7 @@ private fun OnrampProviderBlock(state: OnrampProviderBlockUM.Content, modifier:
color = TangemTheme.colors.text.primary1,
),
) {
append(state.paymentMethodName)
append(state.paymentMethod.name)
}
},
color = TangemTheme.colors.text.tertiary,

View file

@ -1,6 +1,9 @@
package com.tangem.features.onramp.paymentmethod.entity
data class PaymentMethodUM(
import kotlinx.serialization.Serializable
@Serializable
internal data class PaymentMethodUM(
val id: String,
val imageUrl: String,
val name: String,

View file

@ -0,0 +1,95 @@
package com.tangem.features.onramp.providers
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.paymentmethod.SelectPaymentMethodComponent
import com.tangem.features.onramp.providers.entity.ProviderListBottomSheetConfig
import com.tangem.features.onramp.providers.model.SelectProviderModel
import com.tangem.features.onramp.providers.ui.SelectProviderBottomSheet
import com.tangem.features.onramp.providers.ui.SelectProviderBottomSheetContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultSelectProviderComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: SelectProviderComponent.Params,
private val selectPaymentMethodComponentFactory: SelectPaymentMethodComponent.Factory,
) : SelectProviderComponent, AppComponentContext by context {
private val model: SelectProviderModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
override fun dismiss() {
params.onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val bottomSheetConfig = remember(key1 = this) {
TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
SelectProviderBottomSheet(
config = bottomSheetConfig,
content = {
SelectProviderBottomSheetContent(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = TangemTheme.dimens.spacing16),
state = state,
)
},
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: ProviderListBottomSheetConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is ProviderListBottomSheetConfig.PaymentMethods -> selectPaymentMethodComponentFactory.create(
context = childByContext(componentContext),
params = SelectPaymentMethodComponent.Params(
selectedMethodId = config.selectedMethodId,
paymentMethods = config.paymentMethodsUM,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory
interface Factory : SelectProviderComponent.Factory {
override fun create(
context: AppComponentContext,
params: SelectProviderComponent.Params,
): DefaultSelectProviderComponent
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.onramp.providers
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
internal interface SelectProviderComponent : ComposableBottomSheetComponent {
data class Params(
val onProviderClick: (OnrampProviderWithQuote.Data) -> Unit,
val onDismiss: () -> Unit,
val selectedPaymentMethod: OnrampPaymentMethod,
)
interface Factory : ComponentFactory<Params, SelectProviderComponent>
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.providers.di
import com.tangem.features.onramp.providers.DefaultSelectProviderComponent
import com.tangem.features.onramp.providers.SelectProviderComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface SelectProviderComponentModule {
@Binds
@Singleton
fun bindSelectProviderComponentFactory(
factory: DefaultSelectProviderComponent.Factory,
): SelectProviderComponent.Factory
}

View file

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

View file

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

View file

@ -0,0 +1,24 @@
package com.tangem.features.onramp.providers.entity
import com.tangem.core.ui.extensions.TextReference
internal sealed interface ProviderListItemUM {
val providerId: String
data class Available(
override val providerId: String,
val imageUrl: String,
val name: String,
val rate: String,
val isSelected: Boolean,
val onClick: () -> Unit,
) : ProviderListItemUM
data class Unavailable(
override val providerId: String,
val imageUrl: String,
val name: String,
val subtitle: TextReference,
) : ProviderListItemUM
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.onramp.providers.entity
internal data class ProviderListPaymentMethodUM(
val id: String,
val name: String,
val imageUrl: String,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,8 @@
package com.tangem.features.onramp.providers.entity
import kotlinx.collections.immutable.ImmutableList
internal data class ProviderListUM(
val paymentMethod: ProviderListPaymentMethodUM,
val providers: ImmutableList<ProviderListItemUM>,
)

View file

@ -0,0 +1,170 @@
package com.tangem.features.onramp.providers.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.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.format
import com.tangem.domain.onramp.GetOnrampPaymentMethodsUseCase
import com.tangem.domain.onramp.GetOnrampProviderWithQuoteUseCase
import com.tangem.domain.onramp.GetOnrampSelectedPaymentMethodUseCase
import com.tangem.domain.onramp.OnrampSaveSelectedPaymentMethod
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.paymentmethod.entity.PaymentMethodUM
import com.tangem.features.onramp.providers.SelectProviderComponent
import com.tangem.features.onramp.providers.entity.ProviderListBottomSheetConfig
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.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ComponentScoped
internal class SelectProviderModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedPaymentMethodsUseCase: GetOnrampPaymentMethodsUseCase,
private val getOnrampSelectedPaymentMethodUseCase: GetOnrampSelectedPaymentMethodUseCase,
private val getOnrampProviderWithQuoteUseCase: GetOnrampProviderWithQuoteUseCase,
private val saveSelectedPaymentMethod: OnrampSaveSelectedPaymentMethod,
paramsContainer: ParamsContainer,
) : Model() {
val state: StateFlow<ProviderListUM> get() = _state.asStateFlow()
val bottomSheetNavigation: SlotNavigation<ProviderListBottomSheetConfig> = SlotNavigation()
private val params: SelectProviderComponent.Params = paramsContainer.require()
private val _state = MutableStateFlow(getInitialState())
init {
getProviders(params.selectedPaymentMethod)
subscribeToPaymentMethodUpdates()
}
private fun subscribeToPaymentMethodUpdates() {
getOnrampSelectedPaymentMethodUseCase.invoke()
.onEach { it.getOrNull()?.let(::getProviders) }
.launchIn(modelScope)
}
private fun getProviders(paymentMethod: OnrampPaymentMethod) {
modelScope.launch {
getOnrampProviderWithQuoteUseCase.invoke(paymentMethod)
.onRight { quotes ->
_state.update { state -> state.copy(providers = quotes.toProvidersListItems()) }
}
.onLeft { Timber.e(it) }
}
}
private fun getInitialState(): ProviderListUM {
return ProviderListUM(
paymentMethod = ProviderListPaymentMethodUM(
id = params.selectedPaymentMethod.id,
name = params.selectedPaymentMethod.name,
imageUrl = params.selectedPaymentMethod.imageUrl,
onClick = ::openPaymentMethods,
),
providers = emptyList<ProviderListItemUM>().toImmutableList(),
)
}
private fun openPaymentMethods() {
modelScope.launch {
val methods = getSelectedPaymentMethodsUseCase.invoke().getOrNull().orEmpty()
bottomSheetNavigation.activate(
ProviderListBottomSheetConfig.PaymentMethods(
selectedMethodId = _state.value.paymentMethod.id,
paymentMethodsUM = methods.toPaymentUMList(),
),
)
}
}
private fun List<OnrampPaymentMethod>.toPaymentUMList(): List<PaymentMethodUM> = map { method ->
PaymentMethodUM(
id = method.id,
imageUrl = method.imageUrl,
name = method.name,
onSelect = { onPaymentMethodSelected(method) },
)
}
private fun onPaymentMethodSelected(paymentMethod: OnrampPaymentMethod) {
modelScope.launch {
saveSelectedPaymentMethod.invoke(paymentMethod)
_state.update { state ->
val paymentMethodUM = state.paymentMethod.copy(
id = paymentMethod.id,
name = paymentMethod.name,
imageUrl = paymentMethod.imageUrl,
)
state.copy(paymentMethod = paymentMethodUM)
}
bottomSheetNavigation.dismiss()
}
}
private fun List<OnrampProviderWithQuote>.toProvidersListItems() = mapIndexed { index, quote ->
when (quote) {
is OnrampProviderWithQuote.Data -> {
val rate = quote.toAmount.value.format {
crypto(symbol = quote.toAmount.symbol, decimals = quote.toAmount.decimals)
}
ProviderListItemUM.Available(
providerId = quote.provider.id,
imageUrl = quote.provider.info.imageLarge,
name = quote.provider.info.name,
rate = rate,
isSelected = index == 0,
onClick = {
params.onProviderClick(quote)
params.onDismiss()
},
)
}
is OnrampProviderWithQuote.Unavailable.AvailableFrom -> {
val amount = quote.amount.value.format {
crypto(symbol = quote.amount.symbol, decimals = quote.amount.decimals)
}
ProviderListItemUM.Unavailable(
providerId = quote.provider.id,
imageUrl = quote.provider.info.imageLarge,
name = quote.provider.info.name,
subtitle = resourceReference(R.string.express_provider_min_amount, wrappedList(amount)),
)
}
is OnrampProviderWithQuote.Unavailable.AvailableUpTo -> {
val amount = quote.amount.value.format {
crypto(symbol = quote.amount.symbol, decimals = quote.amount.decimals)
}
ProviderListItemUM.Unavailable(
providerId = quote.provider.id,
imageUrl = quote.provider.info.imageLarge,
name = quote.provider.info.name,
subtitle = resourceReference(R.string.express_provider_max_amount, wrappedList(amount)),
)
}
is OnrampProviderWithQuote.Unavailable.NotSupportedPaymentMethod -> {
ProviderListItemUM.Unavailable(
providerId = quote.provider.id,
imageUrl = quote.provider.info.imageLarge,
name = quote.provider.info.name,
subtitle = stringReference(
"Available with ${quote.availablePaymentMethods.joinToString { it.name }}",
),
)
}
}
}.toImmutableList()
}

View file

@ -0,0 +1,199 @@
package com.tangem.features.onramp.providers.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.util.fastForEach
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.providers.entity.ProviderListItemUM
import com.tangem.features.onramp.providers.entity.ProviderListPaymentMethodUM
import com.tangem.features.onramp.providers.entity.ProviderListUM
@Composable
internal fun SelectProviderBottomSheet(config: TangemBottomSheetConfig, content: @Composable () -> Unit) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = config,
titleText = resourceReference(R.string.express_choose_providers_title),
content = { content() },
)
}
@Composable
internal fun SelectProviderBottomSheetContent(state: ProviderListUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.onramp_choose_provider_title_hint),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
PaymentMethodBlock(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing20, bottom = TangemTheme.dimens.spacing16),
state = state.paymentMethod,
)
state.providers.fastForEach { provider ->
key(provider.providerId) { ProviderItem(state = provider) }
}
}
}
@Composable
private fun PaymentMethodBlock(state: ProviderListPaymentMethodUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.border(
width = TangemTheme.dimens.size1,
color = TangemTheme.colors.stroke.secondary,
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
)
.clickable(onClick = state.onClick)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.imageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
Column(modifier = Modifier.weight(1F)) {
Text(
text = stringResource(id = R.string.onramp_pay_with),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
Text(
text = state.name,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.primary1,
)
}
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = R.drawable.ic_chevron_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Composable
private fun ProviderItem(state: ProviderListItemUM, modifier: Modifier = Modifier) {
when (state) {
is ProviderListItemUM.Available -> AvailableProviderItem(
modifier = modifier
.clickable(onClick = state.onClick)
.padding(all = TangemTheme.dimens.spacing12),
state = state,
)
is ProviderListItemUM.Unavailable -> UnavailableProviderItem(
modifier = modifier.padding(all = TangemTheme.dimens.spacing12),
state = state,
)
}
}
@Composable
private fun AvailableProviderItem(state: ProviderListItemUM.Available, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.imageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
Text(
modifier = Modifier.weight(1F),
text = state.name,
style = TangemTheme.typography.subtitle2,
color = if (state.isSelected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
)
Text(
text = state.rate,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
}
private const val UNAVAILABLE_ALPHA = 0.4F
@Composable
private fun UnavailableProviderItem(state: ProviderListItemUM.Unavailable, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8)
.alpha(UNAVAILABLE_ALPHA),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.imageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
Column(modifier = Modifier.weight(1F)) {
Text(
text = state.name,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = state.subtitle.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.onramp.redirect
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.onramp.redirect.model.OnrampRedirectModel
import com.tangem.features.onramp.redirect.ui.OnrampRedirectContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnrampRedirectComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: OnrampRedirectComponent.Params,
) : OnrampRedirectComponent, AppComponentContext by appComponentContext {
private val model: OnrampRedirectModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
BackHandler(onBack = params.onBack)
OnrampRedirectContent(modifier = modifier, state = model.state)
}
@AssistedFactory
interface Factory : OnrampRedirectComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnrampRedirectComponent.Params,
): DefaultOnrampRedirectComponent
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.onramp.redirect
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
internal interface OnrampRedirectComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val onBack: () -> Unit,
val cryptoCurrency: CryptoCurrency,
val onrampProviderWithQuote: OnrampProviderWithQuote.Data,
)
interface Factory : ComponentFactory<Params, OnrampRedirectComponent>
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.redirect.di
import com.tangem.features.onramp.redirect.DefaultOnrampRedirectComponent
import com.tangem.features.onramp.redirect.OnrampRedirectComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface OnrampRedirectComponentModule {
@Binds
@Singleton
fun bindOnrampRedirectComponentFactory(
factory: DefaultOnrampRedirectComponent.Factory,
): OnrampRedirectComponent.Factory
}

View file

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

View file

@ -0,0 +1,6 @@
package com.tangem.features.onramp.redirect.entity
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
data class OnrampRedirectTopBarUM(val title: TextReference, val startButtonUM: TopAppBarButtonUM)

View file

@ -0,0 +1,10 @@
package com.tangem.features.onramp.redirect.entity
import com.tangem.core.ui.extensions.TextReference
internal data class OnrampRedirectUM(
val topBarConfig: OnrampRedirectTopBarUM,
val providerImageUrl: String,
val title: TextReference,
val subtitle: TextReference,
)

View file

@ -0,0 +1,69 @@
package com.tangem.features.onramp.redirect.model
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.navigation.url.UrlOpener
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.onramp.GetOnrampRedirectUrlUseCase
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.redirect.OnrampRedirectComponent
import com.tangem.features.onramp.redirect.entity.OnrampRedirectTopBarUM
import com.tangem.features.onramp.redirect.entity.OnrampRedirectUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
internal class OnrampRedirectModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
private val getOnrampRedirectUrlUseCase: GetOnrampRedirectUrlUseCase,
router: Router,
paramsContainer: ParamsContainer,
) : Model() {
private val params: OnrampRedirectComponent.Params = paramsContainer.require()
val state = OnrampRedirectUM(
topBarConfig = OnrampRedirectTopBarUM(
title = combinedReference(
resourceReference(R.string.common_buy),
stringReference(" ${params.cryptoCurrency.name}"),
),
startButtonUM = TopAppBarButtonUM(
iconRes = R.drawable.ic_close_24,
onIconClicked = router::pop,
enabled = true,
),
),
providerImageUrl = params.onrampProviderWithQuote.provider.info.imageLarge,
title = resourceReference(
R.string.onramp_redirecting_to_provider_title,
wrappedList(params.onrampProviderWithQuote.provider.info.name),
),
subtitle = resourceReference(
R.string.onramp_redirecting_to_provider_subtitle,
wrappedList(params.onrampProviderWithQuote.provider.info.name),
),
)
init {
getRedirectUrl()
}
private fun getRedirectUrl() {
modelScope.launch {
getOnrampRedirectUrlUseCase.invoke(
userWalletId = params.userWalletId,
quote = params.onrampProviderWithQuote,
cryptoCurrency = params.cryptoCurrency,
)
.onLeft { Timber.e(it) }
.onRight { urlOpener.openUrl(it) }
}
}
}

View file

@ -0,0 +1,133 @@
package com.tangem.features.onramp.redirect.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Scaffold
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.appbar.TangemTopAppBar
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.impl.R
import com.tangem.features.onramp.redirect.entity.OnrampRedirectUM
@Composable
internal fun OnrampRedirectContent(state: OnrampRedirectUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.imePadding(),
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
startButton = state.topBarConfig.startButtonUM,
title = state.topBarConfig.title.resolveReference(),
)
},
content = { innerPadding ->
Content(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(horizontal = TangemTheme.dimens.spacing16),
state = state,
)
},
)
}
@Composable
private fun Content(state: OnrampRedirectUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
LogoBlock(providerImageUrl = state.providerImageUrl)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing24),
text = state.title.resolveReference(),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
text = state.subtitle.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun LogoBlock(providerImageUrl: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
modifier = Modifier
.size(TangemTheme.dimens.size64)
.clip(TangemTheme.shapes.roundedCorners8)
.background(TangemTheme.colors.background.primary)
.padding(vertical = TangemTheme.dimens.spacing8, horizontal = TangemTheme.dimens.spacing12),
painter = painterResource(id = R.drawable.ic_tangem_24),
contentDescription = null,
)
LoadingDots()
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size64)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(providerImageUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
}
}
@Composable
private fun LoadingDots(modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size6)
.clip(CircleShape)
.background(color = TangemTheme.colors.icon.accent.copy(alpha = 0.75F)),
)
Box(
modifier = Modifier
.size(TangemTheme.dimens.size6)
.clip(CircleShape)
.background(color = TangemTheme.colors.icon.accent.copy(alpha = 0.45F)),
)
Box(
modifier = Modifier
.size(TangemTheme.dimens.size8)
.clip(CircleShape)
.background(color = TangemTheme.colors.icon.accent),
)
}
}

View file

@ -17,6 +17,7 @@ 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.redirect.OnrampRedirectComponent
import com.tangem.features.onramp.root.entity.OnrampChild
import com.tangem.features.onramp.settings.OnrampSettingsComponent
import dagger.assisted.Assisted
@ -29,6 +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 onrampRedirectComponentFactory: OnrampRedirectComponent.Factory,
) : OnrampComponent, AppComponentContext by context {
private val navigation = StackNavigation<OnrampChild>()
@ -65,6 +67,23 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
params = OnrampMainComponent.Params(
cryptoCurrency = params.cryptoCurrency,
openSettings = { navigation.push(OnrampChild.Settings) },
openRedirectPage = {
navigation.push(
OnrampChild.RedirectPage(
quote = it,
cryptoCurrency = params.cryptoCurrency,
),
)
},
),
)
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(
context = childByContext(componentContext),
params = OnrampRedirectComponent.Params(
userWalletId = params.userWalletId,
onBack = navigation::pop,
cryptoCurrency = config.cryptoCurrency,
onrampProviderWithQuote = config.quote,
),
)
}

View file

@ -1,5 +1,7 @@
package com.tangem.features.onramp.root.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.tokens.model.CryptoCurrency
import kotlinx.serialization.Serializable
@Serializable
@ -9,4 +11,7 @@ internal sealed class OnrampChild {
@Serializable
data object Settings : OnrampChild()
@Serializable
data class RedirectPage(val quote: OnrampProviderWithQuote.Data, val cryptoCurrency: CryptoCurrency) : OnrampChild()
}