Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-11 10:30:45 +02:00
commit 0a6b47b668
101 changed files with 1873 additions and 944 deletions

View file

@ -55,6 +55,7 @@
<activity <activity
android:name="com.tangem.tap.MainActivity" android:name="com.tangem.tap.MainActivity"
android:configChanges="uiMode"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:screenOrientation="portrait" android:screenOrientation="portrait"

View file

@ -7,16 +7,16 @@ import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import java.util.* import java.util.WeakHashMap
import kotlin.reflect.KClass import kotlin.reflect.KClass
class ForegroundActivityObserver : ActivityResultCaller { class ForegroundActivityObserver : ActivityResultCaller {
override var activityResultLauncher: ActivityResultLauncher<Intent>? = null override var activityResultLauncher: ActivityResultLauncher<Intent>? = null
private set private set
private val activities = WeakHashMap<KClass<out Activity>, Activity>() private val activities = WeakHashMap<KClass<out Activity>, AppCompatActivity>()
val foregroundActivity: Activity? val foregroundActivity: AppCompatActivity?
get() = activities.entries get() = activities.entries
.filterNot { it.value.isDestroyed } .filterNot { it.value.isDestroyed }
.firstOrNull() .firstOrNull()
@ -35,7 +35,7 @@ class ForegroundActivityObserver : ActivityResultCaller {
} }
override fun onActivityResumed(activity: Activity) { override fun onActivityResumed(activity: Activity) {
activities[activity::class] = activity activities[activity::class] = activity as? AppCompatActivity
} }
override fun onActivityDestroyed(activity: Activity) { override fun onActivityDestroyed(activity: Activity) {
@ -59,6 +59,6 @@ class ForegroundActivityObserver : ActivityResultCaller {
} }
} }
fun ForegroundActivityObserver.withForegroundActivity(block: (Activity) -> Unit) { fun ForegroundActivityObserver.withForegroundActivity(block: (AppCompatActivity) -> Unit) {
foregroundActivity?.let { block(it) } foregroundActivity?.let { block(it) }
} }

View file

@ -18,6 +18,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.flowWithLifecycle
@ -29,6 +30,7 @@ import com.arkivanov.essenty.lifecycle.asEssentyLifecycle
import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.entity.SerializableIntent import com.tangem.common.routing.entity.SerializableIntent
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
@ -38,7 +40,8 @@ import com.tangem.core.navigation.email.EmailSender
import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.event.StateEvent
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.resolveReference
import com.tangem.data.card.sdk.CardSdkLifecycleObserver import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.data.card.sdk.CardSdkOwner
import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardRepository
@ -114,7 +117,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
lateinit var testerRouter: TesterRouter lateinit var testerRouter: TesterRouter
@Inject @Inject
lateinit var cardSdkLifecycleObserver: CardSdkLifecycleObserver lateinit var cardSdkOwner: CardSdkOwner
@Inject @Inject
lateinit var cardSdkConfigRepository: CardSdkConfigRepository lateinit var cardSdkConfigRepository: CardSdkConfigRepository
@ -174,6 +177,9 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
@Inject @Inject
internal lateinit var routingComponentFactory: RoutingComponent.Factory internal lateinit var routingComponentFactory: RoutingComponent.Factory
@Inject
internal lateinit var appRouter: AppRouter
@Inject @Inject
lateinit var pushNotificationsRouter: PushNotificationsRouter lateinit var pushNotificationsRouter: PushNotificationsRouter
@ -182,7 +188,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
internal val viewModel: MainViewModel by viewModels() internal val viewModel: MainViewModel by viewModels()
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode?> private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode>
// TODO: fixme: inject through DI // TODO: fixme: inject through DI
private val intentProcessor: IntentProcessor = IntentProcessor() private val intentProcessor: IntentProcessor = IntentProcessor()
@ -194,9 +200,10 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
private val onActivityResultCallbacks = mutableListOf<OnActivityResultCallback>() private val onActivityResultCallbacks = mutableListOf<OnActivityResultCallback>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen() // We need to call it before onCreate to prevent unnecessary activity recreation
installAppTheme()
installAppTheme() // We need to call it before onCreate to prevent unnecessary activity recreation val splashScreen = installSplashScreen()
enableEdgeToEdge( enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.auto( navigationBarStyle = SystemBarStyle.auto(
@ -270,7 +277,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
} }
private fun installActivityDependencies() { private fun installActivityDependencies() {
cardSdkLifecycleObserver.onCreate(context = this) cardSdkOwner.register(activity = this)
tangemSdkManager = injectedTangemSdkManager tangemSdkManager = injectedTangemSdkManager
appStateHolder.tangemSdkManager = tangemSdkManager appStateHolder.tangemSdkManager = tangemSdkManager
backupService = BackupService.init(cardSdkConfigRepository.sdk, this) backupService = BackupService.init(cardSdkConfigRepository.sdk, this)
@ -301,14 +308,13 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
private fun installAppTheme() { private fun installAppTheme() {
appThemeModeFlow = createAppThemeModeFlow() appThemeModeFlow = createAppThemeModeFlow()
val mode = runBlocking { appThemeModeFlow.filterNotNull().first() } val mode = runBlocking { appThemeModeFlow.first() }
updateAppTheme(mode) updateAppTheme(mode)
} }
private fun observeAppThemeModeUpdates() { private fun observeAppThemeModeUpdates() {
appThemeModeFlow appThemeModeFlow
.filterNotNull()
.flowWithLifecycle(lifecycle) .flowWithLifecycle(lifecycle)
.onEach(::updateAppTheme) .onEach(::updateAppTheme)
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
@ -324,15 +330,17 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} }
private fun createAppThemeModeFlow(): SharedFlow<AppThemeMode?> { private fun createAppThemeModeFlow(): SharedFlow<AppThemeMode> {
val tangemApplication = application as TangemApplication val tangemApplication = application as TangemApplication
return tangemApplication.getAppThemeModeUseCase() return tangemApplication.getAppThemeModeUseCase()
.filterNotNull()
.distinctUntilChanged()
.map { maybeMode -> .map { maybeMode ->
maybeMode.getOrElse { AppThemeMode.DEFAULT } maybeMode.getOrElse { AppThemeMode.DEFAULT }
} }
.shareIn( .shareIn(
scope = lifecycleScope + Dispatchers.IO, scope = lifecycleScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000), started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
) )
} }
@ -382,7 +390,6 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
override fun onDestroy() { override fun onDestroy() {
intentProcessor.removeAll() intentProcessor.removeAll()
cardSdkLifecycleObserver.onDestroy(this)
super.onDestroy() super.onDestroy()
} }
@ -393,9 +400,6 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
} }
private fun updateAppTheme(appThemeMode: AppThemeMode) { private fun updateAppTheme(appThemeMode: AppThemeMode) {
MutableAppThemeModeHolder.value = appThemeMode
MutableAppThemeModeHolder.isDarkThemeActive = isDarkTheme()
val mode = when (appThemeMode) { val mode = when (appThemeMode) {
AppThemeMode.FORCE_DARK -> AppCompatDelegate.MODE_NIGHT_YES AppThemeMode.FORCE_DARK -> AppCompatDelegate.MODE_NIGHT_YES
AppThemeMode.FORCE_LIGHT -> AppCompatDelegate.MODE_NIGHT_NO AppThemeMode.FORCE_LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
@ -403,7 +407,32 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
} }
setDefaultNightMode(mode) setDefaultNightMode(mode)
delegate.localNightMode = mode
MutableAppThemeModeHolder.value = appThemeMode
MutableAppThemeModeHolder.isDarkThemeActive = isDarkTheme()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
/*
* We need to manually change the background color of the activity when the UI mode changes to prevent
* flickering when navigating between fragments.
*
* `android:configChanges="uiMode"` is set in the manifest.
* */
updateAppBackground()
}
private fun updateAppBackground() {
val backgroundColor = if (isDarkTheme()) {
TangemColorPalette.Dark6
} else {
TangemColorPalette.White
}
findViewById<CoordinatorLayout>(R.id.fragment_container).setBackgroundColor(backgroundColor.toArgb())
} }
private fun isDarkTheme(): Boolean { private fun isDarkTheme(): Boolean {
@ -418,12 +447,6 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
/*
* FIXME: Test functionality. TangemSdk is null on some devices when HomeAction.Read is called
* inside IntentHandler.
*/
cardSdkLifecycleObserver.onCreate(context = this)
lifecycleScope.launch { lifecycleScope.launch {
intentProcessor.handleIntent(intent, true) intentProcessor.handleIntent(intent, true)
} }
@ -505,15 +528,16 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
} }
private fun navigateToInitialScreenIfNeeded(intentWhichStartedActivity: Intent?) { private fun navigateToInitialScreenIfNeeded(intentWhichStartedActivity: Intent?) {
val backStackIsEmpty = supportFragmentManager.backStackEntryCount == 0 val backStack = appRouter.stack
val isOnInitialScreen = backStack.all { it is AppRoute.Welcome || it is AppRoute.Home }
val isNotScannedBefore = store.state.globalState.scanResponse == null val isNotScannedBefore = store.state.globalState.scanResponse == null
val isOnboardingServiceNotActive = !store.state.globalState.onboardingState.onboardingStarted val isOnboardingServiceNotActive = !store.state.globalState.onboardingState.onboardingStarted
when { when {
!backStackIsEmpty && isNotScannedBefore && isOnboardingServiceNotActive -> { !isOnInitialScreen && isNotScannedBefore && isOnboardingServiceNotActive -> {
navigateToInitialScreen(intentWhichStartedActivity) navigateToInitialScreen(intentWhichStartedActivity)
} }
backStackIsEmpty -> { backStack.isEmpty() -> {
navigateToInitialScreen(intentWhichStartedActivity) navigateToInitialScreen(intentWhichStartedActivity)
} }
else -> Unit else -> Unit

View file

@ -367,6 +367,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory {
Log.Level.Network, Log.Level.Network,
Log.Level.Error, Log.Level.Error,
Log.Level.Biometric, Log.Level.Biometric,
Log.Level.Info,
) )
return TangemLogCollector(logLevels, LogFormat.StairsFormatter()) return TangemLogCollector(logLevels, LogFormat.StairsFormatter())
} }

View file

@ -36,11 +36,19 @@ sealed class Settings(
) : Settings("Settings / Card Settings", event, params, error) { ) : Settings("Settings / Card Settings", event, params, error) {
class ButtonFactoryReset : CardSettings("Button - Factory Reset") class ButtonFactoryReset : CardSettings("Button - Factory Reset")
class FactoryResetFinished(error: Throwable? = null) : CardSettings( class FactoryResetFinished(cardsCount: Int? = null, error: Throwable? = null) : CardSettings(
event = "Factory Reset Finished", event = "Factory Reset Finished",
params = buildMap {
cardsCount?.let { put("Cards Count", "$it") }
},
error = error, error = error,
) )
class FactoryResetCanceled(cardsCount: Int) : CardSettings(
event = "Factory Reset Canceled",
params = mapOf("Cards Count" to "$cardsCount"),
)
class UserCodeChanged : CardSettings("User Code Changed") class UserCodeChanged : CardSettings("User Code Changed")
class ButtonChangeSecurityMode : CardSettings("Button - Change Security Mode") class ButtonChangeSecurityMode : CardSettings("Button - Change Security Mode")

View file

@ -0,0 +1,168 @@
package com.tangem.tap.data
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.tangem.Log
import com.tangem.TangemSdk
import com.tangem.common.CardFilter
import com.tangem.common.authentication.AuthenticationManager
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.core.Config
import com.tangem.common.services.secure.SecureStorage
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.crypto.bip39.Wordlist
import com.tangem.data.card.sdk.CardSdkOwner
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.sdk.DefaultSessionViewDelegate
import com.tangem.sdk.extensions.*
import com.tangem.sdk.nfc.NfcManager
import com.tangem.sdk.storage.create
import com.tangem.tap.foregroundActivityObserver
import javax.inject.Inject
import javax.inject.Singleton
/**
* Implementation of CardSDK instance provider
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class DefaultCardSdkProvider @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
) : CardSdkProvider, CardSdkOwner {
private val observer = Observer()
private var holder: Holder? = null
override val sdk: TangemSdk
get() = holder?.sdk ?: tryToRegisterWithForegroundActivity()
override fun register(activity: FragmentActivity) {
if (activity.isDestroyed || activity.isFinishing || activity.isChangingConfigurations) {
val message = "Tangem SDK owner registration skipped: activity is destroyed or finishing"
analyticsEventHandler.send(TangemSdkWarningEvent(message))
Log.info { message }
return
}
if (holder != null) {
unsubscribeAndCleanup()
}
initialize(activity)
activity.lifecycle.addObserver(observer)
Log.info { "Tangem SDK owner registered" }
}
private fun tryToRegisterWithForegroundActivity(): TangemSdk {
val warning = "Tangem SDK holder is null, trying to recreate it with foreground activity"
analyticsEventHandler.send(TangemSdkWarningEvent(warning))
Log.warning { warning }
val activity = foregroundActivityObserver.foregroundActivity
if (activity == null) {
val error = "Tangem SDK holder is null and foreground activity is null"
analyticsEventHandler.send(TangemSdkWarningEvent(error))
Log.error { error }
error(error)
}
register(activity)
val sdk = holder?.sdk
if (sdk == null) {
val error = "Tangem SDK is null after re-registering with foreground activity"
analyticsEventHandler.send(TangemSdkWarningEvent(error))
Log.error { error }
error(error)
}
return sdk
}
private fun initialize(activity: FragmentActivity) {
val secureStorage = SecureStorage.create(activity)
val nfcManager = TangemSdk.initNfcManager(activity)
val authenticationManager = TangemSdk.initAuthenticationManager(activity)
val keystoreManager = TangemSdk.initKeystoreManager(authenticationManager, secureStorage)
val viewDelegate = DefaultSessionViewDelegate(nfcManager, activity)
viewDelegate.sdkConfig = config
val sdk = TangemSdk(
reader = nfcManager.reader,
viewDelegate = viewDelegate,
secureStorage = secureStorage,
authenticationManager = authenticationManager,
keystoreManager = keystoreManager,
wordlist = Wordlist.getWordlist(activity),
config = config,
)
holder = Holder(
activity = activity,
nfcManager = nfcManager,
authenticationManager = authenticationManager,
sdk = sdk,
)
Log.info { "Tangem SDK initialized" }
}
private fun unsubscribeAndCleanup() {
val currentHolder = holder
if (currentHolder == null) {
Log.info { "Tangem SDK already unsubscribed and cleaned up" }
return
}
with(currentHolder) {
nfcManager.unsubscribe(activity)
authenticationManager.unsubscribe(activity)
activity.lifecycle.removeObserver(observer)
}
holder = null
Log.info { "Tangem SDK unsubscribed and cleaned up" }
}
inner class Observer : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
Log.info { "Tangem SDK owner destroyed" }
unsubscribeAndCleanup()
}
}
data class Holder(
val activity: FragmentActivity,
val sdk: TangemSdk,
val nfcManager: NfcManager,
val authenticationManager: AuthenticationManager,
)
private companion object {
val config = Config(
linkedTerminal = true,
allowUntrustedCards = true,
filter = CardFilter(
allowedCardTypes = FirmwareVersion.FirmwareType.entries.toList(),
maxFirmwareVersion = FirmwareVersion(major = 6, minor = 33),
batchIdFilter = CardFilter.Companion.ItemFilter.Deny(
items = setOf("0027", "0030", "0031", "0035"),
),
),
)
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.tap.data
import com.tangem.core.analytics.models.AnalyticsEvent
internal class TangemSdkWarningEvent(message: String) : AnalyticsEvent(
category = "Tangem SDK",
event = "Warning",
error = IllegalStateException(message),
)

View file

@ -33,6 +33,7 @@ internal object CardSDKLoggerModule {
Log.Level.Network, Log.Level.Network,
Log.Level.Error, Log.Level.Error,
Log.Level.Biometric, Log.Level.Biometric,
Log.Level.Info,
) )
return TangemCardSDKLogger( return TangemCardSDKLogger(

View file

@ -1,8 +1,8 @@
package com.tangem.data.card.di package com.tangem.tap.di.data
import com.tangem.data.card.sdk.CardSdkLifecycleObserver import com.tangem.data.card.sdk.CardSdkOwner
import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.data.card.sdk.DefaultCardSdkProvider import com.tangem.tap.data.DefaultCardSdkProvider
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -19,5 +19,5 @@ internal interface CardSdkModule {
@Binds @Binds
@Singleton @Singleton
fun providerCardSdkLifecycleObserver(defaultCardSdkProvider: DefaultCardSdkProvider): CardSdkLifecycleObserver fun providerCardSdkLifecycleObserver(defaultCardSdkProvider: DefaultCardSdkProvider): CardSdkOwner
} }

View file

@ -14,15 +14,15 @@ import com.tangem.tap.domain.sdk.TangemSdkManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.components.SingletonComponent
import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Singleton
@Module @Module
@InstallIn(ViewModelComponent::class) @InstallIn(SingletonComponent::class)
internal object CardDomainModule { internal object CardDomainModule {
@Provides @Provides
@ViewModelScoped @Singleton
fun provideGetBiometricsStatusUseCase( fun provideGetBiometricsStatusUseCase(
cardSdkConfigRepository: CardSdkConfigRepository, cardSdkConfigRepository: CardSdkConfigRepository,
): GetBiometricsStatusUseCase { ): GetBiometricsStatusUseCase {
@ -30,7 +30,7 @@ internal object CardDomainModule {
} }
@Provides @Provides
@ViewModelScoped @Singleton
fun provideSetAccessCodeRequestPolicyUseCase( fun provideSetAccessCodeRequestPolicyUseCase(
cardSdkConfigRepository: CardSdkConfigRepository, cardSdkConfigRepository: CardSdkConfigRepository,
): SetAccessCodeRequestPolicyUseCase { ): SetAccessCodeRequestPolicyUseCase {
@ -38,35 +38,35 @@ internal object CardDomainModule {
} }
@Provides @Provides
@ViewModelScoped @Singleton
fun provideWasWalletAlreadySignedHashesConfirmedUseCase(cardRepository: CardRepository): WasCardScannedUseCase { fun provideWasWalletAlreadySignedHashesConfirmedUseCase(cardRepository: CardRepository): WasCardScannedUseCase {
return WasCardScannedUseCase(cardRepository = cardRepository) return WasCardScannedUseCase(cardRepository = cardRepository)
} }
@Provides @Provides
@ViewModelScoped @Singleton
fun provideSetCardWasScannedUseCase(cardRepository: CardRepository): SetCardWasScannedUseCase { fun provideSetCardWasScannedUseCase(cardRepository: CardRepository): SetCardWasScannedUseCase {
return SetCardWasScannedUseCase(cardRepository = cardRepository) return SetCardWasScannedUseCase(cardRepository = cardRepository)
} }
@Provides @Provides
@ViewModelScoped @Singleton
fun provideIsDemoCardUseCase(): IsDemoCardUseCase = IsDemoCardUseCase(config = DemoConfig()) fun provideIsDemoCardUseCase(): IsDemoCardUseCase = IsDemoCardUseCase(config = DemoConfig())
@Provides @Provides
@ViewModelScoped @Singleton
fun provideDerivePublicKeysUseCase(derivationsRepository: DerivationsRepository): DerivePublicKeysUseCase { fun provideDerivePublicKeysUseCase(derivationsRepository: DerivationsRepository): DerivePublicKeysUseCase {
return DerivePublicKeysUseCase(derivationsRepository = derivationsRepository) return DerivePublicKeysUseCase(derivationsRepository = derivationsRepository)
} }
@Provides @Provides
@ViewModelScoped @Singleton
fun provideIsNeedToBackupUseCase(userWalletsListManager: UserWalletsListManager): IsNeedToBackupUseCase { fun provideIsNeedToBackupUseCase(userWalletsListManager: UserWalletsListManager): IsNeedToBackupUseCase {
return IsNeedToBackupUseCase(userWalletsListManager = userWalletsListManager) return IsNeedToBackupUseCase(userWalletsListManager = userWalletsListManager)
} }
@Provides @Provides
@ViewModelScoped @Singleton
fun provideGetExtendedPublicKeyForCurrencyUseCase( fun provideGetExtendedPublicKeyForCurrencyUseCase(
derivationsRepository: DerivationsRepository, derivationsRepository: DerivationsRepository,
): GetExtendedPublicKeyForCurrencyUseCase { ): GetExtendedPublicKeyForCurrencyUseCase {
@ -74,13 +74,13 @@ internal object CardDomainModule {
} }
@Provides @Provides
@ViewModelScoped @Singleton
fun provideDeleteSavedAccessCodesUseCase(tangemSdkManager: TangemSdkManager): DeleteSavedAccessCodesUseCase { fun provideDeleteSavedAccessCodesUseCase(tangemSdkManager: TangemSdkManager): DeleteSavedAccessCodesUseCase {
return DefaultDeleteSavedAccessCodesUseCase(tangemSdkManager) return DefaultDeleteSavedAccessCodesUseCase(tangemSdkManager)
} }
@Provides @Provides
@ViewModelScoped @Singleton
fun provideResetCardUseCase(tangemSdkManager: TangemSdkManager): ResetCardUseCase { fun provideResetCardUseCase(tangemSdkManager: TangemSdkManager): ResetCardUseCase {
return DefaultResetCardUseCase(tangemSdkManager) return DefaultResetCardUseCase(tangemSdkManager)
} }

View file

@ -398,4 +398,16 @@ internal object TokensDomainModule {
stakingRepository = stakingRepository, stakingRepository = stakingRepository,
) )
} }
@Provides
@Singleton
fun provideRefreshMultiCurrencyWalletQuotesUseCase(
currenciesRepository: CurrenciesRepository,
quotesRepository: QuotesRepository,
): RefreshMultiCurrencyWalletQuotesUseCase {
return RefreshMultiCurrencyWalletQuotesUseCase(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
)
}
} }

View file

@ -1,9 +1,12 @@
package com.tangem.tap.domain.card package com.tangem.tap.domain.card
import arrow.core.Either import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either import arrow.core.raise.either
import arrow.core.right import arrow.fx.coroutines.ResourceScope
import com.tangem.common.* import arrow.fx.coroutines.resourceScope
import com.tangem.common.CompletionResult
import com.tangem.common.UserCodeType
import com.tangem.common.core.TangemError import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError import com.tangem.common.core.TangemSdkError
import com.tangem.common.core.UserCodeRequestPolicy import com.tangem.common.core.UserCodeRequestPolicy
@ -17,14 +20,14 @@ internal class DefaultResetCardUseCase(
private val tangemSdkManager: TangemSdkManager, private val tangemSdkManager: TangemSdkManager,
) : ResetCardUseCase { ) : ResetCardUseCase {
override suspend fun invoke(card: CardDTO): Either<ResetCardError, Unit> = either { override suspend fun invoke(card: CardDTO): Either<ResetCardError, Unit> = resourceScope {
enterRequiredAccessCode(card) { either {
withUserCodeRequestPolicy(card)
tangemSdkManager.resetToFactorySettings( tangemSdkManager.resetToFactorySettings(
cardId = card.cardId, cardId = card.cardId,
allowsRequestAccessCodeFromRepository = true, allowsRequestAccessCodeFromRepository = true,
) ).bind(raise = this)
.doOnSuccess { Unit.right() }
.doOnFailure { raise(it.mapToDomainError()) }
} }
} }
@ -32,23 +35,29 @@ internal class DefaultResetCardUseCase(
cardNumber: Int, cardNumber: Int,
card: CardDTO, card: CardDTO,
userWalletId: UserWalletId, userWalletId: UserWalletId,
): Either<ResetCardError, Unit> = either { ): Either<ResetCardError, Unit> = resourceScope {
enterRequiredAccessCode(card) { either {
tangemSdkManager.resetBackupCard(cardNumber, userWalletId) withUserCodeRequestPolicy(card)
.doOnSuccess { Unit.right() }
.doOnFailure { raise(it.mapToDomainError()) } tangemSdkManager.resetBackupCard(
cardNumber = cardNumber,
userWalletId = userWalletId,
).bind(raise = this)
} }
} }
private suspend fun enterRequiredAccessCode( private suspend fun ResourceScope.withUserCodeRequestPolicy(card: CardDTO) {
card: CardDTO, install(
task: suspend TangemSdkManager.() -> CompletionResult<*>, acquire = {
) { val policyBeforeReset = tangemSdkManager.userCodeRequestPolicy
val policyBeforeReset = tangemSdkManager.userCodeRequestPolicy requestMandatoryAccessCodeEntry(card)
requestMandatoryAccessCodeEntry(card)
tangemSdkManager.task() policyBeforeReset
.doOnResult { tangemSdkManager.setUserCodeRequestPolicy(policyBeforeReset) } },
release = { prevPolicy, _ ->
tangemSdkManager.setUserCodeRequestPolicy(prevPolicy)
},
)
} }
private fun requestMandatoryAccessCodeEntry(card: CardDTO) { private fun requestMandatoryAccessCodeEntry(card: CardDTO) {
@ -65,7 +74,19 @@ internal class DefaultResetCardUseCase(
} }
} }
private fun CompletionResult<*>.bind(raise: Raise<ResetCardError>) {
return when (this) {
is CompletionResult.Failure -> {
val domainError = error.mapToDomainError()
raise.raise(domainError)
}
is CompletionResult.Success -> { /* no-op */
}
}
}
private fun TangemError.mapToDomainError(): ResetCardError { private fun TangemError.mapToDomainError(): ResetCardError {
return if (this is TangemSdkError.UserCancelled) ResetCardError.UserCanceled else ResetCardError.AnotherSdkError return if (this is TangemSdkError.UserCancelled) ResetCardError.UserCanceled else ResetCardError.SdkError
} }
} }

View file

