Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-21 15:24:13 +01:00
commit 858a91d095
22 changed files with 387 additions and 103 deletions

View file

@ -25,7 +25,6 @@ import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.nft.component.NFTComponent import com.tangem.features.nft.component.NFTComponent
import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
import com.tangem.features.onramp.component.* import com.tangem.features.onramp.component.*
import com.tangem.features.pushnotifications.api.PushNotificationsComponent import com.tangem.features.pushnotifications.api.PushNotificationsComponent
@ -112,7 +111,6 @@ internal class ChildFactory @Inject constructor(
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,
private val addressSyncComponentFactory: AddressSyncComponent.Factory,
) { ) {
@Suppress("LongMethod", "CyclomaticComplexMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
@ -262,6 +260,8 @@ internal class ChildFactory @Inject constructor(
OnboardingEntryComponent.Mode.ContinueFinalize OnboardingEntryComponent.Mode.ContinueFinalize
is AppRoute.Onboarding.Mode.UpgradeHotWallet -> is AppRoute.Onboarding.Mode.UpgradeHotWallet ->
OnboardingEntryComponent.Mode.UpgradeHotWallet(mode.userWalletId) OnboardingEntryComponent.Mode.UpgradeHotWallet(mode.userWalletId)
is AppRoute.Onboarding.Mode.AddressSync ->
OnboardingEntryComponent.Mode.AddressSync
}, },
), ),
componentFactory = onboardingEntryComponentFactory, componentFactory = onboardingEntryComponentFactory,
@ -694,13 +694,6 @@ internal class ChildFactory @Inject constructor(
componentFactory = feedEntryComponentFactory, componentFactory = feedEntryComponentFactory,
) )
} }
is AppRoute.AddressSync -> {
createComponentChild(
context = context,
params = AddressSyncComponent.Params(data = Unit),
componentFactory = addressSyncComponentFactory,
)
}
} }
} }
} }

View file

@ -330,6 +330,7 @@ sealed class AppRoute(val path: String) : Route {
data object RecreateWalletTwin : Mode() // reset twins data object RecreateWalletTwin : Mode() // reset twins
data object ContinueFinalize : Mode() // continue finalize process (unfinished backup dialog) data object ContinueFinalize : Mode() // continue finalize process (unfinished backup dialog)
data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() // upgrade hot wallet data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() // upgrade hot wallet
data object AddressSync : Mode()
} }
} }
@ -484,7 +485,4 @@ sealed class AppRoute(val path: String) : Route {
@Serializable @Serializable
data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId") data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId")
@Serializable
data object AddressSync : AppRoute(path = "/address_sync")
} }

View file

@ -237,7 +237,10 @@ internal class CreateWalletStartModel @Inject constructor(
ifRight = { ifRight = {
setLoading(false) setLoading(false)
val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) {
AppRoute.AddressSync AppRoute.Onboarding(
scanResponse = scanResponse,
mode = AppRoute.Onboarding.Mode.AddressSync,
)
} else { } else {
AppRoute.Wallet AppRoute.Wallet
} }

View file

@ -531,7 +531,17 @@ internal class CreateWalletStartModelTest {
model.uiState.value.onScanClick.invoke() model.uiState.value.onScanClick.invoke()
advanceUntilIdle() advanceUntilIdle()
verify { appRouter.replaceAll(routes = arrayOf(AppRoute.AddressSync), onComplete = any()) } verify {
appRouter.replaceAll(
routes = arrayOf(
AppRoute.Onboarding(
scanResponse = testScanResponse,
mode = AppRoute.Onboarding.Mode.AddressSync,
)
),
onComplete = any()
)
}
} }
@Test @Test
@ -573,9 +583,9 @@ internal class CreateWalletStartModelTest {
) )
assert(state.imageResId == R.drawable.img_hardware_wallet) assert(state.imageResId == R.drawable.img_hardware_wallet)
assert(state.shouldShowScanSecondaryButton) assert(state.shouldShowScanSecondaryButton)
assert(state.primaryButtonText == resourceReference(R.string.details_buy_wallet),) assert(state.primaryButtonText == resourceReference(R.string.details_buy_wallet))
assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_mobile_title),) assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_mobile_title))
assert(state.otherMethodDescription == null,) assert(state.otherMethodDescription == null)
assert(state.isScanInProgress.not()) assert(state.isScanInProgress.not())
} }

