Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 17:31:01 +05:00
parent 285bed34a4
commit e5c441a9f0
19 changed files with 573 additions and 2 deletions

View file

@ -9,4 +9,13 @@ android {
}
dependencies {
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/** Domain */
implementation(projects.domain.models)
/** Compose */
implementation(deps.compose.runtime)
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.virtualaccount.onboarding.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
interface VirtualAccountOnboardingComponent : ComposableContentComponent {
sealed class Params {
abstract val userWalletId: UserWalletId
data class Deeplink(override val userWalletId: UserWalletId, val deeplink: String) : Params()
data class FromMain(override val userWalletId: UserWalletId) : Params()
data class FromDetailsScreen(override val userWalletId: UserWalletId) : Params()
}
interface Factory : ComponentFactory<Params, VirtualAccountOnboardingComponent>
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.virtualaccount.onboarding.deeplink
import android.net.Uri
interface OnboardVirtualAccountsDeepLinkHandler {
interface Factory {
fun create(uri: Uri): OnboardVirtualAccountsDeepLinkHandler
}
}

View file

@ -11,11 +11,23 @@ android {
}
dependencies {
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.error)
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Common */
implementation(projects.common.routing)
implementation(projects.common.ui)
/** Api */
implementation(projects.features.virtualAccounts.onboarding.api)
/** Core modules */
implementation(projects.core.configToggles)
/** Domain */
implementation(projects.domain.common)
implementation(projects.domain.models)
implementation(projects.domain.visa)
/** Compose */
implementation(deps.compose.foundation)
@ -27,4 +39,7 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.arrow.core)
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.virtualaccount.onboarding.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.virtualaccount.onboarding.model.VirtualAccountOnboardingModel
import com.tangem.features.virtualaccount.onboarding.ui.VirtualAccountOnboardingScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultVirtualAccountOnboardingComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: VirtualAccountOnboardingComponent.Params,
) : VirtualAccountOnboardingComponent, AppComponentContext by appComponentContext {
private val model: VirtualAccountOnboardingModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
VirtualAccountOnboardingScreen(modifier = modifier, state = state)
}
@AssistedFactory
interface Factory : VirtualAccountOnboardingComponent.Factory {
override fun create(
context: AppComponentContext,
params: VirtualAccountOnboardingComponent.Params,
): DefaultVirtualAccountOnboardingComponent
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.virtualaccount.onboarding.deeplink
import android.net.Uri
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnboardVirtualAccountsDeepLinkHandler @AssistedInject constructor(
@Assisted uri: Uri,
appRouter: AppRouter,
userWalletsListRepository: UserWalletsListRepository,
) : OnboardVirtualAccountsDeepLinkHandler {
init {
val userWalletId = userWalletsListRepository.selectedUserWallet.value?.walletId
if (userWalletId == null) {
TangemLogger.e("Can not open virtual account onboarding deeplink: no selected wallet")
} else {
val mode = AppRoute.VirtualAccountOnboarding.Mode.Deeplink(
userWalletId = userWalletId,
deeplink = uri.toString(),
)
appRouter.push(AppRoute.VirtualAccountOnboarding(mode))
}
}
@AssistedFactory
interface Factory : OnboardVirtualAccountsDeepLinkHandler.Factory {
override fun create(uri: Uri): DefaultOnboardVirtualAccountsDeepLinkHandler
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.virtualaccount.onboarding.di
import com.tangem.features.virtualaccount.onboarding.component.DefaultVirtualAccountOnboardingComponent
import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent
import com.tangem.features.virtualaccount.onboarding.deeplink.DefaultOnboardVirtualAccountsDeepLinkHandler
import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler
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 VirtualAccountOnboardingFeatureModule {
@Binds
fun bindFactory(impl: DefaultVirtualAccountOnboardingComponent.Factory): VirtualAccountOnboardingComponent.Factory
@Binds
@Singleton
fun bindOnboardVirtualAccountsDeepLinkHandlerFactory(
impl: DefaultOnboardVirtualAccountsDeepLinkHandler.Factory,
): OnboardVirtualAccountsDeepLinkHandler.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.virtualaccount.onboarding.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.virtualaccount.onboarding.model.VirtualAccountOnboardingModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface VirtualAccountOnboardingModelsModule {
@Binds
@IntoMap
@ClassKey(VirtualAccountOnboardingModel::class)
fun bindVirtualAccountOnboardingModel(model: VirtualAccountOnboardingModel): Model
}

View file

@ -0,0 +1,96 @@
package com.tangem.features.virtualaccount.onboarding.model
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent
import com.tangem.features.virtualaccount.onboarding.ui.VirtualAccountOnboardingUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class VirtualAccountOnboardingModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val onboardingRepository: OnboardingRepository,
) : Model() {
private val params = paramsContainer.require<VirtualAccountOnboardingComponent.Params>()
val uiState: StateFlow<VirtualAccountOnboardingUM>
field = MutableStateFlow<VirtualAccountOnboardingUM>(VirtualAccountOnboardingUM.Loading(onBack = ::back))
init {
when (params) {
is VirtualAccountOnboardingComponent.Params.Deeplink -> validateDeeplinkAndShow(params.deeplink)
is VirtualAccountOnboardingComponent.Params.FromMain,
is VirtualAccountOnboardingComponent.Params.FromDetailsScreen,
-> showOnboarding()
}
}
private fun validateDeeplinkAndShow(deeplink: String) {
modelScope.launch {
onboardingRepository.validateDeeplink(deeplink)
.onRight { isValid -> if (isValid) showOnboarding() else back() }
.onLeft { back() }
}
}
private fun showOnboarding() {
uiState.update {
VirtualAccountOnboardingUM.Content(
onBack = ::back,
isLoading = false,
onGetCardClick = ::onGetCardClick,
onTermsClick = ::onTermsClick,
onPrivacyClick = ::onPrivacyClick,
)
}
}
private fun onTermsClick() {
// TODO([REDACTED_TASK_KEY]): open the provider Terms of Use link.
}
private fun onPrivacyClick() {
// TODO([REDACTED_TASK_KEY]): open the provider Privacy Policy link.
}
private fun onGetCardClick() {
modelScope.launch {
setLoading(isLoading = true)
delay(STUB_GET_CARD_DELAY_MS)
// TODO: create order and sign challenge [REDACTED_JIRA]
setLoading(isLoading = false)
}
}
private fun setLoading(isLoading: Boolean) {
uiState.update { state ->
when (state) {
is VirtualAccountOnboardingUM.Content -> state.copy(isLoading = isLoading)
is VirtualAccountOnboardingUM.Loading -> state
}
}
}
private fun back() {
router.pop()
}
private companion object {
// TODO([REDACTED_TASK_KEY]): remove the stub delay once create-order + sign-challenge is implemented.
const val STUB_GET_CARD_DELAY_MS = 3000L
}
}

View file

@ -0,0 +1,213 @@
package com.tangem.features.virtualaccount.onboarding.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.Close
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.annotatedReference
import com.tangem.core.ui.extensions.appendColored
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.virtualaccount.onboarding.impl.R
private const val GRADIENT_TRANSPARENT_STOP = 0.45f
private const val GRADIENT_OPAQUE_STOP = 0.72f
private const val TERMS_LINK_TAG = "VA_TERMS"
private const val PRIVACY_LINK_TAG = "VA_PRIVACY"
@Composable
internal fun VirtualAccountOnboardingScreen(state: VirtualAccountOnboardingUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors3.bg.primary),
) {
Image(
painter = painterResource(id = R.drawable.bg_virtual_account_onboarding),
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter,
modifier = Modifier.fillMaxSize(),
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0f to TangemTheme.colors3.bg.primary.copy(alpha = 0f),
GRADIENT_TRANSPARENT_STOP to TangemTheme.colors3.bg.primary.copy(alpha = 0f),
GRADIENT_OPAQUE_STOP to TangemTheme.colors3.bg.primary,
1f to TangemTheme.colors3.bg.primary,
),
),
),
)
when (state) {
is VirtualAccountOnboardingUM.Loading -> Loading(modifier = Modifier.fillMaxSize())
is VirtualAccountOnboardingUM.Content -> Content(state = state)
}
TangemButton.Close(
modifier = Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.padding(top = 4.dp, end = 16.dp),
onClick = state.onBack,
)
}
}
@Composable
private fun Loading(modifier: Modifier = Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = TangemTheme.colors3.icon.primary)
}
}
@Composable
private fun Content(state: VirtualAccountOnboardingUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.systemBarsPadding(),
) {
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Send USD from your bank. Receive USDC",
style = TangemTheme.typography3.heading.medium,
color = TangemTheme.colors3.text.primary,
)
Text(
text = "A dedicated account with US banking details — no deposit or maintenance fees",
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
)
}
TermsCard(
modifier = Modifier.padding(top = 24.dp, start = 8.dp, end = 8.dp),
state = state,
)
}
}
@Composable
private fun TermsCard(state: VirtualAccountOnboardingUM.Content, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 28.dp, bottomEnd = 28.dp)
Column(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(TangemTheme.colors3.bg.opaque.primary)
.border(width = 1.dp, color = TangemTheme.colors3.border.secondary, shape = shape),
) {
Text(
modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
text = buildTermsAndPolicy(
onTermsClick = state.onTermsClick,
onPrivacyClick = state.onPrivacyClick,
).resolveAnnotatedReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
)
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp),
variant = TangemButton.Variant.Primary,
size = TangemButton.Size.X12,
text = stringReference("Open account"),
iconEnd = TangemIconUM.Icon(R.drawable.ic_tangem_24),
isLoading = state.isLoading,
onClick = state.onGetCardClick,
)
}
}
@Composable
private fun buildTermsAndPolicy(onTermsClick: () -> Unit, onPrivacyClick: () -> Unit) = annotatedReference {
val linkColor = TangemTheme.colors3.text.primary
append("By using service, you agree with provider ")
withLink(
link = LinkAnnotation.Clickable(
tag = TERMS_LINK_TAG,
linkInteractionListener = { onTermsClick() },
),
block = { appendColored(text = "Terms of Use", color = linkColor) },
)
append(" and ")
withLink(
link = LinkAnnotation.Clickable(
tag = PRIVACY_LINK_TAG,
linkInteractionListener = { onPrivacyClick() },
),
block = { appendColored(text = "Privacy Policy", color = linkColor) },
)
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun VirtualAccountOnboardingScreenPreview(
@PreviewParameter(VirtualAccountOnboardingStateProvider::class)
state: VirtualAccountOnboardingUM,
) {
TangemThemePreviewRedesign {
VirtualAccountOnboardingScreen(state = state, modifier = Modifier.fillMaxSize())
}
}
private class VirtualAccountOnboardingStateProvider :
CollectionPreviewParameterProvider<VirtualAccountOnboardingUM>(
listOf(
VirtualAccountOnboardingUM.Loading(onBack = {}),
VirtualAccountOnboardingUM.Content(
onBack = {},
isLoading = false,
onGetCardClick = {},
onTermsClick = {},
onPrivacyClick = {},
),
VirtualAccountOnboardingUM.Content(
onBack = {},
isLoading = true,
onGetCardClick = {},
onTermsClick = {},
onPrivacyClick = {},
),
),
)

View file

@ -0,0 +1,22 @@
package com.tangem.features.virtualaccount.onboarding.ui
import androidx.compose.runtime.Immutable
/**
* UI model for the Virtual Account onboarding screen.
*/
@Immutable
internal sealed class VirtualAccountOnboardingUM {
abstract val onBack: () -> Unit
data class Loading(override val onBack: () -> Unit) : VirtualAccountOnboardingUM()
data class Content(
override val onBack: () -> Unit,
val isLoading: Boolean,
val onGetCardClick: () -> Unit,
val onTermsClick: () -> Unit,
val onPrivacyClick: () -> Unit,
) : VirtualAccountOnboardingUM()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB