Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-20 11:27:43 -07:00
parent 5b516f2c9b
commit a82d8cdbba
43 changed files with 829 additions and 89 deletions

View file

@ -207,6 +207,7 @@ dependencies {
implementation(projects.data.txhistory) implementation(projects.data.txhistory)
implementation(projects.data.wallets) implementation(projects.data.wallets)
implementation(projects.data.analytics) implementation(projects.data.analytics)
implementation(projects.data.appsflyer)
implementation(projects.data.transaction) implementation(projects.data.transaction)
implementation(projects.data.visa) implementation(projects.data.visa)
implementation(projects.data.stories) implementation(projects.data.stories)

View file

@ -1,6 +1,7 @@
package com.tangem.tap.common.analytics.appsflyer package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
@ -40,11 +41,20 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
} }
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) { private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
if (deepLinkValue != REFERRAL_DEEP_LINK_VALUE) { when (deepLinkValue) {
TangemLogger.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}") REFERRAL_DEEP_LINK_VALUE -> handleReferral(deepLinkSub1, deepLinkSub2)
return TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE -> handleTangemPayHotWalletOnboarding(deepLinkValue)
else -> TangemLogger.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}")
} }
}
private fun handleTangemPayHotWalletOnboarding(deepLinkValue: String) {
coroutineScope.launch {
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding, deepLinkValue)
}
}
private fun handleReferral(deepLinkSub1: String?, deepLinkSub2: String?) {
@Suppress("NullableToStringCall") @Suppress("NullableToStringCall")
TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2") TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
@ -80,6 +90,8 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private companion object { private companion object {
const val REFERRAL_DEEP_LINK_VALUE = "referral" const val REFERRAL_DEEP_LINK_VALUE = "referral"
const val TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE = "tpay_mobileonboard"
const val DEEP_LINK_VALUE = "deep_link_value" const val DEEP_LINK_VALUE = "deep_link_value"
const val DEEP_LINK_SUB_1 = "deep_link_sub1" const val DEEP_LINK_SUB_1 = "deep_link_sub1"
const val DEEP_LINK_SUB_2 = "deep_link_sub2" const val DEEP_LINK_SUB_2 = "deep_link_sub2"

View file

@ -19,6 +19,8 @@ import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.context.childByContext
@ -29,6 +31,8 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.scan.ScanResponse
@ -83,6 +87,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
private val cardRepository: CardRepository, private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository, private val onboardingRepository: OnboardingRepository,
private val appsFlyerStore: AppsFlyerStore,
private val trackingContextProxy: TrackingContextProxy, private val trackingContextProxy: TrackingContextProxy,
private val scanFailsComponentFactory: ScanFailsComponent.Factory, private val scanFailsComponentFactory: ScanFailsComponent.Factory,
private val scanFailsRequesterProxy: ScanFailsRequesterProxy, private val scanFailsRequesterProxy: ScanFailsRequesterProxy,
@ -93,6 +98,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase, private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase,
private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase, private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase,
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase, private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase,
private val featureTogglesManager: FeatureTogglesManager,
) : RoutingComponent, ) : RoutingComponent,
AppComponentContext by context, AppComponentContext by context,
SnackbarHandler { SnackbarHandler {
@ -200,6 +206,21 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
} }
private suspend fun navigateForEmptyWallets(): AppRoute { private suspend fun navigateForEmptyWallets(): AppRoute {
if (featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING)) {
val tangemPayHotWalletOnboardingDeepLink = appsFlyerStore.getDeeplink(
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding,
)
if (tangemPayHotWalletOnboardingDeepLink != null) {
val hotWalletRoute = AppRoute.TangemPayHotWalletOnboarding
val shouldShowTos = !cardRepository.isTangemTOSAccepted()
return if (shouldShowTos) {
AppRoute.Disclaimer(isTosAccepted = false, nextRoute = hotWalletRoute)
} else {
hotWalletRoute
}
}
}
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull() val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
?: return AppRoute.Home(launchMode = launchMode) ?: return AppRoute.Home(launchMode = launchMode)
return if (shouldAskPushPermission) { return if (shouldAskPushPermission) {

View file

@ -36,6 +36,7 @@ import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendEntryPointComponent import com.tangem.features.send.v2.api.SendEntryPointComponent
import com.tangem.features.staking.api.StakingComponent import com.tangem.features.staking.api.StakingComponent
import com.tangem.features.swap.SwapComponent import com.tangem.features.swap.SwapComponent
import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.* import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.*
@ -109,6 +110,7 @@ internal class ChildFactory @Inject constructor(
private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory, private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory,
private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory, private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory,
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory, private val kycComponentFactory: KycComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val feedEntryComponentFactory: FeedEntryComponent.Factory, private val feedEntryComponentFactory: FeedEntryComponent.Factory,
@ -131,7 +133,10 @@ internal class ChildFactory @Inject constructor(
is AppRoute.Disclaimer -> { is AppRoute.Disclaimer -> {
createComponentChild( createComponentChild(
context = context, context = context,
params = DisclaimerComponent.Params(route.isTosAccepted), params = DisclaimerComponent.Params(
isTosAccepted = route.isTosAccepted,
nextRoute = route.nextRoute,
),
componentFactory = disclaimerComponentFactory, componentFactory = disclaimerComponentFactory,
) )
} }
@ -573,9 +578,9 @@ internal class ChildFactory @Inject constructor(
params = CreateWalletBackupComponent.Params( params = CreateWalletBackupComponent.Params(
userWalletId = route.userWalletId, userWalletId = route.userWalletId,
isUpgradeFlow = route.isUpgradeFlow, isUpgradeFlow = route.isUpgradeFlow,
shouldSetAccessCode = route.shouldSetAccessCode,
analyticsSource = route.analyticsSource, analyticsSource = route.analyticsSource,
analyticsAction = route.analyticsAction, analyticsAction = route.analyticsAction,
nextScreen = route.nextScreen,
), ),
componentFactory = createWalletBackupComponentFactory, componentFactory = createWalletBackupComponentFactory,
) )
@ -586,6 +591,7 @@ internal class ChildFactory @Inject constructor(
params = UpdateAccessCodeComponent.Params( params = UpdateAccessCodeComponent.Params(
userWalletId = route.userWalletId, userWalletId = route.userWalletId,
source = route.source, source = route.source,
nextScreen = route.nextScreen,
), ),
componentFactory = updateAccessCodeComponentFactory, componentFactory = updateAccessCodeComponentFactory,
) )
@ -668,6 +674,9 @@ internal class ChildFactory @Inject constructor(
is AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding -> ContinueOnboarding( is AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding -> ContinueOnboarding(
userWalletId = mode.userWalletId, userWalletId = mode.userWalletId,
) )
is AppRoute.TangemPayOnboarding.Mode.FirstSetup -> HotWalletOnboarding(
userWalletId = mode.userWalletId,
)
is AppRoute.TangemPayOnboarding.Mode.Deeplink -> Deeplink( is AppRoute.TangemPayOnboarding.Mode.Deeplink -> Deeplink(
deeplink = mode.deeplink, deeplink = mode.deeplink,
) )
@ -677,6 +686,13 @@ internal class ChildFactory @Inject constructor(
componentFactory = tangemPayOnboardingComponentFactory, componentFactory = tangemPayOnboardingComponentFactory,
) )
} }
is AppRoute.TangemPayHotWalletOnboarding -> {
createComponentChild(
context = context,
params = Unit,
componentFactory = tangemPayWalletOnboardingComponentFactory,
)
}
is AppRoute.Kyc -> { is AppRoute.Kyc -> {
createComponentChild( createComponentChild(
context = context, context = context,

View file

@ -53,6 +53,7 @@ sealed class AppRoute(val path: String) : Route {
@Serializable @Serializable
data class Disclaimer( data class Disclaimer(
val isTosAccepted: Boolean, val isTosAccepted: Boolean,
val nextRoute: AppRoute? = null,
) : AppRoute(path = "/disclaimer${if (isTosAccepted) "/tos_accepted" else ""}") ) : AppRoute(path = "/disclaimer${if (isTosAccepted) "/tos_accepted" else ""}")
@Serializable @Serializable
@ -405,13 +406,14 @@ sealed class AppRoute(val path: String) : Route {
val analyticsSource: String, val analyticsSource: String,
val analyticsAction: String, val analyticsAction: String,
val isUpgradeFlow: Boolean = false, val isUpgradeFlow: Boolean = false,
val shouldSetAccessCode: Boolean = false, val nextScreen: AppRoute? = null,
) : AppRoute(path = "/create_wallet_backup/${userWalletId.stringValue}") ) : AppRoute(path = "/create_wallet_backup/${userWalletId.stringValue}")
@Serializable @Serializable
data class UpdateAccessCode( data class UpdateAccessCode(
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
val source: String, val source: String,
val nextScreen: AppRoute? = null,
) : AppRoute(path = "/update_access_code/${userWalletId.stringValue}") ) : AppRoute(path = "/update_access_code/${userWalletId.stringValue}")
@Serializable @Serializable
@ -457,6 +459,9 @@ sealed class AppRoute(val path: String) : Route {
val status: AccountStatus.Payment, val status: AccountStatus.Payment,
) : AppRoute(path = "/tangem_pay_details/${status.account}") ) : AppRoute(path = "/tangem_pay_details/${status.account}")
@Serializable
data object TangemPayHotWalletOnboarding : AppRoute(path = "/tangem_pay_hot_wallet_onboarding")
@Serializable @Serializable
data class TangemPayOnboarding( data class TangemPayOnboarding(
val mode: Mode, val mode: Mode,
@ -474,6 +479,11 @@ sealed class AppRoute(val path: String) : Route {
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
) : Mode() ) : Mode()
@Serializable
data class FirstSetup(
val userWalletId: UserWalletId,
) : Mode()
@Serializable @Serializable
data object FromBannerOnMain : Mode() data object FromBannerOnMain : Mode()

View file

@ -102,6 +102,7 @@ sealed class AnalyticsParam {
Portfolio("Portfolio"), Portfolio("Portfolio"),
Staking("Staking"), Staking("Staking"),
Earn("Earn"), Earn("Earn"),
TangemPayHotWalletOnboarding("TangemPayHotWalletOnboarding"),
} }
sealed class TxSentFrom(val value: String) { sealed class TxSentFrom(val value: String) {

View file

@ -82,5 +82,9 @@
{ {
"name": "AND_15009_SWAP_PROVIDER_FILTER_ENABLED", "name": "AND_15009_SWAP_PROVIDER_FILTER_ENABLED",
"version": "undefined" "version": "undefined"
},
{
"name": "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING",
"version": "undefined"
} }
] ]

View file

@ -13,4 +13,19 @@ interface AppsFlyerStore {
suspend fun storeIfAbsent(value: AppsFlyerConversionData) suspend fun storeIfAbsent(value: AppsFlyerConversionData)
suspend fun storeUIDIfAbsent(value: String) suspend fun storeUIDIfAbsent(value: String)
suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String?
suspend fun storeDeeplink(source: AppsFlyerDeeplinkSource, deeplink: String)
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
}
enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding,
;
fun toStoreKey() = when (this) {
TangemPayHotWalletOnboarding -> "tangem_pay_hot_wallet_onboarding"
}
} }

View file

@ -56,8 +56,22 @@ internal class DefaultAppsFlyerStore(
} }
} }
private companion object { override suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? =
appPreferencesStore.getSyncOrNull(stringPreferencesKey(source.toStoreKey()))
override suspend fun storeDeeplink(source: AppsFlyerDeeplinkSource, deeplink: String) {
appPreferencesStore.editData { preferences ->
preferences[stringPreferencesKey(source.toStoreKey())] = deeplink
}
}
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
appPreferencesStore.editData { preferences ->
preferences.remove(stringPreferencesKey(source.toStoreKey()))
}
}
private companion object {
val UID_KEY = stringPreferencesKey("APPS_FLYER_UID") val UID_KEY = stringPreferencesKey("APPS_FLYER_UID")
val CONVERSION_DATA_KEY = stringPreferencesKey("APPS_FLYER_CONVERSION_DATA") val CONVERSION_DATA_KEY = stringPreferencesKey("APPS_FLYER_CONVERSION_DATA")
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -0,0 +1,20 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.appsflyer"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.domain.appsflyer)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.appsflyer
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
import javax.inject.Inject
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource as StoreDeeplinkSource
internal class DefaultAppsFlyerRepository @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
) : AppsFlyerRepository {
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
appsFlyerStore.clearDeeplink(source.toStoreSource())
}
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.data.appsflyer.di
import com.tangem.data.appsflyer.DefaultAppsFlyerRepository
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface AppsFlyerDataModule {
@Binds
@Singleton
fun bindAppsFlyerRepository(repository: DefaultAppsFlyerRepository): AppsFlyerRepository
companion object {
@Provides
fun provideClearAppsFlyerDeeplinkUseCase(
appsFlyerRepository: AppsFlyerRepository,
): ClearAppsFlyerDeeplinkUseCase {
return ClearAppsFlyerDeeplinkUseCase(appsFlyerRepository)
}
}
}

