Updated on 2026-08-14

This commit is contained in:
Tangem 2024-10-25 14:18:36 +05:00
parent 321b026fb4
commit 5ae666646f
19 changed files with 559 additions and 6 deletions

View file

@ -54,6 +54,15 @@ object TradeCryptoMiddleware {
}
private fun proceedBuyAction(state: () -> AppState?, action: TradeCryptoAction.Buy) {
val isOnrampEnabled = store.inject(DaggerGraphState::onrampFeatureToggles).isFeatureEnabled
if (isOnrampEnabled) proceedWithOnramp() else proceedWithLegacyBuyAction(state, action)
}
private fun proceedWithOnramp() {
store.dispatchNavigationAction { push(AppRoute.Onramp) }
}
private fun proceedWithLegacyBuyAction(state: () -> AppState?, action: TradeCryptoAction.Buy) {
val networkAddress = action.cryptoCurrencyStatus.value.networkAddress
?.defaultAddress
?.let(NetworkAddress.Address::value)

View file

@ -11,6 +11,7 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.onramp.component.OnrampComponent
import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.staking.api.navigation.StakingRouter
@ -46,6 +47,7 @@ internal class ChildFactory @Inject constructor(
private val disclaimerComponentFactory: DisclaimerComponent.Factory,
private val manageTokensComponentFactory: ManageTokensComponent.Factory,
private val marketsTokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory,
private val onrampComponentFactory: OnrampComponent.Factory,
private val sendRouter: SendRouter,
private val tokenDetailsRouter: TokenDetailsRouter,
private val walletRouter: WalletRouter,
@ -186,6 +188,13 @@ internal class ChildFactory @Inject constructor(
componentFactory = marketsTokenDetailsComponentFactory,
)
}
is AppRoute.Onramp -> {
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = OnrampComponent.Params(),
componentFactory = onrampComponentFactory,
)
}
}
}

View file

@ -288,4 +288,7 @@ sealed class AppRoute(val path: String) : Route {
val source: String,
)
}
@Serializable
data object Onramp : AppRoute(path = "/onramp")
}

View file

@ -14,6 +14,4 @@ internal data class CurrencyNetworkUM(
) : SelectableItemUM {
override val id: String = network.id.value
data class LongTapConfig(val contractAddress: String, val onLongTap: () -> Unit)
}

View file

@ -285,6 +285,18 @@ private class CustomTokenNetworkSelectorComponentPreviewProvider :
onDerivationPathSelected = {},
),
),
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = UserWalletId(stringValue = "321"),
selectedNetwork = SelectedNetwork(
id = Network.ID(value = "0"),
name = "Ethereum",
derivationPath = Network.DerivationPath.Card("m/44'/0'/0'/0/0"),
canHandleTokens = true,
),
onNetworkSelected = {},
),
),
)
}
// endregion Preview

View file

@ -5,5 +5,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
interface OnrampComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Unit, OnrampComponent>
class Params
interface Factory : ComponentFactory<Params, OnrampComponent>
}

View file

@ -1,6 +1,7 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
@ -23,11 +24,11 @@ dependencies {
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/* AndroidX */
/** AndroidX */
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
/* Compose */
/** Compose */
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.accompanist.systemUiController)
@ -35,4 +36,8 @@ dependencies {
implementation(deps.compose.material3)
implementation(deps.compose.shimmer)
implementation(deps.compose.coil)
implementation(deps.compose.material)
/** Other */
implementation(deps.decompose.ext.compose)
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.onramp.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
internal interface ConfirmResidencyComponent : ComposableBottomSheetComponent {
data class Params(
val countryName: String,
val isOnrampSupported: Boolean,
val countryFlagUrl: String,
val onDismiss: () -> Unit,
)
interface Factory : ComponentFactory<Params, ConfirmResidencyComponent>
}

View file

@ -0,0 +1,55 @@
package com.tangem.features.onramp.component.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.features.onramp.component.ConfirmResidencyComponent
import com.tangem.features.onramp.model.ConfirmResidencyModel
import com.tangem.features.onramp.ui.ConfirmResidencyBottomSheet
import com.tangem.features.onramp.ui.ConfirmResidencyBottomSheetContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultConfirmResidencyComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: ConfirmResidencyComponent.Params,
) : ConfirmResidencyComponent, AppComponentContext by context {
private val model: ConfirmResidencyModel = getOrCreateModel(params)
override fun dismiss() {
params.onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheetConfig = remember {
TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
ConfirmResidencyBottomSheet(
config = bottomSheetConfig,
content = { modifier ->
ConfirmResidencyBottomSheetContent(model = state, modifier = modifier)
},
)
}
@AssistedFactory
interface Factory : ConfirmResidencyComponent.Factory {
override fun create(
context: AppComponentContext,
params: ConfirmResidencyComponent.Params,
): DefaultConfirmResidencyComponent
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.onramp.component.impl
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.decompose.ComposableBottomSheetComponent
import com.tangem.features.onramp.component.OnrampComponent
import com.tangem.features.onramp.component.ConfirmResidencyComponent
import com.tangem.features.onramp.entity.OnrampBottomSheetConfig
import com.tangem.features.onramp.model.OnrampModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnrampComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: OnrampComponent.Params,
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
) : OnrampComponent, AppComponentContext by context {
private val model: OnrampModel = getOrCreateModel(params)
private val confirmResidencyBottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val bottomSheet by confirmResidencyBottomSheetSlot.subscribeAsState()
BackHandler(onBack = router::pop)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: OnrampBottomSheetConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
OnrampBottomSheetConfig.ConfirmResidency -> confirmResidencyComponentFactory.create(
context = childByContext(componentContext),
params = ConfirmResidencyComponent.Params(
countryName = "United States",
isOnrampSupported = true,
countryFlagUrl = "https://hatscripts.github.io/circle-flags/flags/us.svg",
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory
interface Factory : OnrampComponent.Factory {
override fun create(context: AppComponentContext, params: OnrampComponent.Params): DefaultOnrampComponent
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.onramp.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.onramp.component.ConfirmResidencyComponent
import com.tangem.features.onramp.entity.ConfirmResidencyUM
import com.tangem.features.onramp.ui.ConfirmResidencyBottomSheet
import com.tangem.features.onramp.ui.ConfirmResidencyBottomSheetContent
import kotlinx.coroutines.flow.MutableStateFlow
internal class PreviewConfirmResidencyComponent(
initialState: ConfirmResidencyUM = ConfirmResidencyUM(
country = "United States",
countryFlagUrl = "https://hatscripts.github.io/circle-flags/flags/us.svg",
isCountrySupported = true,
primaryButtonConfig = ConfirmResidencyUM.ActionButtonConfig(onClick = {}, text = stringReference("Confirm")),
secondaryButtonConfig = ConfirmResidencyUM.ActionButtonConfig(onClick = {}, text = stringReference("Change")),
),
) : ConfirmResidencyComponent {
private val previewState: MutableStateFlow<ConfirmResidencyUM> = MutableStateFlow(initialState)
override fun dismiss() {
/* no-op */
}
@Composable
override fun BottomSheet() {
val state by previewState.collectAsStateWithLifecycle()
val bottomSheetConfig = TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
ConfirmResidencyBottomSheet(
config = bottomSheetConfig,
content = { modifier ->
ConfirmResidencyBottomSheetContent(model = state, modifier = modifier)
},
)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.onramp.di
import com.tangem.features.onramp.component.ConfirmResidencyComponent
import com.tangem.features.onramp.component.OnrampComponent
import com.tangem.features.onramp.component.impl.DefaultConfirmResidencyComponent
import com.tangem.features.onramp.component.impl.DefaultOnrampComponent
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 OnrampComponentModule {
@Binds
@Singleton
fun bindOnrampComponentFactory(factory: DefaultOnrampComponent.Factory): OnrampComponent.Factory
@Binds
@Singleton
fun bindConfirmResidencyComponentFactory(
factory: DefaultConfirmResidencyComponent.Factory,
): ConfirmResidencyComponent.Factory
}

View file

@ -11,7 +11,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object FeatureModule {
internal object OnrampFeatureModule {
@Provides
@Singleton

View file

@ -0,0 +1,26 @@
package com.tangem.features.onramp.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.model.OnrampModel
import com.tangem.features.onramp.model.ConfirmResidencyModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(DecomposeComponent::class)
internal interface OnrampModelModule {
@Binds
@IntoMap
@ClassKey(OnrampModel::class)
fun provideOnrampModel(model: OnrampModel): Model
@Binds
@IntoMap
@ClassKey(ConfirmResidencyModel::class)
fun provideConfirmResidencyModel(model: ConfirmResidencyModel): Model
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.onramp.entity
import com.tangem.core.ui.extensions.TextReference
data class ConfirmResidencyUM(
val country: String,
val countryFlagUrl: String,
val isCountrySupported: Boolean,
val primaryButtonConfig: ActionButtonConfig,
val secondaryButtonConfig: ActionButtonConfig,
) {
data class ActionButtonConfig(
val onClick: () -> Unit,
val text: TextReference,
)
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.onramp.entity
import kotlinx.serialization.Serializable
@Serializable
internal sealed class OnrampBottomSheetConfig {
@Serializable
data object ConfirmResidency : OnrampBottomSheetConfig()
}

View file

@ -0,0 +1,53 @@
package com.tangem.features.onramp.model
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.features.onramp.component.ConfirmResidencyComponent
import com.tangem.features.onramp.entity.ConfirmResidencyUM
import com.tangem.features.onramp.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@ComponentScoped
internal class ConfirmResidencyModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
) : Model() {
private val params: ConfirmResidencyComponent.Params = paramsContainer.require()
val state: MutableStateFlow<ConfirmResidencyUM> = MutableStateFlow(
ConfirmResidencyUM(
country = params.countryName,
countryFlagUrl = params.countryFlagUrl,
isCountrySupported = params.isOnrampSupported,
primaryButtonConfig = getPrimaryButtonConfig(),
secondaryButtonConfig = ConfirmResidencyUM.ActionButtonConfig(
onClick = ::onChangeClick,
text = resourceReference(R.string.common_change),
),
),
)
private fun getPrimaryButtonConfig() = if (params.isOnrampSupported) {
ConfirmResidencyUM.ActionButtonConfig(
onClick = params.onDismiss,
text = resourceReference(R.string.common_confirm),
)
} else {
ConfirmResidencyUM.ActionButtonConfig(
onClick = ::onCloseClick,
text = resourceReference(R.string.common_close),
)
}
@Suppress("EmptyFunctionBlock")
private fun onCloseClick() {
}
private fun onChangeClick() {
// TODO: [REDACTED_JIRA]
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.onramp.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.features.onramp.component.OnrampComponent
import com.tangem.features.onramp.entity.OnrampBottomSheetConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("MagicNumber")
@ComponentScoped
internal class OnrampModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
paramsContainer: ParamsContainer,
) : Model() {
@Suppress("UnusedPrivateMember")
private val params: OnrampComponent.Params = paramsContainer.require()
val bottomSheetNavigation: SlotNavigation<OnrampBottomSheetConfig> = SlotNavigation()
init {
modelScope.launch {
delay(1500)
bottomSheetNavigation.activate(OnrampBottomSheetConfig.ConfirmResidency)
}
}
fun pop() {
router.pop()
}
}

View file

@ -0,0 +1,165 @@
package com.tangem.features.onramp.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight
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.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onramp.component.ConfirmResidencyComponent
import com.tangem.features.onramp.component.preview.PreviewConfirmResidencyComponent
import com.tangem.features.onramp.entity.ConfirmResidencyUM
import com.tangem.features.onramp.impl.R
@Composable
internal fun ConfirmResidencyBottomSheet(config: TangemBottomSheetConfig, content: @Composable (Modifier) -> Unit) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = config,
addBottomInsets = true,
containerColor = TangemTheme.colors.background.tertiary,
title = { _ -> Title() },
content = {
val contentModifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(bottom = TangemTheme.dimens.spacing16)
.fillMaxWidth()
.wrapContentHeight()
content(contentModifier)
},
)
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
TangemTopAppBar(
modifier = modifier,
title = resourceReference(R.string.onramp_residency_bottomsheet_title),
titleAlignment = Alignment.CenterHorizontally,
height = TangemTopAppBarHeight.BOTTOM_SHEET,
)
}
@Composable
internal fun ConfirmResidencyBottomSheetContent(model: ConfirmResidencyUM, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
CountryContent(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing24),
name = model.country,
flagUrl = model.countryFlagUrl,
isCountrySupported = model.isCountrySupported,
)
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = model.secondaryButtonConfig.text.resolveReference(),
onClick = model.secondaryButtonConfig.onClick,
)
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing12),
text = model.primaryButtonConfig.text.resolveReference(),
onClick = model.primaryButtonConfig.onClick,
)
}
}
@Composable
private fun CountryContent(name: String, flagUrl: String, isCountrySupported: Boolean, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
val flagSize = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }
SubcomposeAsyncImage(
modifier = Modifier.size(TangemTheme.dimens.size36),
model = ImageRequest.Builder(LocalContext.current)
.size(size = flagSize)
.data(flagUrl)
.memoryCacheKey(flagUrl + flagSize)
.crossfade(true)
.allowHardware(false)
.build(),
loading = { CircleShimmer() },
contentDescription = null,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
text = name,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing6),
text = stringResource(
id = if (isCountrySupported) {
R.string.onramp_residency_bottomsheet_country_subtitle
} else {
R.string.onramp_residency_bottomsheet_country_not_supported
},
),
color = if (isCountrySupported) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.warning,
style = TangemTheme.typography.body2,
)
}
}
// region Preview
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_ConfirmResidencyBottomSheet(
@PreviewParameter(ConfirmResidencyComponentPreviewProvider::class) component: ConfirmResidencyComponent,
) {
TangemThemePreview {
component.BottomSheet()
}
}
private class ConfirmResidencyComponentPreviewProvider : PreviewParameterProvider<ConfirmResidencyComponent> {
override val values: Sequence<ConfirmResidencyComponent>
get() = sequenceOf(
PreviewConfirmResidencyComponent(),
PreviewConfirmResidencyComponent(
initialState = ConfirmResidencyUM(
country = "Russia",
countryFlagUrl = "https://hatscripts.github.io/circle-flags/flags/ru.svg",
isCountrySupported = false,
primaryButtonConfig = ConfirmResidencyUM.ActionButtonConfig(
onClick = {},
text = stringReference("Close"),
),
secondaryButtonConfig = ConfirmResidencyUM.ActionButtonConfig(
onClick = {},
text = stringReference("Change"),
),
),
),
)
}