View file

@ -19,6 +19,7 @@ interface OnboardingEntryComponent : ComposableContentComponent {
data object RecreateWalletTwin : Mode() data object RecreateWalletTwin : Mode()
data object ContinueFinalize : Mode() data object ContinueFinalize : Mode()
data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode()
data object AddressSync : Mode()
} }
interface Factory : ComponentFactory<Params, OnboardingEntryComponent> interface Factory : ComponentFactory<Params, OnboardingEntryComponent>

View file

@ -11,11 +11,16 @@ android {
namespace = "com.tangem.features.onboarding.v2.impl" namespace = "com.tangem.features.onboarding.v2.impl"
} }
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies { dependencies {
/** Api */ /** Api */
implementation(projects.features.onboardingV2.api) implementation(projects.features.onboardingV2.api)
implementation(projects.features.manageTokens.api) implementation(projects.features.manageTokens.api)
implementation(projects.features.biometry.api) implementation(projects.features.biometry.api)
implementation(projects.features.pushNotifications.api)
implementation(projects.features.hotWallet.api) implementation(projects.features.hotWallet.api)
implementation(projects.features.tokenRecieve.api) implementation(projects.features.tokenRecieve.api)
@ -87,4 +92,11 @@ dependencies {
/** DI */ /** DI */
implementation(deps.hilt.android) implementation(deps.hilt.android)
kapt(deps.hilt.kapt) kapt(deps.hilt.kapt)
/** 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,13 +1,5 @@
package com.tangem.features.onboarding.v2.addresssync package com.tangem.features.onboarding.v2.addresssync
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
interface AddressSyncComponent : ComposableContentComponent { interface AddressSyncComponent : ComposableContentComponent
data class Params(
val data: Any, // TODO("Will be implemented during [REDACTED_TASK_KEY]")
)
interface Factory : ComponentFactory<Params, AddressSyncComponent>
}

View file

@ -2,38 +2,138 @@ package com.tangem.features.onboarding.v2.addresssync
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.DelicateDecomposeApi
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.value.Value
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.context.AppComponentContext 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.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncIntent
import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel
import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep
import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncContent import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncContent
import dagger.assisted.Assisted import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import dagger.assisted.AssistedFactory import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import dagger.assisted.AssistedInject import com.tangem.features.pushnotifications.api.PushNotificationsParams
internal class DefaultAddressSyncComponent @AssistedInject constructor( @OptIn(DelicateDecomposeApi::class)
@Assisted appComponentContext: AppComponentContext, internal class DefaultAddressSyncComponent(
@Assisted params: AddressSyncComponent.Params, appComponentContext: AppComponentContext,
private val askBiometryComponentFactory: AskBiometryComponent.Factory,
private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory,
) : AppComponentContext by appComponentContext, AddressSyncComponent { ) : AppComponentContext by appComponentContext, AddressSyncComponent {
private val model: AddressSyncModel = getOrCreateModel(params) private val model: AddressSyncModel = getOrCreateModel()
private val childStack: Value<ChildStack<AddressSyncStep, ComposableContentComponent>> =
childStack(
key = "innerStack",
source = model.stackNavigation,
serializer = null,
initialConfiguration = AddressSyncStep.ASK_BIOMETRY,
handleBackButton = true,
childFactory = { configuration, factoryContext ->
createChild(
step = configuration,
childContext = childByContext(factoryContext),
)
},
)
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle() AddressSyncContent(
modifier = modifier,
childContent = {
Children(
stack = childStack,
) { child ->
child.instance.Content(Modifier)
}
},
)
AddressSyncContent(modifier = modifier, state = state) BackHandler {
model.onIntent(AddressSyncIntent.Back)
BackHandler(onBack = state.onBackClick) }
} }
@AssistedFactory private fun createChild(step: AddressSyncStep, childContext: AppComponentContext): ComposableContentComponent {
interface Factory : AddressSyncComponent.Factory { return when (step) {
override fun create( AddressSyncStep.ASK_BIOMETRY -> createAskBiometryComponent(childContext)
context: AppComponentContext, AddressSyncStep.ASK_NOTIFICATIONS -> createPushNotificationComponent(childContext)
params: AddressSyncComponent.Params, AddressSyncStep.ADDRESS_SYNC -> TODO("Will be implemented during [REDACTED_TASK_KEY]")
): DefaultAddressSyncComponent }
}
private fun createAskBiometryComponent(childContext: AppComponentContext): AskBiometryComponent {
return askBiometryComponentFactory.create(
context = childContext,
params = AskBiometryComponent.Params(
isBottomSheetVariant = false,
modelCallbacks = object : AskBiometryComponent.ModelCallbacks {
override fun onAllowed() {
model.onIntent(
AddressSyncIntent.Next(
step = AddressSyncStep.ASK_NOTIFICATIONS,
shouldReplace = true,
),
)
}
override fun onDenied() {
model.onIntent(
AddressSyncIntent.Next(
step = AddressSyncStep.ASK_NOTIFICATIONS,
shouldReplace = false,
),
)
}
},
),
)
}
private fun createPushNotificationComponent(childContext: AppComponentContext): PushNotificationsComponent {
return pushNotificationsComponentFactory.create(
context = childContext,
params = PushNotificationsParams(
modelCallbacks = object : PushNotificationsModelCallbacks {
override fun onAllowSystemPermission() {
model.onIntent(
AddressSyncIntent.Next(
step = AddressSyncStep.ADDRESS_SYNC,
shouldReplace = true,
),
)
}
override fun onDenySystemPermission() {
model.onIntent(
AddressSyncIntent.Next(
step = AddressSyncStep.ADDRESS_SYNC,
shouldReplace = false,
),
)
}
override fun onDismiss() {
model.onIntent(
AddressSyncIntent.Next(
step = AddressSyncStep.ADDRESS_SYNC,
shouldReplace = false,
),
)
}
},
source = AppRoute.PushNotification.Source.Onboarding,
),
)
} }
} }

View file

@ -1,18 +0,0 @@
package com.tangem.features.onboarding.v2.addresssync.di
import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent
import com.tangem.features.onboarding.v2.addresssync.DefaultAddressSyncComponent
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 AddressSyncComponentModule {
@Binds
@Singleton
fun bindAddressSyncComponentFactory(factory: DefaultAddressSyncComponent.Factory): AddressSyncComponent.Factory
}

View file

@ -1,5 +0,0 @@
package com.tangem.features.onboarding.v2.addresssync.entity
internal data class AddressSyncUM(
val onBackClick: () -> Unit,
)

View file

@ -0,0 +1,8 @@
package com.tangem.features.onboarding.v2.addresssync.model
import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep
sealed interface AddressSyncIntent {
data class Next(val step: AddressSyncStep, val shouldReplace: Boolean) : AddressSyncIntent
data object Back : AddressSyncIntent
}

View file

@ -1,22 +1,68 @@
package com.tangem.features.onboarding.v2.addresssync.model package com.tangem.features.onboarding.v2.addresssync.model
import com.arkivanov.decompose.DelicateDecomposeApi
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.push
import com.arkivanov.decompose.router.stack.replaceCurrent
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.features.onboarding.v2.addresssync.entity.AddressSyncUM import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.ShouldAskPermissionUseCase
import com.tangem.domain.settings.ShouldShowAskBiometryUseCase
import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
@OptIn(DelicateDecomposeApi::class)
@ModelScoped @ModelScoped
internal class AddressSyncModel @Inject constructor( internal class AddressSyncModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase,
private val canUseBiometryUseCase: CanUseBiometryUseCase,
private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase,
) : Model() { ) : Model() {
val uiState: StateFlow<AddressSyncUM> val stackNavigation = StackNavigation<AddressSyncStep>()
field: MutableStateFlow<AddressSyncUM> = MutableStateFlow(getInitialState())
private fun getInitialState(): AddressSyncUM { fun onIntent(intent: AddressSyncIntent) {
TODO("Will be implemented during [REDACTED_TASK_KEY]") when (intent) {
is AddressSyncIntent.Next -> nextScreen(intent)
AddressSyncIntent.Back -> goBack()
}
}
private fun nextScreen(next: AddressSyncIntent.Next) {
val (nextStep, replace) = next
if (replace) {
stackNavigation.replaceCurrent(configuration = nextStep)
} else {
stackNavigation.push(configuration = nextStep)
}
modelScope.launch { trySkippingScreen(next) }
}
private suspend fun trySkippingScreen(next: AddressSyncIntent.Next) {
when (next.step) {
AddressSyncStep.ASK_BIOMETRY -> {
val shouldShowAskBiometry = canUseBiometryUseCase.strict() && shouldShowAskBiometryUseCase()
if (shouldShowAskBiometry.not()) {
nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, shouldReplace = true))
}
}
AddressSyncStep.ASK_NOTIFICATIONS -> {
val shouldShowAskNotification = shouldAskPermissionUseCase(PUSH_PERMISSION)
if (shouldShowAskNotification.not()) {
nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ADDRESS_SYNC, shouldReplace = true))
}
}
AddressSyncStep.ADDRESS_SYNC -> Unit
}
}
private fun goBack() {
stackNavigation.pop()
} }
} }

View file

@ -0,0 +1,7 @@
package com.tangem.features.onboarding.v2.addresssync.navigation
enum class AddressSyncStep {
ASK_BIOMETRY,
ASK_NOTIFICATIONS,
ADDRESS_SYNC,
}

View file

@ -2,20 +2,19 @@ package com.tangem.features.onboarding.v2.addresssync.ui
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
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
import com.tangem.features.onboarding.v2.addresssync.entity.AddressSyncUM
@Composable @Composable
internal fun AddressSyncContent(state: AddressSyncUM, modifier: Modifier = Modifier) { internal fun AddressSyncContent(modifier: Modifier = Modifier, childContent: @Composable (Modifier) -> Unit = {}) {
Column( Column(
modifier = modifier modifier = modifier
.background(color = TangemTheme.colors.background.secondary) .background(color = TangemTheme.colors.background.secondary)
@ -24,31 +23,15 @@ internal fun AddressSyncContent(state: AddressSyncUM, modifier: Modifier = Modif
.systemBarsPadding(), .systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AppBarWithBackButton( childContent(Modifier)
onBackClick = state.onBackClick,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = TangemTheme.dimens.spacing16)
.weight(1f),
) {}
} }
} }
@Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
private fun AddressSyncContentPreview(@PreviewParameter(PreviewStateProvider::class) state: AddressSyncUM) { private fun AddressSyncContentPreview() {
TangemThemePreview { TangemThemePreview {
AddressSyncContent(state = state) AddressSyncContent()
} }
} }
private class PreviewStateProvider : CollectionPreviewParameterProvider<AddressSyncUM>(
buildList {
TODO("Will be implemented during [REDACTED_TASK_KEY]")
},
)

View file

@ -82,6 +82,7 @@ internal class OnboardingEntryModel @Inject constructor(
is Mode.UpgradeHotWallet -> OnboardingMultiWalletComponent.Mode.UpgradeHotWallet( is Mode.UpgradeHotWallet -> OnboardingMultiWalletComponent.Mode.UpgradeHotWallet(
userWalletId = mode.userWalletId, userWalletId = mode.userWalletId,
) )
is Mode.AddressSync -> OnboardingMultiWalletComponent.Mode.AddressSync
else -> error("Incorrect onboarding type") else -> error("Incorrect onboarding type")
} }

View file

@ -24,6 +24,7 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi
data object AddBackup : Mode() data object AddBackup : Mode()
data object ContinueFinalize : Mode() data object ContinueFinalize : Mode()
data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode()
data object AddressSync : Mode()
} }
interface Factory : ComponentFactory<Params, OnboardingMultiWalletComponent> interface Factory : ComponentFactory<Params, OnboardingMultiWalletComponent>

View file

@ -25,8 +25,10 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerNavigation import com.tangem.core.decompose.navigation.inner.InnerNavigation
import com.tangem.core.decompose.navigation.inner.InnerNavigationState import com.tangem.core.decompose.navigation.inner.InnerNavigationState
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.onboarding.v2.addresssync.DefaultAddressSyncComponent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.MultiWalletAccessCodeComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.MultiWalletAccessCodeComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent
@ -42,6 +44,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiW
import com.tangem.features.onboarding.v2.multiwallet.impl.ui.OnboardingMultiWallet import com.tangem.features.onboarding.v2.multiwallet.impl.ui.OnboardingMultiWallet
import com.tangem.features.onboarding.v2.multiwallet.impl.ui.WalletArtworksState import com.tangem.features.onboarding.v2.multiwallet.impl.ui.WalletArtworksState
import com.tangem.features.onboarding.v2.util.ResetCardsComponent import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -53,6 +56,8 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor
@Assisted private val params: OnboardingMultiWalletComponent.Params, @Assisted private val params: OnboardingMultiWalletComponent.Params,
private val analyticsHandler: AnalyticsEventHandler, private val analyticsHandler: AnalyticsEventHandler,
private val resetCardsComponentFactory: ResetCardsComponent.Factory, private val resetCardsComponentFactory: ResetCardsComponent.Factory,
private val askBiometryComponentFactory: AskBiometryComponent.Factory,
private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory,
) : OnboardingMultiWalletComponent, AppComponentContext by context { ) : OnboardingMultiWalletComponent, AppComponentContext by context {
private val model: OnboardingMultiWalletModel = getOrCreateModel(params) private val model: OnboardingMultiWalletModel = getOrCreateModel(params)
@ -60,6 +65,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor
private val artworksState = instanceKeeper.getOrCreateSimple(key = "artworksState") { private val artworksState = instanceKeeper.getOrCreateSimple(key = "artworksState") {
MutableStateFlow( MutableStateFlow(
when (model.state.value.currentStep) { when (model.state.value.currentStep) {
AddressSync -> WalletArtworksState.Hidden
UpgradeWallet -> WalletArtworksState.Folded UpgradeWallet -> WalletArtworksState.Folded
CreateWallet -> WalletArtworksState.Folded CreateWallet -> WalletArtworksState.Folded
ChooseBackupOption -> WalletArtworksState.Fan ChooseBackupOption -> WalletArtworksState.Fan
@ -186,6 +192,11 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor
onBack = { model.onBack() }, onBack = { model.onBack() },
onEvent = ::handleFinalizeComponentEvent, onEvent = ::handleFinalizeComponentEvent,
) )
AddressSync -> DefaultAddressSyncComponent(
appComponentContext = childContext,
askBiometryComponentFactory = askBiometryComponentFactory,
pushNotificationsComponentFactory = pushNotificationsComponentFactory,
)
Done -> error("Unexpected Done state") Done -> error("Unexpected Done state")
} }
} }

View file

@ -265,6 +265,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
val userWallet = when (params.parentParams.mode) { val userWallet = when (params.parentParams.mode) {
OnboardingMultiWalletComponent.Mode.Onboarding, OnboardingMultiWalletComponent.Mode.Onboarding,
OnboardingMultiWalletComponent.Mode.ContinueFinalize, OnboardingMultiWalletComponent.Mode.ContinueFinalize,
OnboardingMultiWalletComponent.Mode.AddressSync,
-> { -> {
saveWalletUseCase.invoke( saveWalletUseCase.invoke(
userWallet = userWalletCreated.copy( userWallet = userWalletCreated.copy(

View file

@ -120,8 +120,12 @@ internal class OnboardingMultiWalletModel @Inject constructor(
params.mode is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> { params.mode is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> {
OnboardingMultiWalletState.Step.UpgradeWallet OnboardingMultiWalletState.Step.UpgradeWallet
} }
params.mode == OnboardingMultiWalletComponent.Mode.ContinueFinalize -> params.mode == OnboardingMultiWalletComponent.Mode.ContinueFinalize -> {
OnboardingMultiWalletState.Step.Finalize OnboardingMultiWalletState.Step.Finalize
}
params.mode == OnboardingMultiWalletComponent.Mode.AddressSync -> {
OnboardingMultiWalletState.Step.AddressSync
}
// Add backup button // Add backup button
// Wallet1 without backup and userwallet's scanResponse doesn't contain primary card. // Wallet1 without backup and userwallet's scanResponse doesn't contain primary card.
card.wallets.isNotEmpty() && card.backupStatus == CardDTO.BackupStatus.NoBackup && card.wallets.isNotEmpty() && card.backupStatus == CardDTO.BackupStatus.NoBackup &&

View file

@ -38,6 +38,7 @@ data class OnboardingMultiWalletState(
SeedPhrase, SeedPhrase,
ScanPrimary, ScanPrimary,
AddBackupDevice, AddBackupDevice,
AddressSync,
Finalize, Finalize,
Done, Done,
} }

View file

@ -15,4 +15,5 @@ fun screenTitleByStep(step: OnboardingMultiWalletState.Step): TextReference = wh
OnboardingMultiWalletState.Step.Finalize -> resourceReference(R.string.onboarding_button_finalize_backup) OnboardingMultiWalletState.Step.Finalize -> resourceReference(R.string.onboarding_button_finalize_backup)
OnboardingMultiWalletState.Step.Done -> resourceReference(R.string.common_done) OnboardingMultiWalletState.Step.Done -> resourceReference(R.string.common_done)
OnboardingMultiWalletState.Step.UpgradeWallet -> resourceReference(R.string.common_tangem) OnboardingMultiWalletState.Step.UpgradeWallet -> resourceReference(R.string.common_tangem)
OnboardingMultiWalletState.Step.AddressSync -> TODO("Will be implemented during [REDACTED_TASK_KEY]")
} }

View file

@ -0,0 +1,134 @@
package com.tangem.features.onboarding.v2.addresssync.model
import com.arkivanov.decompose.router.stack.StackNavigation
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.ShouldAskPermissionUseCase
import com.tangem.domain.settings.ShouldShowAskBiometryUseCase
import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class AddressSyncModelTest {
private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase = mockk()
private val canUseBiometryUseCase: CanUseBiometryUseCase = mockk()
private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase = mockk()
@BeforeEach
fun setUp() {
coEvery { canUseBiometryUseCase.strict() } returns false
coEvery { shouldShowAskBiometryUseCase() } returns false
coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false
}
@Test
fun `GIVEN biometry allowed AND should show biometry WHEN Next ASK_BIOMETRY THEN ASK_BIOMETRY on top`() = runTest {
coEvery { canUseBiometryUseCase.strict() } returns true
coEvery { shouldShowAskBiometryUseCase() } returns true
val model = createModel(this)
val stack = model.stackNavigation.trackStack()
model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, shouldReplace = false))
advanceUntilIdle()
assert(stack == listOf(AddressSyncStep.ASK_BIOMETRY))
}
@Test
fun `GIVEN biometry skipped AND notifications required WHEN Next ASK_BIOMETRY THEN ASK_NOTIFICATIONS on top`() =
runTest {
coEvery { canUseBiometryUseCase.strict() } returns true
coEvery { shouldShowAskBiometryUseCase() } returns false
coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns true
val model = createModel(this)
val stack = model.stackNavigation.trackStack()
model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, shouldReplace = false))
advanceUntilIdle()
assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS))
}
@Test
fun `GIVEN biometry skipped AND notifications skipped WHEN Next ASK_BIOMETRY THEN ADDRESS_SYNC on top`() = runTest {
coEvery { canUseBiometryUseCase.strict() } returns true
coEvery { shouldShowAskBiometryUseCase() } returns false
coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false
val model = createModel(this)
val stack = model.stackNavigation.trackStack()
model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, shouldReplace = false))
advanceUntilIdle()
assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC))
}
@Test
fun `GIVEN notifications required WHEN Next ASK_NOTIFICATIONS THEN ASK_NOTIFICATIONS on top`() = runTest {
coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns true
val model = createModel(this)
val stack = model.stackNavigation.trackStack()
model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, shouldReplace = false))
advanceUntilIdle()
assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS))
}
@Test
fun `GIVEN notifications skipped WHEN Next ASK_NOTIFICATIONS THEN ADDRESS_SYNC on top`() = runTest {
coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false
val model = createModel(this)
val stack = model.stackNavigation.trackStack()
model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, shouldReplace = false))
advanceUntilIdle()
assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC))
}
private fun StackNavigation<AddressSyncStep>.trackStack(): List<AddressSyncStep> {
val tracked = mutableListOf<AddressSyncStep>()
subscribe { event ->
val newStack = event.transformer(tracked.toList())
tracked.clear()
tracked.addAll(newStack)
}
return tracked
}
private fun createModel(testScope: TestScope): AddressSyncModel {
return AddressSyncModel(
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
shouldShowAskBiometryUseCase = shouldShowAskBiometryUseCase,
canUseBiometryUseCase = canUseBiometryUseCase,
shouldAskPermissionUseCase = shouldAskPermissionUseCase,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}