View file

@ -26,10 +26,7 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.* import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.*
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase

View file

@ -0,0 +1,13 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.appsflyer"
}
dependencies {
implementation(deps.kotlin.coroutines)
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.appsflyer
enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding,
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.appsflyer.repository
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
interface AppsFlyerRepository {
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.appsflyer.usecase
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
class ClearAppsFlyerDeeplinkUseCase(
private val appsFlyerRepository: AppsFlyerRepository,
) {
suspend operator fun invoke(source: AppsFlyerDeeplinkSource) {
appsFlyerRepository.clearDeeplink(source)
}
}

View file

@ -24,6 +24,7 @@ dependencies {
implementation(projects.domain.core) implementation(projects.domain.core)
implementation(projects.domain.tokens.models) implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models) implementation(projects.domain.wallets.models)
implementation(projects.core.datasource)
/** Security */ /** Security */
implementation(deps.spongecastle.core) implementation(deps.spongecastle.core)

View file

@ -0,0 +1,34 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class CreateHotWalletUseCase @Inject constructor(
private val tangemHotSdk: TangemHotSdk,
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(auth: HotAuth, mnemonicType: MnemonicType): Either<Throwable, UserWallet.Hot> {
return Either.catch {
val hotWalletId = tangemHotSdk.generateWallet(auth, mnemonicType)
val userWallet = hotUserWalletBuilderFactory.create(hotWalletId).build()
saveWalletUseCase(userWallet)
appCoroutineScope.launch {
syncWalletWithRemoteUseCase(userWalletId = userWallet.walletId)
}
userWallet
}
}
}

View file

@ -14,6 +14,9 @@ dependencies {
implementation(projects.core.decompose) implementation(projects.core.decompose)
implementation(projects.core.ui) implementation(projects.core.ui)
/* Common */
implementation(projects.common.routing)
/* Compose */ /* Compose */
implementation(deps.compose.runtime) implementation(deps.compose.runtime)
} }

View file

@ -1,5 +1,6 @@
package com.tangem.features.disclaimer.api.components package com.tangem.features.disclaimer.api.components
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
@ -8,5 +9,6 @@ interface DisclaimerComponent : ComposableContentComponent {
data class Params( data class Params(
val isTosAccepted: Boolean, val isTosAccepted: Boolean,
val nextRoute: AppRoute? = null,
) )
} }

View file

@ -48,6 +48,11 @@ internal class DisclaimerModel @Inject constructor(
router.pop() router.pop()
} else { } else {
cardRepository.acceptTangemTOS() cardRepository.acceptTangemTOS()
val nextRoute = params.nextRoute
if (nextRoute != null) {
router.replaceAll(nextRoute)
return@launch
}
val shouldAskPushPermission = notificationsRepository.shouldShowSubscribeOnNotificationsAfterUpdate() val shouldAskPushPermission = notificationsRepository.shouldShowSubscribeOnNotificationsAfterUpdate()
if (shouldAskPushPermission) { if (shouldAskPushPermission) {
notificationsRepository.setShouldShowNotifications( notificationsRepository.setShouldShowNotifications(

View file

@ -1,5 +1,6 @@
package com.tangem.features.hotwallet package com.tangem.features.hotwallet
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
@ -9,9 +10,9 @@ interface CreateWalletBackupComponent : ComposableContentComponent {
data class Params( data class Params(
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
val isUpgradeFlow: Boolean, val isUpgradeFlow: Boolean,
val shouldSetAccessCode: Boolean,
val analyticsSource: String, val analyticsSource: String,
val analyticsAction: String, val analyticsAction: String,
val nextScreen: AppRoute? = null,
) )
interface Factory : ComponentFactory<Params, CreateWalletBackupComponent> interface Factory : ComponentFactory<Params, CreateWalletBackupComponent>

View file

@ -1,5 +1,6 @@
package com.tangem.features.hotwallet package com.tangem.features.hotwallet
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
@ -8,6 +9,7 @@ interface UpdateAccessCodeComponent : ComposableContentComponent {
data class Params( data class Params(
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
val source: String, val source: String,
val nextScreen: AppRoute? = null,
) )
interface Factory : ComponentFactory<Params, UpdateAccessCodeComponent> interface Factory : ComponentFactory<Params, UpdateAccessCodeComponent>
} }

View file

@ -97,19 +97,15 @@ internal class CreateWalletBackupModel @Inject constructor(
stackNavigation.push( stackNavigation.push(
configuration = CreateWalletBackupRoute.BackupCompleted( configuration = CreateWalletBackupRoute.BackupCompleted(
isUpgradeFlow = params.isUpgradeFlow, isUpgradeFlow = params.isUpgradeFlow,
isLastScreen = !params.shouldSetAccessCode, isLastScreen = params.nextScreen == null,
), ),
) )
} }
fun onManualBackupCompleted() { fun onManualBackupCompleted() {
if (params.shouldSetAccessCode) { val nextScreen = params.nextScreen
router.replaceCurrent( if (nextScreen != null) {
route = AppRoute.UpdateAccessCode( router.replaceCurrent(nextScreen)
userWalletId = params.userWalletId,
source = params.analyticsSource,
),
)
} else { } else {
router.pop() router.pop()
} }

View file

@ -60,7 +60,12 @@ internal class UpdateAccessCodeModel @Inject constructor(
inner class MobileWalletSetupFinishedComponentModelCallbacks : MobileWalletSetupFinishedComponent.ModelCallbacks { inner class MobileWalletSetupFinishedComponentModelCallbacks : MobileWalletSetupFinishedComponent.ModelCallbacks {
override fun onFinishClick() { override fun onFinishClick() {
router.pop() val nextScreen = params.nextScreen
if (nextScreen != null) {
router.replaceCurrent(nextScreen)
} else {
router.pop()
}
} }
} }
} }

View file

@ -0,0 +1,8 @@
package com.tangem.features.tangempay.components
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface TangemPayHotWalletOnboardingComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Unit, TangemPayHotWalletOnboardingComponent>
}

View file

@ -16,6 +16,10 @@ interface TangemPayOnboardingComponent : ComposableContentComponent {
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
) : Params() ) : Params()
data class HotWalletOnboarding(
val userWalletId: UserWalletId,
) : Params()
data object FromBannerOnMain : Params() data object FromBannerOnMain : Params()
data object FromBannerInSettings : Params() data object FromBannerInSettings : Params()

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.tangempay.onboarding.impl" namespace = "com.tangem.features.tangempay.onboarding.impl"
} }
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies { dependencies {
/** Core */ /** Core */
implementation(projects.core.analytics) implementation(projects.core.analytics)
@ -32,8 +36,14 @@ dependencies {
implementation(projects.features.hotWallet.api) implementation(projects.features.hotWallet.api)
/** Domain */ /** Domain */
implementation(projects.domain.appsflyer)
implementation(projects.domain.visa) implementation(projects.domain.visa)
implementation(projects.domain.wallets) implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.hotWallet)
/** Libs */
implementation(tangemDeps.hot.core)
/** Data **/ /** Data **/
implementation(projects.data.visa) implementation(projects.data.visa)
@ -52,4 +62,11 @@ dependencies {
/** Other */ /** Other */
implementation(deps.arrow.core) implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections) implementation(deps.kotlin.immutable.collections)
/** Test */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
} }

View file

@ -1,7 +1,9 @@
package com.tangem.features.tangempay.di package com.tangem.features.tangempay.di
import com.tangem.features.tangempay.components.DefaultTangemPayOnboardingComponent import com.tangem.features.tangempay.components.DefaultTangemPayOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.hotwallet.DefaultTangemPayHotWalletOnboardingComponent
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -13,4 +15,9 @@ internal interface TangemPayOnboardingFeatureModule {
@Binds @Binds
fun bindFactory(impl: DefaultTangemPayOnboardingComponent.Factory): TangemPayOnboardingComponent.Factory fun bindFactory(impl: DefaultTangemPayOnboardingComponent.Factory): TangemPayOnboardingComponent.Factory
@Binds
fun bindHotWalletOnboardingFactory(
impl: DefaultTangemPayHotWalletOnboardingComponent.Factory,
): TangemPayHotWalletOnboardingComponent.Factory
} }