@ -18,7 +18,7 @@ class UserWalletIdPreflightReadFilter(private val expectedUserWalletId: UserWall
override fun onCardRead(card: Card, environment: SessionEnvironment) = Unit override fun onCardRead(card: Card, environment: SessionEnvironment) = Unit
override fun onFullCardRead(card: Card, environment: SessionEnvironment) { override fun onFullCardRead(card: Card, environment: SessionEnvironment) {
val actualUserWalletId = UserWalletIdBuilder.card(card = CardDTO(card)).build() val actualUserWalletId = UserWalletIdBuilder.card(card = CardDTO(card)).build() ?: return
if (expectedUserWalletId != actualUserWalletId) throw TangemSdkError.WalletNotFound() if (expectedUserWalletId != actualUserWalletId) throw TangemSdkError.WalletNotFound()
} }

View file

@ -152,19 +152,29 @@ internal class BiometricUserWalletsListManager(
return CompletionResult.Success(Unit) return CompletionResult.Success(Unit)
} }
changeSelectedUserWalletIdIfNeeded(idsToRemove) if (idsToRemove.size == state.value.userWallets.size) {
return clear()
}
return sensitiveInformationRepository.delete(idsToRemove) return sensitiveInformationRepository.delete(idsToRemove)
.flatMap { publicInformationRepository.delete(idsToRemove) } .flatMap { publicInformationRepository.delete(idsToRemove) }
.map { keysRepository.delete(idsToRemove) } .map { keysRepository.delete(idsToRemove) }
.map { .map {
state.update { prevState -> state.update { prevState ->
val newUserWallets = prevState.userWallets.filter { it.walletId !in idsToRemove } val remainingWallets = prevState.userWallets.filter { it.walletId !in idsToRemove }
val isSelectedWalletDeleted = prevState.selectedUserWalletId in idsToRemove
val newSelectedUserWallet = findOrSetSelectedWallet(
prevSelectedWalletId = prevState.selectedUserWalletId,
userWallets = remainingWallets,
ignorePrevSelectedWallet = isSelectedWalletDeleted,
)
prevState.copy( prevState.copy(
encryptionKeys = prevState.encryptionKeys.filter { it.walletId !in idsToRemove }, encryptionKeys = prevState.encryptionKeys.filter { it.walletId !in idsToRemove },
userWallets = newUserWallets, userWallets = remainingWallets,
isLocked = newUserWallets.any { it.isLocked }, isLocked = remainingWallets.any { it.isLocked },
selectedUserWalletId = newSelectedUserWallet?.walletId,
) )
} }
} }
@ -173,10 +183,10 @@ internal class BiometricUserWalletsListManager(
override suspend fun clear(): CompletionResult<Unit> { override suspend fun clear(): CompletionResult<Unit> {
return sensitiveInformationRepository.clear() return sensitiveInformationRepository.clear()
.flatMap { publicInformationRepository.clear() } .flatMap { publicInformationRepository.clear() }
.map { keysRepository.clear() }
.map { .map {
keysRepository.clear()
selectedUserWalletRepository.set(null) selectedUserWalletRepository.set(null)
lock() state.value = State()
} }
} }
@ -301,47 +311,24 @@ internal class BiometricUserWalletsListManager(
private fun findOrSetSelectedWallet( private fun findOrSetSelectedWallet(
prevSelectedWalletId: UserWalletId?, prevSelectedWalletId: UserWalletId?,
userWallets: List<UserWallet>, userWallets: List<UserWallet>,
ignorePrevSelectedWallet: Boolean = false,
): UserWallet? { ): UserWallet? {
val selectedWalletId = prevSelectedWalletId ?: selectedUserWalletRepository.get() var possibleSelectedUserWallet: UserWallet? = null
var possibleSelectedUserWallet = findSelectedUserWallet(userWallets, selectedWalletId)
if (!ignorePrevSelectedWallet) {
val selectedWalletId = prevSelectedWalletId ?: selectedUserWalletRepository.get()
possibleSelectedUserWallet = findSelectedUserWallet(userWallets, selectedWalletId)
}
if (possibleSelectedUserWallet == null || possibleSelectedUserWallet.isLocked) { if (possibleSelectedUserWallet == null || possibleSelectedUserWallet.isLocked) {
possibleSelectedUserWallet = userWallets.firstOrNull { !it.isLocked } ?: userWallets.firstOrNull() possibleSelectedUserWallet = userWallets.firstOrNull { !it.isLocked } ?: userWallets.firstOrNull()
if (possibleSelectedUserWallet != null) {
selectedUserWalletRepository.set(possibleSelectedUserWallet.walletId)
}
} }
selectedUserWalletRepository.set(possibleSelectedUserWallet?.walletId)
return possibleSelectedUserWallet return possibleSelectedUserWallet
} }
private fun changeSelectedUserWalletIdIfNeeded(walletsIdsToRemove: List<UserWalletId>) {
val remainingWallets = state.value.userWallets.filter {
it.walletId !in walletsIdsToRemove
}
val selectedWallet = findSelectedUserWallet()
when {
remainingWallets.isEmpty() -> {
state.update { prevState ->
prevState.copy(
selectedUserWalletId = null,
)
}
selectedUserWalletRepository.set(null)
}
!remainingWallets.contains(selectedWallet) -> {
val newSelectedWallet = remainingWallets.firstOrNull { !it.isLocked }
state.update { prevState ->
prevState.copy(
selectedUserWalletId = newSelectedWallet?.walletId,
)
}
selectedUserWalletRepository.set(newSelectedWallet?.walletId)
}
}
}
private fun findSelectedUserWallet( private fun findSelectedUserWallet(
userWallets: List<UserWallet> = state.value.userWallets, userWallets: List<UserWallet> = state.value.userWallets,
selectedUserWalletId: UserWalletId? = state.value.selectedUserWalletId, selectedUserWalletId: UserWalletId? = state.value.selectedUserWalletId,

View file

@ -17,5 +17,5 @@ interface CustomTokenInteractor {
/** Save token [customCurrency] */ /** Save token [customCurrency] */
@Throws(TangemError::class) @Throws(TangemError::class)
suspend fun saveToken(customCurrency: CustomCurrency) suspend fun saveToken(customCurrency: CustomCurrency): Result<Unit>
} }

View file

@ -1,5 +1,7 @@
package com.tangem.tap.features.customtoken.impl.domain package com.tangem.tap.features.customtoken.impl.domain
import arrow.core.getOrElse
import arrow.core.raise.result
import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.tokens.utils.CryptoCurrencyFactory import com.tangem.data.tokens.utils.CryptoCurrencyFactory
@ -15,7 +17,6 @@ import com.tangem.tap.domain.model.Currency
import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken
import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store import com.tangem.tap.store
import timber.log.Timber
/** /**
* Default implementation of custom token interactor * Default implementation of custom token interactor
@ -45,16 +46,18 @@ class DefaultCustomTokenInteractor(
) )
} }
override suspend fun saveToken(customCurrency: CustomCurrency) { override suspend fun saveToken(customCurrency: CustomCurrency): Result<Unit> {
val userWallet = getSelectedWalletSyncUseCase().fold(ifLeft = { return }, ifRight = { it }) return result {
val currency = Currency.fromCustomCurrency(customCurrency) val userWallet = getSelectedWalletSyncUseCase().getOrElse {
error("Failed to get selected wallet: $it")
val currencies = listOfNotNull(element = currency.toCryptoCurrency(userWallet.scanResponse))
derivePublicKeysUseCase(userWalletId = userWallet.walletId, currencies = currencies)
.onRight {
addCryptoCurrenciesUseCase(userWalletId = userWallet.walletId, currencies = currencies)
} }
.onLeft { Timber.e("Failed to derive public keys: $it") }
val currency = Currency.fromCustomCurrency(customCurrency)
val currencies = listOfNotNull(element = currency.toCryptoCurrency(userWallet.scanResponse))
derivePublicKeysUseCase(userWalletId = userWallet.walletId, currencies = currencies).bind()
addCryptoCurrenciesUseCase(userWalletId = userWallet.walletId, currencies = currencies).bind()
}
} }
private fun Currency.toCryptoCurrency(scanResponse: ScanResponse): CryptoCurrency? { private fun Currency.toCryptoCurrency(scanResponse: ScanResponse): CryptoCurrency? {

View file

@ -3,9 +3,11 @@ package com.tangem.tap.features.customtoken.impl.presentation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
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.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment import com.tangem.core.ui.screen.ComposeFragment
@ -30,11 +32,13 @@ internal class AddCustomTokenFragment : ComposeFragment() {
val viewModel = hiltViewModel<AddCustomTokenViewModel>().apply { val viewModel = hiltViewModel<AddCustomTokenViewModel>().apply {
LocalLifecycleOwner.current.lifecycle.addObserver(this) LocalLifecycleOwner.current.lifecycle.addObserver(this)
} }
val state by viewModel.uiState.collectAsStateWithLifecycle()
AddCustomTokenScreen( AddCustomTokenScreen(
modifier = Modifier modifier = Modifier
.background(TangemTheme.colors.background.primary) .background(TangemTheme.colors.background.primary)
.systemBarsPadding(), .systemBarsPadding(),
stateHolder = viewModel.uiState, stateHolder = state,
) )
} }
} }

View file

@ -26,8 +26,8 @@ object ContractAddressValidator {
Blockchain.Unknown, Blockchain.Unknown,
Blockchain.Binance, Blockchain.Binance,
Blockchain.BinanceTestnet, Blockchain.BinanceTestnet,
Blockchain.Cardano,
-> SuccessAddressValidator.validate(address) -> SuccessAddressValidator.validate(address)
Blockchain.Cardano -> blockchain.validateContractAddress(address)
else -> blockchain.validateAddress(address) else -> blockchain.validateAddress(address)
} }
} }

View file

@ -1,15 +1,13 @@
package com.tangem.tap.features.customtoken.impl.presentation.viewmodels package com.tangem.tap.features.customtoken.impl.presentation.viewmodels
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tangem.blockchain.blockchains.cardano.CardanoTokenAddressConverter
import com.tangem.blockchain.blockchains.hedera.HederaTokenAddressConverter import com.tangem.blockchain.blockchains.hedera.HederaTokenAddressConverter
import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.Token
@ -48,6 +46,9 @@ import com.tangem.wallet.R
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -79,12 +80,12 @@ internal class AddCustomTokenViewModel @Inject constructor(
private val testActionsHandler = TestActionsHandler() private val testActionsHandler = TestActionsHandler()
private val formStateBuilder = FormStateBuilder() private val formStateBuilder = FormStateBuilder()
private val hederaAddressConverter = HederaTokenAddressConverter() private val hederaAddressConverter = HederaTokenAddressConverter()
private val cardanoTokenAddressConverter = CardanoTokenAddressConverter()
private var currentCryptoCurrencies: List<CryptoCurrency> = emptyList() private var currentCryptoCurrencies: List<CryptoCurrency> = emptyList()
/** Screen state */ /** Screen state */
var uiState by mutableStateOf(getInitialUiState()) val uiState: MutableStateFlow<AddCustomTokenStateHolder> = MutableStateFlow(value = getInitialUiState())
private set
private var foundToken: FoundToken? = null private var foundToken: FoundToken? = null
@ -368,48 +369,54 @@ internal class AddCustomTokenViewModel @Inject constructor(
viewModelScope.launch(dispatchers.main) { viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) { runCatching(dispatchers.io) {
val tokenAddress = convertTokenAddress(selectedNetwork, address) val tokenAddress = convertTokenAddress(selectedNetwork, address)
?: error("TokenAddress is invalid")
featureInteractor.findToken(address = tokenAddress, blockchain = selectedNetwork) featureInteractor.findToken(address = tokenAddress, blockchain = selectedNetwork)
} }
.onSuccess { token -> .onSuccess { token ->
foundToken = token foundToken = token
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
contractAddressInputField = uiState.form.contractAddressInputField.copy( form = state.form.copy(
isLoading = false, contractAddressInputField = state.form.contractAddressInputField.copy(
), isLoading = false,
networkSelectorField = uiState.form.networkSelectorField.copy( ),
selectedItem = formStateBuilder.createNetworkSelectorItem( networkSelectorField = state.form.networkSelectorField.copy(
blockchain = Blockchain.fromNetworkId(token.network.id) selectedItem = formStateBuilder.createNetworkSelectorItem(
?: Blockchain.Unknown, blockchain = Blockchain.fromNetworkId(token.network.id)
?: Blockchain.Unknown,
),
),
tokenNameInputField = state.form.tokenNameInputField.copy(
value = token.name,
isEnabled = false,
),
tokenSymbolInputField = state.form.tokenSymbolInputField.copy(
value = token.symbol,
isEnabled = false,
),
decimalsInputField = state.form.decimalsInputField.copy(
value = token.network.decimalCount,
isEnabled = false,
), ),
), ),
tokenNameInputField = uiState.form.tokenNameInputField.copy( )
value = token.name, }
isEnabled = false,
),
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(
value = token.symbol,
isEnabled = false,
),
decimalsInputField = uiState.form.decimalsInputField.copy(
value = token.network.decimalCount,
isEnabled = false,
),
),
)
} }
.onFailure { .onFailure {
foundToken = null foundToken = null
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
contractAddressInputField = uiState.form.contractAddressInputField.copy( form = state.form.copy(
isLoading = false, contractAddressInputField = state.form.contractAddressInputField.copy(
isLoading = false,
),
tokenNameInputField = state.form.tokenNameInputField.copy(isEnabled = true),
tokenSymbolInputField = state.form.tokenSymbolInputField.copy(isEnabled = true),
decimalsInputField = state.form.decimalsInputField.copy(isEnabled = true),
), ),
tokenNameInputField = uiState.form.tokenNameInputField.copy(isEnabled = true), )
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(isEnabled = true), }
decimalsInputField = uiState.form.decimalsInputField.copy(isEnabled = true),
),
)
Timber.e(it) Timber.e(it)
} }
@ -419,28 +426,31 @@ internal class AddCustomTokenViewModel @Inject constructor(
} }
private fun isDerivationPathSelected(): Boolean { private fun isDerivationPathSelected(): Boolean {
return with(uiState.form.derivationPathSelectorField?.selectedItem?.blockchain) { val blockchain = uiState.value.form.derivationPathSelectorField?.selectedItem?.blockchain
this != null && this != Blockchain.Unknown || val selectorType = uiState.value.form.derivationPathSelectorField?.selectedItem?.type
uiState.form.derivationPathSelectorField?.selectedItem?.type == DerivationPathSelectorType.CUSTOM &&
!uiState.warnings.contains(AddCustomTokenWarning.WrongDerivationPath) return blockchain != null && blockchain != Blockchain.Unknown ||
} selectorType == DerivationPathSelectorType.CUSTOM &&
!uiState.value.warnings.contains(AddCustomTokenWarning.WrongDerivationPath)
} }
private fun updateWarnings() { private fun updateWarnings() {
uiState = uiState.copySealed( uiState.update { state ->
warnings = buildSet { state.copySealed(
when (getCustomTokenType()) { warnings = buildSet {
CustomTokenType.TOKEN -> { when (getCustomTokenType()) {
addAll(getTokenWarningSet()) CustomTokenType.TOKEN -> {
} addAll(getTokenWarningSet())
}
CustomTokenType.BLOCKCHAIN -> { CustomTokenType.BLOCKCHAIN -> {
if (isCustomTokenAlreadyAdded()) add(AddCustomTokenWarning.TokenAlreadyAdded) if (isCustomTokenAlreadyAdded()) add(AddCustomTokenWarning.TokenAlreadyAdded)
if (isDerivationPathSelected()) add(AddCustomTokenWarning.PotentialScamToken) if (isDerivationPathSelected()) add(AddCustomTokenWarning.PotentialScamToken)
}
} }
} },
}, )
) }
} }
private fun getCustomTokenType(): CustomTokenType { private fun getCustomTokenType(): CustomTokenType {
@ -452,24 +462,24 @@ internal class AddCustomTokenViewModel @Inject constructor(
} }
private fun isAnyTokenFieldsFilled(): Boolean { private fun isAnyTokenFieldsFilled(): Boolean {
return with(uiState.form) { return with(uiState.value.form) {
contractAddressInputField.value.isNotEmpty() || tokenNameInputField.value.isNotEmpty() || contractAddressInputField.value.isNotEmpty() || tokenNameInputField.value.isNotEmpty() ||
tokenSymbolInputField.value.isNotEmpty() || decimalsInputField.value.isNotEmpty() tokenSymbolInputField.value.isNotEmpty() || decimalsInputField.value.isNotEmpty()
} }
} }
private fun isAllTokenFieldsFilled(): Boolean { private fun isAllTokenFieldsFilled(): Boolean {
return with(uiState.form) { return with(uiState.value.form) {
contractAddressInputField.value.isNotEmpty() && tokenNameInputField.value.isNotEmpty() && contractAddressInputField.value.isNotEmpty() && tokenNameInputField.value.isNotEmpty() &&
tokenSymbolInputField.value.isNotEmpty() && decimalsInputField.value.isNotEmpty() tokenSymbolInputField.value.isNotEmpty() && decimalsInputField.value.isNotEmpty()
} }
} }
private fun getTokenWarningSet(): Set<AddCustomTokenWarning> { private fun getTokenWarningSet(): Set<AddCustomTokenWarning> {
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain val networkSelectorValue = uiState.value.form.networkSelectorField.selectedItem.blockchain
val isContractAddressFieldEmpty = ContractAddressValidator.validate( val isContractAddressFieldEmpty = ContractAddressValidator.validate(
address = uiState.form.contractAddressInputField.value, address = uiState.value.form.contractAddressInputField.value,
blockchain = networkSelectorValue, blockchain = networkSelectorValue,
).let { ).let {
it is ContractAddressValidatorResult.Error && it.type == AddCustomTokenError.FIELD_IS_EMPTY it is ContractAddressValidatorResult.Error && it.type == AddCustomTokenError.FIELD_IS_EMPTY
@ -503,66 +513,70 @@ internal class AddCustomTokenViewModel @Inject constructor(
} }
private fun isNetworkSelected(): Boolean { private fun isNetworkSelected(): Boolean {
return uiState.form.networkSelectorField.selectedItem.blockchain != Blockchain.Unknown return uiState.value.form.networkSelectorField.selectedItem.blockchain != Blockchain.Unknown
} }
private fun updateFloatingButton() { private fun updateFloatingButton() {
uiState = updateStateWithDerivationError(uiState) uiState.update { state ->
updateStateWithDerivationError(state)
}
if (isCustomTokenAlreadyAdded()) { if (isCustomTokenAlreadyAdded()) {
uiState = uiState.copySealed( uiState.update { state ->
warnings = uiState.warnings + AddCustomTokenWarning.TokenAlreadyAdded, state.copySealed(
floatingButton = uiState.floatingButton.copy(isEnabled = false), warnings = state.warnings + AddCustomTokenWarning.TokenAlreadyAdded,
) floatingButton = state.floatingButton.copy(isEnabled = false),
)
}
return return
} }
val isCorrectDerivationInput = !uiState.warnings.contains(AddCustomTokenWarning.WrongDerivationPath) uiState.update { state ->
val state = when { val isCorrectDerivationInput = !state.warnings.contains(AddCustomTokenWarning.WrongDerivationPath)
isAllTokenFieldsFilled() && isNetworkSelected() -> { val updatedState = when {
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain isAllTokenFieldsFilled() && isNetworkSelected() -> {
val error = ContractAddressValidator.validate( val networkSelectorValue = state.form.networkSelectorField.selectedItem.blockchain
address = uiState.form.contractAddressInputField.value, val error = ContractAddressValidator.validate(
blockchain = networkSelectorValue, address = state.form.contractAddressInputField.value,
) blockchain = networkSelectorValue,
)
val isSupportedToken = getSelectedWalletSyncUseCase().fold( val isSupportedToken = getSelectedWalletSyncUseCase().fold(
ifLeft = { false }, ifLeft = { false },
ifRight = { ifRight = {
it.scanResponse.card.canHandleToken( it.scanResponse.card.canHandleToken(
blockchain = networkSelectorValue, blockchain = networkSelectorValue,
cardTypesResolver = it.scanResponse.cardTypesResolver, cardTypesResolver = it.scanResponse.cardTypesResolver,
) )
},
)
uiState.copySealed(
floatingButton = uiState.floatingButton.copy(
isEnabled = error is ContractAddressValidatorResult.Success &&
isSupportedToken && isCorrectDerivationInput,
),
)
}
isAnyTokenFieldsFilled() -> {
uiState.copySealed(floatingButton = uiState.floatingButton.copy(isEnabled = false))
}
else -> {
uiState.copySealed(
floatingButton = uiState.floatingButton.copy(
isEnabled = if (isNetworkSelected()) {
!isBlockchainAlreadyAdded() && isCorrectDerivationInput
} else {
false
}, },
), )
)
}
}
uiState = state.copySealed( state.copySealed(
warnings = state.warnings - AddCustomTokenWarning.TokenAlreadyAdded, floatingButton = state.floatingButton.copy(
) isEnabled = error is ContractAddressValidatorResult.Success &&
isSupportedToken && isCorrectDerivationInput,
),
)
}
isAnyTokenFieldsFilled() -> {
state.copySealed(floatingButton = state.floatingButton.copy(isEnabled = false))
}
else -> {
state.copySealed(
floatingButton = state.floatingButton.copy(
isEnabled = if (isNetworkSelected()) {
!isBlockchainAlreadyAdded() && isCorrectDerivationInput
} else {
false
},
),
)
}
}
updatedState.copySealed(
warnings = updatedState.warnings - AddCustomTokenWarning.TokenAlreadyAdded,
)
}
} }
private fun updateStateWithDerivationError(uiState: AddCustomTokenStateHolder): AddCustomTokenStateHolder { private fun updateStateWithDerivationError(uiState: AddCustomTokenStateHolder): AddCustomTokenStateHolder {
@ -595,13 +609,16 @@ internal class AddCustomTokenViewModel @Inject constructor(
} }
private fun isTokenAlreadyAdded(): Boolean { private fun isTokenAlreadyAdded(): Boolean {
val networkSelectorValue = uiState.value.form.networkSelectorField.selectedItem.blockchain
val networkId = Blockchain.fromNetworkId(networkSelectorValue.toNetworkId())?.id
val contractAddress = convertTokenAddress(
blockchain = networkSelectorValue,
address = uiState.value.form.contractAddressInputField.value,
) ?: return false // invalid address can't be "already added"
return currentCryptoCurrencies return currentCryptoCurrencies
.filterIsInstance<CryptoCurrency.Token>() .filterIsInstance<CryptoCurrency.Token>()
.any { token -> .any { token ->
val contractAddress = uiState.form.contractAddressInputField.value
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain
val networkId = Blockchain.fromNetworkId(networkSelectorValue.toNetworkId())?.id
val sameId = if (!token.isCustom) { val sameId = if (!token.isCustom) {
// todo after move foundToken to CryptoCurrency model, use only id // todo after move foundToken to CryptoCurrency model, use only id
foundToken?.id == token.id.rawCurrencyId foundToken?.id == token.id.rawCurrencyId
@ -621,7 +638,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
return currentCryptoCurrencies return currentCryptoCurrencies
.filterIsInstance<CryptoCurrency.Coin>() .filterIsInstance<CryptoCurrency.Coin>()
.any { coin -> .any { coin ->
coin.network.id.value == uiState.form.networkSelectorField.selectedItem.blockchain.id && coin.network.id.value == uiState.value.form.networkSelectorField.selectedItem.blockchain.id &&
coin.network.derivationPath.value == getDerivationPath()?.rawPath coin.network.derivationPath.value == getDerivationPath()?.rawPath
} }
} }
@ -630,39 +647,43 @@ internal class AddCustomTokenViewModel @Inject constructor(
when { when {
isNetworkSelected() && type == AddCustomTokenError.INVALID_CONTRACT_ADDRESS -> { isNetworkSelected() && type == AddCustomTokenError.INVALID_CONTRACT_ADDRESS -> {
val isAnotherTokenFieldsFilled = isAnyTokenFieldsFilled() val isAnotherTokenFieldsFilled = isAnyTokenFieldsFilled()
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
contractAddressInputField = uiState.form.contractAddressInputField.copy( form = state.form.copy(
isError = true, contractAddressInputField = state.form.contractAddressInputField.copy(
error = TextReference.Res( isError = true,
id = R.string.custom_token_creation_error_invalid_contract_address, error = TextReference.Res(
id = R.string.custom_token_creation_error_invalid_contract_address,
),
),
tokenNameInputField = state.form.tokenNameInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
),
tokenSymbolInputField = state.form.tokenSymbolInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
),
decimalsInputField = state.form.decimalsInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
), ),
), ),
tokenNameInputField = uiState.form.tokenNameInputField.copy( )
isEnabled = isAnotherTokenFieldsFilled, }
),
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
),
decimalsInputField = uiState.form.decimalsInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
),
),
)
} }
!isNetworkSelected() || type == AddCustomTokenError.FIELD_IS_EMPTY -> { !isNetworkSelected() || type == AddCustomTokenError.FIELD_IS_EMPTY -> {
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
contractAddressInputField = uiState.form.contractAddressInputField.copy(isError = false), form = state.form.copy(
tokenNameInputField = uiState.form.tokenNameInputField.copy(value = "", isEnabled = false), contractAddressInputField = state.form.contractAddressInputField.copy(isError = false),
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy( tokenNameInputField = state.form.tokenNameInputField.copy(value = "", isEnabled = false),
value = "", tokenSymbolInputField = state.form.tokenSymbolInputField.copy(
isEnabled = false, value = "",
isEnabled = false,
),
decimalsInputField = state.form.decimalsInputField.copy(value = "", isEnabled = false),
), ),
decimalsInputField = uiState.form.decimalsInputField.copy(value = "", isEnabled = false), )
), }
)
} }
else -> Unit else -> Unit
@ -670,11 +691,11 @@ internal class AddCustomTokenViewModel @Inject constructor(
} }
private fun getDerivationPath(): DerivationPath? { private fun getDerivationPath(): DerivationPath? {
return when (uiState.form.derivationPathSelectorField?.selectedItem?.type) { return when (uiState.value.form.derivationPathSelectorField?.selectedItem?.type) {
DerivationPathSelectorType.CUSTOM -> DerivationPathSelectorType.CUSTOM ->
createDerivationPathOrNull(uiState.form.derivationPathInputField?.value ?: "") createDerivationPathOrNull(uiState.value.form.derivationPathInputField?.value ?: "")
else -> else ->
getDerivationPathForBlockchain(uiState.form.derivationPathSelectorField?.selectedItem?.blockchain) getDerivationPathForBlockchain(uiState.value.form.derivationPathSelectorField?.selectedItem?.blockchain)
} }
} }
@ -697,7 +718,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
) )
val derivationNetwork = if (blockchain == Blockchain.Unknown) { val derivationNetwork = if (blockchain == Blockchain.Unknown) {
uiState.form.networkSelectorField.selectedItem.blockchain uiState.value.form.networkSelectorField.selectedItem.blockchain
} else { } else {
blockchain blockchain
} }
@ -732,13 +753,15 @@ internal class AddCustomTokenViewModel @Inject constructor(
} }
fun onContactAddressValueChange(enteredValue: String) { fun onContactAddressValueChange(enteredValue: String) {
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
contractAddressInputField = uiState.form.contractAddressInputField.copy(value = enteredValue), form = state.form.copy(
), contractAddressInputField = state.form.contractAddressInputField.copy(value = enteredValue),
) ),
)
}
val selectedNetwork = uiState.form.networkSelectorField.selectedItem.blockchain val selectedNetwork = uiState.value.form.networkSelectorField.selectedItem.blockchain
val validatorResult = ContractAddressValidator.validate( val validatorResult = ContractAddressValidator.validate(
address = enteredValue, address = enteredValue,
blockchain = selectedNetwork, blockchain = selectedNetwork,
@ -746,14 +769,16 @@ internal class AddCustomTokenViewModel @Inject constructor(
when (validatorResult) { when (validatorResult) {
is ContractAddressValidatorResult.Success -> { is ContractAddressValidatorResult.Success -> {
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
contractAddressInputField = uiState.form.contractAddressInputField.copy( form = state.form.copy(
isError = false, contractAddressInputField = state.form.contractAddressInputField.copy(
isLoading = true, isError = false,
isLoading = true,
),
), ),
), )
) }
updateForm(address = enteredValue, selectedNetwork = selectedNetwork) updateForm(address = enteredValue, selectedNetwork = selectedNetwork)
} }
@ -766,76 +791,92 @@ internal class AddCustomTokenViewModel @Inject constructor(
} }
fun onNetworkSelectorItemClick(index: Int) { fun onNetworkSelectorItemClick(index: Int) {
val selectedItem = requireNotNull(uiState.form.networkSelectorField.items.getOrNull(index)) val state = uiState.updateAndGet { state ->
uiState = uiState.copySealed( val selectedItem = requireNotNull(state.form.networkSelectorField.items.getOrNull(index))
form = uiState.form.copy( state.copySealed(
networkSelectorField = uiState.form.networkSelectorField.copy( form = state.form.copy(
selectedItem = selectedItem, networkSelectorField = state.form.networkSelectorField.copy(
selectedItem = selectedItem,
),
showTokenFields = selectedItem.blockchain.canHandleTokens() &&
// workaround cause in Terra we support only 1 token
selectedItem.blockchain != Blockchain.TerraV1,
), ),
showTokenFields = selectedItem.blockchain.canHandleTokens() && )
// workaround cause in Terra we support only 1 token }
selectedItem.blockchain != Blockchain.TerraV1,
), onContactAddressValueChange(state.form.contractAddressInputField.value)
)
onContactAddressValueChange(uiState.form.contractAddressInputField.value)
} }
fun onTokenNameValueChange(enteredValue: String) { fun onTokenNameValueChange(enteredValue: String) {
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
tokenNameInputField = uiState.form.tokenNameInputField.copy(value = enteredValue), form = state.form.copy(
), tokenNameInputField = state.form.tokenNameInputField.copy(value = enteredValue),
) ),
)
}
updateFloatingButton() updateFloatingButton()
} }
fun onTokenSymbolValueChange(enteredValue: String) { fun onTokenSymbolValueChange(enteredValue: String) {
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(value = enteredValue), form = state.form.copy(
), tokenSymbolInputField = state.form.tokenSymbolInputField.copy(value = enteredValue),
) ),
)
}
updateFloatingButton() updateFloatingButton()
} }
fun onDecimalsValueChange(enteredValue: String) { fun onDecimalsValueChange(enteredValue: String) {
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
decimalsInputField = uiState.form.decimalsInputField.copy(value = enteredValue), form = state.form.copy(
), decimalsInputField = state.form.decimalsInputField.copy(value = enteredValue),
) ),
)
}
updateFloatingButton() updateFloatingButton()
} }
fun onDerivationPathSelectorItemClick(index: Int) { fun onDerivationPathSelectorItemClick(index: Int) {
val derivationSelector = requireNotNull(uiState.form.derivationPathSelectorField) uiState.update { state ->
val selected = requireNotNull(derivationSelector.items.getOrNull(index)) val derivationSelector = requireNotNull(state.form.derivationPathSelectorField)
val derivationInputField = requireNotNull(uiState.form.derivationPathInputField) val selected = requireNotNull(derivationSelector.items.getOrNull(index))
uiState = uiState.copySealed( val derivationInputField = requireNotNull(state.form.derivationPathInputField)
form = uiState.form.copy(
derivationPathSelectorField = derivationSelector.copy( state.copySealed(
selectedItem = selected, form = state.form.copy(
derivationPathSelectorField = derivationSelector.copy(
selectedItem = selected,
),
derivationPathInputField = derivationInputField.copy(
showField = selected.type == DerivationPathSelectorType.CUSTOM,
),
), ),
derivationPathInputField = derivationInputField.copy( )
showField = selected.type == DerivationPathSelectorType.CUSTOM, }
),
),
)
updateFloatingButton() updateFloatingButton()
} }
fun onDerivationPathValueChange(enteredValue: String) { fun onDerivationPathValueChange(enteredValue: String) {
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
derivationPathInputField = uiState.form.derivationPathInputField?.copy(value = enteredValue), form = state.form.copy(
), derivationPathInputField = state.form.derivationPathInputField?.copy(value = enteredValue),
) ),
)
}
updateFloatingButton() updateFloatingButton()
} }
fun onAddCustomTokenClick() { fun onAddCustomTokenClick() {
if (!isNetworkSelected()) return if (!isNetworkSelected()) return
val blockchain = uiState.form.networkSelectorField.selectedItem.blockchain val blockchain = uiState.value.form.networkSelectorField.selectedItem.blockchain
when (getSupportBlockchainType(blockchain)) { when (getSupportBlockchainType(blockchain)) {
SupportBlockchainType.SUPPORTED -> { SupportBlockchainType.SUPPORTED -> {
/* no-op */ /* no-op */
@ -854,14 +895,16 @@ internal class AddCustomTokenViewModel @Inject constructor(
CustomTokenType.TOKEN -> { CustomTokenType.TOKEN -> {
val contractAddress = convertTokenAddress( val contractAddress = convertTokenAddress(
blockchain = blockchain, blockchain = blockchain,
address = foundToken?.network?.contractAddress ?: uiState.form.contractAddressInputField.value, address = foundToken?.network?.contractAddress ?: uiState.value.form.contractAddressInputField
) .value,
) ?: error("Contract address is invalid") // impossible to add a token with invalid address
CustomCurrency.CustomToken( CustomCurrency.CustomToken(
token = Token( token = Token(
name = uiState.form.tokenNameInputField.value, name = uiState.value.form.tokenNameInputField.value,
symbol = uiState.form.tokenSymbolInputField.value, symbol = uiState.value.form.tokenSymbolInputField.value,
contractAddress = contractAddress, contractAddress = contractAddress,
decimals = requireNotNull(uiState.form.decimalsInputField.value.toIntOrNull()), decimals = requireNotNull(uiState.value.form.decimalsInputField.value.toIntOrNull()),
id = foundToken?.id, id = foundToken?.id,
), ),
network = blockchain, network = blockchain,
@ -878,71 +921,101 @@ internal class AddCustomTokenViewModel @Inject constructor(
analyticsSender.sendWhenAddTokenButtonClicked(currency) analyticsSender.sendWhenAddTokenButtonClicked(currency)
viewModelScope.launch(dispatchers.io) { viewModelScope.launch {
val oldButtonState = uiState.floatingButton uiState.update { state ->
uiState = uiState.copySealed( state.copySealed(
floatingButton = uiState.floatingButton.copy(isEnabled = false, showProgress = true), floatingButton = state.floatingButton.copy(
) isEnabled = false,
runCatching { featureInteractor.saveToken(currency) } showProgress = true,
),
)
}
val result = featureInteractor.saveToken(currency)
uiState.update { state ->
state.copySealed(
floatingButton = state.floatingButton.copy(
isEnabled = true,
showProgress = false,
),
)
}
result
.onSuccess { featureRouter.openWalletScreen() } .onSuccess { featureRouter.openWalletScreen() }
.onFailure { .onFailure { Timber.e(it, "Unable to save custom token") }
uiState = uiState.copySealed(floatingButton = oldButtonState)
Timber.e(it)
}
} }
} }
} }
private fun convertTokenAddress(blockchain: Blockchain, address: String): String { /** Convert [address] to single address for specific [blockchain] or return null if invalid */
return when (blockchain) { private fun convertTokenAddress(blockchain: Blockchain, address: String): String? {
Blockchain.Hedera, Blockchain.HederaTestnet -> hederaAddressConverter.convertToTokenId(address) return runCatching {
else -> address when (blockchain) {
Blockchain.Hedera, Blockchain.HederaTestnet -> hederaAddressConverter.convertToTokenId(address)
Blockchain.Cardano -> {
// TODO: [REDACTED_JIRA]
cardanoTokenAddressConverter.convertToFingerprint(
address = address,
symbol = uiState.value.form.tokenSymbolInputField.value,
)
}
else -> address
}
} }
.getOrNull()
} }
private inner class TestActionsHandler { private inner class TestActionsHandler {
fun onClearAddressButtonClick() { fun onClearAddressButtonClick() {
uiState = uiState.copySealed( uiState.update { state ->
form = uiState.form.copy( state.copySealed(
contractAddressInputField = uiState.form.contractAddressInputField.copy( form = state.form.copy(
value = "", contractAddressInputField = state.form.contractAddressInputField.copy(
isLoading = false,
isError = false,
error = null,
),
),
)
}
fun onResetButtonClick() {
with(uiState.form) {
uiState = uiState.copySealed(
form = uiState.form.copy(
contractAddressInputField = contractAddressInputField.copy(
value = "", value = "",
isLoading = false, isLoading = false,
isError = false, isError = false,
error = null, error = null,
), ),
networkSelectorField = networkSelectorField.copy(
selectedItem = formStateBuilder.createNetworkSelectorItem(blockchain = Blockchain.Unknown),
),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
derivationPathSelectorField = derivationPathSelectorField?.copy(
isEnabled = true,
selectedItem = formStateBuilder.createDerivationPathSelectorAdditionalItem(
blockchain = Blockchain.Unknown,
type = DerivationPathSelectorType.DEFAULT,
derivationPath = "",
),
),
), ),
) )
} }
} }
fun onResetButtonClick() {
uiState.update { state ->
with(state.form) {
state.copySealed(
form = state.form.copy(
contractAddressInputField = contractAddressInputField.copy(
value = "",
isLoading = false,
isError = false,
error = null,
),
networkSelectorField = networkSelectorField.copy(
selectedItem = formStateBuilder.createNetworkSelectorItem(
blockchain = Blockchain.Unknown,
),
),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
derivationPathSelectorField = derivationPathSelectorField?.copy(
isEnabled = true,
selectedItem = formStateBuilder.createDerivationPathSelectorAdditionalItem(
blockchain = Blockchain.Unknown,
type = DerivationPathSelectorType.DEFAULT,
derivationPath = "",
),
),
),
)
}
}
}
} }
private companion object { private companion object {

View file

@ -4,8 +4,6 @@ import androidx.lifecycle.LifecycleCoroutineScope
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.Action import org.rekotlin.Action
@ -33,7 +31,8 @@ sealed class DetailsAction : Action {
data object ScanCard : DetailsAction() data object ScanCard : DetailsAction()
data class PrepareCardSettingsData(val card: CardDTO, val cardTypesResolver: CardTypesResolver) : DetailsAction() data class PrepareCardSettingsData(val scanResponse: ScanResponse) : DetailsAction()
data object ResetCardSettingsData : DetailsAction() data object ResetCardSettingsData : DetailsAction()
data object ScanAndSaveUserWallet : DetailsAction() { data object ScanAndSaveUserWallet : DetailsAction() {

View file

@ -15,7 +15,6 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.common.TapWorkarounds.isTangemTwins import com.tangem.domain.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.builder.UserWalletIdBuilder
@ -148,7 +147,7 @@ class DetailsMiddleware {
} }
.doOnFailure { error -> .doOnFailure { error ->
if (error is TangemSdkError && error !is TangemSdkError.UserCancelled) { if (error is TangemSdkError && error !is TangemSdkError.UserCancelled) {
Analytics.send(Settings.CardSettings.FactoryResetFinished(error)) Analytics.send(Settings.CardSettings.FactoryResetFinished(error = error))
} }
} }
.doOnResult { .doOnResult {
@ -474,10 +473,7 @@ class DetailsMiddleware {
if (isSameWallet) { if (isSameWallet) {
store.dispatchOnMain( store.dispatchOnMain(
DetailsAction.PrepareCardSettingsData( DetailsAction.PrepareCardSettingsData(scanResponse = scanResponse),
scanResponse.card,
scanResponse.cardTypesResolver,
),
) )
} else { } else {
store.dispatchDialogShow( store.dispatchDialogShow(

View file

@ -5,6 +5,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.common.CardTypesResolver import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.extensions.signedHashesCount import com.tangem.tap.domain.extensions.signedHashesCount
@ -29,11 +30,7 @@ private fun internalReduce(action: Action, state: AppState): DetailsState {
handlePrepareScreen(action, state) handlePrepareScreen(action, state)
} }
is DetailsAction.PrepareCardSettingsData -> { is DetailsAction.PrepareCardSettingsData -> {
handlePrepareCardSettingsScreen( handlePrepareCardSettingsScreen(scanResponse = action.scanResponse, state = detailsState)
card = action.card,
cardTypesResolver = action.cardTypesResolver,
state = detailsState,
)
} }
is DetailsAction.ResetCardSettingsData -> detailsState.copy(cardSettingsState = null) is DetailsAction.ResetCardSettingsData -> detailsState.copy(cardSettingsState = null)
is DetailsAction.ResetToFactory -> { is DetailsAction.ResetToFactory -> {
@ -99,17 +96,17 @@ private fun handlePrepareScreen(action: DetailsAction.PrepareScreen, state: AppS
) )
} }
private fun handlePrepareCardSettingsScreen( private fun handlePrepareCardSettingsScreen(scanResponse: ScanResponse, state: DetailsState): DetailsState {
card: CardDTO, val cardTypesResolver = scanResponse.cardTypesResolver
cardTypesResolver: CardTypesResolver, val card = scanResponse.card
state: DetailsState,
): DetailsState {
val isTangemWallet = cardTypesResolver.isTangemWallet() || cardTypesResolver.isWallet2() val isTangemWallet = cardTypesResolver.isTangemWallet() || cardTypesResolver.isWallet2()
val isShowPasswordResetRadioButton = isTangemWallet && card.backupStatus is CardDTO.BackupStatus.Active val isShowPasswordResetRadioButton = isTangemWallet && card.backupStatus is CardDTO.BackupStatus.Active
val cardSettingsState = CardSettingsState( val cardSettingsState = CardSettingsState(
cardInfo = card.toCardInfo(cardTypesResolver), cardInfo = card.toCardInfo(cardTypesResolver),
scanResponse = scanResponse,
manageSecurityState = prepareSecurityOptions(card, cardTypesResolver), manageSecurityState = prepareSecurityOptions(card, cardTypesResolver),
card = card, card = scanResponse.card,
resetCardAllowed = isResetToFactoryAllowedByCard(card, cardTypesResolver), resetCardAllowed = isResetToFactoryAllowedByCard(card, cardTypesResolver),
resetButtonEnabled = false, resetButtonEnabled = false,
condition1Checked = false, condition1Checked = false,
@ -126,6 +123,7 @@ private fun handlePrepareCardSettingsScreen(
isShowPasswordResetRadioButton = isShowPasswordResetRadioButton, isShowPasswordResetRadioButton = isShowPasswordResetRadioButton,
dialog = null, dialog = null,
) )
return state.copy(cardSettingsState = cardSettingsState) return state.copy(cardSettingsState = cardSettingsState)
} }

View file

@ -39,6 +39,7 @@ data class AccessCodeRecoveryState(
data class CardSettingsState( data class CardSettingsState(
val cardInfo: CardInfo, val cardInfo: CardInfo,
val card: CardDTO, val card: CardDTO,
val scanResponse: ScanResponse,
val manageSecurityState: ManageSecurityState?, val manageSecurityState: ManageSecurityState?,
val resetCardAllowed: Boolean, val resetCardAllowed: Boolean,
val resetButtonEnabled: Boolean, val resetButtonEnabled: Boolean,

View file

@ -4,13 +4,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.utils.popTo import com.tangem.common.routing.utils.popTo
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.card.DeleteSavedAccessCodesUseCase import com.tangem.domain.card.DeleteSavedAccessCodesUseCase
import com.tangem.domain.card.ResetCardUseCase import com.tangem.domain.card.ResetCardUseCase
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount
import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.onUserWalletSelected import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.features.details.redux.CardSettingsState import com.tangem.tap.features.details.redux.CardSettingsState
import com.tangem.tap.features.details.redux.DetailsAction.ResetToFactory import com.tangem.tap.features.details.redux.DetailsAction.ResetToFactory
@ -25,6 +32,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import com.tangem.tap.features.details.redux.CardSettingsState.Dialog as CardSettingsDialog import com.tangem.tap.features.details.redux.CardSettingsState.Dialog as CardSettingsDialog
@Suppress("LongParameterList")
@HiltViewModel @HiltViewModel
internal class ResetCardViewModel @Inject constructor( internal class ResetCardViewModel @Inject constructor(
private val resetCardFeatureToggles: ResetCardFeatureToggles, private val resetCardFeatureToggles: ResetCardFeatureToggles,
@ -32,14 +40,17 @@ internal class ResetCardViewModel @Inject constructor(
private val resetCardUseCase: ResetCardUseCase, private val resetCardUseCase: ResetCardUseCase,
private val deleteSavedAccessCodesUseCase: DeleteSavedAccessCodesUseCase, private val deleteSavedAccessCodesUseCase: DeleteSavedAccessCodesUseCase,
private val deleteWalletUseCase: DeleteWalletUseCase, private val deleteWalletUseCase: DeleteWalletUseCase,
private val userWalletsListManager: UserWalletsListManager,
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel() { ) : ViewModel() {
private val currentUserWallet = getSelectedWalletSyncUseCase().getOrNull() private val firstCardScanResponse = store.state.detailsState.cardSettingsState?.scanResponse
?: error("Selected user wallet can't be null") ?: error("ScanResponse can't be null")
private val currentUserWalletId = createUserWalletId(firstCardScanResponse)
// TODO: move logic to separate domain entity // TODO: move logic to separate domain entity
private val backupCardsCount = (currentUserWallet.getCardsCount() ?: 0) - 1 private var resetBackupCardCount = 0
private var resetCardsCount = 0
fun updateState(state: CardSettingsState?): ResetCardScreenState.ResetCardScreenContent { fun updateState(state: CardSettingsState?): ResetCardScreenState.ResetCardScreenContent {
val descriptionText = state?.cardInfo val descriptionText = state?.cardInfo
@ -84,7 +95,7 @@ internal class ResetCardViewModel @Inject constructor(
CardSettingsDialog.InterruptedResetDialog -> { CardSettingsDialog.InterruptedResetDialog -> {
ResetCardScreenState.ResetCardScreenContent.Dialog.InterruptedReset( ResetCardScreenState.ResetCardScreenContent.Dialog.InterruptedReset(
onConfirmClick = ::onContinueResetClick, onConfirmClick = ::onContinueResetClick,
onDismiss = ::dismissAndFinishFullReset, onDismiss = ::onInterruptedResetDialogDismiss,
) )
} }
CardSettingsDialog.CompletedResetDialog -> { CardSettingsDialog.CompletedResetDialog -> {
@ -107,9 +118,9 @@ internal class ResetCardViewModel @Inject constructor(
private fun makeFullReset() { private fun makeFullReset() {
viewModelScope.launch { viewModelScope.launch {
resetCardUseCase(card = currentUserWallet.scanResponse.card).onRight { resetCardUseCase(card = firstCardScanResponse.card).onRight {
deleteSavedAccessCodesUseCase(currentUserWallet.cardId) deleteSavedAccessCodesUseCase(firstCardScanResponse.card.cardId)
deleteWalletUseCase(currentUserWallet.walletId) deleteWalletUseCase(currentUserWalletId)
val newSelectedWallet = getSelectedWalletSyncUseCase().getOrNull() val newSelectedWallet = getSelectedWalletSyncUseCase().getOrNull()
if (newSelectedWallet != null) { if (newSelectedWallet != null) {
@ -128,12 +139,12 @@ internal class ResetCardViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
resetCardUseCase( resetCardUseCase(
cardNumber = resetCardsCount + 1, cardNumber = resetBackupCardCount + 1,
card = currentUserWallet.scanResponse.card, card = firstCardScanResponse.card,
userWalletId = currentUserWallet.walletId, userWalletId = currentUserWalletId,
) )
.onRight { .onRight {
resetCardsCount++ resetBackupCardCount++
delay(DELAY_SDK_DIALOG_CLOSE) delay(DELAY_SDK_DIALOG_CLOSE)
@ -149,10 +160,23 @@ internal class ResetCardViewModel @Inject constructor(
showDialog(CardSettingsDialog.InterruptedResetDialog) showDialog(CardSettingsDialog.InterruptedResetDialog)
} }
private fun onInterruptedResetDialogDismiss() {
analyticsEventHandler.send(Settings.CardSettings.FactoryResetCanceled(cardsCount = resetBackupCardCount + 1))
dismissAndFinishFullReset()
}
private fun checkRemainingBackupCards() { private fun checkRemainingBackupCards() {
val backupCardsCount = firstCardScanResponse.getBackupCardsCount()
when { when {
backupCardsCount > resetCardsCount -> showDialog(CardSettingsDialog.ContinueResetDialog) backupCardsCount > resetBackupCardCount -> showDialog(CardSettingsDialog.ContinueResetDialog)
backupCardsCount == resetCardsCount -> showDialog(CardSettingsDialog.CompletedResetDialog) backupCardsCount == resetBackupCardCount -> {
analyticsEventHandler.send(
event = Settings.CardSettings.FactoryResetFinished(cardsCount = resetBackupCardCount + 1),
)
showDialog(CardSettingsDialog.CompletedResetDialog)
}
else -> finishFullReset() else -> finishFullReset()
} }
} }
@ -164,12 +188,17 @@ internal class ResetCardViewModel @Inject constructor(
} }
private fun finishFullReset() { private fun finishFullReset() {
val newSelectedWallet = getSelectedWalletSyncUseCase().getOrNull() val newSelectedWallet = userWalletsListManager.selectedUserWalletSync
if (newSelectedWallet != null) { if (newSelectedWallet != null) {
store.dispatchNavigationAction { popTo<AppRoute.Wallet>() } store.dispatchNavigationAction { popTo<AppRoute.Wallet>() }
} else { } else {
store.dispatchNavigationAction { popTo<AppRoute.Home>() } val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync }.isSuccess
if (isLocked && userWalletsListManager.hasUserWallets) {
store.dispatchNavigationAction { popTo<AppRoute.Welcome>() }
} else {
store.dispatchNavigationAction { popTo<AppRoute.Home>() }
}
} }
} }
@ -180,4 +209,21 @@ internal class ResetCardViewModel @Inject constructor(
private fun dismissDialog() { private fun dismissDialog() {
store.dispatch(ResetToFactory.DismissDialog) store.dispatch(ResetToFactory.DismissDialog)
} }
private fun ScanResponse.getBackupCardsCount(): Int {
if (!cardTypesResolver.isMultiwalletAllowed()) return 0
return when (val status = card.backupStatus) {
is CardDTO.BackupStatus.Active -> status.cardCount
is CardDTO.BackupStatus.CardLinked,
is CardDTO.BackupStatus.NoBackup,
null,
-> 0
}
}
private fun createUserWalletId(scanResponse: ScanResponse): UserWalletId {
return UserWalletIdBuilder.scanResponse(scanResponse).build()
?: error("UserWalletId can't be null")
}
} }

View file

@ -32,7 +32,7 @@ private const val HIDE_PROGRESS_DELAY = 400L
object HomeMiddleware { object HomeMiddleware {
val handler = homeMiddleware val handler = homeMiddleware
const val NEW_BUY_WALLET_URL = "https://buy.tangem.com/" const val NEW_BUY_WALLET_URL = "https://buy.tangem.com/?utm_source=tangem&utm_medium=app"
} }
private val homeMiddleware: Middleware<AppState> = { _, _ -> private val homeMiddleware: Middleware<AppState> = { _, _ ->

View file

@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.* import com.tangem.core.ui.components.*
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.wallet.R import com.tangem.wallet.R
@Suppress("LongMethod") @Suppress("LongMethod")

View file

@ -64,6 +64,7 @@ sealed class AnalyticsParam {
data object Send : ScreensSources("Send") data object Send : ScreensSources("Send")
data object Intro : ScreensSources("Introduction") data object Intro : ScreensSources("Introduction")
data object MyWallets : ScreensSources("My Wallets") data object MyWallets : ScreensSources("My Wallets")
data object Token : ScreensSources("Token")
} }
sealed class TxSentFrom(val value: String) { sealed class TxSentFrom(val value: String) {

View file

@ -23,6 +23,9 @@ data class ExchangeProvider(
@Json(name = "privacyPolicy") @Json(name = "privacyPolicy")
val privacyPolicy: String?, val privacyPolicy: String?,
@Json(name = "recommended")
val isRecommended: Boolean = false,
) )
enum class ExchangeProviderType { enum class ExchangeProviderType {
@ -31,4 +34,7 @@ enum class ExchangeProviderType {
@Json(name = "cex") @Json(name = "cex")
CEX, CEX,
@Json(name = "dex-bridge")
DEX_BRIDGE,
} }

View file

@ -23,34 +23,43 @@ data class ExchangeStatusResponse(
enum class ExchangeStatus { enum class ExchangeStatus {
@Json(name = "new") @Json(name = "new")
NEW, New,
@Json(name = "waiting") @Json(name = "waiting")
WAITING, Waiting,
@Json(name = "confirming") @Json(name = "confirming")
CONFIRMING, Confirming,
@Json(name = "exchanging") @Json(name = "exchanging")
EXCHANGING, Exchanging,
@Json(name = "sending") @Json(name = "sending")
SENDING, Sending,
@Json(name = "finished") @Json(name = "finished")
FINISHED, Finished,
@Json(name = "failed") @Json(name = "failed")
FAILED, Failed,
@Json(name = "refunded") @Json(name = "refunded")
REFUNDED, Refunded,
@Json(name = "verifying") @Json(name = "verifying")
VERIFYING, Verifying,
@Json(name = "expired") @Json(name = "expired")
CANCELLED, Cancelled,
@Json(name = "waiting-tx-hash")
WaitingTxHash,
@Json(name = "tx-failed")
TxFailed,
@Json(name = "unknown")
Unknown,
} }
data class ExchangeStatusError( data class ExchangeStatusError(

View file

@ -53,14 +53,6 @@ object PreferencesKeys {
val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") } val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") }
val IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY by lazy {
booleanPreferencesKey(name = "isWalletSwapPromoChangellyShown")
}
val IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY by lazy {
booleanPreferencesKey(name = "isTokenSwapPromoChangellyShown")
}
val IS_WALLET_TRAVALA_PROMO_SHOWN_KEY by lazy { val IS_WALLET_TRAVALA_PROMO_SHOWN_KEY by lazy {
booleanPreferencesKey(name = "isWalletTravalaPromoShown") booleanPreferencesKey(name = "isWalletTravalaPromoShown")
} }
@ -93,6 +85,14 @@ object PreferencesKeys {
val IS_WALLET_NAMES_MIGRATION_DONE_KEY by lazy { booleanPreferencesKey(name = "isWalletNamesMigrationDone") } val IS_WALLET_NAMES_MIGRATION_DONE_KEY by lazy { booleanPreferencesKey(name = "isWalletNamesMigrationDone") }
val IS_WALLET_SWAP_PROMO_OKX_SHOW_KEY by lazy {
booleanPreferencesKey(name = "isWalletSwapPromoOkxShown")
}
val IS_TOKEN_SWAP_PROMO_OKX_SHOW_KEY by lazy {
booleanPreferencesKey(name = "isTokenSwapPromoOkxShown")
}
fun getStart2CoinTOSAcceptedKey(region: String?) = booleanPreferencesKey(name = "start2Coin_tos_accepted_$region") fun getStart2CoinTOSAcceptedKey(region: String?) = booleanPreferencesKey(name = "start2Coin_tos_accepted_$region")
// region Permission // region Permission

View file

@ -235,6 +235,7 @@
<string name="express_exchange_status_subtitle">Данные провайдера. Сумма к получению может измениться в зависимости от рыночных условий.</string> <string name="express_exchange_status_subtitle">Данные провайдера. Сумма к получению может измениться в зависимости от рыночных условий.</string>
<string name="express_exchange_status_title">Статус обмена</string> <string name="express_exchange_status_title">Статус обмена</string>
<string name="express_exchange_status_verifying">Требуется верификация</string> <string name="express_exchange_status_verifying">Требуется верификация</string>
<string name="express_exchange_status_waiting_tx_hash">Ожидание хеша транзакции</string>
<string name="express_exchange_token_list_subtitle">Список токенов в вашем кошельке</string> <string name="express_exchange_token_list_subtitle">Список токенов в вашем кошельке</string>
<string name="express_fetch_best_rates">Получение наилучших курсов...</string> <string name="express_fetch_best_rates">Получение наилучших курсов...</string>
<string name="express_floating_rate">Плавающая ставка</string> <string name="express_floating_rate">Плавающая ставка</string>
@ -248,6 +249,7 @@
<string name="express_provider_min_amount">Доступно с %s</string> <string name="express_provider_min_amount">Доступно с %s</string>
<string name="express_provider_not_available">Недоступно для этой пары</string> <string name="express_provider_not_available">Недоступно для этой пары</string>
<string name="express_provider_permission_needed">Требуется разрешение</string> <string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="express_provider_recommended">Рекомендовано</string>
<string name="express_terms_of_use">Условиями использования</string> <string name="express_terms_of_use">Условиями использования</string>
<string name="express_token_list_empty_search">Токены не найдены. Пожалуйста, попробуйте другой запрос</string> <string name="express_token_list_empty_search">Токены не найдены. Пожалуйста, попробуйте другой запрос</string>
<string name="express_transaction_id">ID: %s</string> <string name="express_transaction_id">ID: %s</string>
@ -584,6 +586,8 @@
<string name="story_meet_title">Встречайте Tangem</string> <string name="story_meet_title">Встречайте Tangem</string>
<string name="story_web3_description">Обменивайте, покупайте NFT, получайте займы и делайте вклады в более чем 100 различных децентрализованных сервисах</string> <string name="story_web3_description">Обменивайте, покупайте NFT, получайте займы и делайте вклады в более чем 100 различных децентрализованных сервисах</string>
<string name="story_web3_title">Поддержка Web 3.0</string> <string name="story_web3_title">Поддержка Web 3.0</string>
<string name="swap_promo_text">Обменивайте больше токенов по лучшим курсам прямо в вашем кошельке.</string>
<string name="swap_promo_title">Новый провайдер обмена!</string>
<string name="swapping_alert_cex_description">В сумму включено: \n• комиссия провайдера сервиса\n• комиссия сети за отправку %s от биржи обратно на адрес пользователя</string> <string name="swapping_alert_cex_description">В сумму включено: \n• комиссия провайдера сервиса\n• комиссия сети за отправку %s от биржи обратно на адрес пользователя</string>
<string name="swapping_alert_dex_description">В сумму включена комиссия провайдера сервиса.</string> <string name="swapping_alert_dex_description">В сумму включена комиссия провайдера сервиса.</string>
<string name="swapping_alert_title">Комиссии</string> <string name="swapping_alert_title">Комиссии</string>

View file

@ -238,6 +238,7 @@
<string name="express_exchange_status_subtitle">Provider-sourced data. Estimated amount subject to change due to market conditions.</string> <string name="express_exchange_status_subtitle">Provider-sourced data. Estimated amount subject to change due to market conditions.</string>
<string name="express_exchange_status_title">Exchange status</string> <string name="express_exchange_status_title">Exchange status</string>
<string name="express_exchange_status_verifying">Verification required</string> <string name="express_exchange_status_verifying">Verification required</string>
<string name="express_exchange_status_waiting_tx_hash">Awaiting transaction hash</string>
<string name="express_exchange_token_list_subtitle">List of all tokens added to your wallet</string> <string name="express_exchange_token_list_subtitle">List of all tokens added to your wallet</string>
<string name="express_fetch_best_rates">Fetching best rates...</string> <string name="express_fetch_best_rates">Fetching best rates...</string>
<string name="express_floating_rate">Floating rate</string> <string name="express_floating_rate">Floating rate</string>
@ -251,6 +252,7 @@
<string name="express_provider_min_amount">Available from %s</string> <string name="express_provider_min_amount">Available from %s</string>
<string name="express_provider_not_available">Unavailable for this pair</string> <string name="express_provider_not_available">Unavailable for this pair</string>
<string name="express_provider_permission_needed">Permission Required</string> <string name="express_provider_permission_needed">Permission Required</string>
<string name="express_provider_recommended">Recommended</string>
<string name="express_terms_of_use">Terms of Use</string> <string name="express_terms_of_use">Terms of Use</string>
<string name="express_token_list_empty_search">No tokens found. Please try another request</string> <string name="express_token_list_empty_search">No tokens found. Please try another request</string>
<string name="express_transaction_id">ID: %s</string> <string name="express_transaction_id">ID: %s</string>
@ -610,6 +612,8 @@
<string name="story_meet_title">Meet Tangem</string> <string name="story_meet_title">Meet Tangem</string>
<string name="story_web3_description">Exchange, buy NFT\'s, make loans and deposits in more than 100 different decentralized services</string> <string name="story_web3_description">Exchange, buy NFT\'s, make loans and deposits in more than 100 different decentralized services</string>
<string name="story_web3_title">Web 3.0 Compatible</string> <string name="story_web3_title">Web 3.0 Compatible</string>
<string name="swap_promo_text">Exchange more tokens at better rates directly in your wallet.</string>
<string name="swap_promo_title">New Swap Provider Available!</string>
<string name="swapping_alert_cex_description">The amount includes:\n• service provider\'s fee\n• network fee for sending %s from the exchange back to the user\'s address.</string> <string name="swapping_alert_cex_description">The amount includes:\n• service provider\'s fee\n• network fee for sending %s from the exchange back to the user\'s address.</string>
<string name="swapping_alert_dex_description">The amount includes the service provider\'s fee.</string> <string name="swapping_alert_dex_description">The amount includes the service provider\'s fee.</string>
<string name="swapping_alert_title">Fees</string> <string name="swapping_alert_title">Fees</string>

View file

@ -0,0 +1,163 @@
package com.tangem.core.ui.components.notifications
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.R
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonColors
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalIsInDarkTheme
import com.tangem.core.ui.res.TangemColorPalette.Dark6
import com.tangem.core.ui.res.TangemColorPalette.Light4
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
private val OxkPromoColor = Color(0xFFBCFF2F)
@Composable
fun OkxPromoNotification(config: NotificationConfig, modifier: Modifier = Modifier) {
Column(
modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(Dark6),
) {
Content(config = config)
Button(config = config)
}
}
@Composable
private fun Content(config: NotificationConfig) {
Row {
Icon(
painter = rememberVectorPainter(image = ImageVector.vectorResource(id = R.drawable.img_okx_dex_logo)),
contentDescription = null,
tint = TangemTheme.colors.icon.constant,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing12)
.align(Alignment.CenterVertically),
)
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
modifier = Modifier
.weight(1f)
.padding(TangemTheme.dimens.spacing12),
) {
Text(
text = config.title.resolveReference(),
style = TangemTheme.typography.button,
color = OxkPromoColor,
)
Text(
text = config.subtitle.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.constantWhite,
)
}
config.onCloseClick?.let {
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(R.drawable.ic_close_24),
),
contentDescription = null,
tint = TangemTheme.colors.icon.constant,
modifier =
Modifier
.padding(
top = TangemTheme.dimens.spacing8,
end = TangemTheme.dimens.spacing8,
)
.size(TangemTheme.dimens.size20)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(radius = TangemTheme.dimens.radius10),
onClick = it,
),
)
}
}
}
@Composable
private fun Button(config: NotificationConfig) {
val button = config.buttonsState as? NotificationConfig.ButtonsState.SecondaryButtonConfig
button?.let {
val isDarkMode = LocalIsInDarkTheme.current
TangemButton(
text = button.text.resolveReference(),
icon = TangemButtonIconPosition.Start(button.iconResId ?: R.drawable.ic_exchange_vertical_24),
onClick = button.onClick,
colors = TangemButtonColors(
backgroundColor = if (isDarkMode) Light4 else TangemTheme.colors.button.secondary,
contentColor = Dark6,
disabledBackgroundColor = TangemTheme.colors.button.disabled,
disabledContentColor = TangemTheme.colors.text.disabled,
),
enabled = true,
showProgress = false,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
)
}
}
// region Preview
@Preview(widthDp = 360)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun OkxPromoNotification_Preview(
@PreviewParameter(OkxPromoNotificationPreviewProvider::class) data: NotificationConfig,
) {
TangemThemePreview {
OkxPromoNotification(data)
}
}
private class OkxPromoNotificationPreviewProvider : PreviewParameterProvider<NotificationConfig> {
override val values: Sequence<NotificationConfig>
get() = sequenceOf(
NotificationConfig(
title = resourceReference(R.string.swap_promo_title),
subtitle = resourceReference(R.string.swap_promo_text),
iconResId = R.drawable.img_okx_dex_logo,
onCloseClick = {},
),
NotificationConfig(
title = resourceReference(R.string.swap_promo_title),
subtitle = resourceReference(R.string.swap_promo_text),
iconResId = R.drawable.img_okx_dex_logo,
onCloseClick = {},
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.token_swap_promotion_button),
onClick = {},
),
),
)
}
// endregion

View file

@ -59,7 +59,7 @@ fun getActiveIconRes(blockchainId: String): Int {
"pls", "pls/test" -> R.drawable.img_pls_22 "pls", "pls/test" -> R.drawable.img_pls_22
"zkSyncEra", "zkSyncEra/test" -> R.drawable.img_zksync_22 "zkSyncEra", "zkSyncEra/test" -> R.drawable.img_zksync_22
"moonbeam", "moonbeam/test" -> R.drawable.img_moonbeam_22 "moonbeam", "moonbeam/test" -> R.drawable.img_moonbeam_22
"manta", "manta/test" -> R.drawable.img_manta_22 "manta-pacific", "manta/test" -> R.drawable.img_manta_22
"polygonZkEVM", "polygonZkEVM/test" -> R.drawable.img_polygon_22 "polygonZkEVM", "polygonZkEVM/test" -> R.drawable.img_polygon_22
"moonriver", "moonriver/test" -> R.drawable.img_moonriver_22 "moonriver", "moonriver/test" -> R.drawable.img_moonriver_22
"mantle", "mantle/test" -> R.drawable.img_mantle_22 "mantle", "mantle/test" -> R.drawable.img_mantle_22
@ -269,7 +269,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int {
"pls", "pls/test" -> R.drawable.ic_pls_22 "pls", "pls/test" -> R.drawable.ic_pls_22
"zkSyncEra", "zkSyncEra/test" -> R.drawable.ic_zksync_22 "zkSyncEra", "zkSyncEra/test" -> R.drawable.ic_zksync_22
"moonbeam", "moonbeam/test" -> R.drawable.ic_moonbeam_22 "moonbeam", "moonbeam/test" -> R.drawable.ic_moonbeam_22
"manta", "manta/test" -> R.drawable.ic_manta_22 "manta-pacific", "manta/test" -> R.drawable.ic_manta_22
"polygonZkEVM", "polygonZkEVM/test" -> R.drawable.ic_polygon_22 "polygonZkEVM", "polygonZkEVM/test" -> R.drawable.ic_polygon_22
"moonriver", "moonriver/test" -> R.drawable.ic_moonriver_22 "moonriver", "moonriver/test" -> R.drawable.ic_moonriver_22
"mantle", "mantle/test" -> R.drawable.ic_mantle_22 "mantle", "mantle/test" -> R.drawable.ic_mantle_22

View file

@ -83,6 +83,7 @@ class TangemColors internal constructor(
warning: Color, warning: Color,
attention: Color, attention: Color,
accent: Color = TangemColorPalette.Azure, accent: Color = TangemColorPalette.Azure,
constant: Color = TangemColorPalette.White,
) { ) {
var primary1 by mutableStateOf(primary1) var primary1 by mutableStateOf(primary1)
private set private set
@ -100,6 +101,7 @@ class TangemColors internal constructor(
private set private set
var attention by mutableStateOf(attention) var attention by mutableStateOf(attention)
private set private set
var constant by mutableStateOf(constant)
fun update(other: Icon) { fun update(other: Icon) {
primary1 = other.primary1 primary1 = other.primary1
@ -110,6 +112,7 @@ class TangemColors internal constructor(
accent = other.accent accent = other.accent
warning = other.warning warning = other.warning
attention = other.attention attention = other.attention
constant = other.constant
} }
} }

View file

@ -1,5 +1,6 @@
package com.tangem.core.ui.screen package com.tangem.core.ui.screen
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -22,6 +23,18 @@ abstract class ComposeFragment : Fragment(), ComposeScreen {
} }
} }
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
/*
* We need to manually dispatch configuration changes to the Compose view.
*
* `android:configChanges="uiMode"` is set in the manifest.
* */
view?.dispatchConfigurationChanged(newConfig)
}
/** /**
* Inflates transitions for the fragment. Override this method to customize * Inflates transitions for the fragment. Override this method to customize
* enter and exit transitions for the fragment. * enter and exit transitions for the fragment.

View file

@ -0,0 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:viewportHeight="1232.1" android:viewportWidth="2516" android:width="49.00901dp">
<path android:fillColor="#FF000000" android:pathData="m740.2,0L23.5,0c-4.4,0 -8.7,1.6 -11.8,4.6 -3.1,2.9 -4.9,6.9 -4.9,11v670.6c0,4.1 1.8,8.1 4.9,11 3.1,2.9 7.4,4.6 11.8,4.6h716.7c4.4,0 8.7,-1.6 11.8,-4.6 3.1,-2.9 4.9,-6.9 4.9,-11L756.9,15.6c0,-4.1 -1.8,-8.1 -4.9,-11 -3.1,-2.9 -7.4,-4.6 -11.8,-4.6ZM506.9,452.3c0,4.1 -1.8,8.1 -4.9,11 -3.1,2.9 -7.4,4.6 -11.8,4.6h-216.7c-4.4,0 -8.7,-1.6 -11.8,-4.6 -3.1,-2.9 -4.9,-6.9 -4.9,-11v-202.8c0,-4.1 1.8,-8.1 4.9,-11 3.1,-2.9 7.4,-4.6 11.8,-4.6h216.7c4.4,0 8.7,1.6 11.8,4.6 3.1,2.9 4.9,6.9 4.9,11v202.8Z"/>
<path android:fillColor="#FF000000" android:pathData="m2240.6,234h-216.7c-9.2,0 -16.7,7 -16.7,15.6v202.8c0,8.6 7.5,15.6 16.7,15.6h216.7c9.2,0 16.7,-7 16.7,-15.6v-202.8c0,-8.6 -7.5,-15.6 -16.7,-15.6h0Z"/>
<path android:fillColor="#FF000000" android:pathData="m1990.7,0h-216.7c-9.2,0 -16.7,7 -16.7,15.6v202.8c0,8.6 7.5,15.6 16.7,15.6h216.7c9.2,0 16.7,-7 16.7,-15.6V15.6c0,-8.6 -7.5,-15.6 -16.7,-15.6Z"/>
<path android:fillColor="#FF000000" android:pathData="m2490.8,0h-216.7c-9.2,0 -16.7,7 -16.7,15.6v202.8c0,8.6 7.5,15.6 16.7,15.6h216.7c9.2,0 16.7,-7 16.7,-15.6V15.6c0,-8.6 -7.5,-15.6 -16.7,-15.6Z"/>
<path android:fillColor="#FF000000" android:pathData="m1990.7,467.9h-216.7c-9.2,0 -16.7,7 -16.7,15.6v202.8c0,8.6 7.5,15.6 16.7,15.6h216.7c9.2,0 16.7,-7 16.7,-15.6v-202.8c0,-8.6 -7.5,-15.6 -16.7,-15.6Z"/>
<path android:fillColor="#FF000000" android:pathData="m2490.8,467.9h-216.7c-9.2,0 -16.7,7 -16.7,15.6v202.8c0,8.6 7.5,15.6 16.7,15.6h216.7c9.2,0 16.7,-7 16.7,-15.6v-202.8c0,-8.6 -7.5,-15.6 -16.7,-15.6Z"/>
<path android:fillColor="#FF000000" android:pathData="m1615.3,0h-216.7c-9.2,0 -16.7,7 -16.7,15.6v202.8c0,8.6 7.5,15.6 16.7,15.6h216.7c9.2,0 16.7,-7 16.7,-15.6V15.6c0,-8.6 -7.5,-15.6 -16.7,-15.6Z"/>
<path android:fillColor="#FF000000" android:pathData="m1615.3,467.9h-216.7c-9.2,0 -16.7,7 -16.7,15.6v202.8c0,8.6 7.5,15.6 16.7,15.6h216.7c9.2,0 16.7,-7 16.7,-15.6v-202.8c0,-8.6 -7.5,-15.6 -16.7,-15.6Z"/>
<path android:fillColor="#FF000000" android:pathData="m1381.9,249.4c0,-4.1 -1.8,-8.1 -4.9,-11 -3.1,-2.9 -7.4,-4.6 -11.8,-4.6h-233.4V15.6c0,-4.1 -1.8,-8.1 -4.9,-11 -3.1,-2.9 -7.4,-4.6 -11.8,-4.6h-216.7c-4.4,0 -8.7,1.6 -11.8,4.6 -3.1,2.9 -4.9,6.9 -4.9,11v670.3c0,4.1 1.8,8.1 4.9,11 3.1,2.9 7.4,4.6 11.8,4.6h216.7c4.4,0 8.7,-1.6 11.8,-4.6 3.1,-2.9 4.9,-6.9 4.9,-11v-218.2h233.4c4.4,0 8.7,-1.6 11.8,-4.6 3.1,-2.9 4.9,-6.9 4.9,-11v-202.8Z"/>
<path android:fillColor="#FF000000" android:pathData="m1675.2,885c33.8,0 64.5,6.8 92,20.4 27.5,13.6 49.2,33.4 65.2,59.5 16,26.1 24,57.2 24,93.2s-8,67.2 -24,93.4c-16,26.3 -37.6,46.2 -65,60 -27.4,13.8 -58.1,20.6 -92.2,20.6h-125.2v-347.1h125.2ZM1666.2,1163.8c34.5,0 60.5,-9.2 78.3,-27.5 17.7,-18.3 26.6,-44.4 26.6,-78.3s-8.9,-59.4 -26.8,-77.5c-17.9,-18.2 -43.9,-27.3 -78,-27.3h-35.1v210.6h35.1Z"/>
<path android:fillColor="#FF000000" android:pathData="m2125.7,1092.2h-135.6v69.7h157v70.2h-238.1v-347.1h232.4v69.7h-151.3v68.3h135.6v69.2Z"/>
<path android:fillColor="#FF000000" android:pathData="m2509.8,885l-121.4,168.4 127.6,178.8h-100.1l-77.3,-112.9 -78.7,112.9h-92.9l127.6,-176.9 -121.4,-170.3h99.1l71.6,104.3 72.6,-104.3h93.4Z"/>
<path android:fillColor="#FF000000" android:pathData="M0,1033.9h1381.6v49h-1381.6z"/>
</vector>

View file

@ -1,17 +0,0 @@
package com.tangem.data.card.sdk
import android.content.Context
/**
* Lifecycle observer for creating Card SDK instance
*
[REDACTED_AUTHOR]
*/
interface CardSdkLifecycleObserver {
/** Callback of creating activity [context] */
fun onCreate(context: Context)
/** Callback of destroying activity [context] */
fun onDestroy(context: Context)
}

View file

@ -0,0 +1,13 @@
package com.tangem.data.card.sdk
import androidx.fragment.app.FragmentActivity
/**
* Lifecycle observer for creating Card SDK instance
*
[REDACTED_AUTHOR]
*/
interface CardSdkOwner {
fun register(activity: FragmentActivity)
}

View file

@ -7,7 +7,7 @@ import com.tangem.TangemSdk
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
internal interface CardSdkProvider { interface CardSdkProvider {
/** CardSDK instance */ /** CardSDK instance */
val sdk: TangemSdk val sdk: TangemSdk

View file

@ -1,51 +0,0 @@
package com.tangem.data.card.sdk
import android.content.Context
import androidx.fragment.app.FragmentActivity
import com.tangem.TangemSdk
import com.tangem.common.CardFilter
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.core.Config
import com.tangem.sdk.extensions.initWithBiometrics
import javax.inject.Inject
import javax.inject.Singleton
/**
* Implementation of CardSDK instance provider
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class DefaultCardSdkProvider @Inject constructor() : CardSdkProvider, CardSdkLifecycleObserver {
override val sdk: TangemSdk
get() = requireNotNull(value = _sdk) { "Impossible to get the TangemSdk when activity is destroyed" }
private var _sdk: TangemSdk? = null
override fun onCreate(context: Context) {
_sdk = TangemSdk.initWithBiometrics(activity = context as FragmentActivity, config = config)
}
override fun onDestroy(context: Context) {
// Commented out to prevent crash on getting sdk when it's null.
// FIXME: We still should find the real cause and fix it properly.
// idea: pass everywhere DefaultCardSdkProvider instead sdk to reach lazy access to sdk property
// _sdk = null
}
private companion object {
val config = Config(
linkedTerminal = true,
allowUntrustedCards = true,
filter = CardFilter(
allowedCardTypes = FirmwareVersion.FirmwareType.values().toList(),
maxFirmwareVersion = FirmwareVersion(major = 6, minor = 33),
batchIdFilter = CardFilter.Companion.ItemFilter.Deny(
items = setOf("0027", "0030", "0031", "0035"),
),
),
)
}
}

View file

@ -32,8 +32,20 @@ internal class DefaultPromoRepository(
}.getOrNull() }.getOrNull()
} }
override suspend fun getOkxPromoBanner(): PromoBanner? {
// TODO disabled for 5.12, enable for 5.12.1
return null
// return runCatching(dispatchers.io) {
// promoResponseConverter.convert(
// tangemApi.getPromotionInfo(OKX)
// .getOrThrow(),
// )
// }.getOrNull()
}
private companion object { private companion object {
private const val CHANGELLY_NAME = "changelly" private const val CHANGELLY_NAME = "changelly"
private const val TRAVALA = "travala" private const val TRAVALA = "travala"
// private const val OKX = "okx"
} }
} }

View file

@ -1,8 +1,8 @@
package com.tangem.data.settings package com.tangem.data.settings
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TOKEN_SWAP_PROMO_OKX_SHOW_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PROMO_OKX_SHOW_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_TRAVALA_PROMO_SHOWN_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_TRAVALA_PROMO_SHOWN_KEY
import com.tangem.datasource.local.preferences.utils.get import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.store import com.tangem.datasource.local.preferences.utils.store
@ -16,23 +16,23 @@ class DefaultPromoSettingsRepository(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
) : PromoSettingsRepository { ) : PromoSettingsRepository {
override fun isReadyToShowWalletSwapPromo(): Flow<Boolean> { override fun isReadyToShowWalletSwapPromo(): Flow<Boolean> {
return appPreferencesStore.get(IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY, true) return appPreferencesStore.get(IS_WALLET_SWAP_PROMO_OKX_SHOW_KEY, true)
} }
override fun isReadyToShowTokenSwapPromo(): Flow<Boolean> { override fun isReadyToShowTokenSwapPromo(): Flow<Boolean> {
return appPreferencesStore.get(IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY, true) return appPreferencesStore.get(IS_TOKEN_SWAP_PROMO_OKX_SHOW_KEY, true)
} }
override suspend fun setNeverToShowWalletSwapPromo() { override suspend fun setNeverToShowWalletSwapPromo() {
appPreferencesStore.store( appPreferencesStore.store(
key = IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY, key = IS_WALLET_SWAP_PROMO_OKX_SHOW_KEY,
value = false, value = false,
) )
} }
override suspend fun setNeverToShowTokenSwapPromo() { override suspend fun setNeverToShowTokenSwapPromo() {
appPreferencesStore.store( appPreferencesStore.store(
key = IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY, key = IS_TOKEN_SWAP_PROMO_OKX_SHOW_KEY,
value = false, value = false,
) )
} }

View file

@ -87,8 +87,11 @@ internal object TokensDataModule {
@Provides @Provides
@Singleton @Singleton
fun provideDefaultMarketCoinsRepository(expressAssetsStore: ExpressAssetsStore): MarketCryptoCurrencyRepository { fun provideDefaultMarketCoinsRepository(
return DefaultMarketCryptoCurrencyRepository(expressAssetsStore) expressAssetsStore: ExpressAssetsStore,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
): MarketCryptoCurrencyRepository {
return DefaultMarketCryptoCurrencyRepository(expressAssetsStore, coroutineDispatcherProvider)
} }
@Provides @Provides

View file

@ -314,26 +314,28 @@ internal class DefaultCurrenciesRepository(
networkId: Network.ID, networkId: Network.ID,
derivationPath: Network.DerivationPath, derivationPath: Network.DerivationPath,
): CryptoCurrency.Coin { ): CryptoCurrency.Coin {
val userWallet = getUserWallet(userWalletId) return withContext(dispatchers.io) {
ensureIsCorrectUserWallet(userWallet = userWallet, isMultiCurrencyWalletExpected = true) val userWallet = getUserWallet(userWalletId)
ensureIsCorrectUserWallet(userWallet = userWallet, isMultiCurrencyWalletExpected = true)
fetchTokensIfCacheExpired(userWallet = userWallet, refresh = false) fetchTokensIfCacheExpired(userWallet = userWallet, refresh = false)
val storedTokens = requireNotNull(userTokensStore.getSyncOrNull(userWallet.walletId)) { val storedTokens = requireNotNull(userTokensStore.getSyncOrNull(userWallet.walletId)) {
"Unable to find tokens response for user wallet with provided ID: $userWalletId" "Unable to find tokens response for user wallet with provided ID: $userWalletId"
}
val blockchain = Blockchain.fromId(networkId.value)
val blockchainNetworkId = blockchain.toNetworkId()
val coinId = blockchain.toCoinId()
val storedCoin = storedTokens.tokens
.find {
it.networkId == blockchainNetworkId && it.id == coinId && it.derivationPath == derivationPath.value
} ?: error("Coin in this network $networkId not found")
val coin = responseCurrenciesFactory.createCurrency(storedCoin, userWallet.scanResponse)
coin as? CryptoCurrency.Coin ?: error("Unable to create currency")
} }
val blockchain = Blockchain.fromId(networkId.value)
val blockchainNetworkId = blockchain.toNetworkId()
val coinId = blockchain.toCoinId()
val storedCoin = storedTokens.tokens
.find {
it.networkId == blockchainNetworkId && it.id == coinId && it.derivationPath == derivationPath.value
} ?: error("Coin in this network $networkId not found")
val coin = responseCurrenciesFactory.createCurrency(storedCoin, userWallet.scanResponse)
return coin as? CryptoCurrency.Coin ?: error("Unable to create currency")
} }
override fun isTokensGrouped(userWalletId: UserWalletId): Flow<Boolean> { override fun isTokensGrouped(userWalletId: UserWalletId): Flow<Boolean> {
@ -395,29 +397,31 @@ internal class DefaultCurrenciesRepository(
} }
override suspend fun getFeePaidCurrency(userWalletId: UserWalletId, currency: CryptoCurrency): FeePaidCurrency { override suspend fun getFeePaidCurrency(userWalletId: UserWalletId, currency: CryptoCurrency): FeePaidCurrency {
val blockchain = Blockchain.fromId(currency.network.id.value) return withContext(dispatchers.io) {
return when (val feePaidCurrency = blockchain.feePaidCurrency()) { val blockchain = Blockchain.fromId(currency.network.id.value)
FeePaidSdkCurrency.Coin -> FeePaidCurrency.Coin when (val feePaidCurrency = blockchain.feePaidCurrency()) {
FeePaidSdkCurrency.SameCurrency -> FeePaidCurrency.SameCurrency FeePaidSdkCurrency.Coin -> FeePaidCurrency.Coin
is FeePaidSdkCurrency.Token -> { FeePaidSdkCurrency.SameCurrency -> FeePaidCurrency.SameCurrency
val balance = walletManagersFacade.tokenBalance( is FeePaidSdkCurrency.Token -> {
userWalletId = userWalletId, val balance = walletManagersFacade.tokenBalance(
network = currency.network, userWalletId = userWalletId,
name = feePaidCurrency.token.name, network = currency.network,
symbol = feePaidCurrency.token.symbol, name = feePaidCurrency.token.name,
contractAddress = feePaidCurrency.token.contractAddress, symbol = feePaidCurrency.token.symbol,
decimals = feePaidCurrency.token.decimals, contractAddress = feePaidCurrency.token.contractAddress,
id = feePaidCurrency.token.id, decimals = feePaidCurrency.token.decimals,
) id = feePaidCurrency.token.id,
FeePaidCurrency.Token( )
tokenId = getTokenId(network = currency.network, sdkToken = feePaidCurrency.token), FeePaidCurrency.Token(
name = feePaidCurrency.token.name, tokenId = getTokenId(network = currency.network, sdkToken = feePaidCurrency.token),
symbol = feePaidCurrency.token.symbol, name = feePaidCurrency.token.name,
contractAddress = feePaidCurrency.token.contractAddress, symbol = feePaidCurrency.token.symbol,
balance = balance, contractAddress = feePaidCurrency.token.contractAddress,
) balance = balance,
)
}
is FeePaidSdkCurrency.FeeResource -> FeePaidCurrency.FeeResource(currency = feePaidCurrency.currency)
} }
is FeePaidSdkCurrency.FeeResource -> FeePaidCurrency.FeeResource(currency = feePaidCurrency.currency)
} }
} }

View file

@ -5,9 +5,12 @@ import com.tangem.datasource.local.token.ExpressAssetsStore
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository
import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
class DefaultMarketCryptoCurrencyRepository( class DefaultMarketCryptoCurrencyRepository(
private val expressAssetsStore: ExpressAssetsStore, private val expressAssetsStore: ExpressAssetsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MarketCryptoCurrencyRepository { ) : MarketCryptoCurrencyRepository {
override suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { override suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
@ -15,11 +18,16 @@ class DefaultMarketCryptoCurrencyRepository(
} }
private suspend fun getExchangeableFlag(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { private suspend fun getExchangeableFlag(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE return withContext(dispatchers.io) {
val contractAddress =
(cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE
return expressAssetsStore.getSyncOrNull(userWalletId)?.find { val asset = expressAssetsStore.getSyncOrNull(userWalletId)?.find {
it.network == cryptoCurrency.network.backendId && it.network == cryptoCurrency.network.backendId &&
it.contractAddress.equals(contractAddress, ignoreCase = true) it.contractAddress.equals(contractAddress, ignoreCase = true)
}?.exchangeAvailable ?: false }
asset?.exchangeAvailable ?: false
}
} }
} }

View file

@ -51,6 +51,19 @@ internal class DefaultQuotesRepository(
.flowOn(dispatchers.io) .flowOn(dispatchers.io)
} }
override suspend fun fetchQuotes(currenciesIds: Set<CryptoCurrency.ID>) {
withContext(dispatchers.io) {
val selectedAppCurrency = requireNotNull(
value = appPreferencesStore.getObjectSyncOrNull<CurrenciesResponse.Currency>(
key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
),
lazyMessage = { "Unable to get selected application currency to update quotes" },
)
fetchExpiredQuotes(currenciesIds, selectedAppCurrency.id, true)
}
}
override suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote> { override suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote> {
return withContext(dispatchers.io) { return withContext(dispatchers.io) {
val selectedAppCurrency = requireNotNull( val selectedAppCurrency = requireNotNull(

View file

@ -35,16 +35,19 @@ class DefaultTxHistoryRepository(
private val sdkPageConverter by lazy { SdkPageConverter() } private val sdkPageConverter by lazy { SdkPageConverter() }
override suspend fun getTxHistoryItemsCount(userWalletId: UserWalletId, currency: CryptoCurrency): Int { override suspend fun getTxHistoryItemsCount(userWalletId: UserWalletId, currency: CryptoCurrency): Int {
val userWallet = getUserWallet(userWalletId) return withContext(dispatchers.io) {
val state = walletManagersFacade.getTxHistoryState( val userWallet = getUserWallet(userWalletId)
userWalletId = userWallet.walletId, val state = walletManagersFacade.getTxHistoryState(
currency = currency, userWalletId = userWallet.walletId,
) currency = currency,
return when (state) { )
is TxHistoryState.Failed.FetchError -> throw TxHistoryStateError.DataError(state.exception)
is TxHistoryState.NotImplemented -> throw TxHistoryStateError.TxHistoryNotImplemented when (state) {
is TxHistoryState.Success.Empty -> throw TxHistoryStateError.EmptyTxHistories is TxHistoryState.Failed.FetchError -> throw TxHistoryStateError.DataError(state.exception)
is TxHistoryState.Success.HasTransactions -> state.txCount is TxHistoryState.NotImplemented -> throw TxHistoryStateError.TxHistoryNotImplemented
is TxHistoryState.Success.Empty -> throw TxHistoryStateError.EmptyTxHistories
is TxHistoryState.Success.HasTransactions -> state.txCount
}
} }
} }

View file

@ -1,8 +1,8 @@
package com.tangem.domain.card.models package com.tangem.domain.card.models
sealed interface ResetCardError { sealed class ResetCardError {
data object UserCanceled : ResetCardError data object UserCanceled : ResetCardError()
data object AnotherSdkError : ResetCardError data object SdkError : ResetCardError()
} }

View file

@ -5,11 +5,11 @@ import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading import com.tangem.domain.core.utils.lceLoading
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.channels.ProducerScope import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import kotlin.experimental.ExperimentalTypeInference import kotlin.experimental.ExperimentalTypeInference
/** /**
@ -25,35 +25,43 @@ typealias LceFlow<E, C> = Flow<Lce<E, C>>
* It provides methods to handle [Lce] instances and raise errors within a [Flow]. * It provides methods to handle [Lce] instances and raise errors within a [Flow].
* *
* @property raise The [LceRaise] instance that this class wraps. * @property raise The [LceRaise] instance that this class wraps.
* @property scope The [ProducerScope] that this class operates within. * @property producerScope The [ProducerScope] instance that this class wraps.
* @property ifLoading The function to call if a loading state is raised. * @property ifLoading The function to call if a loading state is raised.
*/ */
class LceFlowScope<E : Any, C : Any> @PublishedApi internal constructor( class LceFlowScope<E : Any, C : Any> @PublishedApi internal constructor(
private val raise: LceRaise<E>, private val raise: LceRaise<E>,
private val scope: ProducerScope<Lce<E, C>>, private val producerScope: ProducerScope<Lce<E, C>>,
private val ifLoading: suspend LceFlowScope<E, C>.(C?) -> Unit, private val ifLoading: suspend LceFlowScope<E, C>.(C?) -> Unit,
) : Raise<E>, CoroutineScope by scope { ) : Raise<E>, CoroutineScope by producerScope {
/** /**
* Raises an [Lce] instance within the [ProducerScope]. * Sends a error of type [E] within the [ProducerScope] and then closes it for send.
* It closes the [ProducerScope] after raise. * All subsequent sends will be ignored.
* *
* @param r The [Lce] instance to raise. * This method blocks the coroutine until a error is handled by the receiver.
*
* If the [ProducerScope] is already closed for send (e.g. after rising another error), it just raises [r]
* without closing.
*
* @param r Error to raise.
*/ */
override fun raise(r: E): Nothing { override fun raise(r: E): Nothing {
scope.launch(NonCancellable) { producerScope.trySendBlocking(r.lceError())
scope.send(r.lceError()) producerScope.close()
scope.close()
}
raise.raise(r.lceError()) raise.raise(r.lceError())
} }
/** /**
* Sends a content value within the [ProducerScope]. * Sends a [content] value within the [ProducerScope].
*
* If the content is still loading, it calls [ifLoading] lambda to retrieve a state. * If the content is still loading, it calls [ifLoading] lambda to retrieve a state.
* Otherwise, it wraps the content in a [Lce.Content] state. * Otherwise, it wraps the content in a [Lce.Content] state.
* *
* This method suspends until the [Lce] instance is handled by the receiver.
*
* If the [ProducerScope] is closed for send (e.g. after rising a error), it does nothing.
*
* @param content The content value to send. * @param content The content value to send.
* @param isStillLoading A flag indicating whether the content is still loading. * @param isStillLoading A flag indicating whether the content is still loading.
*/ */
@ -65,11 +73,23 @@ class LceFlowScope<E : Any, C : Any> @PublishedApi internal constructor(
content.lceContent() content.lceContent()
} }
scope.send(value) send(value)
} }
/**
* Sends an [Lce] instance within the [ProducerScope].
*
* This method suspends until the [Lce] instance is handled by the receiver.
*
* If the [ProducerScope] is closed for send (e.g. after rising a error), it does nothing.
*
* @param value The [Lce] instance to send.
*/
@OptIn(DelicateCoroutinesApi::class)
suspend fun send(value: Lce<E, C>) { suspend fun send(value: Lce<E, C>) {
scope.send(value) if (producerScope.isClosedForSend) return
producerScope.send(value)
} }
} }
@ -94,7 +114,7 @@ fun <E : Any, C : Any> lceFlow(
lce { lce {
val scope = LceFlowScope( val scope = LceFlowScope(
raise = this@lce, raise = this@lce,
scope = this@channelFlow, producerScope = this@channelFlow,
ifLoading = ifLoading, ifLoading = ifLoading,
) )

View file

@ -54,6 +54,10 @@ interface CardTypesResolver {
fun isVoltInuWallet(): Boolean fun isVoltInuWallet(): Boolean
fun isVividWallet(): Boolean
fun isPastelWallet(): Boolean
fun isWhiteWallet(): Boolean fun isWhiteWallet(): Boolean
fun isWallet2(): Boolean fun isWallet2(): Boolean

View file

@ -72,6 +72,14 @@ internal class TangemCardTypesResolver(
override fun isVoltInuWallet(): Boolean = card.batchId == VOLT_INU_WALLET_BATCH_ID override fun isVoltInuWallet(): Boolean = card.batchId == VOLT_INU_WALLET_BATCH_ID
override fun isVividWallet(): Boolean = card.batchId == VIVID_LEMON_WALLET_BATCH_ID ||
card.batchId == VIVID_AQUA_WALLET_BATCH_ID ||
card.batchId == VIVID_GRAPEFRUIT_WALLET_BATCH_ID
override fun isPastelWallet(): Boolean = card.batchId == PASTEL_PEACH_WALLET_BATCH_ID ||
card.batchId == PASTEL_GRASS_WALLET_BATCH_ID ||
card.batchId == PASTEL_AIR_WALLET_BATCH_ID
override fun isWhiteWallet(): Boolean { override fun isWhiteWallet(): Boolean {
return walletData == null && card.firmwareVersion <= FirmwareVersion.HDWalletAvailable return walletData == null && card.firmwareVersion <= FirmwareVersion.HDWalletAvailable
} }
@ -190,5 +198,13 @@ internal class TangemCardTypesResolver(
const val COQ_WALLET_BATCH_ID = "AF28" const val COQ_WALLET_BATCH_ID = "AF28"
const val COIN_METRICA_WALLET_BATCH_ID = "AF27" const val COIN_METRICA_WALLET_BATCH_ID = "AF27"
const val VOLT_INU_WALLET_BATCH_ID = "AF35" const val VOLT_INU_WALLET_BATCH_ID = "AF35"
// VIVID WALLETS
const val VIVID_LEMON_WALLET_BATCH_ID = "AF40"
const val VIVID_AQUA_WALLET_BATCH_ID = "AF41"
const val VIVID_GRAPEFRUIT_WALLET_BATCH_ID = "AF42"
// PASTEL WALLETS
const val PASTEL_PEACH_WALLET_BATCH_ID = "AF43"
const val PASTEL_AIR_WALLET_BATCH_ID = "AF44"
const val PASTEL_GRASS_WALLET_BATCH_ID = "AF45"
} }
} }

View file

@ -87,11 +87,13 @@ class DefaultWalletManagersFacade(
Blockchain.fromId(it.id.value) to it.derivationPath.value Blockchain.fromId(it.id.value) to it.derivationPath.value
} }
walletManagersStore.remove(userWalletId) { walletManager -> withContext(dispatchers.io) {
val wallet = walletManager.wallet walletManagersStore.remove(userWalletId) { walletManager ->
val blockchainToDerivationPath = wallet.blockchain to wallet.publicKey.derivationPath?.rawPath val wallet = walletManager.wallet
val blockchainToDerivationPath = wallet.blockchain to wallet.publicKey.derivationPath?.rawPath
blockchainToDerivationPath in blockchainsToDerivationPaths blockchainToDerivationPath in blockchainsToDerivationPaths
}
} }
} }
@ -110,18 +112,20 @@ class DefaultWalletManagersFacade(
network: Network, network: Network,
networkTokens: List<CryptoCurrency.Token>, networkTokens: List<CryptoCurrency.Token>,
) { ) {
val walletManager = walletManagersStore.getSyncOrNull( withContext(dispatchers.io) {
userWalletId = userWalletId, val walletManager = walletManagersStore.getSyncOrNull(
blockchain = Blockchain.fromId(network.id.value), userWalletId = userWalletId,
derivationPath = network.derivationPath.value, blockchain = Blockchain.fromId(network.id.value),
) ?: return derivationPath = network.derivationPath.value,
val tokensToRemove = sdkTokenConverter.convertList(networkTokens) ) ?: return@withContext
val tokensToRemove = sdkTokenConverter.convertList(networkTokens)
tokensToRemove.forEach { token -> tokensToRemove.forEach { token ->
walletManager.removeToken(token) walletManager.removeToken(token)
}
walletManagersStore.store(userWalletId, walletManager)
} }
walletManagersStore.store(userWalletId, walletManager)
} }
override suspend fun updatePendingTransactions( override suspend fun updatePendingTransactions(
@ -576,12 +580,16 @@ class DefaultWalletManagersFacade(
userWalletId: UserWalletId, userWalletId: UserWalletId,
currency: CryptoCurrency, currency: CryptoCurrency,
): AssetRequirementsCondition? { ): AssetRequirementsCondition? {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = currency.network) return withContext(dispatchers.io) {
val currencyType = cryptoCurrencyTypeConverter.convert(currency) val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = currency.network)
if (walletManager !is AssetRequirementsManager || !walletManager.hasRequirements(currencyType)) return null val currencyType = cryptoCurrencyTypeConverter.convert(currency)
if (walletManager !is AssetRequirementsManager || !walletManager.hasRequirements(currencyType)) {
return@withContext null
}
val condition = walletManager.requirementsCondition(currencyType) ?: return null val condition = walletManager.requirementsCondition(currencyType) ?: return@withContext null
return requirementsConditionConverter.convert(condition) requirementsConditionConverter.convert(condition)
}
} }
override suspend fun associateAsset( override suspend fun associateAsset(
@ -589,15 +597,18 @@ class DefaultWalletManagersFacade(
currency: CryptoCurrency, currency: CryptoCurrency,
signer: CommonSigner, signer: CommonSigner,
): SimpleResult { ): SimpleResult {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = currency.network) return withContext(dispatchers.io) {
val currencyType = cryptoCurrencyTypeConverter.convert(currency) val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = currency.network)
val currencyType = cryptoCurrencyTypeConverter.convert(currency)
if (walletManager !is AssetRequirementsManager) { if (walletManager !is AssetRequirementsManager) {
return SimpleResult.Failure( return@withContext SimpleResult.Failure(
BlockchainSdkError.CustomError("WalletManager is not implemented AssetRequirementsManager"), BlockchainSdkError.CustomError("WalletManager is not implemented AssetRequirementsManager"),
) )
}
walletManager.fulfillRequirements(currencyType, signer)
} }
return walletManager.fulfillRequirements(currencyType, signer)
} }
override suspend fun checkUtxoConsolidationAvailability(userWalletId: UserWalletId, network: Network): Boolean { override suspend fun checkUtxoConsolidationAvailability(userWalletId: UserWalletId, network: Network): Boolean {

View file

@ -1,18 +1,43 @@
package com.tangem.domain.tokens.models.analytics package com.tangem.domain.tokens.models.analytics
import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed class TokenSwapPromoAnalyticsEvent( sealed class TokenSwapPromoAnalyticsEvent(
event: String, event: String,
params: Map<String, String> = mapOf(), params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Swap Promo", event, params, null) { ) : AnalyticsEvent(category = "Promotion", event = event, params = params) {
class NoticePromotionBanner(
object Close : TokenSwapPromoAnalyticsEvent(event = "Button - Close") source: AnalyticsParam.ScreensSources,
programName: ProgramName,
class Exchange(
token: String,
) : TokenSwapPromoAnalyticsEvent( ) : TokenSwapPromoAnalyticsEvent(
event = "Button - Exchange Now", event = "Notice - Promotion Banner",
params = mapOf("Token" to token), params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to programName.name,
),
) )
class PromotionBannerClicked(
source: AnalyticsParam.ScreensSources,
programName: ProgramName,
action: BannerAction,
) : TokenSwapPromoAnalyticsEvent(
event = "Promo Banner Clicked",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to programName.name,
"Action" to action.action,
),
) {
sealed class BannerAction(val action: String) {
data object Clicked : BannerAction(action = "Clicked")
data object Closed : BannerAction(action = "Closed")
}
}
enum class ProgramName {
Travala,
OKX,
}
} }

View file

@ -5,7 +5,6 @@ import arrow.core.raise.Raise
import arrow.core.raise.catch import arrow.core.raise.catch
import arrow.core.raise.either import arrow.core.raise.either
import arrow.core.toNonEmptyListOrNull import arrow.core.toNonEmptyListOrNull
import com.tangem.domain.tokens.error.AddCurrencyError
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -32,9 +31,9 @@ class AddCryptoCurrenciesUseCase(
* *
* @param userWalletId The ID of the user's wallet. * @param userWalletId The ID of the user's wallet.
* @param currency Cryptocurrency to add. * @param currency Cryptocurrency to add.
* @return Either an [AddCurrencyError] or [Unit] indicating the success of the operation. * @return Either an [Throwable] or [Unit] indicating the success of the operation.
*/ */
suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Either<AddCurrencyError, Unit> { suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Either<Throwable, Unit> {
return invoke(userWalletId, listOf(currency)) return invoke(userWalletId, listOf(currency))
} }
@ -47,13 +46,13 @@ class AddCryptoCurrenciesUseCase(
* @param userWalletId The ID of the user's wallet. * @param userWalletId The ID of the user's wallet.
* @param cryptoCurrency Token to add. * @param cryptoCurrency Token to add.
* @param network Network where we add * @param network Network where we add
* @return Either an [AddCurrencyError] or [Unit] indicating the success of the operation. * @return Either an [Throwable] or [Unit] indicating the success of the operation.
*/ */
suspend operator fun invoke( suspend operator fun invoke(
userWalletId: UserWalletId, userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency.Token, cryptoCurrency: CryptoCurrency.Token,
network: Network, network: Network,
): Either<AddCurrencyError, Unit> = either { ): Either<Throwable, Unit> = either {
val tokenToAdd = currenciesRepository.createTokenCurrency(cryptoCurrency = cryptoCurrency, network = network) val tokenToAdd = currenciesRepository.createTokenCurrency(cryptoCurrency = cryptoCurrency, network = network)
invoke(userWalletId = userWalletId, currencies = listOf(tokenToAdd)) invoke(userWalletId = userWalletId, currencies = listOf(tokenToAdd))
} }
@ -66,14 +65,14 @@ class AddCryptoCurrenciesUseCase(
* *
* @param userWalletId The ID of the user's wallet. * @param userWalletId The ID of the user's wallet.
* @param currencies The list of cryptocurrencies to add. * @param currencies The list of cryptocurrencies to add.
* @return Either an [AddCurrencyError] or [Unit] indicating the success of the operation. * @return Either an [Throwable] or [Unit] indicating the success of the operation.
*/ */
suspend operator fun invoke( suspend operator fun invoke(
userWalletId: UserWalletId, userWalletId: UserWalletId,
currencies: List<CryptoCurrency>, currencies: List<CryptoCurrency>,
): Either<AddCurrencyError, Unit> = either { ): Either<Throwable, Unit> = either {
val existingCurrencies = catch({ currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) }) { val existingCurrencies = catch({ currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) }) {
raise(AddCurrencyError.DataError(it)) raise(it)
} }
val currenciesToAdd = currencies val currenciesToAdd = currencies
.filterNot(existingCurrencies::contains) .filterNot(existingCurrencies::contains)
@ -81,7 +80,7 @@ class AddCryptoCurrenciesUseCase(
?: return@either ?: return@either
catch({ currenciesRepository.addCurrencies(userWalletId, currenciesToAdd) }) { catch({ currenciesRepository.addCurrencies(userWalletId, currenciesToAdd) }) {
raise(AddCurrencyError.DataError(it)) raise(it)
} }
refreshUpdatedNetworks(userWalletId, currenciesToAdd, existingCurrencies) refreshUpdatedNetworks(userWalletId, currenciesToAdd, existingCurrencies)
@ -91,7 +90,7 @@ class AddCryptoCurrenciesUseCase(
* Refreshes the network statuses for tokens that have corresponding coins in the * Refreshes the network statuses for tokens that have corresponding coins in the
* [existingCurrencies] list. * [existingCurrencies] list.
*/ */
private suspend fun Raise<AddCurrencyError>.refreshUpdatedNetworks( private suspend fun Raise<Throwable>.refreshUpdatedNetworks(
userWalletId: UserWalletId, userWalletId: UserWalletId,
currenciesToAdd: List<CryptoCurrency>, currenciesToAdd: List<CryptoCurrency>,
existingCurrencies: List<CryptoCurrency>, existingCurrencies: List<CryptoCurrency>,
@ -114,7 +113,7 @@ class AddCryptoCurrenciesUseCase(
) )
}, },
) { ) {
raise(AddCurrencyError.DataError(it)) raise(it)
} }
} }

View file

@ -87,7 +87,7 @@ class GetCurrencyWarningsUseCase(
): Flow<CryptoCurrencyWarning?> { ): Flow<CryptoCurrencyWarning?> {
val currency = currencyStatus.currency val currency = currencyStatus.currency
val cryptoStatuses = operations.getCurrenciesStatusesSync() val cryptoStatuses = operations.getCurrenciesStatusesSync()
val promoBanner = promoRepository.getChangellyPromoBanner() val promoBanner = promoRepository.getOkxPromoBanner()
return combine( return combine(
showSwapPromoTokenUseCase().conflate(), showSwapPromoTokenUseCase().conflate(),
flowOf(marketCryptoCurrencyRepository.isExchangeable(userWalletId, currency)).conflate(), flowOf(marketCryptoCurrencyRepository.isExchangeable(userWalletId, currency)).conflate(),

View file

@ -0,0 +1,53 @@
package com.tangem.domain.tokens
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.tokens.error.QuotesError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
class RefreshMultiCurrencyWalletQuotesUseCase(
private val quotesRepository: QuotesRepository,
private val currenciesRepository: CurrenciesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<QuotesError, Unit> {
return either {
val currencies = fetchCurrencies(userWalletId = userWalletId)
.getOrElse { raise(QuotesError.DataError(it)) }
coroutineScope {
val fetchQuotes = async {
fetchQuotes(
currenciesIds = currencies.mapTo(destination = hashSetOf(), transform = CryptoCurrency::id),
)
}
awaitAll(fetchQuotes)
}
}
}
private suspend fun fetchCurrencies(userWalletId: UserWalletId): Either<Throwable, List<CryptoCurrency>> {
return either {
catch(
block = { currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId, false) },
catch = { raise(it) },
)
}
}
private suspend fun fetchQuotes(currenciesIds: Set<CryptoCurrency.ID>) {
catch(
block = { quotesRepository.fetchQuotes(currenciesIds) },
catch = { /* Ignore error */ },
)
}
}

View file

@ -1,6 +0,0 @@
package com.tangem.domain.tokens.error
sealed class AddCurrencyError {
data class DataError(val cause: Throwable) : AddCurrencyError()
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.tokens.error
sealed class QuotesError {
data class DataError(val cause: Throwable) : QuotesError()
}

View file

@ -7,4 +7,6 @@ interface PromoRepository {
suspend fun getChangellyPromoBanner(): PromoBanner? suspend fun getChangellyPromoBanner(): PromoBanner?
suspend fun getTravalaPromoBanner(): PromoBanner? suspend fun getTravalaPromoBanner(): PromoBanner?
suspend fun getOkxPromoBanner(): PromoBanner?
} }

View file

@ -31,4 +31,6 @@ interface QuotesRepository {
suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote> suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote>
suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote? suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote?
suspend fun fetchQuotes(currenciesIds: Set<CryptoCurrency.ID>)
} }

View file

@ -25,4 +25,6 @@ internal class MockQuotesRepository(
return quotes.map { it.getOrElse { e -> throw e } }.first() return quotes.map { it.getOrElse { e -> throw e } }.first()
.first { it.rawCurrencyId == currencyId.rawCurrencyId } .first { it.rawCurrencyId == currencyId.rawCurrencyId }
} }
override suspend fun fetchQuotes(currenciesIds: Set<CryptoCurrency.ID>) {}
} }

View file

@ -104,6 +104,6 @@ internal class ItemsBuilder @Inject constructor(
) )
private companion object { private companion object {
const val BUY_TANGEM_URL = "https://buy.tangem.com/" const val BUY_TANGEM_URL = "https://buy.tangem.com/?utm_source=tangem&utm_medium=app"
} }
} }

View file

@ -2,6 +2,7 @@ package com.tangem.features.send.impl.presentation
import android.os.Bundle import android.os.Bundle
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.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -60,7 +61,9 @@ internal class SendFragment : ComposeFragment() {
@Composable @Composable
override fun ScreenContent(modifier: Modifier) { override fun ScreenContent(modifier: Modifier) {
val currentState = viewModel.stateRouter.currentState.collectAsStateWithLifecycle() val currentState = viewModel.stateRouter.currentState.collectAsStateWithLifecycle()
SendScreen(viewModel.uiState, currentState.value) val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SendScreen(uiState, currentState.value)
} }
override fun onDestroy() { override fun onDestroy() {

View file

@ -2,9 +2,6 @@ package com.tangem.features.send.impl.presentation.viewmodel
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.* import androidx.lifecycle.*
import arrow.core.Either import arrow.core.Either
import arrow.core.getOrElse import arrow.core.getOrElse
@ -126,7 +123,7 @@ internal class SendViewModel @Inject constructor(
private val stateFactory = SendStateFactory( private val stateFactory = SendStateFactory(
clickIntents = this, clickIntents = this,
stateRouterProvider = Provider { stateRouter }, stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState }, currentStateProvider = Provider { uiState.value },
userWalletProvider = Provider { userWallet }, userWalletProvider = Provider { userWallet },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
@ -136,7 +133,7 @@ internal class SendViewModel @Inject constructor(
private val recipientStateFactory = RecipientSendFactory( private val recipientStateFactory = RecipientSendFactory(
stateRouterProvider = Provider { stateRouter }, stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState }, currentStateProvider = Provider { uiState.value },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
isUtxoConsolidationAvailableProvider = Provider { isUtxoConsolidationAvailable }, isUtxoConsolidationAvailableProvider = Provider { isUtxoConsolidationAvailable },
validateWalletMemoUseCase = validateWalletMemoUseCase, validateWalletMemoUseCase = validateWalletMemoUseCase,
@ -144,14 +141,14 @@ internal class SendViewModel @Inject constructor(
private val amountStateFactory = AmountStateFactory( private val amountStateFactory = AmountStateFactory(
stateRouterProvider = Provider { stateRouter }, stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState }, currentStateProvider = Provider { uiState.value },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
) )
private val feeStateFactory = FeeStateFactory( private val feeStateFactory = FeeStateFactory(
clickIntents = this, clickIntents = this,
stateRouterProvider = Provider { stateRouter }, stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState }, currentStateProvider = Provider { uiState.value },
feeCryptoCurrencyStatusProvider = Provider { feeCryptoCurrencyStatus }, feeCryptoCurrencyStatusProvider = Provider { feeCryptoCurrencyStatus },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
isFeeApproximateUseCase = isFeeApproximateUseCase, isFeeApproximateUseCase = isFeeApproximateUseCase,
@ -160,13 +157,13 @@ internal class SendViewModel @Inject constructor(
private val eventStateFactory = SendEventStateFactory( private val eventStateFactory = SendEventStateFactory(
clickIntents = this, clickIntents = this,
stateRouterProvider = Provider { stateRouter }, stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState }, currentStateProvider = Provider { uiState.value },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
feeStateFactory = feeStateFactory, feeStateFactory = feeStateFactory,
) )
private val feeNotificationFactory = FeeNotificationFactory( private val feeNotificationFactory = FeeNotificationFactory(
currentStateProvider = Provider { uiState }, currentStateProvider = Provider { uiState.value },
stateRouterProvider = Provider { stateRouter }, stateRouterProvider = Provider { stateRouter },
clickIntents = this, clickIntents = this,
) )
@ -174,7 +171,7 @@ internal class SendViewModel @Inject constructor(
private val sendNotificationFactory = SendNotificationFactory( private val sendNotificationFactory = SendNotificationFactory(
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
feeCryptoCurrencyStatusProvider = Provider { feeCryptoCurrencyStatus }, feeCryptoCurrencyStatusProvider = Provider { feeCryptoCurrencyStatus },
currentStateProvider = Provider { uiState }, currentStateProvider = Provider { uiState.value },
userWalletProvider = Provider { userWallet }, userWalletProvider = Provider { userWallet },
stateRouterProvider = Provider { stateRouter }, stateRouterProvider = Provider { stateRouter },
isSubtractAvailableProvider = Provider { isAmountSubtractAvailable }, isSubtractAvailableProvider = Provider { isAmountSubtractAvailable },
@ -189,15 +186,16 @@ internal class SendViewModel @Inject constructor(
private val sendScreenAnalyticSender by lazy(LazyThreadSafetyMode.NONE) { private val sendScreenAnalyticSender by lazy(LazyThreadSafetyMode.NONE) {
SendScreenAnalyticSender( SendScreenAnalyticSender(
stateRouterProvider = Provider { stateRouter }, stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState }, currentStateProvider = Provider { uiState.value },
analyticsEventHandler = analyticsEventHandler, analyticsEventHandler = analyticsEventHandler,
cryptoCurrencyProvider = Provider { cryptoCurrency }, cryptoCurrencyProvider = Provider { cryptoCurrency },
) )
} }
// todo convert to StateFlow // todo convert to StateFlow
var uiState: SendUiState by mutableStateOf(stateFactory.getInitialState()) val uiState: MutableStateFlow<SendUiState> = MutableStateFlow(
private set value = stateFactory.getInitialState(),
)
private var userWallet: UserWallet by Delegates.notNull() private var userWallet: UserWallet by Delegates.notNull()
private var userWallets: List<AvailableWallet> = emptyList() private var userWallets: List<AvailableWallet> = emptyList()
@ -274,7 +272,7 @@ internal class SendViewModel @Inject constructor(
.conflate() .conflate()
.distinctUntilChanged() .distinctUntilChanged()
.onEach { .onEach {
uiState = stateFactory.getOnHideBalanceState(isBalanceHidden = it.isBalanceHidden) uiState.value = stateFactory.getOnHideBalanceState(isBalanceHidden = it.isBalanceHidden)
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
.saveIn(balanceHidingJobHolder) .saveIn(balanceHidingJobHolder)
@ -347,15 +345,15 @@ internal class SendViewModel @Inject constructor(
feeCryptoCurrencyStatus = feeCurrencyStatus feeCryptoCurrencyStatus = feeCurrencyStatus
subscribeOnQRScannerResult() subscribeOnQRScannerResult()
when { when {
uiState.sendState?.isSuccess == true -> return uiState.value.sendState?.isSuccess == true -> return
transactionId != null && amount != null && destinationAddress != null -> { transactionId != null && amount != null && destinationAddress != null -> {
loadFee() loadFee()
uiState = stateFactory.getReadyState(amount, destinationAddress, memo) uiState.value = stateFactory.getReadyState(amount, destinationAddress, memo)
stateRouter.showSend() stateRouter.showSend()
updateNotifications() updateNotifications()
} }
else -> { else -> {
uiState = stateFactory.getReadyState() uiState.value = stateFactory.getReadyState()
getWalletsAndRecent() getWalletsAndRecent()
stateRouter.showRecipient() stateRouter.showRecipient()
updateNotifications() updateNotifications()
@ -379,9 +377,9 @@ internal class SendViewModel @Inject constructor(
} }
}.onSuccess { result -> }.onSuccess { result ->
userWallets = result userWallets = result
uiState = recipientStateFactory.onLoadedWalletsList(wallets = userWallets) uiState.value = recipientStateFactory.onLoadedWalletsList(wallets = userWallets)
}.onFailure { }.onFailure {
uiState = recipientStateFactory.onLoadedWalletsList(wallets = emptyList()) uiState.value = recipientStateFactory.onLoadedWalletsList(wallets = emptyList())
} }
} }
} }
@ -419,7 +417,7 @@ internal class SendViewModel @Inject constructor(
pageSize = RECENT_TX_SIZE, pageSize = RECENT_TX_SIZE,
).getOrElse { emptyList() } ).getOrElse { emptyList() }
} }
uiState = recipientStateFactory.onLoadedHistoryList(txHistory = txHistoryList) uiState.value = recipientStateFactory.onLoadedHistoryList(txHistory = txHistoryList)
} }
private fun onStateActive() { private fun onStateActive() {
@ -430,7 +428,7 @@ internal class SendViewModel @Inject constructor(
SendUiStateType.EditFee, SendUiStateType.EditFee,
-> loadFee() -> loadFee()
SendUiStateType.Send -> { SendUiStateType.Send -> {
uiState = stateFactory.getIsAmountSubtractedState(isAmountSubtractAvailable) uiState.value = stateFactory.getIsAmountSubtractedState(isAmountSubtractAvailable)
sendIdleTimer = SystemClock.elapsedRealtime() sendIdleTimer = SystemClock.elapsedRealtime()
} }
else -> Unit else -> Unit
@ -443,7 +441,7 @@ internal class SendViewModel @Inject constructor(
sendNotificationFactory.create() sendNotificationFactory.create()
.conflate() .conflate()
.distinctUntilChanged() .distinctUntilChanged()
.onEach { uiState = stateFactory.getSendNotificationState(notifications = it) } .onEach { uiState.value = stateFactory.getSendNotificationState(notifications = it) }
.flowOn(dispatchers.main) .flowOn(dispatchers.main)
.launchIn(viewModelScope) .launchIn(viewModelScope)
.saveIn(sendNotificationsJobHolder) .saveIn(sendNotificationsJobHolder)
@ -453,7 +451,7 @@ internal class SendViewModel @Inject constructor(
feeNotificationFactory.create() feeNotificationFactory.create()
.conflate() .conflate()
.distinctUntilChanged() .distinctUntilChanged()
.onEach { uiState = feeStateFactory.getFeeNotificationState(notifications = it) } .onEach { uiState.value = feeStateFactory.getFeeNotificationState(notifications = it) }
.flowOn(dispatchers.main) .flowOn(dispatchers.main)
.launchIn(viewModelScope) .launchIn(viewModelScope)
.saveIn(feeNotificationsJobHolder) .saveIn(feeNotificationsJobHolder)
@ -463,7 +461,7 @@ internal class SendViewModel @Inject constructor(
override fun popBackStack() = stateRouter.popBackStack() override fun popBackStack() = stateRouter.popBackStack()
override fun onBackClick() { override fun onBackClick() {
cancelFeeRequest() cancelFeeRequest()
stateRouter.onBackClick(isSuccess = uiState.sendState?.isSuccess == true) stateRouter.onBackClick(isSuccess = uiState.value.sendState?.isSuccess == true)
} }
override fun onCloseClick() { override fun onCloseClick() {
@ -479,8 +477,8 @@ internal class SendViewModel @Inject constructor(
override fun onNextClick(isFromEdit: Boolean) { override fun onNextClick(isFromEdit: Boolean) {
val currentState = stateRouter.currentState.value val currentState = stateRouter.currentState.value
uiState = stateFactory.syncEditStates(isFromEdit = isFromEdit) uiState.value = stateFactory.syncEditStates(isFromEdit = isFromEdit)
sendScreenAnalyticSender.send(currentState.type, uiState) sendScreenAnalyticSender.send(currentState.type, uiState.value)
when (currentState.type) { when (currentState.type) {
SendUiStateType.Fee, SendUiStateType.Fee,
SendUiStateType.EditFee, SendUiStateType.EditFee,
@ -507,9 +505,9 @@ internal class SendViewModel @Inject constructor(
} }
override fun onFailedTxEmailClick(errorMessage: String) { override fun onFailedTxEmailClick(errorMessage: String) {
val recipient = uiState.recipientState?.addressTextField?.value val recipient = uiState.value.recipientState?.addressTextField?.value
val feeValue = uiState.feeState?.fee?.amount?.value val feeValue = uiState.value.feeState?.fee?.amount?.value
val amountValue = (uiState.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value val amountValue = (uiState.value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
val receivingAmount = if (amountValue != null && feeValue != null) { val receivingAmount = if (amountValue != null && feeValue != null) {
checkAndCalculateSubtractedAmount( checkAndCalculateSubtractedAmount(
@ -517,7 +515,7 @@ internal class SendViewModel @Inject constructor(
cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue, amountValue = amountValue,
feeValue = feeValue, feeValue = feeValue,
reduceAmountBy = uiState.sendState?.reduceAmountBy ?: BigDecimal.ZERO, reduceAmountBy = uiState.value.sendState?.reduceAmountBy ?: BigDecimal.ZERO,
) )
} else { } else {
null null
@ -539,20 +537,20 @@ internal class SendViewModel @Inject constructor(
innerRouter.openTokenDetails(userWalletId, currency) innerRouter.openTokenDetails(userWalletId, currency)
private fun onFeeNext(): Boolean { private fun onFeeNext(): Boolean {
val feeState = uiState.getFeeState(stateRouter.isEditState) val feeState = uiState.value.getFeeState(stateRouter.isEditState)
val feeSelectorState = feeState?.feeSelectorState as? FeeSelectorState.Content ?: return false val feeSelectorState = feeState?.feeSelectorState as? FeeSelectorState.Content ?: return false
if (checkIfFeeTooLow(feeSelectorState)) { if (checkIfFeeTooLow(feeSelectorState)) {
uiState = eventStateFactory.getFeeTooLowAlert( uiState.value = eventStateFactory.getFeeTooLowAlert(
onConsume = { uiState = eventStateFactory.onConsumeEventState() }, onConsume = { uiState.value = eventStateFactory.onConsumeEventState() },
) )
return true return true
} }
return checkIfFeeTooHigh( return checkIfFeeTooHigh(
feeSelectorState = feeSelectorState, feeSelectorState = feeSelectorState,
onShow = { diff -> onShow = { diff ->
uiState = eventStateFactory.getFeeTooHighAlert( uiState.value = eventStateFactory.getFeeTooHighAlert(
diff = diff, diff = diff,
onConsume = { uiState = eventStateFactory.onConsumeEventState() }, onConsume = { uiState.value = eventStateFactory.onConsumeEventState() },
) )
}, },
) )
@ -584,20 +582,20 @@ internal class SendViewModel @Inject constructor(
// region amount state clicks // region amount state clicks
override fun onCurrencyChangeClick(isFiat: Boolean) { override fun onCurrencyChangeClick(isFiat: Boolean) {
uiState = amountStateFactory.getOnCurrencyChangedState(isFiat) uiState.value = amountStateFactory.getOnCurrencyChangedState(isFiat)
} }
override fun onAmountValueChange(value: String) { override fun onAmountValueChange(value: String) {
uiState = amountStateFactory.getOnAmountValueChange(value) uiState.value = amountStateFactory.getOnAmountValueChange(value)
} }
override fun onMaxValueClick() { override fun onMaxValueClick() {
uiState = amountStateFactory.getOnMaxAmountClick() uiState.value = amountStateFactory.getOnMaxAmountClick()
analyticsEventHandler.send(SendAnalyticEvents.MaxAmountButtonClicked) analyticsEventHandler.send(SendAnalyticEvents.MaxAmountButtonClicked)
} }
override fun onAmountPasteTriggerDismiss() { override fun onAmountPasteTriggerDismiss() {
uiState = amountStateFactory.getOnAmountPastedTriggerDismiss() uiState.value = amountStateFactory.getOnAmountPastedTriggerDismiss()
} }
// endregion // endregion
@ -606,10 +604,10 @@ internal class SendViewModel @Inject constructor(
override fun onRecipientAddressValueChange(value: String, type: EnterAddressSource?) { override fun onRecipientAddressValueChange(value: String, type: EnterAddressSource?) {
viewModelScope.launch { viewModelScope.launch {
if (!checkIfXrpAddressValue(value)) { if (!checkIfXrpAddressValue(value)) {
uiState = recipientStateFactory.onRecipientAddressValueChange(value, isValuePasted = type != null) uiState.value = recipientStateFactory.onRecipientAddressValueChange(value, isValuePasted = type != null)
uiState = recipientStateFactory.getOnRecipientAddressValidationStarted() uiState.value = recipientStateFactory.getOnRecipientAddressValidationStarted()
val isValidAddress = validateAddress(value) val isValidAddress = validateAddress(value)
uiState = recipientStateFactory.getOnRecipientAddressValidState(value, isValidAddress) uiState.value = recipientStateFactory.getOnRecipientAddressValidState(value, isValidAddress)
type?.let { type?.let {
analyticsEventHandler.send( analyticsEventHandler.send(
SendAnalyticEvents.AddressEntered( SendAnalyticEvents.AddressEntered(
@ -626,11 +624,11 @@ internal class SendViewModel @Inject constructor(
override fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean) { override fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean) {
viewModelScope.launch { viewModelScope.launch {
if (!checkIfXrpAddressValue(value)) { if (!checkIfXrpAddressValue(value)) {
uiState = recipientStateFactory.getOnRecipientMemoValueChange(value, isValuePasted) uiState.value = recipientStateFactory.getOnRecipientMemoValueChange(value, isValuePasted)
uiState = recipientStateFactory.getOnRecipientAddressValidationStarted() uiState.value = recipientStateFactory.getOnRecipientAddressValidationStarted()
val recipientState = uiState.getRecipientState(stateRouter.isEditState) val recipientState = uiState.value.getRecipientState(stateRouter.isEditState)
val maybeValidAddress = validateAddress(recipientState?.addressTextField?.value.orEmpty()) val maybeValidAddress = validateAddress(recipientState?.addressTextField?.value.orEmpty())
uiState = recipientStateFactory.getOnRecipientMemoValidState(value, maybeValidAddress.isRight()) uiState.value = recipientStateFactory.getOnRecipientMemoValidState(value, maybeValidAddress.isRight())
} }
}.saveIn(memoValidationJobHolder) }.saveIn(memoValidationJobHolder)
} }
@ -652,21 +650,22 @@ internal class SendViewModel @Inject constructor(
private suspend fun checkIfXrpAddressValue(value: String): Boolean { private suspend fun checkIfXrpAddressValue(value: String): Boolean {
return BlockchainUtils.decodeRippleXAddress(value, cryptoCurrency.network.id.value)?.let { decodedAddress -> return BlockchainUtils.decodeRippleXAddress(value, cryptoCurrency.network.id.value)?.let { decodedAddress ->
uiState = uiState.value =
recipientStateFactory.onRecipientAddressValueChange(value, isXAddress = true, isValuePasted = true) recipientStateFactory.onRecipientAddressValueChange(value, isXAddress = true, isValuePasted = true)
uiState = recipientStateFactory.getOnXAddressMemoState() uiState.value = recipientStateFactory.getOnXAddressMemoState()
val isValidAddress = validateAddress(decodedAddress.address) val isValidAddress = validateAddress(decodedAddress.address)
uiState = recipientStateFactory.getOnRecipientAddressValidState(decodedAddress.address, isValidAddress) uiState.value =
recipientStateFactory.getOnRecipientAddressValidState(decodedAddress.address, isValidAddress)
true true
} ?: false } ?: false
} }
private fun onEnteredValidAddress(isNotValid: Boolean) { private fun onEnteredValidAddress(isNotValid: Boolean) {
uiState = recipientStateFactory.getHiddenRecentListState(isNotValid = isNotValid) uiState.value = recipientStateFactory.getHiddenRecentListState(isNotValid = isNotValid)
} }
private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean) { private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean) {
val memo = uiState.getRecipientState(stateRouter.isEditState)?.memoTextField?.value val memo = uiState.value.getRecipientState(stateRouter.isEditState)?.memoTextField?.value
val isValidMemo = validateMemo(memo) val isValidMemo = validateMemo(memo)
val isRecent = type == EnterAddressSource.RecentAddress val isRecent = type == EnterAddressSource.RecentAddress
@ -678,7 +677,7 @@ internal class SendViewModel @Inject constructor(
override fun feeReload() = loadFee() override fun feeReload() = loadFee()
override fun onFeeSelectorClick(feeType: FeeType) { override fun onFeeSelectorClick(feeType: FeeType) {
uiState = feeStateFactory.onFeeSelectedState(feeType) uiState.value = feeStateFactory.onFeeSelectedState(feeType)
updateFeeNotifications() updateFeeNotifications()
if (feeType == FeeType.Custom) { if (feeType == FeeType.Custom) {
analyticsEventHandler.send(SendAnalyticEvents.CustomFeeButtonClicked) analyticsEventHandler.send(SendAnalyticEvents.CustomFeeButtonClicked)
@ -686,7 +685,7 @@ internal class SendViewModel @Inject constructor(
} }
override fun onCustomFeeValueChange(index: Int, value: String) { override fun onCustomFeeValueChange(index: Int, value: String) {
uiState = feeStateFactory.onCustomFeeValueChange(index, value) uiState.value = feeStateFactory.onCustomFeeValueChange(index, value)
updateFeeNotifications() updateFeeNotifications()
} }
@ -702,14 +701,14 @@ internal class SendViewModel @Inject constructor(
private fun loadFee() { private fun loadFee() {
viewModelScope.launch { viewModelScope.launch {
val isShowStatus = uiState.feeState?.fee == null val isShowStatus = uiState.value.feeState?.fee == null
if (isShowStatus) { if (isShowStatus) {
uiState = feeStateFactory.onFeeOnLoadingState() uiState.value = feeStateFactory.onFeeOnLoadingState()
updateNotifications() updateNotifications()
} }
val result = callFeeUseCase()?.fold( val result = callFeeUseCase()?.fold(
ifRight = { ifRight = {
uiState = feeStateFactory.onFeeOnLoadedState(it) uiState.value = feeStateFactory.onFeeOnLoadedState(it)
sendIdleTimer = SystemClock.elapsedRealtime() sendIdleTimer = SystemClock.elapsedRealtime()
}, },
ifLeft = { ifLeft = {
@ -725,7 +724,7 @@ internal class SendViewModel @Inject constructor(
} }
private fun onFeeLoadFailed(isShowStatus: Boolean) { private fun onFeeLoadFailed(isShowStatus: Boolean) {
if (isShowStatus) uiState = feeStateFactory.onFeeOnErrorState() if (isShowStatus) uiState.value = feeStateFactory.onFeeOnErrorState()
} }
private suspend fun checkIfSubtractAvailable() { private suspend fun checkIfSubtractAvailable() {
@ -744,8 +743,8 @@ internal class SendViewModel @Inject constructor(
private suspend fun callFeeUseCase(): Either<GetFeeError, TransactionFee>? { private suspend fun callFeeUseCase(): Either<GetFeeError, TransactionFee>? {
val isFromConfirmation = stateRouter.currentState.value.isFromConfirmation val isFromConfirmation = stateRouter.currentState.value.isFromConfirmation
val amountState = uiState.getAmountState(isFromConfirmation) as? AmountState.Data ?: return null val amountState = uiState.value.getAmountState(isFromConfirmation) as? AmountState.Data ?: return null
val recipientState = uiState.getRecipientState(isFromConfirmation) ?: return null val recipientState = uiState.value.getRecipientState(isFromConfirmation) ?: return null
val amount = amountState.amountTextField.cryptoAmount.value ?: return null val amount = amountState.amountTextField.cryptoAmount.value ?: return null
return getFeeUseCase.invoke( return getFeeUseCase.invoke(
@ -759,10 +758,10 @@ internal class SendViewModel @Inject constructor(
// region send state clicks // region send state clicks
override fun onSendClick() { override fun onSendClick() {
val sendState = uiState.sendState ?: return val sendState = uiState.value.sendState ?: return
if (sendState.isSuccess) popBackStack() if (sendState.isSuccess) popBackStack()
uiState = stateFactory.getSendingStateUpdate(isSending = true) uiState.value = stateFactory.getSendingStateUpdate(isSending = true)
if (SystemClock.elapsedRealtime() - sendIdleTimer < CHECK_FEE_UPDATE_DELAY) { if (SystemClock.elapsedRealtime() - sendIdleTimer < CHECK_FEE_UPDATE_DELAY) {
verifyAndSendTransaction() verifyAndSendTransaction()
} else { } else {
@ -772,22 +771,22 @@ internal class SendViewModel @Inject constructor(
} }
override fun showAmount() { override fun showAmount() {
uiState = stateFactory.syncEditStates(isFromEdit = false) uiState.value = stateFactory.syncEditStates(isFromEdit = false)
stateRouter.showAmount(isFromConfirmation = true) stateRouter.showAmount(isFromConfirmation = true)
setNeverToShowTapHelp() setNeverToShowTapHelp()
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount)) analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount))
} }
override fun showRecipient() { override fun showRecipient() {
uiState = stateFactory.syncEditStates(isFromEdit = false) uiState.value = stateFactory.syncEditStates(isFromEdit = false)
stateRouter.showRecipient(isFromConfirmation = true) stateRouter.showRecipient(isFromConfirmation = true)
uiState = stateFactory.getHiddenTapHelpState() uiState.value = stateFactory.getHiddenTapHelpState()
setNeverToShowTapHelp() setNeverToShowTapHelp()
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address)) analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address))
} }
override fun showFee() { override fun showFee() {
uiState = stateFactory.syncEditStates(isFromEdit = false) uiState.value = stateFactory.syncEditStates(isFromEdit = false)
stateRouter.showFee(isFromConfirmation = true) stateRouter.showFee(isFromConfirmation = true)
setNeverToShowTapHelp() setNeverToShowTapHelp()
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee)) analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee))
@ -798,7 +797,7 @@ internal class SendViewModel @Inject constructor(
} }
override fun onExploreClick() { override fun onExploreClick() {
val sendState = uiState.sendState ?: return val sendState = uiState.value.sendState ?: return
analyticsEventHandler.send(SendAnalyticEvents.ExploreButtonClicked) analyticsEventHandler.send(SendAnalyticEvents.ExploreButtonClicked)
innerRouter.openUrl(sendState.txUrl) innerRouter.openUrl(sendState.txUrl)
} }
@ -813,7 +812,7 @@ internal class SendViewModel @Inject constructor(
reduceAmountTo: BigDecimal?, reduceAmountTo: BigDecimal?,
clazz: Class<out SendNotification>, clazz: Class<out SendNotification>,
) { ) {
uiState = when { uiState.value = when {
reduceAmountBy != null && reduceAmountByDiff != null -> amountStateFactory.getOnAmountReduceByState( reduceAmountBy != null && reduceAmountByDiff != null -> amountStateFactory.getOnAmountReduceByState(
reduceAmountBy = reduceAmountBy, reduceAmountBy = reduceAmountBy,
reduceAmountByDiff = reduceAmountByDiff, reduceAmountByDiff = reduceAmountByDiff,
@ -822,20 +821,22 @@ internal class SendViewModel @Inject constructor(
else -> return else -> return
} }
uiState = sendNotificationFactory.dismissNotificationState(clazz) uiState.value = sendNotificationFactory.dismissNotificationState(clazz)
updateNotifications() updateNotifications()
} }
override fun onNotificationCancel(clazz: Class<out SendNotification>) { override fun onNotificationCancel(clazz: Class<out SendNotification>) {
uiState = sendNotificationFactory.dismissNotificationState(clazz = clazz, isIgnored = true) uiState.value = sendNotificationFactory.dismissNotificationState(clazz = clazz, isIgnored = true)
} }
private fun verifyAndSendTransaction() { private fun verifyAndSendTransaction() {
val recipient = uiState.recipientState?.addressTextField?.value ?: return val recipient = uiState.value.recipientState?.addressTextField?.value ?: return
val feeState = uiState.feeState ?: return val feeState = uiState.value.feeState ?: return
val fee = feeState.fee ?: return val fee = feeState.fee ?: return
val memo = uiState.recipientState?.memoTextField?.value val memo = uiState.value.recipientState?.memoTextField?.value
val amountValue = (uiState.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value ?: return val amountValue = (uiState.value.amountState as? AmountState.Data)
?.amountTextField?.cryptoAmount?.value
?: return
val feeValue = fee.amount.value ?: return val feeValue = fee.amount.value ?: return
val receivingAmount = checkAndCalculateSubtractedAmount( val receivingAmount = checkAndCalculateSubtractedAmount(
@ -843,7 +844,7 @@ internal class SendViewModel @Inject constructor(
cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue, amountValue = amountValue,
feeValue = feeValue, feeValue = feeValue,
reduceAmountBy = uiState.sendState?.reduceAmountBy ?: BigDecimal.ZERO, reduceAmountBy = uiState.value.sendState?.reduceAmountBy ?: BigDecimal.ZERO,
) )
viewModelScope.launch { viewModelScope.launch {
@ -857,10 +858,10 @@ internal class SendViewModel @Inject constructor(
).fold( ).fold(
ifLeft = { ifLeft = {
Timber.e(it) Timber.e(it)
uiState = stateFactory.getSendingStateUpdate(isSending = false) uiState.value = stateFactory.getSendingStateUpdate(isSending = false)
uiState = eventStateFactory.getGenericErrorState( uiState.value = eventStateFactory.getGenericErrorState(
error = it, error = it,
onConsume = { uiState = eventStateFactory.onConsumeEventState() }, onConsume = { uiState.value = eventStateFactory.onConsumeEventState() },
) )
}, },
ifRight = { txData -> ifRight = { txData ->
@ -871,21 +872,23 @@ internal class SendViewModel @Inject constructor(
} }
private suspend fun sendTransaction(txData: TransactionData.Uncompiled) { private suspend fun sendTransaction(txData: TransactionData.Uncompiled) {
sendTransactionUseCase( val result = sendTransactionUseCase(
txData = txData, txData = txData,
userWallet = userWallet, userWallet = userWallet,
network = cryptoCurrency.network, network = cryptoCurrency.network,
).fold( )
uiState.value = stateFactory.getSendingStateUpdate(isSending = false)
result.fold(
ifLeft = { error -> ifLeft = { error ->
uiState = stateFactory.getSendingStateUpdate(isSending = false) uiState.value = eventStateFactory.getSendTransactionErrorState(
uiState = eventStateFactory.getSendTransactionErrorState(
error = error, error = error,
onConsume = { uiState = eventStateFactory.onConsumeEventState() }, onConsume = { uiState.value = eventStateFactory.onConsumeEventState() },
) )
analyticsEventHandler.send(SendAnalyticEvents.TransactionError(cryptoCurrency.symbol)) analyticsEventHandler.send(SendAnalyticEvents.TransactionError(cryptoCurrency.symbol))
}, },
ifRight = { ifRight = {
uiState = stateFactory.getSendingStateUpdate(isSending = false)
updateTransactionStatus(txData) updateTransactionStatus(txData)
addTokenToWalletIfNeeded() addTokenToWalletIfNeeded()
scheduleUpdates() scheduleUpdates()
@ -897,7 +900,7 @@ internal class SendViewModel @Inject constructor(
private fun addTokenToWalletIfNeeded() { private fun addTokenToWalletIfNeeded() {
if (cryptoCurrency !is CryptoCurrency.Token) return if (cryptoCurrency !is CryptoCurrency.Token) return
val recipientState = uiState.getRecipientState(stateRouter.isEditState) ?: return val recipientState = uiState.value.getRecipientState(stateRouter.isEditState) ?: return
val destinationAddress = recipientState.addressTextField.value val destinationAddress = recipientState.addressTextField.value
val receivingUserWallet = userWallets.firstOrNull { it.address == destinationAddress } ?: return val receivingUserWallet = userWallets.firstOrNull { it.address == destinationAddress } ?: return
@ -916,7 +919,7 @@ internal class SendViewModel @Inject constructor(
userWalletId = userWalletId, userWalletId = userWalletId,
network = cryptoCurrency.network, network = cryptoCurrency.network,
).getOrElse { "" } ).getOrElse { "" }
uiState = stateFactory.getTransactionSendState(txData, txUrl) uiState.value = stateFactory.getTransactionSendState(txData, txUrl)
} }
private fun scheduleUpdates() { private fun scheduleUpdates() {
@ -934,7 +937,7 @@ internal class SendViewModel @Inject constructor(
} }
private fun onCheckFeeUpdate() { private fun onCheckFeeUpdate() {
val sendState = uiState.sendState ?: return val sendState = uiState.value.sendState ?: return
val isSuccess = sendState.isSuccess val isSuccess = sendState.isSuccess
val noErrorNotifications = sendState.notifications.none { it is SendNotification.Error } val noErrorNotifications = sendState.notifications.none { it is SendNotification.Error }
@ -942,30 +945,30 @@ internal class SendViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val feeUpdatedState = callFeeUseCase()?.fold( val feeUpdatedState = callFeeUseCase()?.fold(
ifRight = { ifRight = {
uiState = stateFactory.getSendingStateUpdate(isSending = false) uiState.value = stateFactory.getSendingStateUpdate(isSending = false)
eventStateFactory.getFeeUpdatedAlert( eventStateFactory.getFeeUpdatedAlert(
fee = it, fee = it,
onConsume = { uiState = eventStateFactory.onConsumeEventState() }, onConsume = { uiState.value = eventStateFactory.onConsumeEventState() },
onFeeNotIncreased = { onFeeNotIncreased = {
uiState = stateFactory.getSendingStateUpdate(isSending = true) uiState.value = stateFactory.getSendingStateUpdate(isSending = true)
verifyAndSendTransaction() verifyAndSendTransaction()
}, },
) )
}, },
ifLeft = { ifLeft = {
uiState = stateFactory.getSendingStateUpdate(isSending = false) uiState.value = stateFactory.getSendingStateUpdate(isSending = false)
eventStateFactory.getFeeUnreachableErrorState( eventStateFactory.getFeeUnreachableErrorState(
onConsume = { uiState = eventStateFactory.onConsumeEventState() }, onConsume = { uiState.value = eventStateFactory.onConsumeEventState() },
) )
}, },
) )
uiState = if (feeUpdatedState != null) { uiState.value = if (feeUpdatedState != null) {
feeUpdatedState feeUpdatedState
} else { } else {
uiState = stateFactory.getSendingStateUpdate(isSending = false) uiState.value = stateFactory.getSendingStateUpdate(isSending = false)
eventStateFactory.getFeeUnreachableErrorState( eventStateFactory.getFeeUnreachableErrorState(
onConsume = { uiState = eventStateFactory.onConsumeEventState() }, onConsume = { uiState.value = eventStateFactory.onConsumeEventState() },
) )
} }
} }
@ -976,12 +979,12 @@ internal class SendViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
neverShowTapHelpUseCase() neverShowTapHelpUseCase()
} }
uiState = stateFactory.getHiddenTapHelpState() uiState.value = stateFactory.getHiddenTapHelpState()
} }
private fun showErrorAlert() { private fun showErrorAlert() {
uiState = eventStateFactory.getGenericErrorState( uiState.value = eventStateFactory.getGenericErrorState(
onConsume = { uiState = eventStateFactory.onConsumeEventState() }, onConsume = { uiState.value = eventStateFactory.onConsumeEventState() },
) )
} }
// endregion // endregion

View file

@ -45,6 +45,7 @@ class SwapPairInfoConverter : Converter<SwapPairsWithProviders, PairsWithProvide
imageLarge = exchangeProvider.imageLargeUrl, imageLarge = exchangeProvider.imageLargeUrl,
termsOfUse = exchangeProvider.termsOfUse, termsOfUse = exchangeProvider.termsOfUse,
privacyPolicy = exchangeProvider.privacyPolicy, privacyPolicy = exchangeProvider.privacyPolicy,
isRecommended = exchangeProvider.isRecommended,
) )
} }
@ -61,6 +62,7 @@ class SwapPairInfoConverter : Converter<SwapPairsWithProviders, PairsWithProvide
imageLarge = additionalProvider.imageLargeUrl, imageLarge = additionalProvider.imageLargeUrl,
termsOfUse = additionalProvider.termsOfUse, termsOfUse = additionalProvider.termsOfUse,
privacyPolicy = additionalProvider.privacyPolicy, privacyPolicy = additionalProvider.privacyPolicy,
isRecommended = additionalProvider.isRecommended,
) )
} }
@ -68,6 +70,7 @@ class SwapPairInfoConverter : Converter<SwapPairsWithProviders, PairsWithProvide
return when (type) { return when (type) {
ExchangeProviderType.DEX -> ExchangeProviderTypeDomain.DEX ExchangeProviderType.DEX -> ExchangeProviderTypeDomain.DEX
ExchangeProviderType.CEX -> ExchangeProviderTypeDomain.CEX ExchangeProviderType.CEX -> ExchangeProviderTypeDomain.CEX
ExchangeProviderType.DEX_BRIDGE -> ExchangeProviderTypeDomain.DEX_BRIDGE
} }
} }
} }

View file

@ -11,6 +11,7 @@ data class ExchangeStatusModel(
enum class ExchangeStatus { enum class ExchangeStatus {
New, New,
Waiting, Waiting,
WaitingTxHash,
Confirming, Confirming,
Verifying, Verifying,
Exchanging, Exchanging,
@ -19,4 +20,5 @@ enum class ExchangeStatus {
Finished, Finished,
Refunded, Refunded,
Cancelled, Cancelled,
Unknown,
} }

View file

@ -26,6 +26,7 @@ data class SavedSwapTransactionListModelInner(
val transactions: List<SavedSwapTransactionModel>, val transactions: List<SavedSwapTransactionModel>,
) )
// TODO refactor to use separate models to store
data class SavedSwapTransactionModel( data class SavedSwapTransactionModel(
val txId: String, val txId: String,
val timestamp: Long, val timestamp: Long,

View file

@ -25,6 +25,10 @@ data class CryptoCurrencySwapInfo(
* *
* @property providerId provider id * @property providerId provider id
* @property rateTypes supported rate types * @property rateTypes supported rate types
* @property isRecommended flag that indicates if this provider is recommended
*
* Uses to store transaction data in datastore, when extends - should always add default value
* to support backward compatibility
*/ */
data class SwapProvider( data class SwapProvider(
val providerId: String, val providerId: String,
@ -34,11 +38,13 @@ data class SwapProvider(
val imageLarge: String, val imageLarge: String,
val termsOfUse: String?, val termsOfUse: String?,
val privacyPolicy: String?, val privacyPolicy: String?,
val isRecommended: Boolean = false,
) )
enum class ExchangeProviderType { enum class ExchangeProviderType(val providerName: String) {
DEX, DEX("DEX"),
CEX, CEX("CEX"),
DEX_BRIDGE("DEX/Bridge"),
} }
/** /**

View file

@ -14,6 +14,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.appcurrency.extenstions.unwrap
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.demo.DemoConfig import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase
import com.tangem.domain.tokens.model.* import com.tangem.domain.tokens.model.*
import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.model.FeePaidCurrency
@ -60,6 +61,7 @@ internal class SwapInteractorImpl @Inject constructor(
private val sendTransactionUseCase: SendTransactionUseCase, private val sendTransactionUseCase: SendTransactionUseCase,
private val createTransactionUseCase: CreateTransactionUseCase, private val createTransactionUseCase: CreateTransactionUseCase,
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val quotesRepository: QuotesRepository, private val quotesRepository: QuotesRepository,
private val swapTransactionRepository: SwapTransactionRepository, private val swapTransactionRepository: SwapTransactionRepository,
private val currencyChecksRepository: CurrencyChecksRepository, private val currencyChecksRepository: CurrencyChecksRepository,
@ -275,7 +277,7 @@ internal class SwapInteractorImpl @Inject constructor(
val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null) val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null)
val networkId = fromToken.currency.network.backendId val networkId = fromToken.currency.network.backendId
when (provider.type) { when (provider.type) {
ExchangeProviderType.DEX -> { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
manageDex( manageDex(
networkId = networkId, networkId = networkId,
fromToken = fromToken, fromToken = fromToken,
@ -349,7 +351,7 @@ internal class SwapInteractorImpl @Inject constructor(
) )
} else { } else {
provider to getQuotesState( provider to getQuotesState(
exchangeProviderType = ExchangeProviderType.DEX, exchangeProviderType = provider.type,
quoteDataModel = quotes, quoteDataModel = quotes,
amount = amount, amount = amount,
fromToken = fromToken, fromToken = fromToken,
@ -420,13 +422,17 @@ internal class SwapInteractorImpl @Inject constructor(
fromToken.network.backendId, fromToken.network.backendId,
fromToken.network.derivationPath.value, fromToken.network.derivationPath.value,
) ?: ProxyAmount.empty() ) ?: ProxyAmount.empty()
// ignore if amount is bigger than balance
if (amount.value > nativeBalance.value) {
return
}
val fee = when (txFee) { val fee = when (txFee) {
TxFeeState.Empty -> BigDecimal.ZERO TxFeeState.Empty -> BigDecimal.ZERO
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue
is TxFeeState.SingleFeeState -> txFee.fee.feeValue is TxFeeState.SingleFeeState -> txFee.fee.feeValue
} }
val minAvailableAmount = nativeBalance.value - existentialDeposit - fee val minAvailableAmount = nativeBalance.value - existentialDeposit - fee
if (nativeBalance.value.minus(amount.value) < existentialDeposit) { if (nativeBalance.value.minus(amount.value + fee) < existentialDeposit) {
warnings.add(Warning.ExistentialDepositWarning(existentialDeposit, minAvailableAmount)) warnings.add(Warning.ExistentialDepositWarning(existentialDeposit, minAvailableAmount))
} }
} }
@ -568,6 +574,9 @@ internal class SwapInteractorImpl @Inject constructor(
includeFeeInAmount: IncludeFeeInAmount, includeFeeInAmount: IncludeFeeInAmount,
fee: TxFee, fee: TxFee,
): SwapTransactionState { ): SwapTransactionState {
val cardId = getSelectedWallet()?.scanResponse?.card?.cardId ?: return SwapTransactionState.UnknownError
if (isDemoCardUseCase(cardId)) return SwapTransactionState.DemoMode
return when (swapProvider.type) { return when (swapProvider.type) {
ExchangeProviderType.CEX -> { ExchangeProviderType.CEX -> {
val amountDecimal = toBigDecimalOrNull(amountToSwap) val amountDecimal = toBigDecimalOrNull(amountToSwap)
@ -586,8 +595,9 @@ internal class SwapInteractorImpl @Inject constructor(
userWalletId = requireNotNull(getSelectedWallet()).walletId, userWalletId = requireNotNull(getSelectedWallet()).walletId,
) )
} }
ExchangeProviderType.DEX -> { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
onSwapDex( onSwapDex(
provider = swapProvider,
networkId = currencyToSend.currency.network.backendId, networkId = currencyToSend.currency.network.backendId,
swapData = requireNotNull(swapData), swapData = requireNotNull(swapData),
currencyToSendStatus = currencyToSend, currencyToSendStatus = currencyToSend,
@ -639,6 +649,7 @@ internal class SwapInteractorImpl @Inject constructor(
} }
private suspend fun onSwapDex( private suspend fun onSwapDex(
provider: SwapProvider,
networkId: String, networkId: String,
swapData: SwapDataModel, swapData: SwapDataModel,
currencyToSendStatus: CryptoCurrencyStatus, currencyToSendStatus: CryptoCurrencyStatus,
@ -650,7 +661,8 @@ internal class SwapInteractorImpl @Inject constructor(
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals)
val derivationPath = currencyToSendStatus.currency.network.derivationPath.value val derivationPath = currencyToSendStatus.currency.network.derivationPath.value
val dataToSign = (swapData.transaction as ExpressTransactionModel.DEX).txData val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX
val dataToSign = dexTransaction.txData
val txData = createTransactionUseCase( val txData = createTransactionUseCase(
amount = amount.value.convertToAmount(currencyToSendStatus.currency), amount = amount.value.convertToAmount(currencyToSendStatus.currency),
fee = getFeeForTransaction( fee = getFeeForTransaction(
@ -684,6 +696,17 @@ internal class SwapInteractorImpl @Inject constructor(
txHash = txHash, txHash = txHash,
payInExtraId = swapData.transaction.txExtraId, payInExtraId = swapData.transaction.txExtraId,
) )
if (provider.type == ExchangeProviderType.DEX_BRIDGE) {
val timestamp = System.currentTimeMillis()
storeSwapTransaction(
currencyToSend = currencyToSendStatus,
currencyToGet = currencyToGetStatus,
amount = amount,
swapProvider = provider,
swapDataModel = swapData,
timestamp = timestamp,
)
}
storeLastCryptoCurrencyId(currencyToGetStatus.currency) storeLastCryptoCurrencyId(currencyToGetStatus.currency)
SwapTransactionState.TxSent( SwapTransactionState.TxSent(
fromAmount = amountFormatter.formatSwapAmountToUI( fromAmount = amountFormatter.formatSwapAmountToUI(
@ -700,16 +723,7 @@ internal class SwapInteractorImpl @Inject constructor(
timestamp = System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
) )
}, },
ifLeft = { ifLeft = { handleSendTxError(it) },
when (it) {
SendTransactionError.UserCancelledError -> SwapTransactionState.UserCancelled
is SendTransactionError.BlockchainSdkError -> SwapTransactionState.BlockchainError
is SendTransactionError.TangemSdkError -> SwapTransactionState.TangemSdkError
is SendTransactionError.NetworkError -> SwapTransactionState.NetworkError
is SendTransactionError.DemoCardError -> SwapTransactionState.DemoMode
else -> SwapTransactionState.UnknownError
}
},
) )
} }
@ -781,14 +795,7 @@ internal class SwapInteractorImpl @Inject constructor(
val derivationPath = currencyToSend.currency.network.derivationPath.value val derivationPath = currencyToSend.currency.network.derivationPath.value
return result.fold( return result.fold(
ifLeft = { ifLeft = {
when (it) { handleSendTxError(it)
SendTransactionError.UserCancelledError -> SwapTransactionState.UserCancelled
is SendTransactionError.BlockchainSdkError -> SwapTransactionState.BlockchainError
is SendTransactionError.TangemSdkError -> SwapTransactionState.TangemSdkError
is SendTransactionError.NetworkError -> SwapTransactionState.NetworkError
is SendTransactionError.DemoCardError -> SwapTransactionState.DemoMode
else -> SwapTransactionState.UnknownError
}
}, },
ifRight = { txHash -> ifRight = { txHash ->
repository.exchangeSent( repository.exchangeSent(
@ -834,6 +841,17 @@ internal class SwapInteractorImpl @Inject constructor(
) )
} }
private fun handleSendTxError(txError: SendTransactionError?): SwapTransactionState {
return when (txError) {
SendTransactionError.UserCancelledError -> SwapTransactionState.UserCancelled
is SendTransactionError.BlockchainSdkError -> SwapTransactionState.BlockchainError
is SendTransactionError.TangemSdkError -> SwapTransactionState.TangemSdkError
is SendTransactionError.NetworkError -> SwapTransactionState.NetworkError
is SendTransactionError.DemoCardError -> SwapTransactionState.DemoMode
else -> SwapTransactionState.UnknownError
}
}
private fun getFeeForTransaction(fee: TxFee, blockchain: Blockchain): Fee { private fun getFeeForTransaction(fee: TxFee, blockchain: Blockchain): Fee {
val feeAmountValue = fee.feeValue val feeAmountValue = fee.feeValue
val feeAmount = Amount( val feeAmount = Amount(
@ -882,11 +900,12 @@ internal class SwapInteractorImpl @Inject constructor(
swapProvider: SwapProvider, swapProvider: SwapProvider,
swapDataModel: SwapDataModel, swapDataModel: SwapDataModel,
timestamp: Long, timestamp: Long,
txExternalUrl: String, txExternalUrl: String? = null,
txExternalId: String, txExternalId: String? = null,
) { ) {
val selectedWallet = getSelectedWallet() ?: return
swapTransactionRepository.storeTransaction( swapTransactionRepository.storeTransaction(
userWalletId = UserWalletId(userWalletManager.getWalletId()), userWalletId = selectedWallet.walletId,
fromCryptoCurrency = currencyToSend.currency, fromCryptoCurrency = currencyToSend.currency,
toCryptoCurrency = currencyToGet.currency, toCryptoCurrency = currencyToGet.currency,
transaction = SavedSwapTransactionModel( transaction = SavedSwapTransactionModel(
@ -1072,7 +1091,7 @@ internal class SwapInteractorImpl @Inject constructor(
) )
when (exchangeProviderType) { when (exchangeProviderType) {
ExchangeProviderType.DEX -> { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
val state = updatePermissionState( val state = updatePermissionState(
networkId = networkId, networkId = networkId,
fromTokenStatus = fromToken, fromTokenStatus = fromToken,
@ -1407,19 +1426,25 @@ internal class SwapInteractorImpl @Inject constructor(
swapAmount = swapAmount, swapAmount = swapAmount,
spenderAddress = requireNotNull(spenderAddress) { "Spender address is null" }, spenderAddress = requireNotNull(spenderAddress) { "Spender address is null" },
) )
val feeData = try { val userWallet = getSelectedWallet()
transactionManager.getFee( val cardId = userWallet?.scanResponse?.card?.cardId
networkId = networkId, val feeData = if (cardId != null && isDemoCardUseCase(cardId)) {
amountToSend = BigDecimal.ZERO, getDemoFees(fromTokenStatus.currency)
currencyToSend = swapCurrencyConverter.convert(repository.getNativeTokenForNetwork(networkId)), } else {
destinationAddress = fromToken.getContractAddress(), try {
increaseBy = INCREASE_GAS_LIMIT_BY, transactionManager.getFee(
data = transactionData, networkId = networkId,
derivationPath = derivationPath, amountToSend = BigDecimal.ZERO,
) currencyToSend = swapCurrencyConverter.convert(repository.getNativeTokenForNetwork(networkId)),
} catch (e: Exception) { destinationAddress = fromToken.getContractAddress(),
Timber.e(e, "Failed to get fee") increaseBy = INCREASE_GAS_LIMIT_BY,
null data = transactionData,
derivationPath = derivationPath,
)
} catch (e: Exception) {
Timber.e(e, "Failed to get fee")
null
}
} }
val feeState = feeData?.let { val feeState = feeData?.let {
when (feeData) { when (feeData) {
@ -1803,10 +1828,37 @@ internal class SwapInteractorImpl @Inject constructor(
.toMap() .toMap()
} }
private fun getDemoFees(cryptoCurrency: CryptoCurrency): ProxyFees.MultipleFees {
val demoFee = ProxyAmount(
currencySymbol = cryptoCurrency.symbol,
value = minDemoFee,
decimals = cryptoCurrency.decimals,
)
return ProxyFees.MultipleFees(
minFee = ProxyFee.Common(
gasLimit = 1.toBigInteger(),
fee = demoFee,
),
normalFee = ProxyFee.Common(
gasLimit = 1.toBigInteger(),
fee = demoFee.copy(value = normalDemoFee),
),
priorityFee = ProxyFee.Common(
gasLimit = 1.toBigInteger(),
fee = demoFee.copy(value = priorityDemoFee),
),
)
}
companion object { companion object {
@Suppress("UnusedPrivateMember") @Suppress("UnusedPrivateMember")
private const val INCREASE_GAS_LIMIT_BY = 112 // 12% private const val INCREASE_GAS_LIMIT_BY = 112 // 12%
private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5% private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5%
private const val INFINITY_SYMBOL = "" private const val INFINITY_SYMBOL = ""
private val minDemoFee = "0.0001".toBigDecimal()
private val normalDemoFee = "0.0002".toBigDecimal()
private val priorityDemoFee = "0.0003".toBigDecimal()
} }
} }

View file

@ -43,6 +43,7 @@ class SwapDomainModule {
@SwapScope sendTransactionUseCase: SendTransactionUseCase, @SwapScope sendTransactionUseCase: SendTransactionUseCase,
@SwapScope createTransactionUseCase: CreateTransactionUseCase, @SwapScope createTransactionUseCase: CreateTransactionUseCase,
createTransactionDataExtrasUseCase: CreateTransactionDataExtrasUseCase, createTransactionDataExtrasUseCase: CreateTransactionDataExtrasUseCase,
isDemoCardUseCase: IsDemoCardUseCase,
quotesRepository: QuotesRepository, quotesRepository: QuotesRepository,
swapTransactionRepository: SwapTransactionRepository, swapTransactionRepository: SwapTransactionRepository,
appCurrencyRepository: AppCurrencyRepository, appCurrencyRepository: AppCurrencyRepository,
@ -62,6 +63,7 @@ class SwapDomainModule {
sendTransactionUseCase = sendTransactionUseCase, sendTransactionUseCase = sendTransactionUseCase,
createTransactionUseCase = createTransactionUseCase, createTransactionUseCase = createTransactionUseCase,
createTransactionExtrasUseCase = createTransactionDataExtrasUseCase, createTransactionExtrasUseCase = createTransactionDataExtrasUseCase,
isDemoCardUseCase = isDemoCardUseCase,
quotesRepository = quotesRepository, quotesRepository = quotesRepository,
swapTransactionRepository = swapTransactionRepository, swapTransactionRepository = swapTransactionRepository,
appCurrencyRepository = appCurrencyRepository, appCurrencyRepository = appCurrencyRepository,

View file

@ -6,6 +6,7 @@ import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.FeeType import com.tangem.feature.swap.domain.models.ui.FeeType
private const val SWAP_CATEGORY = "Swap" private const val SWAP_CATEGORY = "Swap"
private const val PROMO_CATEGORY = "Promo"
sealed class SwapEvents( sealed class SwapEvents(
event: String, event: String,
@ -17,7 +18,7 @@ sealed class SwapEvents(
params = mapOf("Token" to token), params = mapOf("Token" to token),
) )
object SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked") data object SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")
data class ChooseTokenScreenOpened(val availableTokens: Boolean) : SwapEvents( data class ChooseTokenScreenOpened(val availableTokens: Boolean) : SwapEvents(
event = "Choose Token Screen Opened", event = "Choose Token Screen Opened",
@ -37,7 +38,7 @@ sealed class SwapEvents(
params = mapOf("Send Token" to sendToken, "Receive Token" to receiveToken), params = mapOf("Send Token" to sendToken, "Receive Token" to receiveToken),
) )
object ButtonGivePermissionClicked : SwapEvents(event = "Button - Give permission") data object ButtonGivePermissionClicked : SwapEvents(event = "Button - Give permission")
data class ButtonPermissionApproveClicked( data class ButtonPermissionApproveClicked(
val sendToken: String, val sendToken: String,
@ -52,9 +53,9 @@ sealed class SwapEvents(
), ),
) )
object ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel") data object ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel")
object ButtonSwipeClicked : SwapEvents(event = "Button - Swipe") data object ButtonSwipeClicked : SwapEvents(event = "Button - Swipe")
data class SwapInProgressScreen( data class SwapInProgressScreen(
val provider: SwapProvider, val provider: SwapProvider,
@ -71,7 +72,7 @@ sealed class SwapEvents(
), ),
) )
object ProviderClicked : SwapEvents("Provider Clicked") data object ProviderClicked : SwapEvents("Provider Clicked")
data class ProviderChosen(val provider: SwapProvider) : SwapEvents( data class ProviderChosen(val provider: SwapProvider) : SwapEvents(
event = "Provider Chosen", event = "Provider Chosen",
@ -88,7 +89,7 @@ sealed class SwapEvents(
params = mapOf("Token" to token), params = mapOf("Token" to token),
) )
object NoticeNoAvailableTokensToSwap : SwapEvents("Notice - No Available Tokens To Swap") data object NoticeNoAvailableTokensToSwap : SwapEvents("Notice - No Available Tokens To Swap")
data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents( data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents(
event = "Notice - Not Enough Fee", event = "Notice - Not Enough Fee",
@ -110,4 +111,20 @@ sealed class SwapEvents(
"Error code" to errorCode.toString(), "Error code" to errorCode.toString(),
), ),
) )
// region Promo activity
data class ChangellyActivity(
val promoState: PromoState,
) : AnalyticsEvent(
category = PROMO_CATEGORY,
event = "Changelly Activity",
params = mapOf(
"State" to promoState.name,
),
) {
sealed class PromoState(val name: String) {
data object Native : PromoState("Native")
data object Recommended : PromoState("Recommended")
}
}
} }

View file

@ -44,9 +44,10 @@ sealed class ProviderState {
@Immutable @Immutable
sealed class AdditionalBadge { sealed class AdditionalBadge {
object BestTrade : AdditionalBadge() data object BestTrade : AdditionalBadge()
object Empty : AdditionalBadge() data object Empty : AdditionalBadge()
object PermissionRequired : AdditionalBadge() data object PermissionRequired : AdditionalBadge()
data object Recommended : AdditionalBadge()
} }
@Immutable @Immutable
@ -54,6 +55,7 @@ sealed class ProviderState {
NONE, CLICK, SELECT NONE, CLICK, SELECT
} }
// Prefix will be disabled in 5.12 but mechanism is still implemented
@Immutable @Immutable
enum class PrefixType { enum class PrefixType {
NONE, PROVIDED_BY NONE, PROVIDED_BY

View file

@ -87,6 +87,7 @@ fun ProviderItem(state: ProviderState, modifier: Modifier = Modifier, isSelected
} }
} }
// Be careful when will replace with InputRowBestRate, because RecommendedBadge was added
@Deprecated("Replace with InputRowBestRate") @Deprecated("Replace with InputRowBestRate")
@Suppress("LongMethod") @Suppress("LongMethod")
@Composable @Composable
@ -142,16 +143,12 @@ private fun ProviderContentState(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
) )
} }
val badgeModifier = Modifier.padding(start = TangemTheme.dimens.spacing4)
when (state.additionalBadge) { when (state.additionalBadge) {
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem( ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(badgeModifier)
Modifier.padding(start = TangemTheme.dimens.spacing4), ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(badgeModifier)
) ProviderState.AdditionalBadge.Recommended -> RecommendedItem(badgeModifier)
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem( ProviderState.AdditionalBadge.Empty -> Unit
Modifier.padding(start = TangemTheme.dimens.spacing4),
)
ProviderState.AdditionalBadge.Empty -> {
// no-op
}
} }
} }
Row( Row(
@ -385,6 +382,23 @@ private fun PermissionBadgeItem(modifier: Modifier = Modifier) {
} }
} }
@Composable
private fun RecommendedItem(modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = TangemTheme.shapes.roundedCornersLarge,
),
) {
Text(
text = stringResource(id = R.string.express_provider_recommended),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
)
}
}
// region Preview // region Preview
@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)

View file

@ -677,6 +677,7 @@ internal class StateBuilder(
return when (dataError) { return when (dataError) {
is DataError.ExchangeTooSmallAmountError -> { is DataError.ExchangeTooSmallAmountError -> {
swapProvider.convertToAvailableFromProviderState( swapProvider.convertToAvailableFromProviderState(
swapProvider = swapProvider,
alertText = resourceReference( alertText = resourceReference(
R.string.express_provider_min_amount, R.string.express_provider_min_amount,
wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)), wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)),
@ -687,6 +688,7 @@ internal class StateBuilder(
} }
is DataError.ExchangeTooBigAmountError -> { is DataError.ExchangeTooBigAmountError -> {
swapProvider.convertToAvailableFromProviderState( swapProvider.convertToAvailableFromProviderState(
swapProvider = swapProvider,
alertText = resourceReference( alertText = resourceReference(
R.string.express_provider_max_amount, R.string.express_provider_max_amount,
wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)), wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)),
@ -1005,13 +1007,15 @@ internal class StateBuilder(
val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount)) val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount))
val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount)) val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount))
val shouldShowStatus = providerState.type == ExchangeProviderType.CEX.providerName ||
providerState.type == ExchangeProviderType.DEX_BRIDGE.providerName
return uiState.copy( return uiState.copy(
successState = SwapSuccessStateHolder( successState = SwapSuccessStateHolder(
timestamp = swapTransactionState.timestamp, timestamp = swapTransactionState.timestamp,
txUrl = txUrl, txUrl = txUrl,
providerName = stringReference(providerState.name), providerName = stringReference(providerState.name),
providerType = stringReference(providerState.type), providerType = stringReference(providerState.type),
showStatusButton = providerState.type == ExchangeProviderType.CEX.name, showStatusButton = shouldShowStatus,
providerIcon = providerState.iconUrl, providerIcon = providerState.iconUrl,
rate = providerState.subtitle, rate = providerState.subtitle,
fee = stringReference("${fee.feeCryptoFormatted} (${fee.feeFiatFormatted})"), fee = stringReference("${fee.feeCryptoFormatted} (${fee.feeFiatFormatted})"),
@ -1093,7 +1097,7 @@ internal class StateBuilder(
): SwapStateHolder { ): SwapStateHolder {
val message = when (providerType) { val message = when (providerType) {
ExchangeProviderType.CEX -> resourceReference(R.string.swapping_alert_cex_description, wrappedList(token)) ExchangeProviderType.CEX -> resourceReference(R.string.swapping_alert_cex_description, wrappedList(token))
ExchangeProviderType.DEX -> { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
val refs = buildList { val refs = buildList {
if (isPriceImpact) { if (isPriceImpact) {
add(resourceReference(R.string.swapping_high_price_impact_description)) add(resourceReference(R.string.swapping_high_price_impact_description))
@ -1540,7 +1544,9 @@ internal class StateBuilder(
val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol
val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol
val rateString = "1 $fromCurrencySymbol$rate $toCurrencySymbol" val rateString = "1 $fromCurrencySymbol$rate $toCurrencySymbol"
val badge = if (isNeedBestRateBadge && isBestRate) { val badge = if (isRecommended) {
ProviderState.AdditionalBadge.Recommended
} else if (isNeedBestRateBadge && isBestRate) {
ProviderState.AdditionalBadge.BestTrade ProviderState.AdditionalBadge.BestTrade
} else { } else {
ProviderState.AdditionalBadge.Empty ProviderState.AdditionalBadge.Empty
@ -1549,12 +1555,12 @@ internal class StateBuilder(
id = this.providerId, id = this.providerId,
name = this.name, name = this.name,
iconUrl = this.imageLarge, iconUrl = this.imageLarge,
type = this.type.toString(), type = this.type.providerName,
subtitle = stringReference(rateString), subtitle = stringReference(rateString),
additionalBadge = badge, additionalBadge = badge,
selectionType = selectionType, selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty, percentLowerThenBest = PercentDifference.Empty,
namePrefix = ProviderState.PrefixType.PROVIDED_BY, namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick, onProviderClick = onProviderClick,
) )
} }
@ -1569,6 +1575,8 @@ internal class StateBuilder(
val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.cryptoCurrencyStatus.currency) val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.cryptoCurrencyStatus.currency)
val additionalBadge = if (state.permissionState is PermissionDataState.PermissionReadyForRequest) { val additionalBadge = if (state.permissionState is PermissionDataState.PermissionReadyForRequest) {
ProviderState.AdditionalBadge.PermissionRequired ProviderState.AdditionalBadge.PermissionRequired
} else if (isRecommended) {
ProviderState.AdditionalBadge.Recommended
} else { } else {
ProviderState.AdditionalBadge.Empty ProviderState.AdditionalBadge.Empty
} }
@ -1576,7 +1584,7 @@ internal class StateBuilder(
id = this.providerId, id = this.providerId,
name = this.name, name = this.name,
iconUrl = this.imageLarge, iconUrl = this.imageLarge,
type = this.type.toString(), type = this.type.providerName,
subtitle = stringReference(rateString), subtitle = stringReference(rateString),
additionalBadge = additionalBadge, additionalBadge = additionalBadge,
selectionType = selectionType, selectionType = selectionType,
@ -1605,24 +1613,26 @@ internal class StateBuilder(
} }
private fun SwapProvider.convertToAvailableFromProviderState( private fun SwapProvider.convertToAvailableFromProviderState(
swapProvider: SwapProvider,
alertText: TextReference, alertText: TextReference,
selectionType: ProviderState.SelectionType, selectionType: ProviderState.SelectionType,
onProviderClick: (String) -> Unit, onProviderClick: (String) -> Unit,
): ProviderState { ): ProviderState {
val additionalBadge = if (swapProvider.isRecommended) {
ProviderState.AdditionalBadge.Recommended
} else {
ProviderState.AdditionalBadge.Empty
}
return ProviderState.Content( return ProviderState.Content(
id = this.providerId, id = this.providerId,
name = this.name, name = this.name,
iconUrl = this.imageLarge, iconUrl = this.imageLarge,
type = this.type.toString(), type = this.type.providerName,
selectionType = selectionType, selectionType = selectionType,
subtitle = alertText, subtitle = alertText,
additionalBadge = ProviderState.AdditionalBadge.Empty, additionalBadge = additionalBadge,
percentLowerThenBest = PercentDifference.Empty, percentLowerThenBest = PercentDifference.Empty,
namePrefix = if (selectionType != ProviderState.SelectionType.SELECT) { namePrefix = ProviderState.PrefixType.NONE,
ProviderState.PrefixType.PROVIDED_BY
} else {
ProviderState.PrefixType.NONE
},
onProviderClick = onProviderClick, onProviderClick = onProviderClick,
) )
} }

View file

@ -154,7 +154,7 @@ private val state = SwapSuccessStateHolder(
txUrl = "https://www.google.com/#q=nam", txUrl = "https://www.google.com/#q=nam",
fee = TextReference.Str("1 000 DAI ~ 1 000 MATIC"), fee = TextReference.Str("1 000 DAI ~ 1 000 MATIC"),
providerName = TextReference.Str("1inch"), providerName = TextReference.Str("1inch"),
providerType = TextReference.Str(ExchangeProviderType.DEX.name), providerType = TextReference.Str(ExchangeProviderType.DEX.providerName),
showStatusButton = false, showStatusButton = false,
providerIcon = "", providerIcon = "",
fromTokenAmount = TextReference.Str("1 000 DAI"), fromTokenAmount = TextReference.Str("1 000 DAI"),

View file

@ -282,7 +282,10 @@ private fun Content(
if (amountEquivalent != null) { if (amountEquivalent != null) {
if (type is TransactionCardType.ReadOnly) { if (type is TransactionCardType.ReadOnly) {
Row(modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20)) { Row(
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
verticalAlignment = Alignment.CenterVertically,
) {
if (priceImpact is PriceImpact.Value) { if (priceImpact is PriceImpact.Value) {
Text( Text(
text = makePriceImpactBalanceWarning( text = makePriceImpactBalanceWarning(
@ -291,8 +294,6 @@ private fun Content(
), ),
color = TangemTheme.colors.text.tertiary, color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2, style = TangemTheme.typography.body2,
modifier = Modifier
.align(Alignment.CenterVertically),
) )
} else { } else {
AnimatedContent(targetState = amountEquivalent, label = "") { AnimatedContent(targetState = amountEquivalent, label = "") {
@ -312,7 +313,7 @@ private fun Content(
modifier = Modifier.size(size = TangemTheme.dimens.size20), modifier = Modifier.size(size = TangemTheme.dimens.size20),
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_alert_24), painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null, contentDescription = null,
tint = if (priceImpact is PriceImpact.Value) { tint = if (priceImpact is PriceImpact.Value) {
TangemTheme.colors.text.attention TangemTheme.colors.text.attention

View file

@ -435,8 +435,11 @@ internal class SwapViewModel @Inject constructor(
if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) { if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) {
currentSelected currentSelected
} else { } else {
findBestQuoteProvider(consideredProviders.getLastLoadedSuccessStates()) val successLoadedData = consideredProviders.getLastLoadedSuccessStates()
?: consideredProviders.keys.first() val recommendedProvider = successLoadedData.keys.firstOrNull { it.isRecommended }
val bestQuotesProvider = findBestQuoteProvider(successLoadedData)
triggerPromoProviderEvent(recommendedProvider, bestQuotesProvider)
recommendedProvider ?: bestQuotesProvider ?: consideredProviders.keys.first()
} }
} else { } else {
state.keys.first() state.keys.first()
@ -1181,11 +1184,28 @@ internal class SwapViewModel @Inject constructor(
} }
} }
private fun triggerPromoProviderEvent(recommendedProvider: SwapProvider?, bestQuotesProvider: SwapProvider?) {
// for now send event only for changelly
if (recommendedProvider == null ||
recommendedProvider.providerId != CHANGELLY_PROVIDER_ID ||
bestQuotesProvider == null
) {
return
}
val event = if (recommendedProvider.providerId == bestQuotesProvider.providerId) {
SwapEvents.ChangellyActivity(SwapEvents.ChangellyActivity.PromoState.Native)
} else {
SwapEvents.ChangellyActivity(SwapEvents.ChangellyActivity.PromoState.Recommended)
}
analyticsEventHandler.send(event = event)
}
private companion object { private companion object {
const val loggingTag = "SwapViewModel" const val loggingTag = "SwapViewModel"
const val INITIAL_AMOUNT = "" const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L const val UPDATE_DELAY = 10000L
const val DEBOUNCE_AMOUNT_DELAY = 1000L const val DEBOUNCE_AMOUNT_DELAY = 1000L
const val UPDATE_BALANCE_DELAY_MILLIS = 11000L const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
const val CHANGELLY_PROVIDER_ID = "changelly"
} }
} }

View file

@ -2,7 +2,9 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.models.analytics.TokenSwapPromoAnalyticsEvent
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
@ -35,12 +37,15 @@ internal class TokenDetailsNotificationsAnalyticsSender(
-> TokenDetailsAnalyticsEvent.Notice.NotEnoughFee( -> TokenDetailsAnalyticsEvent.Notice.NotEnoughFee(
currency = cryptoCurrency, currency = cryptoCurrency,
) )
is TokenDetailsNotification.SwapPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner(
programName = TokenSwapPromoAnalyticsEvent.ProgramName.OKX,
source = AnalyticsParam.ScreensSources.Token,
)
is TokenDetailsNotification.NetworksUnreachable, is TokenDetailsNotification.NetworksUnreachable,
is TokenDetailsNotification.ExistentialDeposit, is TokenDetailsNotification.ExistentialDeposit,
is TokenDetailsNotification.NetworksNoAccount, is TokenDetailsNotification.NetworksNoAccount,
is TokenDetailsNotification.TopUpWithoutReserve, is TokenDetailsNotification.TopUpWithoutReserve,
is TokenDetailsNotification.RentInfo, is TokenDetailsNotification.RentInfo,
is TokenDetailsNotification.SwapPromo,
is TokenDetailsNotification.NetworkShutdown, is TokenDetailsNotification.NetworkShutdown,
is TokenDetailsNotification.HederaAssociateWarning, is TokenDetailsNotification.HederaAssociateWarning,
is TokenDetailsNotification.KoinosMana, is TokenDetailsNotification.KoinosMana,

View file

@ -2,7 +2,10 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.componen
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.* import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.features.tokendetails.impl.R import com.tangem.features.tokendetails.impl.R
@ -45,16 +48,9 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
val onCloseClick: () -> Unit, val onCloseClick: () -> Unit,
) : TokenDetailsNotification( ) : TokenDetailsNotification(
config = NotificationConfig( config = NotificationConfig(
title = resourceReference( title = resourceReference(id = R.string.swap_promo_title),
id = R.string.token_swap_changelly_promotion_title, subtitle = resourceReference(id = R.string.swap_promo_text),
formatArgs = wrappedList("0%"), iconResId = R.drawable.img_okx_dex_logo,
),
subtitle = resourceReference(
id = R.string.token_swap_changelly_promotion_message,
formatArgs = wrappedList("0%", startDateTime.dayOfMonth, endDateTime.dayOfMonth),
),
iconResId = R.drawable.img_swap_promo,
backgroundResId = R.drawable.img_swap_promo_green_banner_background,
onCloseClick = onCloseClick, onCloseClick = onCloseClick,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(id = com.tangem.core.ui.R.string.token_swap_promotion_button), text = resourceReference(id = com.tangem.core.ui.R.string.token_swap_promotion_button),

View file

@ -162,6 +162,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
private fun getStatuses(status: ExchangeStatus?, hasFailed: Boolean = false): ImmutableList<ExchangeStatusState> { private fun getStatuses(status: ExchangeStatus?, hasFailed: Boolean = false): ImmutableList<ExchangeStatusState> {
if (status == null) return persistentListOf() if (status == null) return persistentListOf()
val isWaiting = status == ExchangeStatus.New || status == ExchangeStatus.Waiting val isWaiting = status == ExchangeStatus.New || status == ExchangeStatus.Waiting
val isWaitingTxHash = status == ExchangeStatus.WaitingTxHash
val isConfirming = status == ExchangeStatus.Confirming val isConfirming = status == ExchangeStatus.Confirming
val isVerifying = status == ExchangeStatus.Verifying val isVerifying = status == ExchangeStatus.Verifying
val isExchanging = status == ExchangeStatus.Exchanging val isExchanging = status == ExchangeStatus.Exchanging
@ -174,27 +175,35 @@ internal class TokenDetailsSwapTransactionsStateConverter(
val isExchangingDone = !isExchanging && isConfirmingDone val isExchangingDone = !isExchanging && isConfirmingDone
val isSendingDone = !isSending && !isVerifying && !isFailed && isExchangingDone val isSendingDone = !isSending && !isVerifying && !isFailed && isExchangingDone
return if (status == ExchangeStatus.Cancelled) { return buildList {
listOf(cancelledStep()) when {
} else { status == ExchangeStatus.Cancelled -> add(cancelledStep())
listOf( isWaitingTxHash -> add(waitTxStep())
waitStep(isWaiting, isWaitingDone), // ExchangeStatus.Unknown is temporary added for 1inch
confirmStep(isConfirming, isConfirmingDone), status == ExchangeStatus.Unknown -> add(unknownStateStep())
exchangeStep( else -> {
isExchanging = isExchanging, add(waitStep(isWaiting, isWaitingDone))
isExchangingDone = isExchangingDone, add(confirmStep(isConfirming, isConfirmingDone))
isRefunded = isRefunded, add(
hasFailed = hasFailed, exchangeStep(
isVerifying = isVerifying, isExchanging = isExchanging,
isFailed = isFailed, isExchangingDone = isExchangingDone,
), isRefunded = isRefunded,
sendStep( hasFailed = hasFailed,
isSending = isSending, isVerifying = isVerifying,
isSendingDone = isSendingDone, isFailed = isFailed,
isRefunded = isRefunded, ),
hasFailed = hasFailed, )
), add(
) sendStep(
isSending = isSending,
isSendingDone = isSendingDone,
isRefunded = isRefunded,
hasFailed = hasFailed,
),
)
}
}
}.toPersistentList() }.toPersistentList()
} }
@ -216,6 +225,20 @@ internal class TokenDetailsSwapTransactionsStateConverter(
isDone = isNewDone, isDone = isNewDone,
) )
private fun waitTxStep() = ExchangeStatusState(
status = ExchangeStatus.Verifying,
text = TextReference.Res(R.string.express_exchange_status_waiting_tx_hash),
isActive = false,
isDone = false,
)
private fun unknownStateStep() = ExchangeStatusState(
status = ExchangeStatus.Failed,
text = TextReference.Res(R.string.express_exchange_status_failed),
isActive = false,
isDone = true,
)
private fun confirmStep(isConfirming: Boolean, isConfirmingDone: Boolean) = ExchangeStatusState( private fun confirmStep(isConfirming: Boolean, isConfirmingDone: Boolean) = ExchangeStatusState(
status = ExchangeStatus.Confirming, status = ExchangeStatus.Confirming,
text = when { text = when {

View file

@ -31,7 +31,7 @@ import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBotto
import com.tangem.core.ui.components.marketprice.MarketPriceBlock import com.tangem.core.ui.components.marketprice.MarketPriceBlock
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationWithBackground import com.tangem.core.ui.components.notifications.OkxPromoNotification
import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.txHistoryItems import com.tangem.core.ui.components.transactions.txHistoryItems
import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.event.EventEffect
@ -116,7 +116,7 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) {
contentType = { it.config::class.java }, contentType = { it.config::class.java },
itemContent = { itemContent = {
if (it is TokenDetailsNotification.SwapPromo) { if (it is TokenDetailsNotification.SwapPromo) {
NotificationWithBackground( OkxPromoNotification(
config = it.config, config = it.config,
modifier = itemModifier.animateItemPlacement(), modifier = itemModifier.animateItemPlacement(),
) )

View file

@ -70,7 +70,7 @@ private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetC
SpacerH12() SpacerH12()
ExchangeProvider( ExchangeProvider(
providerName = TextReference.Str(config.provider.name), providerName = TextReference.Str(config.provider.name),
providerType = TextReference.Str(config.provider.type.name), providerType = TextReference.Str(config.provider.type.providerName),
providerTxId = config.txExternalId, providerTxId = config.txExternalId,
imageUrl = config.provider.imageLarge, imageUrl = config.provider.imageLarge,
) )

View file

@ -163,6 +163,7 @@ internal class ExchangeStatusFactory(
return when (status) { return when (status) {
ExchangeStatus.New, ExchangeStatus.New,
ExchangeStatus.Waiting, ExchangeStatus.Waiting,
ExchangeStatus.WaitingTxHash,
ExchangeStatus.Sending, ExchangeStatus.Sending,
ExchangeStatus.Confirming, ExchangeStatus.Confirming,
ExchangeStatus.Exchanging, ExchangeStatus.Exchanging,

View file

@ -8,6 +8,7 @@ import com.tangem.blockchain.common.address.AddressType
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.bundle.unbundle import com.tangem.common.routing.bundle.unbundle
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.deeplink.DeepLinksRegistry import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.deeplink.global.BuyCurrencyDeepLink import com.tangem.core.deeplink.global.BuyCurrencyDeepLink
import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.clipboard.ClipboardManager
@ -565,7 +566,13 @@ internal class TokenDetailsViewModel @Inject constructor(
ifRight = { it }, ifRight = { it },
) )
if (extendedKey.isNotBlank()) { if (extendedKey.isNotBlank()) {
router.share(extendedKey) hapticManager.vibrateMeduim()
clipboardManager.setText(text = extendedKey)
internalUiState.value = stateFactory.getStateAndTriggerEvent(
state = internalUiState.value,
errorMessage = resourceReference(R.string.wallet_notification_address_copied),
setUiState = { internalUiState.value = it },
)
} }
} }
} }
@ -737,14 +744,26 @@ internal class TokenDetailsViewModel @Inject constructor(
override fun onSwapPromoDismiss() { override fun onSwapPromoDismiss() {
viewModelScope.launch(dispatchers.main) { viewModelScope.launch(dispatchers.main) {
shouldShowSwapPromoTokenUseCase.neverToShow() shouldShowSwapPromoTokenUseCase.neverToShow()
analyticsEventsHandler.send(TokenSwapPromoAnalyticsEvent.Close) analyticsEventsHandler.send(
TokenSwapPromoAnalyticsEvent.PromotionBannerClicked(
source = AnalyticsParam.ScreensSources.Token,
programName = TokenSwapPromoAnalyticsEvent.ProgramName.OKX,
action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Closed,
),
)
} }
} }
override fun onSwapPromoClick() { override fun onSwapPromoClick() {
viewModelScope.launch(dispatchers.main) { viewModelScope.launch(dispatchers.main) {
shouldShowSwapPromoTokenUseCase.neverToShow() shouldShowSwapPromoTokenUseCase.neverToShow()
analyticsEventsHandler.send(TokenSwapPromoAnalyticsEvent.Exchange(cryptoCurrency.symbol)) analyticsEventsHandler.send(
TokenSwapPromoAnalyticsEvent.PromotionBannerClicked(
source = AnalyticsParam.ScreensSources.Token,
programName = TokenSwapPromoAnalyticsEvent.ProgramName.OKX,
action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Clicked,
),
)
} }
onSwapClick(ScenarioUnavailabilityReason.None) onSwapClick(ScenarioUnavailabilityReason.None)
} }

View file

@ -121,38 +121,4 @@ sealed class WalletScreenAnalyticsEvent {
data object DeleteWalletTapped : MainScreen(event = "Button - Delete Wallet Tapped") data object DeleteWalletTapped : MainScreen(event = "Button - Delete Wallet Tapped")
} }
sealed class Promotion(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Promotion", event = event, params = params) {
class NoticePromotionBanner(
source: AnalyticsParam.ScreensSources,
programName: String,
) : Promotion(
event = "Notice - Promotion Banner",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to programName,
),
)
class PromotionBannerClicked(
source: AnalyticsParam.ScreensSources,
programName: String,
action: BannerAction,
) : Promotion(
event = "Promo Banner Clicked",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to programName,
"Action" to action.action,
),
) {
sealed class BannerAction(val action: String) {
data object Clicked : BannerAction(action = "Clicked")
data object Closed : BannerAction(action = "Closed")
}
}
}
} }

View file

@ -3,7 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.analytics.utils
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.domain.tokens.models.analytics.TokenSwapPromoAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
@ -46,16 +46,19 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.Informational.MissingAddresses -> MainScreen.MissingAddresses is WalletNotification.Informational.MissingAddresses -> MainScreen.MissingAddresses
is WalletNotification.RateApp -> MainScreen.HowDoYouLikeTangem is WalletNotification.RateApp -> MainScreen.HowDoYouLikeTangem
is WalletNotification.Critical.BackupError -> MainScreen.BackupError is WalletNotification.Critical.BackupError -> MainScreen.BackupError
is WalletNotification.TravalaPromo -> WalletScreenAnalyticsEvent.Promotion.NoticePromotionBanner( is WalletNotification.TravalaPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner(
source = AnalyticsParam.ScreensSources.Main, source = AnalyticsParam.ScreensSources.Main,
programName = "Travala", programName = TokenSwapPromoAnalyticsEvent.ProgramName.Travala,
)
is WalletNotification.SwapPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner(
source = AnalyticsParam.ScreensSources.Main,
programName = TokenSwapPromoAnalyticsEvent.ProgramName.OKX,
) )
is WalletNotification.UnlockWallets -> null // See [SelectedWalletAnalyticsSender] is WalletNotification.UnlockWallets -> null // See [SelectedWalletAnalyticsSender]
is WalletNotification.Informational.NoAccount, is WalletNotification.Informational.NoAccount,
is WalletNotification.Warning.LowSignatures, is WalletNotification.Warning.LowSignatures,
is WalletNotification.Warning.SomeNetworksUnreachable, is WalletNotification.Warning.SomeNetworksUnreachable,
is WalletNotification.Warning.NetworksUnreachable, is WalletNotification.Warning.NetworksUnreachable,
is WalletNotification.SwapPromo,
-> null -> null
} }
} }

View file

@ -6,7 +6,7 @@ import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.promo.PromoBanner import com.tangem.domain.promo.PromoBanner
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
import com.tangem.domain.settings.ShouldShowTravalaPromoWalletUseCase import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase
import com.tangem.domain.tokens.GetTokenListUseCase import com.tangem.domain.tokens.GetTokenListUseCase
import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
@ -34,7 +34,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val getTokenListUseCase: GetTokenListUseCase, private val getTokenListUseCase: GetTokenListUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
private val shouldShowTravalaPromoWalletUseCase: ShouldShowTravalaPromoWalletUseCase, private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase,
private val promoRepository: PromoRepository, private val promoRepository: PromoRepository,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase, private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator, private val backupValidator: BackupValidator,
@ -45,18 +45,18 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotification>> { fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotification>> {
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
val travalaPromoFlow = flow { emit(promoRepository.getTravalaPromoBanner()) } val promoFlow = flow { emit(promoRepository.getOkxPromoBanner()) }
return combine( return combine(
flow = getTokenListUseCase.launch(userWallet.walletId).conflate(), flow = getTokenListUseCase.launch(userWallet.walletId).conflate(),
flow2 = isReadyToShowRateAppUseCase().conflate(), flow2 = isReadyToShowRateAppUseCase().conflate(),
flow3 = isNeedToBackupUseCase(userWallet.walletId).conflate(), flow3 = isNeedToBackupUseCase(userWallet.walletId).conflate(),
flow4 = shouldShowTravalaPromoWalletUseCase().conflate(), flow4 = shouldShowSwapPromoWalletUseCase().conflate(),
flow5 = travalaPromoFlow.conflate(), flow5 = promoFlow.conflate(),
) { maybeTokenList, isReadyToShowRating, isNeedToBackup, shouldShowTravalaPromo, promoBanner -> ) { maybeTokenList, isReadyToShowRating, isNeedToBackup, shouldShowPromo, promoBanner ->
readyForRateAppNotification = true readyForRateAppNotification = true
buildList { buildList {
addTravalaPromoNotification(shouldShowTravalaPromo, promoBanner, clickIntents) addSwapPromoNotification(shouldShowPromo, promoBanner, clickIntents)
addCriticalNotifications(userWallet) addCriticalNotifications(userWallet)
@ -69,18 +69,16 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
} }
} }
private fun MutableList<WalletNotification>.addTravalaPromoNotification( private fun MutableList<WalletNotification>.addSwapPromoNotification(
shouldShowPromo: Boolean, shouldShowPromo: Boolean,
promoBanner: PromoBanner?, promoBanner: PromoBanner?,
clickIntents: WalletClickIntents, clickIntents: WalletClickIntents,
) { ) {
promoBanner ?: return promoBanner ?: return
val promoNotification = WalletNotification.TravalaPromo( val promoNotification = WalletNotification.SwapPromo(
startDateTime = promoBanner.bannerState.timeline.start, startDateTime = promoBanner.bannerState.timeline.start,
endDateTime = promoBanner.bannerState.timeline.end, endDateTime = promoBanner.bannerState.timeline.end,
bannerLink = promoBanner.bannerState.link, onCloseClick = clickIntents::onCloseSwapPromoClick,
onBookNowButtonClick = clickIntents::onTravalaPromoClick,
onCloseClick = clickIntents::onCloseTravalaPromoClick,
) )
addIf( addIf(
element = promoNotification, element = promoNotification,

View file

@ -7,8 +7,9 @@ fun UserWallet.getCardsCount(): Int? {
return if (isMultiCurrency) { return if (isMultiCurrency) {
when (val status = scanResponse.card.backupStatus) { when (val status = scanResponse.card.backupStatus) {
is CardDTO.BackupStatus.Active -> status.cardCount + 1 is CardDTO.BackupStatus.Active -> status.cardCount + 1
is CardDTO.BackupStatus.CardLinked -> status.cardCount + 1 is CardDTO.BackupStatus.NoBackup,
is CardDTO.BackupStatus.NoBackup -> 1 is CardDTO.BackupStatus.CardLinked,
-> 1
null -> 1 // Multi-currency wallet without backup function. Example, 4.12 null -> 1 // Multi-currency wallet without backup function. Example, 4.12
} }
} else { } else {

View file

@ -47,6 +47,8 @@ internal object WalletImageResolver {
cardTypesResolver.isCOQWallet() -> userWallet.resolveCOQWallet() cardTypesResolver.isCOQWallet() -> userWallet.resolveCOQWallet()
cardTypesResolver.isCoinMetricaWallet() -> userWallet.resolveCoinMetricaWallet() cardTypesResolver.isCoinMetricaWallet() -> userWallet.resolveCoinMetricaWallet()
cardTypesResolver.isVoltInuWallet() -> userWallet.resolveVoltInuWallet() cardTypesResolver.isVoltInuWallet() -> userWallet.resolveVoltInuWallet()
cardTypesResolver.isVividWallet() -> userWallet.resolveVividWallet()
cardTypesResolver.isPastelWallet() -> userWallet.resolvePastelWallet()
cardTypesResolver.isWallet2() -> userWallet.resolveWallet2() cardTypesResolver.isWallet2() -> userWallet.resolveWallet2()
cardTypesResolver.isShibaWallet() -> userWallet.resolveShibaWallet() cardTypesResolver.isShibaWallet() -> userWallet.resolveShibaWallet()
cardTypesResolver.isTangemWallet() -> userWallet.resolveWallet1() cardTypesResolver.isTangemWallet() -> userWallet.resolveWallet1()
@ -227,6 +229,22 @@ internal object WalletImageResolver {
) )
} }
private fun UserWallet.resolveVividWallet(): Int? {
// for multicolored cards use image of 3 cards in all cases
return resolveWallet2(
oneBackupResId = R.drawable.ill_vivid_cards3_120_106,
twoBackupResId = R.drawable.ill_vivid_cards3_120_106,
)
}
private fun UserWallet.resolvePastelWallet(): Int? {
// for multicolored cards use image of 3 cards in all cases
return resolveWallet2(
oneBackupResId = R.drawable.ill_pastel_cards3_120_106,
twoBackupResId = R.drawable.ill_pastel_cards3_120_106,
)
}
private fun UserWallet.resolveWallet1(): Int? { private fun UserWallet.resolveWallet1(): Int? {
return resolveWalletWithBackups { count -> return resolveWalletWithBackups { count ->
when (count) { when (count) {

View file

@ -14,6 +14,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -36,6 +37,7 @@ internal class WalletStateController @Inject constructor() {
} }
fun update(transformer: WalletScreenStateTransformer) { fun update(transformer: WalletScreenStateTransformer) {
Timber.d("Applying: ${transformer::class.simpleName}")
mutableUiState.update(function = transformer::transform) mutableUiState.update(function = transformer::transform)
} }

View file

@ -2,7 +2,10 @@ package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.* import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.impl.R
import org.joda.time.DateTime import org.joda.time.DateTime
@ -205,16 +208,9 @@ sealed class WalletNotification(val config: NotificationConfig) {
val onCloseClick: () -> Unit, val onCloseClick: () -> Unit,
) : WalletNotification( ) : WalletNotification(
config = NotificationConfig( config = NotificationConfig(
title = resourceReference( title = resourceReference(id = R.string.swap_promo_title),
id = R.string.main_swap_changelly_promotion_title, subtitle = resourceReference(id = R.string.swap_promo_text),
formatArgs = wrappedList("0%"), iconResId = R.drawable.img_okx_dex_logo,
),
subtitle = resourceReference(
id = R.string.main_swap_changelly_promotion_message,
formatArgs = wrappedList("0%", startDateTime.dayOfMonth, endDateTime.dayOfMonth),
),
iconResId = R.drawable.img_swap_promo,
backgroundResId = R.drawable.img_swap_promo_green_banner_background,
onCloseClick = onCloseClick, onCloseClick = onCloseClick,
), ),
) )

View file

@ -1,22 +1,32 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState
import timber.log.Timber import timber.log.Timber
import java.math.BigDecimal
internal class SetTokenListErrorTransformer( internal class SetTokenListErrorTransformer(
userWalletId: UserWalletId, private val selectedWallet: UserWallet,
private val error: TokenListError, private val error: TokenListError,
) : WalletStateTransformer(userWalletId) { private val appCurrency: AppCurrency,
) : WalletStateTransformer(selectedWallet.walletId) {
override fun transform(prevState: WalletState): WalletState { override fun transform(prevState: WalletState): WalletState {
return when (error) { return when (error) {
is TokenListError.EmptyTokens -> { is TokenListError.EmptyTokens -> {
when (prevState) { when (prevState) {
is WalletState.MultiCurrency.Content -> { is WalletState.MultiCurrency.Content -> {
prevState.copy(tokensListState = WalletTokensListState.Empty) prevState.copy(
walletCardState = prevState.walletCardState.toLoadedState(),
tokensListState = WalletTokensListState.Empty,
)
} }
is WalletState.MultiCurrency.Locked -> { is WalletState.MultiCurrency.Locked -> {
Timber.w("Impossible to load tokens list for locked wallet") Timber.w("Impossible to load tokens list for locked wallet")
@ -37,4 +47,21 @@ internal class SetTokenListErrorTransformer(
-> prevState -> prevState
} }
} }
private fun WalletCardState.toLoadedState(): WalletCardState {
return WalletCardState.Content(
id = id,
title = title,
additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = selectedWallet),
imageResId = imageResId,
onRenameClick = onRenameClick,
onDeleteClick = onDeleteClick,
balance = BigDecimalFormatter.formatFiatAmount(
fiatAmount = BigDecimal.ZERO,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
),
cardCount = selectedWallet.getCardsCount(),
)
}
} }

View file

@ -61,6 +61,11 @@ internal abstract class BasicTokenListSubscriber(
}, },
flow2 = getSelectedAppCurrencyUseCase().distinctUntilChanged(), flow2 = getSelectedAppCurrencyUseCase().distinctUntilChanged(),
transform = { maybeTokenList, maybeAppCurrency -> transform = { maybeTokenList, maybeAppCurrency ->
val appCurrency = maybeAppCurrency.getOrElse { e ->
Timber.e("Failed to load app currency: $e")
AppCurrency.Default
}
val tokenList = maybeTokenList.getOrElse( val tokenList = maybeTokenList.getOrElse(
ifLoading = { maybeContent -> ifLoading = { maybeContent ->
val isRefreshing = stateHolder.getWalletState(userWallet.walletId) val isRefreshing = stateHolder.getWalletState(userWallet.walletId)
@ -74,16 +79,17 @@ internal abstract class BasicTokenListSubscriber(
}, },
ifError = { e -> ifError = { e ->
Timber.e("Failed to load token list: $e") Timber.e("Failed to load token list: $e")
SetTokenListErrorTransformer(userWallet.walletId, e) stateHolder.update(
SetTokenListErrorTransformer(
selectedWallet = userWallet,
error = e,
appCurrency = appCurrency,
),
)
return@combine return@combine
}, },
) )
val appCurrency = maybeAppCurrency.getOrElse { e ->
Timber.e("Failed to load app currency: $e")
AppCurrency.Default
}
updateContent(tokenList, appCurrency) updateContent(tokenList, appCurrency)
walletWithFundsChecker.check(tokenList) walletWithFundsChecker.check(tokenList)
}, },

View file

@ -5,7 +5,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationWithBackground import com.tangem.core.ui.components.notifications.OkxPromoNotification
import com.tangem.core.ui.components.notifications.TravalaNotificationWithBackground import com.tangem.core.ui.components.notifications.TravalaNotificationWithBackground
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
@ -29,7 +29,7 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
// TODO develop promo banner general component // TODO develop promo banner general component
when (it) { when (it) {
is WalletNotification.SwapPromo -> { is WalletNotification.SwapPromo -> {
NotificationWithBackground( OkxPromoNotification(
config = it.config, config = it.config,
modifier = modifier.animateItemPlacement(), modifier = modifier.animateItemPlacement(),
) )

View file

@ -7,6 +7,7 @@ import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.navigation.settings.SettingsManager import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.settings.* import com.tangem.domain.settings.*
import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
@ -21,7 +22,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.PushNotificationsBottomSheetConfig import com.tangem.feature.wallet.presentation.wallet.state.model.PushNotificationsBottomSheetConfig
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent.DemonstrateWalletsScrollPreview.Direction import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent.DemonstrateWalletsScrollPreview.Direction
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletPullToRefreshConfig
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.transformers.* import com.tangem.feature.wallet.presentation.wallet.state.transformers.*
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
@ -61,6 +61,7 @@ internal class WalletViewModel @Inject constructor(
private val selectedWalletAnalyticsSender: SelectedWalletAnalyticsSender, private val selectedWalletAnalyticsSender: SelectedWalletAnalyticsSender,
private val walletDeepLinksHandler: WalletDeepLinksHandler, private val walletDeepLinksHandler: WalletDeepLinksHandler,
private val walletNameMigrationUseCase: WalletNameMigrationUseCase, private val walletNameMigrationUseCase: WalletNameMigrationUseCase,
private val refreshMultiCurrencyWalletQuotesUseCase: RefreshMultiCurrencyWalletQuotesUseCase,
private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase, private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase,
private val isFirstTimeAskingPermissionUseCase: IsFirstTimeAskingPermissionUseCase, private val isFirstTimeAskingPermissionUseCase: IsFirstTimeAskingPermissionUseCase,
private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase,
@ -206,7 +207,7 @@ internal class WalletViewModel @Inject constructor(
} }
} }
// We need to update the current wallet if the application was in the background for more than 10 seconds // We need to update the current wallet quotes if the application was in the background for more than 10 seconds
// and then returned to the foreground // and then returned to the foreground
private fun subscribeToScreenBackgroundState() { private fun subscribeToScreenBackgroundState() {
screenLifecycleProvider.isBackgroundState screenLifecycleProvider.isBackgroundState
@ -214,7 +215,7 @@ internal class WalletViewModel @Inject constructor(
refreshWalletJobHolder.cancel() refreshWalletJobHolder.cancel()
when { when {
isBackground -> needToRefreshTimer() isBackground -> needToRefreshTimer()
needToRefreshWallet && !isBackground -> triggerRefreshWallet() needToRefreshWallet && !isBackground -> triggerRefreshWalletQuotes()
} }
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
@ -227,13 +228,15 @@ internal class WalletViewModel @Inject constructor(
}.saveIn(refreshWalletJobHolder) }.saveIn(refreshWalletJobHolder)
} }
private fun triggerRefreshWallet() { private fun triggerRefreshWalletQuotes() {
needToRefreshWallet = false needToRefreshWallet = false
val state = stateHolder.uiState.value val state = stateHolder.uiState.value
val wallet = state.wallets.getOrNull(state.selectedWalletIndex) ?: return val wallet = state.wallets.getOrNull(state.selectedWalletIndex) ?: return
wallet.pullToRefreshConfig.onRefresh.invoke( viewModelScope.launch {
WalletPullToRefreshConfig.ShowRefreshState(false), refreshMultiCurrencyWalletQuotesUseCase(wallet.walletCardState.id).getOrElse {
) Timber.e("Failed to refreshMultiCurrencyWalletQuotesUseCase $it")
}
}.saveIn(refreshWalletJobHolder)
} }
private suspend fun updateWallets(action: WalletsUpdateActionResolver.Action) { private suspend fun updateWallets(action: WalletsUpdateActionResolver.Action) {
@ -244,6 +247,14 @@ internal class WalletViewModel @Inject constructor(
is WalletsUpdateActionResolver.Action.DeleteWallet -> deleteWallet(action) is WalletsUpdateActionResolver.Action.DeleteWallet -> deleteWallet(action)
is WalletsUpdateActionResolver.Action.UnlockWallet -> unlockWallet(action) is WalletsUpdateActionResolver.Action.UnlockWallet -> unlockWallet(action)
is WalletsUpdateActionResolver.Action.UpdateWalletCardCount -> { is WalletsUpdateActionResolver.Action.UpdateWalletCardCount -> {
// refresh loader to use actual user wallet
walletScreenContentLoader.load(
userWallet = action.selectedWallet,
clickIntents = clickIntents,
isRefresh = true,
coroutineScope = viewModelScope,
)
stateHolder.update(transformer = UpdateWalletCardsCountTransformer(action.selectedWallet)) stateHolder.update(transformer = UpdateWalletCardsCountTransformer(action.selectedWallet))
} }
is WalletsUpdateActionResolver.Action.UpdateWalletName -> { is WalletsUpdateActionResolver.Action.UpdateWalletName -> {

View file

@ -1,6 +1,8 @@
package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap
import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.settings.NeverToShowWalletsScrollPreview import com.tangem.domain.settings.NeverToShowWalletsScrollPreview
import com.tangem.domain.tokens.FetchCardTokenListUseCase import com.tangem.domain.tokens.FetchCardTokenListUseCase
@ -38,6 +40,7 @@ internal class WalletClickIntents @Inject constructor(
private val fetchTokenListUseCase: FetchTokenListUseCase, private val fetchTokenListUseCase: FetchTokenListUseCase,
private val fetchCardTokenListUseCase: FetchCardTokenListUseCase, private val fetchCardTokenListUseCase: FetchCardTokenListUseCase,
private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val neverToShowWalletsScrollPreview: NeverToShowWalletsScrollPreview, private val neverToShowWalletsScrollPreview: NeverToShowWalletsScrollPreview,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
@ -118,7 +121,13 @@ internal class WalletClickIntents @Inject constructor(
} }
maybeFetchResult.onLeft { maybeFetchResult.onLeft {
stateHolder.update(SetTokenListErrorTransformer(userWalletId = userWallet.walletId, error = it)) stateHolder.update(
SetTokenListErrorTransformer(
selectedWallet = userWallet,
error = it,
appCurrency = getSelectedAppCurrencyUseCase.unwrap(),
),
)
} }
stateHolder.update( stateHolder.update(

View file

@ -14,13 +14,13 @@ import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase
import com.tangem.domain.settings.ShouldShowTravalaPromoWalletUseCase import com.tangem.domain.settings.ShouldShowTravalaPromoWalletUseCase
import com.tangem.domain.tokens.FetchTokenListUseCase import com.tangem.domain.tokens.FetchTokenListUseCase
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.models.analytics.TokenSwapPromoAnalyticsEvent
import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType
import com.tangem.domain.wallets.models.UnlockWalletsError import com.tangem.domain.wallets.models.UnlockWalletsError
import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.UnlockWalletsUseCase import com.tangem.domain.wallets.usecase.UnlockWalletsUseCase
import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.domain.ScanCardToUnlockWalletClickHandler import com.tangem.feature.wallet.presentation.wallet.domain.ScanCardToUnlockWalletClickHandler
@ -222,6 +222,13 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
} }
override fun onCloseSwapPromoClick() { override fun onCloseSwapPromoClick() {
analyticsEventHandler.send(
TokenSwapPromoAnalyticsEvent.PromotionBannerClicked(
source = AnalyticsParam.ScreensSources.Main,
programName = TokenSwapPromoAnalyticsEvent.ProgramName.OKX,
action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Closed,
),
)
viewModelScope.launch(dispatchers.main) { viewModelScope.launch(dispatchers.main) {
shouldShowSwapPromoWalletUseCase.neverToShow() shouldShowSwapPromoWalletUseCase.neverToShow()
} }
@ -229,10 +236,10 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
override fun onTravalaPromoClick(link: String?) { override fun onTravalaPromoClick(link: String?) {
analyticsEventHandler.send( analyticsEventHandler.send(
WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked( TokenSwapPromoAnalyticsEvent.PromotionBannerClicked(
source = AnalyticsParam.ScreensSources.Main, source = AnalyticsParam.ScreensSources.Main,
programName = "Travala", programName = TokenSwapPromoAnalyticsEvent.ProgramName.Travala,
action = WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked.BannerAction.Clicked, action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Clicked,
), ),
) )
link?.let { link?.let {
@ -244,10 +251,10 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
override fun onCloseTravalaPromoClick() { override fun onCloseTravalaPromoClick() {
analyticsEventHandler.send( analyticsEventHandler.send(
WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked( TokenSwapPromoAnalyticsEvent.PromotionBannerClicked(
source = AnalyticsParam.ScreensSources.Main, source = AnalyticsParam.ScreensSources.Main,
programName = "Travala", programName = TokenSwapPromoAnalyticsEvent.ProgramName.Travala,
action = WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked.BannerAction.Closed, action = TokenSwapPromoAnalyticsEvent.PromotionBannerClicked.BannerAction.Closed,
), ),
) )
viewModelScope.launch(dispatchers.main) { viewModelScope.launch(dispatchers.main) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Some files were not shown because too many files have changed in this diff Show more