Updated on 2026-08-14

This commit is contained in:
Tangem 2024-10-04 17:29:01 +05:00
parent 7228c72553
commit 5b60d94e60
29 changed files with 1034 additions and 110 deletions

View file

@ -12,6 +12,7 @@ import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.currency.getNetwork
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.BackendId
import com.tangem.domain.card.repository.DerivationsRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.model.CryptoCurrency
@ -73,15 +74,15 @@ internal class DefaultDerivationsRepository(
override suspend fun hasMissedDerivations(
userWalletId: UserWalletId,
networksWithDerivationPath: Map<Network.ID, String?>,
networksWithDerivationPath: Map<BackendId, String?>,
): Boolean {
val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("User wallet not found")
val derivations = MissedDerivationsFinder(scanResponse = userWallet.scanResponse)
.findByNetworks(
networksWithDerivationPath.mapNotNull { (networkId, extraDerivationPath) ->
networksWithDerivationPath.mapNotNull { (backendId, extraDerivationPath) ->
getNetwork(
blockchain = Blockchain.fromNetworkId(networkId.value) ?: return@mapNotNull null,
blockchain = Blockchain.fromNetworkId(backendId) ?: return@mapNotNull null,
extraDerivationPath = extraDerivationPath,
scanResponse = userWallet.scanResponse,
)

View file

@ -23,6 +23,7 @@ import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.global.GlobalState
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletAction
import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE
import com.tangem.tap.features.saveWallet.redux.SaveWalletAction
import com.tangem.tap.mainScope
@ -82,6 +83,56 @@ object OnboardingHelper {
}
}
fun saveWallet(
scanResponse: ScanResponse,
accessCode: String? = null,
backupCardsIds: List<String>? = null,
hasBackupError: Boolean = false,
) {
Analytics.setContext(scanResponse)
scope.launch {
val settingsRepository = store.inject(DaggerGraphState::settingsRepository)
when {
// When should save user wallets, then save card without navigate to save wallet screen
store.inject(DaggerGraphState::walletsRepository).shouldSaveUserWalletsSync() -> {
store.dispatchWithMain(
SaveWalletAction.ProvideBackupInfo(
scanResponse = scanResponse,
accessCode = accessCode,
backupCardsIds = backupCardsIds?.toSet(),
),
)
store.dispatchWithMain(
SaveWalletAction.SaveWalletAfterBackup(
hasBackupError = hasBackupError,
shouldNavigateToWallet = false,
),
)
}
// When should not save user wallets but device has biometry and save wallet screen has not been shown,
// then open save wallet screen
tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen() -> {
proceedWithScanResponse(scanResponse, backupCardsIds, hasBackupError)
delay(timeMillis = 1_200)
store.dispatchOnMain(
SaveWalletAction.ProvideBackupInfo(
scanResponse = scanResponse,
accessCode = accessCode,
backupCardsIds = backupCardsIds?.toSet(),
),
)
}
// If device has no biometry and save wallet screen has been shown, then go through old scenario
else -> {
proceedWithScanResponse(scanResponse, backupCardsIds, hasBackupError)
}
}
}
}
fun trySaveWalletAndNavigateToWalletScreen(
scanResponse: ScanResponse,
accessCode: String? = null,
@ -103,7 +154,12 @@ object OnboardingHelper {
),
)
store.dispatchWithMain(SaveWalletAction.SaveWalletAfterBackup(hasBackupError))
store.dispatchWithMain(
SaveWalletAction.SaveWalletAfterBackup(
hasBackupError = hasBackupError,
shouldNavigateToWallet = true,
),
)
}
// When should not save user wallets but device has biometry and save wallet screen has not been shown,
// then open save wallet screen
@ -200,6 +256,7 @@ object OnboardingHelper {
}
.doOnSuccess {
mainScope.launch { store.onUserWalletSelected(userWallet) }
store.dispatch(OnboardingWalletAction.WalletSaved(userWallet.walletId))
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.tap.features.onboarding.products.wallet.redux
import android.net.Uri
import com.tangem.common.CompletionResult
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.onboarding.data.model.CreateWalletResponse
import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource
import com.tangem.tap.domain.tasks.product.CreateProductWalletTaskResponse
@ -28,6 +29,8 @@ sealed class OnboardingWalletAction : Action {
class SetThirdCardArtworkUrl(val artworkUri: Uri?) : OnboardingWalletAction()
data object OnBackPressed : OnboardingWalletAction()
data class WalletSaved(val userWalletId: UserWalletId) : OnboardingWalletAction()
}
sealed class OnboardingWallet2Action : OnboardingWalletAction() {
@ -89,6 +92,8 @@ sealed class BackupAction : Action {
data class FinishBackup(val withAnalytics: Boolean = true) : BackupAction()
data class BackupFinished(val userWalletId: UserWalletId?) : BackupAction()
data object DiscardBackup : BackupAction()
data object DiscardSavedBackup : BackupAction()
data object ResumeFoundUnfinishedBackup : BackupAction()

View file

@ -5,7 +5,6 @@ import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.guard
import com.tangem.common.extensions.ifNotNull
import com.tangem.common.extensions.toHexString
import com.tangem.common.routing.AppRoute
@ -19,6 +18,7 @@ import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.Artwork
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.onboarding.data.model.CreateWalletResponse
import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource
import com.tangem.feature.wallet.presentation.wallet.domain.BackupValidator
@ -164,16 +164,7 @@ private fun handleWalletAction(action: Action) {
}
is OnboardingWalletAction.FinishOnboarding -> {
store.dispatch(GlobalAction.Onboarding.Stop)
if (scanResponse == null) {
action.scope.launch {
readCard { newScanResponse ->
handleFinishOnboardind(newScanResponse)
}
}
} else {
handleFinishOnboardind(scanResponse)
}
navigateToWalletScreen()
}
is OnboardingWalletAction.ResumeBackup -> {
val newAction = when (val backupState = backupService.currentState) {
@ -197,10 +188,10 @@ private fun handleWalletAction(action: Action) {
}
}
private fun handleFinishOnboardind(scanResponse: ScanResponse) {
private fun handleFinishBackup(scanResponse: ScanResponse) {
val backupState = store.state.onboardingWalletState.backupState
val updatedScanResponse = updateScanResponseAfterBackup(scanResponse, backupState)
OnboardingHelper.trySaveWalletAndNavigateToWalletScreen(
OnboardingHelper.saveWallet(
scanResponse = updatedScanResponse,
accessCode = backupState.accessCode,
backupCardsIds = backupState.backupCardIds,
@ -208,7 +199,18 @@ private fun handleFinishOnboardind(scanResponse: ScanResponse) {
)
}
private suspend fun readCard(onSuccess: (ScanResponse) -> Unit) {
private fun navigateToWalletScreen() {
mainScope.launch {
val settingsRepository = store.inject(DaggerGraphState::settingsRepository)
store.dispatchNavigationAction { replaceAll(AppRoute.Wallet) }
if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) {
delay(timeMillis = 1_800)
store.dispatchNavigationAction { push(AppRoute.SaveWallet) }
}
}
}
private suspend fun readCard(onSuccess: suspend (ScanResponse) -> Unit) {
val shouldSaveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes()
store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy(
@ -403,7 +405,7 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
val onboardingWalletState = appState()?.onboardingWalletState ?: return
val backupState = onboardingWalletState.backupState
val scanResponse = globalState.onboardingState.onboardingManager?.scanResponse
var scanResponse = globalState.onboardingState.onboardingManager?.scanResponse
val card = scanResponse?.card
when (action) {
@ -573,6 +575,15 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
cardIds = gatherCardIds(backupState, card),
)
}
if (scanResponse == null) {
scope.launch {
readCard { newScanResponse ->
handleFinishBackup(newScanResponse)
}
}
} else {
handleFinishBackup(scanResponse)
}
}
is BackupAction.FinishBackup -> {
scope.launch {
@ -580,28 +591,31 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
Analytics.send(Onboarding.Backup.Finished(backupState.backupCardsNumber))
}
var userWallet: UserWallet? = null
if (scanResponse != null) {
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
val userWallet = UserWalletBuilder(scanResponse, walletNameGenerateUseCase)
.backupCardsIds(backupState.backupCardIds.toSet())
.build()
.guard {
Timber.e("User wallet not created")
return@launch
}
userWallet = createUserWallet(
scanResponse = requireNotNull(value = scanResponse, lazyMessage = { "ScanResponse is null" }),
backupState = backupState,
)
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
userWalletsListManager.update(
userWalletId = userWallet.walletId,
update = { wallet ->
wallet.copy(
scanResponse = updateScanResponseAfterBackup(
scanResponse = wallet.scanResponse,
backupState = backupState,
userWalletsListManager.save(
userWallet = userWallet.copy(
scanResponse = updateScanResponseAfterBackup(
scanResponse = requireNotNull(
value = scanResponse,
lazyMessage = { "ScanResponse is null" },
),
)
},
backupState = backupState,
),
),
canOverride = true,
)
} else {
readCard { newScanResponse ->
scanResponse = newScanResponse
userWallet = createUserWallet(newScanResponse, backupState)
}
}
val notActivatedCardIds = gatherCardIds(backupState, card).mapNotNull {
@ -613,11 +627,18 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
}
// All cardIds may already be activated if the backup was skipped before.
if (notActivatedCardIds.isEmpty()) return@launch
if (notActivatedCardIds.isEmpty()) {
delay(1000)
store.dispatch(BackupAction.BackupFinished(userWallet?.walletId))
return@launch
}
Analytics.send(Onboarding.Finished())
store.state.globalState.onboardingState.onboardingManager?.finishActivation(notActivatedCardIds)
handleFinishBackup(requireNotNull(scanResponse))
delay(1000)
store.dispatch(BackupAction.BackupFinished(userWalletId = userWallet?.walletId))
}
}
@ -629,6 +650,16 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
}
}
private suspend fun createUserWallet(scanResponse: ScanResponse, backupState: BackupState): UserWallet {
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
return requireNotNull(
value = UserWalletBuilder(scanResponse, walletNameGenerateUseCase)
.backupCardsIds(backupState.backupCardIds.toSet())
.build(),
lazyMessage = { "User wallet not created" },
)
}
fun updateArtworks(addedBackupCardsCount: Int, card: Card) {
mainScope.launch {
withIOContext {

View file

@ -1,6 +1,7 @@
package com.tangem.tap.features.onboarding.products.wallet.redux
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.redux.OnboardingManageTokensAction
import com.tangem.tap.backupService
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
@ -10,12 +11,23 @@ object OnboardingWalletReducer {
fun reduce(action: Action, state: AppState): OnboardingWalletState = internalReduce(action, state)
}
@Suppress("CyclomaticComplexMethod")
private fun internalReduce(action: Action, appState: AppState): OnboardingWalletState {
val state = appState.onboardingWalletState
return when (action) {
is GlobalAction.Onboarding -> ReducerForGlobalAction.reduce(action, state)
is BackupAction -> state.copy(backupState = BackupReducer.reduce(action, state.backupState))
is BackupAction.BackupFinished -> {
state.copy(
step = if (action.userWalletId != null) {
OnboardingWalletStep.ManageTokens
} else {
OnboardingWalletStep.Done
},
userWalletId = action.userWalletId,
)
}
is BackupAction -> state.copy(backupState = BackupReducer.reduce(action = action, state = state.backupState))
is OnboardingWallet2Action -> OnboardingWallet2Reducer.reduce(action, state)
is OnboardingWalletAction.GetToCreateWalletStep -> state.copy(
step = OnboardingWalletStep.CreateWallet,
@ -36,6 +48,11 @@ private fun internalReduce(action: Action, appState: AppState): OnboardingWallet
walletImages = state.walletImages.copy(thirdCardImage = action.artworkUri),
)
}
is OnboardingManageTokensAction.CurrenciesSaved -> state.copy(step = OnboardingWalletStep.Done)
is OnboardingWalletAction.WalletSaved -> state.copy(
step = OnboardingWalletStep.ManageTokens,
userWalletId = action.userWalletId,
)
is OnboardingWalletAction.Done -> state.copy(step = OnboardingWalletStep.Done)
else -> state
}

View file

@ -3,6 +3,7 @@ package com.tangem.tap.features.onboarding.products.wallet.redux
import android.graphics.Bitmap
import android.net.Uri
import com.tangem.domain.redux.StateDialog
import com.tangem.domain.wallets.models.UserWalletId
import org.rekotlin.StateType
/**
@ -15,18 +16,19 @@ data class OnboardingWalletState(
val walletImages: WalletImages = WalletImages(),
val showConfetti: Boolean = false,
val isRingOnboarding: Boolean = false,
val userWalletId: UserWalletId? = null,
) : StateType {
@Suppress("MagicNumber")
fun getMaxProgress(): Int {
val baseProgress = 6
val baseProgress = 7
return getWallet2Progress() + baseProgress
}
@Suppress("ComplexMethod", "MagicNumber")
fun getProgressStep(): Int {
val progressByStep = when (step) {
OnboardingWalletStep.CreateWallet -> 1
OnboardingWalletStep.CreateWallet, OnboardingWalletStep.None -> 1
OnboardingWalletStep.Backup -> {
when (backupState.backupStep) {
BackupStep.InitBackup -> 2
@ -36,11 +38,11 @@ data class OnboardingWalletState(
BackupStep.ReenterAccessCode -> 4
BackupStep.SetAccessCode -> 4
BackupStep.WritePrimaryCard, is BackupStep.WriteBackupCard -> 5
BackupStep.Finished -> getMaxProgress()
BackupStep.Finished -> 6
}
}
OnboardingWalletStep.ManageTokens -> 6
OnboardingWalletStep.Done -> getMaxProgress()
else -> 1
}
return getWallet2Progress() + progressByStep
@ -60,7 +62,7 @@ data class OnboardingWallet2State(
)
enum class OnboardingWalletStep {
None, CreateWallet, Backup, Done
None, CreateWallet, Backup, ManageTokens, Done
}
data class BackupState(

View file

@ -3,6 +3,8 @@ package com.tangem.tap.features.onboarding.products.wallet.ui
import android.content.res.ColorStateList
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
@ -10,12 +12,16 @@ import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.core.view.MenuProvider
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager
import by.kirich1409.viewbindingdelegate.viewBinding
import coil.load
import com.arkivanov.decompose.defaultComponentContext
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayoutMediator
import com.tangem.common.CardIdFormatter
@ -25,15 +31,22 @@ import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.di.RootAppComponentContext
import com.tangem.core.ui.extensions.setStatusBarColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.windowsize.rememberWindowSize
import com.tangem.datasource.utils.isNullOrEmpty
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.onboarding.data.model.CreateWalletResponse
import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.SeedPhraseMediator
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.SeedPhraseRouter
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.SeedPhraseViewModel
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.sdk.ui.widget.leapfrogWidget.LeapfrogWidget
import com.tangem.sdk.ui.widget.leapfrogWidget.PropertyCalculator
import com.tangem.tap.common.analytics.events.Onboarding
@ -56,6 +69,7 @@ import com.tangem.wallet.databinding.ViewOnboardingProgressBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.rekotlin.StoreSubscriber
import javax.inject.Inject
@Suppress("LargeClass", "MagicNumber")
@AndroidEntryPoint
@ -64,10 +78,20 @@ class OnboardingWalletFragment :
StoreSubscriber<OnboardingWalletState>,
FragmentOnBackPressedHandler {
@Inject
internal lateinit var onboardingManageTokensComponentFactory: OnboardingManageTokensComponent.Factory
@Inject
@RootAppComponentContext
internal lateinit var rootComponentContext: AppComponentContext
private var onboardingManageTokensComponent: OnboardingManageTokensComponent? = null
internal val binding: FragmentOnboardingWalletBinding by viewBinding(FragmentOnboardingWalletBinding::bind)
internal val pbBinding: ViewOnboardingProgressBinding by viewBinding(ViewOnboardingProgressBinding::bind)
internal val bindingSeedPhrase: LayoutOnboardingSeedPhraseBinding by lazy { binding.onboardingSeedPhraseContainer }
private val bindingManageTokens by lazy { binding.onboardingManageTokensContainer }
private val canSkipBackup by lazy { arguments?.getBoolean(AppRoute.OnboardingWallet.CAN_SKIP_BACKUP_KEY) ?: true }
@ -173,26 +197,28 @@ class OnboardingWalletFragment :
override fun onStop() {
super.onStop()
onboardingManageTokensComponent = null
store.unsubscribe(this)
}
override fun newState(state: OnboardingWalletState) {
if (activity == null || view == null) return
Handler(Looper.getMainLooper()).post {
animator.updateBackupState(state.backupState)
requireActivity().invalidateOptionsMenu()
animator.updateBackupState(state.backupState)
requireActivity().invalidateOptionsMenu()
pbBinding.pbState.max = state.getMaxProgress()
pbBinding.pbState.progress = state.getProgressStep()
pbBinding.pbState.max = state.getMaxProgress()
pbBinding.pbState.progress = state.getProgressStep()
when {
state.wallet2State != null -> {
seedPhraseStateHandler.newState(this, state, seedPhraseViewModel)
updateWalletImagesState(state.walletImages)
}
else -> {
updateWalletImagesState(state.walletImages)
handleOnboardingStep(state)
when {
state.wallet2State != null -> {
seedPhraseStateHandler.newState(this, state, seedPhraseViewModel)
updateWalletImagesState(state.walletImages)
}
else -> {
updateWalletImagesState(state.walletImages)
handleOnboardingStep(state)
}
}
}
}
@ -220,14 +246,53 @@ class OnboardingWalletFragment :
internal fun handleOnboardingStep(state: OnboardingWalletState) {
when (state.step) {
OnboardingWalletStep.CreateWallet -> setupCreateWalletState()
OnboardingWalletStep.Backup -> setBackupState(
state = state.backupState,
)
OnboardingWalletStep.Backup -> setBackupState(state = state.backupState)
OnboardingWalletStep.ManageTokens -> setManageTokensState(requireNotNull(state.userWalletId))
OnboardingWalletStep.Done -> showSuccess()
else -> {}
}
}
private fun initializeOnboardingManageTokensComponent(userWalletId: UserWalletId) {
val componentContext = rootComponentContext.childByContext(
componentContext = defaultComponentContext(onBackPressedDispatcher = null),
)
onboardingManageTokensComponent = onboardingManageTokensComponentFactory.create(
context = componentContext,
params = OnboardingManageTokensComponent.Params(userWalletId = userWalletId),
)
}
private fun setManageTokensState(userWalletId: UserWalletId) {
with(binding) {
tvHeader.show()
tvBody.show()
viewPagerBackupInfo.hide()
tabLayoutBackupInfo.hide()
onboardingWalletContainer.show()
layoutButtonsCommon.btnWalletAlternativeAction.hide()
onboardingWalletContainer.hide()
toolbar.title = getText(R.string.onboarding_add_tokens)
}
pbBinding.pbState.show()
bindingSeedPhrase.onboardingSeedPhraseContainer.hide()
if (onboardingManageTokensComponent == null) {
initializeOnboardingManageTokensComponent(userWalletId)
}
with(bindingManageTokens) {
onboardingManageTokensContainer.hide()
onboardingManageTokensContainer.show()
onboardingManageTokensContainer.setContent {
TangemTheme(
isDark = isSystemInDarkTheme(),
windowSize = rememberWindowSize(activity = requireActivity()),
) {
onboardingManageTokensComponent?.Content(modifier = Modifier.fillMaxSize())
}
}
}
}
private fun setupCreateWalletState() = with(binding) {
layoutButtonsCommon.btnWalletMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
layoutButtonsCommon.btnWalletMainAction.setIconResource(R.drawable.ic_tangem_24)
@ -256,7 +321,10 @@ class OnboardingWalletFragment :
BackupStep.ReenterAccessCode -> showReenterAccessCode(state)
is BackupStep.WritePrimaryCard -> showWritePrimaryCard(state)
is BackupStep.WriteBackupCard -> showWriteBackupCard(state)
BackupStep.Finished -> showSuccess()
BackupStep.Finished -> {
// don't need to navigate here"
// showSuccess()
}
}
}
@ -454,6 +522,8 @@ class OnboardingWalletFragment :
tvBody.show()
viewPagerBackupInfo.hide()
tabLayoutBackupInfo.hide()
onboardingWalletContainer.show()
bindingManageTokens.onboardingManageTokensContainer.hide()
tvBody.text = getText(R.string.onboarding_subtitle_success_tangem_wallet_onboarding)
layoutButtonsCommon.btnWalletMainAction.text = getText(R.string.onboarding_button_continue_wallet)

View file

@ -24,5 +24,8 @@ internal sealed interface SaveWalletAction : Action {
data object Cancel : SaveWalletAction
}
data class SaveWalletAfterBackup(val hasBackupError: Boolean) : SaveWalletAction
data class SaveWalletAfterBackup(
val hasBackupError: Boolean,
val shouldNavigateToWallet: Boolean,
) : SaveWalletAction
}

View file

@ -16,6 +16,7 @@ import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletAction
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -44,7 +45,11 @@ internal class SaveWalletMiddleware {
is SaveWalletAction.AllowToUseBiometrics -> allowToUseBiometrics(state)
is SaveWalletAction.EnrollBiometrics.Enroll -> enrollBiometrics()
is SaveWalletAction.Dismiss -> dismiss(state)
is SaveWalletAction.SaveWalletAfterBackup -> saveWalletAfterBackup(state, action.hasBackupError)
is SaveWalletAction.SaveWalletAfterBackup -> saveWalletAfterBackup(
state = state,
hasBackupError = action.hasBackupError,
shouldNavigateToWallet = action.shouldNavigateToWallet,
)
is SaveWalletAction.AllowToUseBiometrics.Success,
is SaveWalletAction.AllowToUseBiometrics.Error,
is SaveWalletAction.ProvideBackupInfo,
@ -55,7 +60,11 @@ internal class SaveWalletMiddleware {
}
}
private fun saveWalletAfterBackup(state: SaveWalletState, hasBackupError: Boolean) {
private fun saveWalletAfterBackup(
state: SaveWalletState,
hasBackupError: Boolean,
shouldNavigateToWallet: Boolean,
) {
scope.launch {
val backupInfo = state.backupInfo ?: error("Backup info is null")
@ -77,8 +86,13 @@ internal class SaveWalletMiddleware {
.doOnFailure { error ->
Timber.e(error, "Unable to save user wallet")
}
.doOnSuccess { mainScope.launch { store.onUserWalletSelected(userWallet) } }
.doOnResult { navigateToWallet() }
.doOnSuccess {
mainScope.launch { store.onUserWalletSelected(userWallet) }
if (!shouldNavigateToWallet) {
store.dispatch(OnboardingWalletAction.WalletSaved(userWallet.walletId))
}
}
.doOnResult { if (shouldNavigateToWallet) navigateToWallet() }
}
}

View file

@ -258,6 +258,13 @@
android:layout_height="match_parent"
android:visibility="gone" />
<include
android:id="@+id/onboarding_manage_tokens_container"
layout="@layout/layout_onboarding_manage_tokens"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</androidx.appcompat.widget.LinearLayoutCompat>
<include

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.compose.ui.platform.ComposeView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/onboarding_manage_tokens_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View file

@ -9,6 +9,9 @@ import com.tangem.domain.wallets.models.UserWalletId
*
[REDACTED_AUTHOR]
*/
typealias BackendId = String
class HasMissedDerivationsUseCase(
private val derivationsRepository: DerivationsRepository,
) {
@ -16,7 +19,7 @@ class HasMissedDerivationsUseCase(
/** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */
suspend operator fun invoke(
userWalletId: UserWalletId,
networksWithDerivationPath: Map<Network.ID, String?>,
networksWithDerivationPath: Map<BackendId, String?>,
): Boolean {
return derivationsRepository.hasMissedDerivations(userWalletId, networksWithDerivationPath)
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.card.repository
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.card.BackendId
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
@ -26,6 +27,6 @@ interface DerivationsRepository {
/** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */
suspend fun hasMissedDerivations(
userWalletId: UserWalletId,
networksWithDerivationPath: Map<Network.ID, String?>,
networksWithDerivationPath: Map<BackendId, String?>,
): Boolean
}

View file

@ -0,0 +1,7 @@
package com.tangem.domain.redux
import org.rekotlin.Action
sealed class OnboardingManageTokensAction : Action {
data object CurrenciesSaved : OnboardingManageTokensAction()
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
interface OnboardingManageTokensComponent : ComposableContentComponent {
data class Params(val userWalletId: UserWalletId)
interface Factory : ComponentFactory<Params, OnboardingManageTokensComponent>
}

View file

@ -23,8 +23,9 @@ dependencies {
implementation(projects.core.analytics)
/* Project - Domain */
implementation(projects.domain.manageTokens)
implementation(projects.domain.card)
implementation(projects.domain.legacy)
implementation(projects.domain.manageTokens)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
@ -49,4 +50,5 @@ dependencies {
implementation(deps.kotlin.immutable.collections)
implementation(deps.decompose.ext.compose)
implementation(deps.timber)
implementation(deps.reKotlin) // need for legacy onboarding
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.managetokens.component.impl
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.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.model.OnboardingManageTokensModel
import com.tangem.features.managetokens.ui.OnboardingManageTokensContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnboardingManageTokensComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: OnboardingManageTokensComponent.Params,
) : OnboardingManageTokensComponent, AppComponentContext by context {
private val model: OnboardingManageTokensModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
OnboardingManageTokensContent(modifier = modifier, state = state)
}
@AssistedFactory
interface Factory : OnboardingManageTokensComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnboardingManageTokensComponent.Params,
): DefaultOnboardingManageTokensComponent
}
}

View file

@ -26,7 +26,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
internal class PreviewManageTokensComponent(
private val isLoading: Boolean = false,
private val isLoading: Boolean,
params: ManageTokensComponent.Params,
) : ManageTokensComponent {
private val changedItemsIds: MutableSet<String> = mutableSetOf()
@ -36,14 +37,21 @@ internal class PreviewManageTokensComponent(
value = ManageTokensUM.ManageContent(
popBack = {},
items = items,
topBar = ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = {},
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = {},
),
),
topBar = if (params.userWalletId != null) {
ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = {},
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = {},
),
)
} else {
ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_search_tokens),
onBackButtonClick = {},
)
},
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",

View file

@ -0,0 +1,114 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.managetokens.OnboardingManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.OnboardingManageTokensContent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
internal class PreviewOnboardingManageTokensComponent(
private val isLoading: Boolean = false,
) : OnboardingManageTokensComponent {
private val state = OnboardingManageTokensUM(
items = initItems(),
isInitialBatchLoading = false,
isNextBatchLoading = true,
loadMore = { false },
onBack = {},
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
),
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Later(
onClick = {},
showProgress = false,
),
)
@Composable
override fun Content(modifier: Modifier) {
OnboardingManageTokensContent(state = state, modifier = modifier)
}
private fun initItems() = List(size = 30) { index ->
if (isLoading) {
CurrencyItemUM.Loading(index)
} else {
if (index < 2) {
getCustomItem(index)
} else {
getBasicItem(index)
}
}
}.toPersistentList()
private fun getCustomItem(index: Int) = CurrencyItemUM.Custom(
id = ManagedCryptoCurrency.ID(index.toString()),
name = "Custom token $index",
symbol = "CT$index",
icon = CurrencyIconState.CustomTokenIcon(
tint = Color.White,
background = Color.Black,
topBadgeIconResId = R.drawable.img_eth_22,
isGrayscale = false,
showCustomBadge = true,
),
onRemoveClick = {},
)
private fun getBasicItem(index: Int) = CurrencyItemUM.Basic(
id = ManagedCryptoCurrency.ID(index.toString()),
name = "Currency $index",
symbol = "C$index",
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_btc_22,
isGrayscale = false,
showCustomBadge = false,
),
networks = if (index == 2) {
CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks())
} else {
CurrencyItemUM.Basic.NetworksUM.Collapsed
},
onExpandClick = {},
)
private fun getCurrencyNetworks() = List(size = 3) { networkIndex ->
CurrencyNetworkUM(
network = Network(
id = Network.ID(networkIndex.toString()),
backendId = networkIndex.toString(),
name = "Network $networkIndex",
currencySymbol = "N$networkIndex",
derivationPath = Network.DerivationPath.Card(""),
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = false,
canHandleTokens = false,
),
name = "NETWORK$networkIndex",
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = networkIndex == 0,
isSelected = false,
onSelectedStateChange = {},
onLongClick = {},
)
}.toImmutableList()
}

View file

@ -16,6 +16,12 @@ internal interface ComponentModule {
@Singleton
fun bindManageTokensComponentFactory(factory: DefaultManageTokensComponent.Factory): ManageTokensComponent.Factory
@Binds
@Singleton
fun bindOnboardingManageTokensComponentFactory(
factory: DefaultOnboardingManageTokensComponent.Factory,
): OnboardingManageTokensComponent.Factory
@Binds
@Singleton
fun bindAddCustomTokenComponentFactory(

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.managetokens.model.CustomTokenFormModel
import com.tangem.features.managetokens.model.CustomTokenSelectorModel
import com.tangem.features.managetokens.model.ManageTokensModel
import com.tangem.features.managetokens.model.OnboardingManageTokensModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -20,6 +21,11 @@ internal interface ModelModule {
@ClassKey(ManageTokensModel::class)
fun provideManageTokensModel(model: ManageTokensModel): Model
@Binds
@IntoMap
@ClassKey(OnboardingManageTokensModel::class)
fun provideOnboardingManageTokensModel(model: OnboardingManageTokensModel): Model
@Binds
@IntoMap
@ClassKey(CustomTokenFormModel::class)

View file

@ -14,7 +14,7 @@ internal sealed class ManageTokensUM {
abstract val isInitialBatchLoading: Boolean
abstract val isNextBatchLoading: Boolean
abstract val items: ImmutableList<CurrencyItemUM>
abstract val topBar: ManageTokensTopBarUM
abstract val topBar: ManageTokensTopBarUM?
abstract val search: SearchBarUM
abstract val loadMore: () -> Boolean
abstract val scrollToTop: StateEvent<Unit>
@ -24,7 +24,7 @@ internal sealed class ManageTokensUM {
override val isInitialBatchLoading: Boolean,
override val isNextBatchLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val topBar: ManageTokensTopBarUM?,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),
@ -35,7 +35,7 @@ internal sealed class ManageTokensUM {
override val isInitialBatchLoading: Boolean,
override val isNextBatchLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val topBar: ManageTokensTopBarUM?,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),

View file

@ -0,0 +1,41 @@
package com.tangem.features.managetokens.entity.managetokens
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import kotlinx.collections.immutable.ImmutableList
internal data class OnboardingManageTokensUM(
val onBack: () -> Unit,
val isInitialBatchLoading: Boolean,
val isNextBatchLoading: Boolean,
val items: ImmutableList<CurrencyItemUM>,
val loadMore: () -> Boolean,
val search: SearchBarUM,
val scrollToTop: StateEvent<Unit> = consumedEvent(),
val actionButtonConfig: ActionButtonConfig,
) {
sealed class ActionButtonConfig {
abstract val onClick: () -> Unit
abstract val showProgress: Boolean
data class Continue(
override val onClick: () -> Unit,
override val showProgress: Boolean = false,
val showTangemIcon: Boolean,
) : ActionButtonConfig()
data class Later(
override val onClick: () -> Unit,
override val showProgress: Boolean = false,
) : ActionButtonConfig()
fun copySealed(showProgress: Boolean): ActionButtonConfig {
return when (this) {
is Continue -> copy(showProgress = showProgress)
is Later -> copy(showProgress = showProgress)
}
}
}
}

View file

@ -75,7 +75,7 @@ internal class ManageTokensModel @Inject constructor(
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(params)
manageTokensListManager.launchPagination(source = params.source, userWalletId = params.userWalletId)
}
}
@ -159,12 +159,7 @@ internal class ManageTokensModel @Inject constructor(
}
}
.sample(periodMillis = 1_000)
.onEach { query ->
manageTokensListManager.search(
userWalletId = params.userWalletId,
query = query,
)
}
.onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) }
.launchIn(modelScope)
}
@ -272,10 +267,7 @@ internal class ManageTokensModel @Inject constructor(
if (state.isInitialBatchLoading || state.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(
userWalletId = params.userWalletId,
query = state.search.query,
)
manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query)
}
return true

View file

@ -0,0 +1,297 @@
package com.tangem.features.managetokens.model
import arrow.core.flatten
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.card.HasMissedDerivationsUseCase
import com.tangem.domain.managetokens.SaveManagedTokensUseCase
import com.tangem.domain.redux.OnboardingManageTokensAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.managetokens.OnboardingManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.list.ChangedCurrencies
import com.tangem.features.managetokens.utils.list.ManageTokensListManager
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ComponentScoped
internal class OnboardingManageTokensModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val manageTokensListManager: ManageTokensListManager,
private val messageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
private val saveManagedTokensUseCase: SaveManagedTokensUseCase,
private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer,
) : Model() {
private val params: OnboardingManageTokensComponent.Params = paramsContainer.require()
val state: MutableStateFlow<OnboardingManageTokensUM> = MutableStateFlow(getInitialState())
init {
manageTokensListManager.uiItems
.onEach { items -> updateItems(items) }
.launchIn(modelScope)
manageTokensListManager.paginationStatus
.onEach { status -> updatePaginationStatus(status) }
.launchIn(modelScope)
manageTokensListManager.currenciesToAdd
.onEach(::handleNewAddedCurrencies)
.launchIn(modelScope)
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(
source = ManageTokensSource.ONBOARDING,
userWalletId = params.userWalletId,
)
}
}
private fun getInitialState(): OnboardingManageTokensUM {
analyticsEventHandler.send(ManageTokensAnalyticEvent.ScreenOpened(source = ManageTokensSource.ONBOARDING))
return OnboardingManageTokensUM(
isInitialBatchLoading = true,
isNextBatchLoading = false,
items = getLoadingItems(),
loadMore = ::loadMoreItems,
onBack = {},
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = ::searchCurrencies,
isActive = false,
onActiveChange = ::toggleSearchBar,
),
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Later(
onClick = ::onLaterClick,
showProgress = false,
),
)
}
@OptIn(FlowPreview::class)
private fun observeSearchQueryChanges() {
state
.distinctUntilChanged { old, new ->
// It's also used to skip search activation to avoid searching an empty query
old.search.query == new.search.query &&
(old.search.isActive == new.search.isActive || new.search.isActive)
}
.transform { state ->
val query = state.search.query
if (state.search.isActive) {
emit(query)
}
}
.sample(periodMillis = 1_000)
.onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) }
.launchIn(modelScope)
}
private fun updateItems(items: ImmutableList<CurrencyItemUM>) {
val updatedState = state.updateAndGet { state -> state.copy(items = items) }
if (updatedState.items.isEmpty() && updatedState.search.isActive) {
val event = ManageTokensAnalyticEvent.TokensIsNotFound(
query = updatedState.search.query,
source = ManageTokensSource.ONBOARDING,
)
analyticsEventHandler.send(event)
}
}
private fun updatePaginationStatus(status: PaginationStatus<*>) {
state.update { state ->
when (status) {
is PaginationStatus.None,
is PaginationStatus.InitialLoading,
-> {
if (state.search.isActive) {
state.copy(items = getLoadingItems())
} else {
state.copy(items = getLoadingItems(), isInitialBatchLoading = true)
}
}
is PaginationStatus.NextBatchLoading -> state.copy(isNextBatchLoading = true)
is PaginationStatus.InitialLoadingError -> {
val message = SnackbarMessage(
message = status.throwable.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
state.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
is PaginationStatus.Paginating -> {
(status.lastResult as? BatchFetchResult.Error)?.let { fetchError ->
Timber.e(fetchError.throwable)
}
state.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
scrollToTop = if (state.isInitialBatchLoading && state.items.isNotEmpty()) {
triggeredEvent(
data = Unit,
onConsume = ::consumeScrollToTopEvent,
)
} else {
state.scrollToTop
},
)
}
is PaginationStatus.EndOfPagination -> state.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
}
}
private fun getLoadingItems(): ImmutableList<CurrencyItemUM> {
return List(size = 10) { index ->
CurrencyItemUM.Loading(index)
}.toPersistentList()
}
private fun consumeScrollToTopEvent() {
state.update { state -> state.copy(scrollToTop = consumedEvent()) }
}
private suspend fun handleNewAddedCurrencies(currenciesToAdd: ChangedCurrencies) {
if (currenciesToAdd.isEmpty()) {
state.update { state ->
state.copy(
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Later(onClick = ::onLaterClick),
)
}
} else {
val hasMissedDerivations = hasMissedDerivationsUseCase.invoke(
userWalletId = params.userWalletId,
networksWithDerivationPath = currenciesToAdd.values
.flatten()
.toSet()
.associate { it.backendId to null },
)
state.update { state ->
state.copy(
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Continue(
onClick = ::saveChanges,
showTangemIcon = hasMissedDerivations,
),
)
}
}
}
private fun loadMoreItems(): Boolean {
val state = state.value
if (state.isInitialBatchLoading || state.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query)
}
return true
}
private fun saveChanges() = resource(
acquire = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = true))
}
},
release = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = false))
}
},
) {
val event = ManageTokensAnalyticEvent.TokenAdded(
tokensCount = manageTokensListManager.currenciesToAdd.value.values.sumOf { it.size },
source = ManageTokensSource.ONBOARDING,
)
analyticsEventHandler.send(event)
saveManagedTokensUseCase(
userWalletId = requireNotNull(params.userWalletId),
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).getOrElse {
Timber.e(it, "Failed to save changes")
return@resource
}
reduxStateHolder.dispatch(OnboardingManageTokensAction.CurrenciesSaved)
}
private fun onLaterClick() = resource(
acquire = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = true))
}
},
release = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = false))
}
},
) {
reduxStateHolder.dispatch(OnboardingManageTokensAction.CurrenciesSaved)
}
private fun searchCurrencies(query: String) {
state.update { state ->
state.copy(
search = state.search.copy(
query = query,
isActive = true,
),
)
}
}
private fun toggleSearchBar(isActive: Boolean) {
state.update { state ->
state.copy(
search = state.search.copy(
isActive = isActive,
),
)
}
}
}