View file

@ -2,6 +2,7 @@ package com.tangem.features.tangempay.di
import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.features.tangempay.hotwallet.TangemPayHotWalletOnboardingModel
import com.tangem.features.tangempay.model.TangemPayOnboardingModel import com.tangem.features.tangempay.model.TangemPayOnboardingModel
import com.tangem.features.tangempay.model.TangemPayWalletSelectorModel import com.tangem.features.tangempay.model.TangemPayWalletSelectorModel
import dagger.Binds import dagger.Binds
@ -23,4 +24,9 @@ internal interface TangemPayOnboardingModelsModule {
@IntoMap @IntoMap
@ClassKey(TangemPayWalletSelectorModel::class) @ClassKey(TangemPayWalletSelectorModel::class)
fun bindTangemPayWalletSelectorModel(model: TangemPayWalletSelectorModel): Model fun bindTangemPayWalletSelectorModel(model: TangemPayWalletSelectorModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayHotWalletOnboardingModel::class)
fun bindHotWalletOnboardingModel(model: TangemPayHotWalletOnboardingModel): Model
} }

View file

@ -0,0 +1,39 @@
package com.tangem.features.tangempay.hotwallet
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.core.ui.components.SystemBarsIconsDisposable
import com.tangem.core.ui.res.ForceDarkTheme
import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultTangemPayHotWalletOnboardingComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Unit,
) : TangemPayHotWalletOnboardingComponent, AppComponentContext by context {
private val model: TangemPayHotWalletOnboardingModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
SystemBarsIconsDisposable(darkIcons = false)
ForceDarkTheme {
TangemPayHotWalletOnboardingScreen(
state = state,
modifier = modifier,
)
}
}
@AssistedFactory
interface Factory : TangemPayHotWalletOnboardingComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultTangemPayHotWalletOnboardingComponent
}
}

View file

@ -0,0 +1,102 @@
package com.tangem.features.tangempay.hotwallet
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.features.tangempay.onboarding.api.R
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class TangemPayHotWalletOnboardingModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val createHotWalletUseCase: CreateHotWalletUseCase,
private val isHotWalletCreationSupported: IsHotWalletCreationSupported,
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
private val router: Router,
private val uiMessageSender: UiMessageSender,
private val urlOpener: UrlOpener,
) : Model() {
val uiState: StateFlow<TangemPayHotWalletOnboardingUM>
field = MutableStateFlow(
TangemPayHotWalletOnboardingUM(
isLoading = false,
onGetCardClick = ::onGetCardClick,
onTermsClick = ::onTermsClick,
),
)
private fun onTermsClick() {
urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK)
}
private fun onGetCardClick() {
uiState.update { it.copy(isLoading = true) }
if (!isHotWalletCreationSupported()) {
uiMessageSender.send(
Dialogs.hotWalletCreationNotSupportedDialog(isHotWalletCreationSupported.getLeastVersionName()),
)
modelScope.launch { clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) }
router.replaceCurrent(AppRoute.Home())
return
}
modelScope.launch {
runSuspendCatching {
val userWallet = createHotWalletUseCase.invoke(
auth = HotAuth.NoAuth,
mnemonicType = MnemonicType.Words12,
).getOrElse { throw it }
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
router.replaceCurrent(
AppRoute.CreateWalletBackup(
userWalletId = userWallet.walletId,
analyticsSource = AnalyticsParam.ScreensSources.TangemPayHotWalletOnboarding.value,
analyticsAction = WalletSettingsAnalyticEvents.RecoveryPhraseScreenAction.Backup.value,
nextScreen = AppRoute.UpdateAccessCode(
userWalletId = userWallet.walletId,
source = AnalyticsParam.ScreensSources.TangemPayHotWalletOnboarding.value,
nextScreen = AppRoute.TangemPayOnboarding(
mode = AppRoute.TangemPayOnboarding.Mode.FirstSetup(userWallet.walletId),
),
),
),
)
}.onFailure {
uiState.update { state -> state.copy(isLoading = false) }
uiMessageSender.send(
DialogMessage(
title = TextReference.Res(R.string.common_something_went_wrong),
message = TextReference.Res(R.string.common_unknown_error),
),
)
}
}
}
}