View file

@ -59,7 +59,9 @@ import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM
@ -128,19 +130,21 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi
}
@Composable
private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, search: SearchBarUM, modifier: Modifier = Modifier) {
private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM?, search: SearchBarUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.background(TangemTheme.colors.background.primary),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
TangemTopAppBar(
title = topBar.title.resolveReference(),
startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick),
endButton = when (topBar) {
is ManageTokensTopBarUM.ManageContent -> topBar.endButton
is ManageTokensTopBarUM.ReadContent -> null
},
)
if (topBar != null) {
TangemTopAppBar(
title = topBar.title.resolveReference(),
startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick),
endButton = when (topBar) {
is ManageTokensTopBarUM.ManageContent -> topBar.endButton
is ManageTokensTopBarUM.ReadContent -> null
},
)
}
SearchBar(
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing12)
@ -196,7 +200,7 @@ private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) {
}
@Composable
private fun Currencies(
internal fun Currencies(
listState: LazyListState,
items: ImmutableList<CurrencyItemUM>,
showLoadingItem: Boolean,
@ -466,8 +470,24 @@ private fun Preview_ManageTokens(
private class PreviewManageTokensComponentProvider : PreviewParameterProvider<ManageTokensComponent> {
override val values: Sequence<ManageTokensComponent>
get() = sequenceOf(
PreviewManageTokensComponent(),
PreviewManageTokensComponent(isLoading = true),
PreviewManageTokensComponent(
isLoading = true,
params = ManageTokensComponent.Params(
source = ManageTokensSource.ONBOARDING,
userWalletId = UserWalletId("wallet_id"),
),
),
PreviewManageTokensComponent(
isLoading = false,
params = ManageTokensComponent.Params(source = ManageTokensSource.ONBOARDING, userWalletId = null),
),
PreviewManageTokensComponent(
isLoading = false,
params = ManageTokensComponent.Params(
source = ManageTokensSource.ONBOARDING,
userWalletId = UserWalletId("wallet_id"),
),
),
)
}
// endregion Preview

View file

@ -0,0 +1,167 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.res.LocalSnackbarHostState
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.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.component.preview.PreviewOnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.managetokens.OnboardingManageTokensUM
import com.tangem.features.managetokens.impl.R
@Composable
internal fun OnboardingManageTokensContent(state: OnboardingManageTokensUM, modifier: Modifier = Modifier) {
val keyboardController = LocalSoftwareKeyboardController.current
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
keyboardController?.hide()
return super.onPreScroll(available, source)
}
}
}
Scaffold(
modifier = modifier.nestedScroll(nestedScrollConnection),
containerColor = TangemTheme.colors.background.primary,
contentWindowInsets = WindowInsetsZero,
topBar = {
SearchBar(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing12, horizontal = TangemTheme.dimens.spacing16),
state = state.search,
)
},
content = { innerPadding ->
Content(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
state = state,
)
},
snackbarHost = {
TangemSnackbarHost(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
hostState = LocalSnackbarHostState.current,
)
},
floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
FloatingActionButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
config = state.actionButtonConfig,
)
},
)
}
@Composable
private fun Content(state: OnboardingManageTokensUM, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
Box(modifier = modifier) {
Currencies(
modifier = Modifier.fillMaxSize(),
listState = listState,
items = state.items,
showLoadingItem = state.isNextBatchLoading,
onLoadMore = state.loadMore,
isEditable = true,
)
BottomFade(modifier = Modifier.align(Alignment.BottomCenter))
}
EventEffect(event = state.scrollToTop) {
listState.animateScrollToItem(index = 0)
}
}
@Composable
private fun FloatingActionButton(config: OnboardingManageTokensUM.ActionButtonConfig, modifier: Modifier = Modifier) {
when (config) {
is OnboardingManageTokensUM.ActionButtonConfig.Continue -> ContinueButton(config = config, modifier = modifier)
is OnboardingManageTokensUM.ActionButtonConfig.Later -> SecondaryButton(
modifier = modifier,
text = stringResource(id = R.string.common_later),
showProgress = config.showProgress,
onClick = config.onClick,
)
}
}
@Composable
private fun ContinueButton(
config: OnboardingManageTokensUM.ActionButtonConfig.Continue,
modifier: Modifier = Modifier,
) {
if (config.showTangemIcon) {
PrimaryButtonIconEnd(
modifier = modifier,
text = stringResource(id = R.string.common_continue),
iconResId = R.drawable.ic_tangem_24,
showProgress = config.showProgress,
onClick = config.onClick,
)
} else {
PrimaryButton(
modifier = modifier,
text = stringResource(id = R.string.common_continue),
showProgress = config.showProgress,
onClick = config.onClick,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 800)
@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ManageTokens(
@PreviewParameter(PreviewOnboardingManageTokensComponentProvider::class) component: OnboardingManageTokensComponent,
) {
TangemThemePreview {
component.Content(Modifier.fillMaxWidth())
}
}
private class PreviewOnboardingManageTokensComponentProvider :
CollectionPreviewParameterProvider<PreviewOnboardingManageTokensComponent>(
collection = listOf(
PreviewOnboardingManageTokensComponent(isLoading = false),
PreviewOnboardingManageTokensComponent(isLoading = true),
),
)
// endregion

View file

@ -16,7 +16,6 @@ import com.tangem.domain.managetokens.model.*
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.impl.R
@ -81,9 +80,9 @@ internal class ManageTokensListManager @Inject constructor(
.distinctUntilChanged()
val uiItems: Flow<ImmutableList<CurrencyItemUM>> = uiManager.items
suspend fun launchPagination(params: ManageTokensComponent.Params) = coroutineScope {
suspend fun launchPagination(source: ManageTokensSource, userWalletId: UserWalletId?) = coroutineScope {
scope = this
source = params.source
this@ManageTokensListManager.source = source
val batchFlow = getManagedTokensUseCase(
context = ManageTokensListBatchingContext(
@ -93,13 +92,13 @@ internal class ManageTokensListManager @Inject constructor(
)
batchFlow.state
.onEach { state -> updateState(state, params.userWalletId) }
.onEach { state -> updateState(state, userWalletId) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
// Initial load
reload(params.userWalletId)
reload(userWalletId)
}
suspend fun reload(userWalletId: UserWalletId?) {

View file

@ -195,7 +195,7 @@ internal class MarketsPortfolioModel @Inject constructor(
hasMissedDerivationsUseCase.invoke(
userWalletId = selectedWalletId,
networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty()
.associate { Network.ID(it.networkId) to null },
.associate { it.networkId to null },
)
} else {
false