View file

@ -0,0 +1,142 @@
package com.tangem.features.tangempay.hotwallet
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.R
import com.tangem.core.ui.components.TextButton
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.tangempay.ui.TangemPayOnboardingBlock
@Composable
internal fun TangemPayHotWalletOnboardingScreen(state: TangemPayHotWalletOnboardingUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsetsZero,
content = { paddingValues ->
Content(
state = state,
modifier = Modifier.padding(paddingValues),
)
},
)
}
@Composable
private fun Content(state: TangemPayHotWalletOnboardingUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(
brush = Brush.linearGradient(
colors = listOf(
TangemTheme.colors.background.primary,
Color.Black,
),
),
)
.systemBarsPadding()
.verticalScroll(rememberScrollState()),
) {
Text(
modifier = Modifier.padding(40.dp),
text = stringResourceSafe(R.string.tangempay_onboarding_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Image(
modifier = Modifier.fillMaxWidth(),
painter = painterResource(R.drawable.img_hot_wallet_onboarding),
contentDescription = null,
contentScale = ContentScale.FillWidth,
)
Features(modifier = Modifier.padding(horizontal = 40.dp))
Spacer(Modifier.weight(1f))
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
NavigationPrimaryButton(
primaryButton = NavigationButton(
textReference = resourceReference(R.string.tangempay_onboarding_get_card_button_text),
iconRes = R.drawable.ic_tangem_24,
isIconVisible = true,
shouldShowProgress = state.isLoading,
onClick = state.onGetCardClick,
),
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.tangem_pay_terms_fees_limits),
onClick = state.onTermsClick,
colors = TangemButtonsDefaults.defaultTextButtonColors.copy(
contentColor = TangemTheme.colors.text.primary1,
),
)
}
}
}
@Composable
private fun Features(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
TangemPayOnboardingBlock(
painterRes = R.drawable.ic_mobile_wallet_icon_24,
titleRef = TextReference.Res(R.string.tangempay_onboarding_setup_wallet_title),
descriptionRef = TextReference.Res(R.string.tangempay_onboarding_setup_wallet_description),
)
TangemPayOnboardingBlock(
painterRes = R.drawable.ic_shopping_basket_24,
titleRef = TextReference.Res(R.string.tangempay_onboarding_purchases_title),
descriptionRef = TextReference.Res(R.string.tangempay_onboarding_purchases_description),
)
TangemPayOnboardingBlock(
painterRes = R.drawable.ic_credit_card_add_24,
titleRef = TextReference.Res(R.string.tangempay_onboarding_pay_title),
descriptionRef = TextReference.Res(R.string.tangempay_onboarding_pay_description),
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
TangemPayHotWalletOnboardingScreen(
state = TangemPayHotWalletOnboardingUM(
isLoading = false,
onGetCardClick = {},
onTermsClick = {},
),
modifier = Modifier.fillMaxSize(),
)
}
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.tangempay.hotwallet
internal data class TangemPayHotWalletOnboardingUM(
val isLoading: Boolean,
val onGetCardClick: () -> Unit,
val onTermsClick: () -> Unit,
)

View file

@ -70,6 +70,9 @@ internal class TangemPayOnboardingModel @Inject constructor(
is TangemPayOnboardingComponent.Params.ContinueOnboarding -> { is TangemPayOnboardingComponent.Params.ContinueOnboarding -> {
openKyc(userWalletId = params.userWalletId) openKyc(userWalletId = params.userWalletId)
} }
is TangemPayOnboardingComponent.Params.HotWalletOnboarding -> {
startOnboarding(userWalletId = params.userWalletId)
}
is TangemPayOnboardingComponent.Params.FromBannerInSettings, is TangemPayOnboardingComponent.Params.FromBannerInSettings,
is TangemPayOnboardingComponent.Params.FromBannerOnMain, is TangemPayOnboardingComponent.Params.FromBannerOnMain,
-> showOnboarding() -> showOnboarding()
@ -104,7 +107,9 @@ internal class TangemPayOnboardingModel @Inject constructor(
} }
} }
when (params) { when (params) {
is TangemPayOnboardingComponent.Params.ContinueOnboarding -> openKyc(userWalletId) is TangemPayOnboardingComponent.Params.ContinueOnboarding,
is TangemPayOnboardingComponent.Params.HotWalletOnboarding,
-> openKyc(userWalletId)
else -> startOnboarding(userWalletId) else -> startOnboarding(userWalletId)
} }
} }
@ -227,6 +232,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
is TangemPayOnboardingComponent.Params.Deeplink, is TangemPayOnboardingComponent.Params.Deeplink,
is TangemPayOnboardingComponent.Params.ContinueOnboarding, is TangemPayOnboardingComponent.Params.ContinueOnboarding,
is TangemPayOnboardingComponent.Params.HotWalletOnboarding,
-> null -> null
} }
} }

View file

@ -1,7 +1,6 @@
package com.tangem.features.tangempay.ui package com.tangem.features.tangempay.ui
import android.content.res.Configuration import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -9,7 +8,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -23,14 +21,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreview
@ -120,9 +113,10 @@ private fun TangemPayOnboardingContent(state: TangemPayOnboardingScreenState.Con
.padding(horizontal = 12.dp), .padding(horizontal = 12.dp),
) )
} }
FooterButtons( TangemPayOnboardingButtons(
modifier = Modifier.padding(bottom = 16.dp), modifier = Modifier.padding(bottom = 16.dp),
primaryButtonConfig = state.buttonConfig, onGetCardClick = state.buttonConfig.onClick,
isLoading = state.buttonConfig.isLoading,
onTermsClick = state.onTermsClick, onTermsClick = state.onTermsClick,
) )
} }
@ -154,63 +148,6 @@ internal fun TangemPayOnboardingBlocks(modifier: Modifier = Modifier) {
} }
} }
@Composable
private fun TangemPayOnboardingBlock(
@DrawableRes painterRes: Int,
titleRef: TextReference,
descriptionRef: TextReference,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier) {
Icon(
painter = painterResource(id = painterRes),
contentDescription = null,
modifier = Modifier.size(width = 24.dp, height = 24.dp),
tint = TangemTheme.colors.icon.accent,
)
Column(
modifier = Modifier
.padding(start = 12.dp)
.fillMaxWidth(),
) {
Text(
text = titleRef.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
text = descriptionRef.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
@Composable
private fun FooterButtons(
primaryButtonConfig: TangemPayOnboardingScreenState.Content.ButtonConfig,
onTermsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.tangem_pay_terms_fees_limits),
onClick = onTermsClick,
)
NavigationPrimaryButton(
primaryButton = NavigationButton(
textReference = resourceReference(R.string.tangempay_onboarding_get_card_button_text),
iconRes = R.drawable.ic_tangem_24,
isIconVisible = true,
shouldShowProgress = primaryButtonConfig.isLoading,
onClick = primaryButtonConfig.onClick,
),
)
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable

View file

@ -0,0 +1,42 @@
package com.tangem.features.tangempay.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
@Composable
internal fun TangemPayOnboardingButtons(
onGetCardClick: () -> Unit,
isLoading: Boolean,
onTermsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.tangem_pay_terms_fees_limits),
onClick = onTermsClick,
)
NavigationPrimaryButton(
primaryButton = NavigationButton(
textReference = resourceReference(R.string.tangempay_onboarding_get_card_button_text),
iconRes = R.drawable.ic_tangem_24,
isIconVisible = true,
shouldShowProgress = isLoading,
onClick = onGetCardClick,
),
)
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.features.tangempay.ui
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun TangemPayOnboardingBlock(
@DrawableRes painterRes: Int,
titleRef: TextReference,
descriptionRef: TextReference,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier) {
Icon(
painter = painterResource(id = painterRes),
contentDescription = null,
modifier = Modifier.size(width = 24.dp, height = 24.dp),
tint = TangemTheme.colors.icon.accent,
)
Column(
modifier = Modifier
.padding(start = 12.dp)
.fillMaxWidth(),
) {
Text(
text = titleRef.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
text = descriptionRef.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}

View file

@ -0,0 +1,120 @@
package com.tangem.features.tangempay.hotwallet
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
internal class TangemPayHotWalletOnboardingModelTest {
private val createHotWalletUseCase: CreateHotWalletUseCase = mockk()
private val isHotWalletCreationSupported: IsHotWalletCreationSupported = mockk() {
every { getLeastVersionName() } returns "Android 10"
}
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk()
private val router: Router = mockk(relaxed = true)
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val testUserWalletId = UserWalletId("1234567890ABCDEF")
private val testUserWallet: UserWallet.Hot = mockk(relaxed = true) {
every { walletId } returns testUserWalletId
}
@Nested
inner class OnTermsClick {
@Test
fun `WHEN onTermsClick THEN urlOpener called with terms link`() = runTest {
val model = createModel()
model.uiState.value.onTermsClick.invoke()
verify { urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) }
}
}
@Nested
inner class OnGetCardClick {
@Test
fun `GIVEN hot wallet creation not supported WHEN onGetCardClick THEN wallet creation not attempted`() =
runTest {
every { isHotWalletCreationSupported() } returns false
coEvery { clearAppsFlyerDeeplinkUseCase(any()) } just Runs
val model = createModel()
model.uiState.value.onGetCardClick.invoke()
verify { uiMessageSender.send(any()) }
verify { router.replaceCurrent(AppRoute.Home()) }
coVerify { clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) }
coVerify(exactly = 0) { createHotWalletUseCase(any(), any()) }
}
@Test
fun `GIVEN hot wallet supported AND wallet creation succeeds WHEN onGetCardClick THEN deeplink cleared AND navigate to CreateWalletBackup`() =
runTest {
every { isHotWalletCreationSupported() } returns true
coEvery { createHotWalletUseCase(HotAuth.NoAuth, MnemonicType.Words12) } returns testUserWallet.right()
coEvery { clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } just Runs
val model = createModel()
model.uiState.value.onGetCardClick.invoke()
coVerify { clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) }
verify {
router.replaceCurrent(
match { it is AppRoute.CreateWalletBackup && it.userWalletId == testUserWalletId },
)
}
}
@Test
fun `GIVEN hot wallet supported AND wallet creation fails WHEN onGetCardClick THEN error dialog sent`() =
runTest {
every { isHotWalletCreationSupported() } returns true
coEvery {
createHotWalletUseCase(HotAuth.NoAuth, MnemonicType.Words12)
} returns RuntimeException("error").left()
val model = createModel()
model.uiState.value.onGetCardClick.invoke()
assertThat(model.uiState.value.isLoading).isFalse()
verify { uiMessageSender.send(match<DialogMessage> { true }) }
coVerify(exactly = 0) { clearAppsFlyerDeeplinkUseCase(any()) }
verify(exactly = 0) { router.replaceCurrent(any()) }
}
}
private fun createModel(): TangemPayHotWalletOnboardingModel {
return TangemPayHotWalletOnboardingModel(
dispatchers = TestingCoroutineDispatcherProvider(),
createHotWalletUseCase = createHotWalletUseCase,
isHotWalletCreationSupported = isHotWalletCreationSupported,
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
router = router,
uiMessageSender = uiMessageSender,
urlOpener = urlOpener,
)
}
}

View file

@ -432,9 +432,12 @@ internal class WalletSettingsModel @Inject constructor(
AppRoute.CreateWalletBackup( AppRoute.CreateWalletBackup(
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
isUpgradeFlow = false, isUpgradeFlow = false,
shouldSetAccessCode = true,
analyticsSource = AnalyticsParam.ScreensSources.WalletSettings.value, analyticsSource = AnalyticsParam.ScreensSources.WalletSettings.value,
analyticsAction = RecoveryPhraseScreenAction.AccessCode.value, analyticsAction = RecoveryPhraseScreenAction.AccessCode.value,
nextScreen = AppRoute.UpdateAccessCode(
userWalletId = params.userWalletId,
source = AnalyticsParam.ScreensSources.WalletSettings.value,
),
), ),
) )
closeBs() closeBs()

View file

@ -361,6 +361,7 @@ include(":domain:push-notification-preferences")
include(":domain:transaction") include(":domain:transaction")
include(":domain:transaction:models") include(":domain:transaction:models")
include(":domain:analytics") include(":domain:analytics")
include(":domain:appsflyer")
include(":domain:visa") include(":domain:visa")
include(":domain:visa:models") include(":domain:visa:models")
include(":domain:payment") include(":domain:payment")
@ -423,6 +424,7 @@ include(":data:txhistory")
include(":data:wallets") include(":data:wallets")
include(":data:analytics") include(":data:analytics")
include(":data:transaction") include(":data:transaction")
include(":data:appsflyer")
include(":data:visa") include(":data:visa")
include(":data:payment") include(":data:payment")
include(":data:virtual-account") include(":data:virtual-account")