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
android:name="com.tangem.tap.MainActivity"
android:configChanges="uiMode"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"

View file

@ -7,16 +7,16 @@ import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import java.util.*
import java.util.WeakHashMap
import kotlin.reflect.KClass
class ForegroundActivityObserver : ActivityResultCaller {
override var activityResultLauncher: ActivityResultLauncher<Intent>? = null
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
.filterNot { it.value.isDestroyed }
.firstOrNull()
@ -35,7 +35,7 @@ class ForegroundActivityObserver : ActivityResultCaller {
}
override fun onActivityResumed(activity: Activity) {
activities[activity::class] = activity
activities[activity::class] = activity as? AppCompatActivity
}
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) }
}

View file

@ -18,6 +18,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
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.Snackbar
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.entity.SerializableIntent
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.extensions.TextReference
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.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardRepository
@ -114,7 +117,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
lateinit var testerRouter: TesterRouter
@Inject
lateinit var cardSdkLifecycleObserver: CardSdkLifecycleObserver
lateinit var cardSdkOwner: CardSdkOwner
@Inject
lateinit var cardSdkConfigRepository: CardSdkConfigRepository
@ -174,6 +177,9 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
@Inject
internal lateinit var routingComponentFactory: RoutingComponent.Factory
@Inject
internal lateinit var appRouter: AppRouter
@Inject
lateinit var pushNotificationsRouter: PushNotificationsRouter
@ -182,7 +188,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
internal val viewModel: MainViewModel by viewModels()
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode?>
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode>
// TODO: fixme: inject through DI
private val intentProcessor: IntentProcessor = IntentProcessor()
@ -194,9 +200,10 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
private val onActivityResultCallbacks = mutableListOf<OnActivityResultCallback>()
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(
navigationBarStyle = SystemBarStyle.auto(
@ -270,7 +277,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
}
private fun installActivityDependencies() {
cardSdkLifecycleObserver.onCreate(context = this)
cardSdkOwner.register(activity = this)
tangemSdkManager = injectedTangemSdkManager
appStateHolder.tangemSdkManager = tangemSdkManager
backupService = BackupService.init(cardSdkConfigRepository.sdk, this)
@ -301,14 +308,13 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
private fun installAppTheme() {
appThemeModeFlow = createAppThemeModeFlow()
val mode = runBlocking { appThemeModeFlow.filterNotNull().first() }
val mode = runBlocking { appThemeModeFlow.first() }
updateAppTheme(mode)
}
private fun observeAppThemeModeUpdates() {
appThemeModeFlow
.filterNotNull()
.flowWithLifecycle(lifecycle)
.onEach(::updateAppTheme)
.launchIn(lifecycleScope)
@ -324,15 +330,17 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
private fun createAppThemeModeFlow(): SharedFlow<AppThemeMode?> {
private fun createAppThemeModeFlow(): SharedFlow<AppThemeMode> {
val tangemApplication = application as TangemApplication
return tangemApplication.getAppThemeModeUseCase()
.filterNotNull()
.distinctUntilChanged()
.map { maybeMode ->
maybeMode.getOrElse { AppThemeMode.DEFAULT }
}
.shareIn(
scope = lifecycleScope + Dispatchers.IO,
scope = lifecycleScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
)
}
@ -382,7 +390,6 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
override fun onDestroy() {
intentProcessor.removeAll()
cardSdkLifecycleObserver.onDestroy(this)
super.onDestroy()
}
@ -393,9 +400,6 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
}
private fun updateAppTheme(appThemeMode: AppThemeMode) {
MutableAppThemeModeHolder.value = appThemeMode
MutableAppThemeModeHolder.isDarkThemeActive = isDarkTheme()
val mode = when (appThemeMode) {
AppThemeMode.FORCE_DARK -> AppCompatDelegate.MODE_NIGHT_YES
AppThemeMode.FORCE_LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
@ -403,7 +407,32 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
}
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 {
@ -418,12 +447,6 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
override fun onNewIntent(intent: 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 {
intentProcessor.handleIntent(intent, true)
}
@ -505,15 +528,16 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
}
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 isOnboardingServiceNotActive = !store.state.globalState.onboardingState.onboardingStarted
when {
!backStackIsEmpty && isNotScannedBefore && isOnboardingServiceNotActive -> {
!isOnInitialScreen && isNotScannedBefore && isOnboardingServiceNotActive -> {
navigateToInitialScreen(intentWhichStartedActivity)
}
backStackIsEmpty -> {
backStack.isEmpty() -> {
navigateToInitialScreen(intentWhichStartedActivity)
}
else -> Unit

View file

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

View file

@ -36,11 +36,19 @@ sealed class Settings(
) : Settings("Settings / Card Settings", event, params, error) {
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",
params = buildMap {
cardsCount?.let { put("Cards Count", "$it") }
},
error = error,
)
class FactoryResetCanceled(cardsCount: Int) : CardSettings(
event = "Factory Reset Canceled",
params = mapOf("Cards Count" to "$cardsCount"),
)
class UserCodeChanged : CardSettings("User Code Changed")
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.Error,
Log.Level.Biometric,
Log.Level.Info,
)
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.DefaultCardSdkProvider
import com.tangem.tap.data.DefaultCardSdkProvider
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -19,5 +19,5 @@ internal interface CardSdkModule {
@Binds
@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.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(ViewModelComponent::class)
@InstallIn(SingletonComponent::class)
internal object CardDomainModule {
@Provides
@ViewModelScoped
@Singleton
fun provideGetBiometricsStatusUseCase(
cardSdkConfigRepository: CardSdkConfigRepository,
): GetBiometricsStatusUseCase {
@ -30,7 +30,7 @@ internal object CardDomainModule {
}
@Provides
@ViewModelScoped
@Singleton
fun provideSetAccessCodeRequestPolicyUseCase(
cardSdkConfigRepository: CardSdkConfigRepository,
): SetAccessCodeRequestPolicyUseCase {
@ -38,35 +38,35 @@ internal object CardDomainModule {
}
@Provides
@ViewModelScoped
@Singleton
fun provideWasWalletAlreadySignedHashesConfirmedUseCase(cardRepository: CardRepository): WasCardScannedUseCase {
return WasCardScannedUseCase(cardRepository = cardRepository)
}
@Provides
@ViewModelScoped
@Singleton
fun provideSetCardWasScannedUseCase(cardRepository: CardRepository): SetCardWasScannedUseCase {
return SetCardWasScannedUseCase(cardRepository = cardRepository)
}
@Provides
@ViewModelScoped
@Singleton
fun provideIsDemoCardUseCase(): IsDemoCardUseCase = IsDemoCardUseCase(config = DemoConfig())
@Provides
@ViewModelScoped
@Singleton
fun provideDerivePublicKeysUseCase(derivationsRepository: DerivationsRepository): DerivePublicKeysUseCase {
return DerivePublicKeysUseCase(derivationsRepository = derivationsRepository)
}
@Provides
@ViewModelScoped
@Singleton
fun provideIsNeedToBackupUseCase(userWalletsListManager: UserWalletsListManager): IsNeedToBackupUseCase {
return IsNeedToBackupUseCase(userWalletsListManager = userWalletsListManager)
}
@Provides
@ViewModelScoped
@Singleton
fun provideGetExtendedPublicKeyForCurrencyUseCase(
derivationsRepository: DerivationsRepository,
): GetExtendedPublicKeyForCurrencyUseCase {
@ -74,13 +74,13 @@ internal object CardDomainModule {
}
@Provides
@ViewModelScoped
@Singleton
fun provideDeleteSavedAccessCodesUseCase(tangemSdkManager: TangemSdkManager): DeleteSavedAccessCodesUseCase {
return DefaultDeleteSavedAccessCodesUseCase(tangemSdkManager)
}
@Provides
@ViewModelScoped
@Singleton
fun provideResetCardUseCase(tangemSdkManager: TangemSdkManager): ResetCardUseCase {
return DefaultResetCardUseCase(tangemSdkManager)
}

View file

@ -398,4 +398,16 @@ internal object TokensDomainModule {
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
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.right
import com.tangem.common.*
import arrow.fx.coroutines.ResourceScope
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.TangemSdkError
import com.tangem.common.core.UserCodeRequestPolicy
@ -17,14 +20,14 @@ internal class DefaultResetCardUseCase(
private val tangemSdkManager: TangemSdkManager,
) : ResetCardUseCase {
override suspend fun invoke(card: CardDTO): Either<ResetCardError, Unit> = either {
enterRequiredAccessCode(card) {
override suspend fun invoke(card: CardDTO): Either<ResetCardError, Unit> = resourceScope {
either {
withUserCodeRequestPolicy(card)
tangemSdkManager.resetToFactorySettings(
cardId = card.cardId,
allowsRequestAccessCodeFromRepository = true,
)
.doOnSuccess { Unit.right() }
.doOnFailure { raise(it.mapToDomainError()) }
).bind(raise = this)
}
}
@ -32,23 +35,29 @@ internal class DefaultResetCardUseCase(
cardNumber: Int,
card: CardDTO,
userWalletId: UserWalletId,
): Either<ResetCardError, Unit> = either {
enterRequiredAccessCode(card) {
tangemSdkManager.resetBackupCard(cardNumber, userWalletId)
.doOnSuccess { Unit.right() }
.doOnFailure { raise(it.mapToDomainError()) }
): Either<ResetCardError, Unit> = resourceScope {
either {
withUserCodeRequestPolicy(card)
tangemSdkManager.resetBackupCard(
cardNumber = cardNumber,
userWalletId = userWalletId,
).bind(raise = this)
}
}
private suspend fun enterRequiredAccessCode(
card: CardDTO,
task: suspend TangemSdkManager.() -> CompletionResult<*>,
) {
val policyBeforeReset = tangemSdkManager.userCodeRequestPolicy
requestMandatoryAccessCodeEntry(card)
private suspend fun ResourceScope.withUserCodeRequestPolicy(card: CardDTO) {
install(
acquire = {
val policyBeforeReset = tangemSdkManager.userCodeRequestPolicy
requestMandatoryAccessCodeEntry(card)
tangemSdkManager.task()
.doOnResult { tangemSdkManager.setUserCodeRequestPolicy(policyBeforeReset) }
policyBeforeReset
},
release = { prevPolicy, _ ->
tangemSdkManager.setUserCodeRequestPolicy(prevPolicy)
},
)
}
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 {
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 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()
}

View file

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

View file

@ -17,5 +17,5 @@ interface CustomTokenInteractor {
/** Save token [customCurrency] */
@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
import arrow.core.getOrElse
import arrow.core.raise.result
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
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.proxy.redux.DaggerGraphState
import com.tangem.tap.store
import timber.log.Timber
/**
* Default implementation of custom token interactor
@ -45,16 +46,18 @@ class DefaultCustomTokenInteractor(
)
}
override suspend fun saveToken(customCurrency: CustomCurrency) {
val userWallet = getSelectedWalletSyncUseCase().fold(ifLeft = { return }, ifRight = { it })
val currency = Currency.fromCustomCurrency(customCurrency)
val currencies = listOfNotNull(element = currency.toCryptoCurrency(userWallet.scanResponse))
derivePublicKeysUseCase(userWalletId = userWallet.walletId, currencies = currencies)
.onRight {
addCryptoCurrenciesUseCase(userWalletId = userWallet.walletId, currencies = currencies)
override suspend fun saveToken(customCurrency: CustomCurrency): Result<Unit> {
return result {
val userWallet = getSelectedWalletSyncUseCase().getOrElse {
error("Failed to get selected wallet: $it")
}
.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? {

View file

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

View file

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

View file

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

View file

@ -4,8 +4,6 @@ import androidx.lifecycle.LifecycleCoroutineScope
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
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 org.rekotlin.Action
@ -33,7 +31,8 @@ sealed class DetailsAction : Action {
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 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.domain.apptheme.model.AppThemeMode
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.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
@ -148,7 +147,7 @@ class DetailsMiddleware {
}
.doOnFailure { error ->
if (error is TangemSdkError && error !is TangemSdkError.UserCancelled) {
Analytics.send(Settings.CardSettings.FactoryResetFinished(error))
Analytics.send(Settings.CardSettings.FactoryResetFinished(error = error))
}
}
.doOnResult {
@ -474,10 +473,7 @@ class DetailsMiddleware {
if (isSameWallet) {
store.dispatchOnMain(
DetailsAction.PrepareCardSettingsData(
scanResponse.card,
scanResponse.cardTypesResolver,
),
DetailsAction.PrepareCardSettingsData(scanResponse = scanResponse),
)
} else {
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.util.cardTypesResolver
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.redux.AppState
import com.tangem.tap.domain.extensions.signedHashesCount
@ -29,11 +30,7 @@ private fun internalReduce(action: Action, state: AppState): DetailsState {
handlePrepareScreen(action, state)
}
is DetailsAction.PrepareCardSettingsData -> {
handlePrepareCardSettingsScreen(
card = action.card,
cardTypesResolver = action.cardTypesResolver,
state = detailsState,
)
handlePrepareCardSettingsScreen(scanResponse = action.scanResponse, state = detailsState)
}
is DetailsAction.ResetCardSettingsData -> detailsState.copy(cardSettingsState = null)
is DetailsAction.ResetToFactory -> {
@ -99,17 +96,17 @@ private fun handlePrepareScreen(action: DetailsAction.PrepareScreen, state: AppS
)
}
private fun handlePrepareCardSettingsScreen(
card: CardDTO,
cardTypesResolver: CardTypesResolver,
state: DetailsState,
): DetailsState {
private fun handlePrepareCardSettingsScreen(scanResponse: ScanResponse, state: DetailsState): DetailsState {
val cardTypesResolver = scanResponse.cardTypesResolver
val card = scanResponse.card
val isTangemWallet = cardTypesResolver.isTangemWallet() || cardTypesResolver.isWallet2()
val isShowPasswordResetRadioButton = isTangemWallet && card.backupStatus is CardDTO.BackupStatus.Active
val cardSettingsState = CardSettingsState(
cardInfo = card.toCardInfo(cardTypesResolver),
scanResponse = scanResponse,
manageSecurityState = prepareSecurityOptions(card, cardTypesResolver),
card = card,
card = scanResponse.card,
resetCardAllowed = isResetToFactoryAllowedByCard(card, cardTypesResolver),
resetButtonEnabled = false,
condition1Checked = false,
@ -126,6 +123,7 @@ private fun handlePrepareCardSettingsScreen(
isShowPasswordResetRadioButton = isShowPasswordResetRadioButton,
dialog = null,
)
return state.copy(cardSettingsState = cardSettingsState)
}

View file

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

View file

@ -4,13 +4,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.common.routing.AppRoute
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.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.GetSelectedWalletSyncUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount
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.features.details.redux.CardSettingsState
import com.tangem.tap.features.details.redux.DetailsAction.ResetToFactory
@ -25,6 +32,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import com.tangem.tap.features.details.redux.CardSettingsState.Dialog as CardSettingsDialog
@Suppress("LongParameterList")
@HiltViewModel
internal class ResetCardViewModel @Inject constructor(
private val resetCardFeatureToggles: ResetCardFeatureToggles,
@ -32,14 +40,17 @@ internal class ResetCardViewModel @Inject constructor(
private val resetCardUseCase: ResetCardUseCase,
private val deleteSavedAccessCodesUseCase: DeleteSavedAccessCodesUseCase,
private val deleteWalletUseCase: DeleteWalletUseCase,
private val userWalletsListManager: UserWalletsListManager,
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel() {
private val currentUserWallet = getSelectedWalletSyncUseCase().getOrNull()
?: error("Selected user wallet can't be null")
private val firstCardScanResponse = store.state.detailsState.cardSettingsState?.scanResponse
?: error("ScanResponse can't be null")
private val currentUserWalletId = createUserWalletId(firstCardScanResponse)
// TODO: move logic to separate domain entity
private val backupCardsCount = (currentUserWallet.getCardsCount() ?: 0) - 1
private var resetCardsCount = 0
private var resetBackupCardCount = 0
fun updateState(state: CardSettingsState?): ResetCardScreenState.ResetCardScreenContent {
val descriptionText = state?.cardInfo
@ -84,7 +95,7 @@ internal class ResetCardViewModel @Inject constructor(
CardSettingsDialog.InterruptedResetDialog -> {
ResetCardScreenState.ResetCardScreenContent.Dialog.InterruptedReset(
onConfirmClick = ::onContinueResetClick,
onDismiss = ::dismissAndFinishFullReset,
onDismiss = ::onInterruptedResetDialogDismiss,
)
}
CardSettingsDialog.CompletedResetDialog -> {
@ -107,9 +118,9 @@ internal class ResetCardViewModel @Inject constructor(
private fun makeFullReset() {
viewModelScope.launch {
resetCardUseCase(card = currentUserWallet.scanResponse.card).onRight {
deleteSavedAccessCodesUseCase(currentUserWallet.cardId)
deleteWalletUseCase(currentUserWallet.walletId)
resetCardUseCase(card = firstCardScanResponse.card).onRight {
deleteSavedAccessCodesUseCase(firstCardScanResponse.card.cardId)
deleteWalletUseCase(currentUserWalletId)
val newSelectedWallet = getSelectedWalletSyncUseCase().getOrNull()
if (newSelectedWallet != null) {
@ -128,12 +139,12 @@ internal class ResetCardViewModel @Inject constructor(
viewModelScope.launch {
resetCardUseCase(
cardNumber = resetCardsCount + 1,
card = currentUserWallet.scanResponse.card,
userWalletId = currentUserWallet.walletId,
cardNumber = resetBackupCardCount + 1,
card = firstCardScanResponse.card,
userWalletId = currentUserWalletId,
)
.onRight {
resetCardsCount++
resetBackupCardCount++
delay(DELAY_SDK_DIALOG_CLOSE)
@ -149,10 +160,23 @@ internal class ResetCardViewModel @Inject constructor(
showDialog(CardSettingsDialog.InterruptedResetDialog)
}
private fun onInterruptedResetDialogDismiss() {
analyticsEventHandler.send(Settings.CardSettings.FactoryResetCanceled(cardsCount = resetBackupCardCount + 1))
dismissAndFinishFullReset()
}
private fun checkRemainingBackupCards() {
val backupCardsCount = firstCardScanResponse.getBackupCardsCount()
when {
backupCardsCount > resetCardsCount -> showDialog(CardSettingsDialog.ContinueResetDialog)
backupCardsCount == resetCardsCount -> showDialog(CardSettingsDialog.CompletedResetDialog)
backupCardsCount > resetBackupCardCount -> showDialog(CardSettingsDialog.ContinueResetDialog)
backupCardsCount == resetBackupCardCount -> {
analyticsEventHandler.send(
event = Settings.CardSettings.FactoryResetFinished(cardsCount = resetBackupCardCount + 1),
)
showDialog(CardSettingsDialog.CompletedResetDialog)
}
else -> finishFullReset()
}
}
@ -164,12 +188,17 @@ internal class ResetCardViewModel @Inject constructor(
}
private fun finishFullReset() {
val newSelectedWallet = getSelectedWalletSyncUseCase().getOrNull()
val newSelectedWallet = userWalletsListManager.selectedUserWalletSync
if (newSelectedWallet != null) {
store.dispatchNavigationAction { popTo<AppRoute.Wallet>() }
} 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() {
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 {
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> = { _, _ ->

View file

@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
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.TangemThemePreview
import com.tangem.wallet.R
@Suppress("LongMethod")

View file

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

View file

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

View file

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

View file

@ -53,14 +53,6 @@ object PreferencesKeys {
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 {
booleanPreferencesKey(name = "isWalletTravalaPromoShown")
}
@ -93,6 +85,14 @@ object PreferencesKeys {
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")
// region Permission

View file

@ -235,6 +235,7 @@
<string name="express_exchange_status_subtitle">Данные провайдера. Сумма к получению может измениться в зависимости от рыночных условий.</string>
<string name="express_exchange_status_title">Статус обмена</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_fetch_best_rates">Получение наилучших курсов...</string>
<string name="express_floating_rate">Плавающая ставка</string>
@ -248,6 +249,7 @@
<string name="express_provider_min_amount">Доступно с %s</string>
<string name="express_provider_not_available">Недоступно для этой пары</string>
<string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="express_provider_recommended">Рекомендовано</string>
<string name="express_terms_of_use">Условиями использования</string>
<string name="express_token_list_empty_search">Токены не найдены. Пожалуйста, попробуйте другой запрос</string>
<string name="express_transaction_id">ID: %s</string>
@ -584,6 +586,8 @@
<string name="story_meet_title">Встречайте Tangem</string>
<string name="story_web3_description">Обменивайте, покупайте NFT, получайте займы и делайте вклады в более чем 100 различных децентрализованных сервисах</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_dex_description">В сумму включена комиссия провайдера сервиса.</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_title">Exchange status</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_fetch_best_rates">Fetching best rates...</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_not_available">Unavailable for this pair</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_token_list_empty_search">No tokens found. Please try another request</string>
<string name="express_transaction_id">ID: %s</string>
@ -610,6 +612,8 @@
<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_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_dex_description">The amount includes the service provider\'s fee.</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
"zkSyncEra", "zkSyncEra/test" -> R.drawable.img_zksync_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
"moonriver", "moonriver/test" -> R.drawable.img_moonriver_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
"zkSyncEra", "zkSyncEra/test" -> R.drawable.ic_zksync_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
"moonriver", "moonriver/test" -> R.drawable.ic_moonriver_22
"mantle", "mantle/test" -> R.drawable.ic_mantle_22

View file

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

View file

@ -1,5 +1,6 @@
package com.tangem.core.ui.screen
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
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
* 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]
*/
internal interface CardSdkProvider {
interface CardSdkProvider {
/** CardSDK instance */
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()
}
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 const val CHANGELLY_NAME = "changelly"
private const val TRAVALA = "travala"
// private const val OKX = "okx"
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.data.settings
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_WALLET_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_OKX_SHOW_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.store
@ -16,23 +16,23 @@ class DefaultPromoSettingsRepository(
private val appPreferencesStore: AppPreferencesStore,
) : PromoSettingsRepository {
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> {
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() {
appPreferencesStore.store(
key = IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY,
key = IS_WALLET_SWAP_PROMO_OKX_SHOW_KEY,
value = false,
)
}
override suspend fun setNeverToShowTokenSwapPromo() {
appPreferencesStore.store(
key = IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY,
key = IS_TOKEN_SWAP_PROMO_OKX_SHOW_KEY,
value = false,
)
}

View file

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

View file

@ -314,26 +314,28 @@ internal class DefaultCurrenciesRepository(
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): CryptoCurrency.Coin {
val userWallet = getUserWallet(userWalletId)
ensureIsCorrectUserWallet(userWallet = userWallet, isMultiCurrencyWalletExpected = true)
return withContext(dispatchers.io) {
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)) {
"Unable to find tokens response for user wallet with provided ID: $userWalletId"
val storedTokens = requireNotNull(userTokensStore.getSyncOrNull(userWallet.walletId)) {
"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> {
@ -395,29 +397,31 @@ internal class DefaultCurrenciesRepository(
}
override suspend fun getFeePaidCurrency(userWalletId: UserWalletId, currency: CryptoCurrency): FeePaidCurrency {
val blockchain = Blockchain.fromId(currency.network.id.value)
return when (val feePaidCurrency = blockchain.feePaidCurrency()) {
FeePaidSdkCurrency.Coin -> FeePaidCurrency.Coin
FeePaidSdkCurrency.SameCurrency -> FeePaidCurrency.SameCurrency
is FeePaidSdkCurrency.Token -> {
val balance = walletManagersFacade.tokenBalance(
userWalletId = userWalletId,
network = currency.network,
name = feePaidCurrency.token.name,
symbol = feePaidCurrency.token.symbol,
contractAddress = feePaidCurrency.token.contractAddress,
decimals = feePaidCurrency.token.decimals,
id = feePaidCurrency.token.id,
)
FeePaidCurrency.Token(
tokenId = getTokenId(network = currency.network, sdkToken = feePaidCurrency.token),
name = feePaidCurrency.token.name,
symbol = feePaidCurrency.token.symbol,
contractAddress = feePaidCurrency.token.contractAddress,
balance = balance,
)
return withContext(dispatchers.io) {
val blockchain = Blockchain.fromId(currency.network.id.value)
when (val feePaidCurrency = blockchain.feePaidCurrency()) {
FeePaidSdkCurrency.Coin -> FeePaidCurrency.Coin
FeePaidSdkCurrency.SameCurrency -> FeePaidCurrency.SameCurrency
is FeePaidSdkCurrency.Token -> {
val balance = walletManagersFacade.tokenBalance(
userWalletId = userWalletId,
network = currency.network,
name = feePaidCurrency.token.name,
symbol = feePaidCurrency.token.symbol,
contractAddress = feePaidCurrency.token.contractAddress,
decimals = feePaidCurrency.token.decimals,
id = feePaidCurrency.token.id,
)
FeePaidCurrency.Token(
tokenId = getTokenId(network = currency.network, sdkToken = feePaidCurrency.token),
name = feePaidCurrency.token.name,
symbol = feePaidCurrency.token.symbol,
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.repository.MarketCryptoCurrencyRepository
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
class DefaultMarketCryptoCurrencyRepository(
private val expressAssetsStore: ExpressAssetsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MarketCryptoCurrencyRepository {
override suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
@ -15,11 +18,16 @@ class DefaultMarketCryptoCurrencyRepository(
}
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 {
it.network == cryptoCurrency.network.backendId &&
it.contractAddress.equals(contractAddress, ignoreCase = true)
}?.exchangeAvailable ?: false
val asset = expressAssetsStore.getSyncOrNull(userWalletId)?.find {
it.network == cryptoCurrency.network.backendId &&
it.contractAddress.equals(contractAddress, ignoreCase = true)
}
asset?.exchangeAvailable ?: false
}
}
}

View file

@ -51,6 +51,19 @@ internal class DefaultQuotesRepository(
.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> {
return withContext(dispatchers.io) {
val selectedAppCurrency = requireNotNull(

View file

@ -35,16 +35,19 @@ class DefaultTxHistoryRepository(
private val sdkPageConverter by lazy { SdkPageConverter() }
override suspend fun getTxHistoryItemsCount(userWalletId: UserWalletId, currency: CryptoCurrency): Int {
val userWallet = getUserWallet(userWalletId)
val state = walletManagersFacade.getTxHistoryState(
userWalletId = userWallet.walletId,
currency = currency,
)
return when (state) {
is TxHistoryState.Failed.FetchError -> throw TxHistoryStateError.DataError(state.exception)
is TxHistoryState.NotImplemented -> throw TxHistoryStateError.TxHistoryNotImplemented
is TxHistoryState.Success.Empty -> throw TxHistoryStateError.EmptyTxHistories
is TxHistoryState.Success.HasTransactions -> state.txCount
return withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
val state = walletManagersFacade.getTxHistoryState(
userWalletId = userWallet.walletId,
currency = currency,
)
when (state) {
is TxHistoryState.Failed.FetchError -> throw TxHistoryStateError.DataError(state.exception)
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
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.lceLoading
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
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].
*
* @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.
*/
class LceFlowScope<E : Any, C : Any> @PublishedApi internal constructor(
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,
) : Raise<E>, CoroutineScope by scope {
) : Raise<E>, CoroutineScope by producerScope {
/**
* Raises an [Lce] instance within the [ProducerScope].
* It closes the [ProducerScope] after raise.
* Sends a error of type [E] within the [ProducerScope] and then closes it for send.
* 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 {
scope.launch(NonCancellable) {
scope.send(r.lceError())
scope.close()
}
producerScope.trySendBlocking(r.lceError())
producerScope.close()
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.
* 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 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()
}
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>) {
scope.send(value)
if (producerScope.isClosedForSend) return
producerScope.send(value)
}
}
@ -94,7 +114,7 @@ fun <E : Any, C : Any> lceFlow(
lce {
val scope = LceFlowScope(
raise = this@lce,
scope = this@channelFlow,
producerScope = this@channelFlow,
ifLoading = ifLoading,
)

View file

@ -54,6 +54,10 @@ interface CardTypesResolver {
fun isVoltInuWallet(): Boolean
fun isVividWallet(): Boolean
fun isPastelWallet(): Boolean
fun isWhiteWallet(): 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 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 {
return walletData == null && card.firmwareVersion <= FirmwareVersion.HDWalletAvailable
}
@ -190,5 +198,13 @@ internal class TangemCardTypesResolver(
const val COQ_WALLET_BATCH_ID = "AF28"
const val COIN_METRICA_WALLET_BATCH_ID = "AF27"
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
}
walletManagersStore.remove(userWalletId) { walletManager ->
val wallet = walletManager.wallet
val blockchainToDerivationPath = wallet.blockchain to wallet.publicKey.derivationPath?.rawPath
withContext(dispatchers.io) {
walletManagersStore.remove(userWalletId) { walletManager ->
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,
networkTokens: List<CryptoCurrency.Token>,
) {
val walletManager = walletManagersStore.getSyncOrNull(
userWalletId = userWalletId,
blockchain = Blockchain.fromId(network.id.value),
derivationPath = network.derivationPath.value,
) ?: return
val tokensToRemove = sdkTokenConverter.convertList(networkTokens)
withContext(dispatchers.io) {
val walletManager = walletManagersStore.getSyncOrNull(
userWalletId = userWalletId,
blockchain = Blockchain.fromId(network.id.value),
derivationPath = network.derivationPath.value,
) ?: return@withContext
val tokensToRemove = sdkTokenConverter.convertList(networkTokens)
tokensToRemove.forEach { token ->
walletManager.removeToken(token)
tokensToRemove.forEach { token ->
walletManager.removeToken(token)
}
walletManagersStore.store(userWalletId, walletManager)
}
walletManagersStore.store(userWalletId, walletManager)
}
override suspend fun updatePendingTransactions(
@ -576,12 +580,16 @@ class DefaultWalletManagersFacade(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): AssetRequirementsCondition? {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = currency.network)
val currencyType = cryptoCurrencyTypeConverter.convert(currency)
if (walletManager !is AssetRequirementsManager || !walletManager.hasRequirements(currencyType)) return null
return withContext(dispatchers.io) {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = currency.network)
val currencyType = cryptoCurrencyTypeConverter.convert(currency)
if (walletManager !is AssetRequirementsManager || !walletManager.hasRequirements(currencyType)) {
return@withContext null
}
val condition = walletManager.requirementsCondition(currencyType) ?: return null
return requirementsConditionConverter.convert(condition)
val condition = walletManager.requirementsCondition(currencyType) ?: return@withContext null
requirementsConditionConverter.convert(condition)
}
}
override suspend fun associateAsset(
@ -589,15 +597,18 @@ class DefaultWalletManagersFacade(
currency: CryptoCurrency,
signer: CommonSigner,
): SimpleResult {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = currency.network)
val currencyType = cryptoCurrencyTypeConverter.convert(currency)
return withContext(dispatchers.io) {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = currency.network)
val currencyType = cryptoCurrencyTypeConverter.convert(currency)
if (walletManager !is AssetRequirementsManager) {
return SimpleResult.Failure(
BlockchainSdkError.CustomError("WalletManager is not implemented AssetRequirementsManager"),
)
if (walletManager !is AssetRequirementsManager) {
return@withContext SimpleResult.Failure(
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 {

View file

@ -1,18 +1,43 @@
package com.tangem.domain.tokens.models.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed class TokenSwapPromoAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Swap Promo", event, params, null) {
object Close : TokenSwapPromoAnalyticsEvent(event = "Button - Close")
class Exchange(
token: String,
) : AnalyticsEvent(category = "Promotion", event = event, params = params) {
class NoticePromotionBanner(
source: AnalyticsParam.ScreensSources,
programName: ProgramName,
) : TokenSwapPromoAnalyticsEvent(
event = "Button - Exchange Now",
params = mapOf("Token" to token),
event = "Notice - Promotion Banner",
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.either
import arrow.core.toNonEmptyListOrNull
import com.tangem.domain.tokens.error.AddCurrencyError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -32,9 +31,9 @@ class AddCryptoCurrenciesUseCase(
*
* @param userWalletId The ID of the user's wallet.
* @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))
}
@ -47,13 +46,13 @@ class AddCryptoCurrenciesUseCase(
* @param userWalletId The ID of the user's wallet.
* @param cryptoCurrency Token to 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(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency.Token,
network: Network,
): Either<AddCurrencyError, Unit> = either {
): Either<Throwable, Unit> = either {
val tokenToAdd = currenciesRepository.createTokenCurrency(cryptoCurrency = cryptoCurrency, network = network)
invoke(userWalletId = userWalletId, currencies = listOf(tokenToAdd))
}
@ -66,14 +65,14 @@ class AddCryptoCurrenciesUseCase(
*
* @param userWalletId The ID of the user's wallet.
* @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(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
): Either<AddCurrencyError, Unit> = either {
): Either<Throwable, Unit> = either {
val existingCurrencies = catch({ currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) }) {
raise(AddCurrencyError.DataError(it))
raise(it)
}
val currenciesToAdd = currencies
.filterNot(existingCurrencies::contains)
@ -81,7 +80,7 @@ class AddCryptoCurrenciesUseCase(
?: return@either
catch({ currenciesRepository.addCurrencies(userWalletId, currenciesToAdd) }) {
raise(AddCurrencyError.DataError(it))
raise(it)
}
refreshUpdatedNetworks(userWalletId, currenciesToAdd, existingCurrencies)
@ -91,7 +90,7 @@ class AddCryptoCurrenciesUseCase(
* Refreshes the network statuses for tokens that have corresponding coins in the
* [existingCurrencies] list.
*/
private suspend fun Raise<AddCurrencyError>.refreshUpdatedNetworks(
private suspend fun Raise<Throwable>.refreshUpdatedNetworks(
userWalletId: UserWalletId,
currenciesToAdd: 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?> {
val currency = currencyStatus.currency
val cryptoStatuses = operations.getCurrenciesStatusesSync()
val promoBanner = promoRepository.getChangellyPromoBanner()
val promoBanner = promoRepository.getOkxPromoBanner()
return combine(
showSwapPromoTokenUseCase().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 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 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()
.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 {
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 androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -60,7 +61,9 @@ internal class SendFragment : ComposeFragment() {
@Composable
override fun ScreenContent(modifier: Modifier) {
val currentState = viewModel.stateRouter.currentState.collectAsStateWithLifecycle()
SendScreen(viewModel.uiState, currentState.value)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SendScreen(uiState, currentState.value)
}
override fun onDestroy() {

View file

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

View file

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

View file

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

View file

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

View file

@ -25,6 +25,10 @@ data class CryptoCurrencySwapInfo(
*
* @property providerId provider id
* @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(
val providerId: String,
@ -34,11 +38,13 @@ data class SwapProvider(
val imageLarge: String,
val termsOfUse: String?,
val privacyPolicy: String?,
val isRecommended: Boolean = false,
)
enum class ExchangeProviderType {
DEX,
CEX,
enum class ExchangeProviderType(val providerName: String) {
DEX("DEX"),
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.repository.AppCurrencyRepository
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase
import com.tangem.domain.tokens.model.*
import com.tangem.domain.tokens.model.FeePaidCurrency
@ -60,6 +61,7 @@ internal class SwapInteractorImpl @Inject constructor(
private val sendTransactionUseCase: SendTransactionUseCase,
private val createTransactionUseCase: CreateTransactionUseCase,
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val quotesRepository: QuotesRepository,
private val swapTransactionRepository: SwapTransactionRepository,
private val currencyChecksRepository: CurrencyChecksRepository,
@ -275,7 +277,7 @@ internal class SwapInteractorImpl @Inject constructor(
val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null)
val networkId = fromToken.currency.network.backendId
when (provider.type) {
ExchangeProviderType.DEX -> {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
manageDex(
networkId = networkId,
fromToken = fromToken,
@ -349,7 +351,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
} else {
provider to getQuotesState(
exchangeProviderType = ExchangeProviderType.DEX,
exchangeProviderType = provider.type,
quoteDataModel = quotes,
amount = amount,
fromToken = fromToken,
@ -420,13 +422,17 @@ internal class SwapInteractorImpl @Inject constructor(
fromToken.network.backendId,
fromToken.network.derivationPath.value,
) ?: ProxyAmount.empty()
// ignore if amount is bigger than balance
if (amount.value > nativeBalance.value) {
return
}
val fee = when (txFee) {
TxFeeState.Empty -> BigDecimal.ZERO
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue
is TxFeeState.SingleFeeState -> txFee.fee.feeValue
}
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))
}
}
@ -568,6 +574,9 @@ internal class SwapInteractorImpl @Inject constructor(
includeFeeInAmount: IncludeFeeInAmount,
fee: TxFee,
): SwapTransactionState {
val cardId = getSelectedWallet()?.scanResponse?.card?.cardId ?: return SwapTransactionState.UnknownError
if (isDemoCardUseCase(cardId)) return SwapTransactionState.DemoMode
return when (swapProvider.type) {
ExchangeProviderType.CEX -> {
val amountDecimal = toBigDecimalOrNull(amountToSwap)
@ -586,8 +595,9 @@ internal class SwapInteractorImpl @Inject constructor(
userWalletId = requireNotNull(getSelectedWallet()).walletId,
)
}
ExchangeProviderType.DEX -> {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
onSwapDex(
provider = swapProvider,
networkId = currencyToSend.currency.network.backendId,
swapData = requireNotNull(swapData),
currencyToSendStatus = currencyToSend,
@ -639,6 +649,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
private suspend fun onSwapDex(
provider: SwapProvider,
networkId: String,
swapData: SwapDataModel,
currencyToSendStatus: CryptoCurrencyStatus,
@ -650,7 +661,8 @@ internal class SwapInteractorImpl @Inject constructor(
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals)
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(
amount = amount.value.convertToAmount(currencyToSendStatus.currency),
fee = getFeeForTransaction(
@ -684,6 +696,17 @@ internal class SwapInteractorImpl @Inject constructor(
txHash = txHash,
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)
SwapTransactionState.TxSent(
fromAmount = amountFormatter.formatSwapAmountToUI(
@ -700,16 +723,7 @@ internal class SwapInteractorImpl @Inject constructor(
timestamp = System.currentTimeMillis(),
)
},
ifLeft = {
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
}
},
ifLeft = { handleSendTxError(it) },
)
}
@ -781,14 +795,7 @@ internal class SwapInteractorImpl @Inject constructor(
val derivationPath = currencyToSend.currency.network.derivationPath.value
return result.fold(
ifLeft = {
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
}
handleSendTxError(it)
},
ifRight = { txHash ->
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 {
val feeAmountValue = fee.feeValue
val feeAmount = Amount(
@ -882,11 +900,12 @@ internal class SwapInteractorImpl @Inject constructor(
swapProvider: SwapProvider,
swapDataModel: SwapDataModel,
timestamp: Long,
txExternalUrl: String,
txExternalId: String,
txExternalUrl: String? = null,
txExternalId: String? = null,
) {
val selectedWallet = getSelectedWallet() ?: return
swapTransactionRepository.storeTransaction(
userWalletId = UserWalletId(userWalletManager.getWalletId()),
userWalletId = selectedWallet.walletId,
fromCryptoCurrency = currencyToSend.currency,
toCryptoCurrency = currencyToGet.currency,
transaction = SavedSwapTransactionModel(
@ -1072,7 +1091,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
when (exchangeProviderType) {
ExchangeProviderType.DEX -> {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
val state = updatePermissionState(
networkId = networkId,
fromTokenStatus = fromToken,
@ -1407,19 +1426,25 @@ internal class SwapInteractorImpl @Inject constructor(
swapAmount = swapAmount,
spenderAddress = requireNotNull(spenderAddress) { "Spender address is null" },
)
val feeData = try {
transactionManager.getFee(
networkId = networkId,
amountToSend = BigDecimal.ZERO,
currencyToSend = swapCurrencyConverter.convert(repository.getNativeTokenForNetwork(networkId)),
destinationAddress = fromToken.getContractAddress(),
increaseBy = INCREASE_GAS_LIMIT_BY,
data = transactionData,
derivationPath = derivationPath,
)
} catch (e: Exception) {
Timber.e(e, "Failed to get fee")
null
val userWallet = getSelectedWallet()
val cardId = userWallet?.scanResponse?.card?.cardId
val feeData = if (cardId != null && isDemoCardUseCase(cardId)) {
getDemoFees(fromTokenStatus.currency)
} else {
try {
transactionManager.getFee(
networkId = networkId,
amountToSend = BigDecimal.ZERO,
currencyToSend = swapCurrencyConverter.convert(repository.getNativeTokenForNetwork(networkId)),
destinationAddress = fromToken.getContractAddress(),
increaseBy = INCREASE_GAS_LIMIT_BY,
data = transactionData,
derivationPath = derivationPath,
)
} catch (e: Exception) {
Timber.e(e, "Failed to get fee")
null
}
}
val feeState = feeData?.let {
when (feeData) {
@ -1803,10 +1828,37 @@ internal class SwapInteractorImpl @Inject constructor(
.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 {
@Suppress("UnusedPrivateMember")
private const val INCREASE_GAS_LIMIT_BY = 112 // 12%
private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5%
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 createTransactionUseCase: CreateTransactionUseCase,
createTransactionDataExtrasUseCase: CreateTransactionDataExtrasUseCase,
isDemoCardUseCase: IsDemoCardUseCase,
quotesRepository: QuotesRepository,
swapTransactionRepository: SwapTransactionRepository,
appCurrencyRepository: AppCurrencyRepository,
@ -62,6 +63,7 @@ class SwapDomainModule {
sendTransactionUseCase = sendTransactionUseCase,
createTransactionUseCase = createTransactionUseCase,
createTransactionExtrasUseCase = createTransactionDataExtrasUseCase,
isDemoCardUseCase = isDemoCardUseCase,
quotesRepository = quotesRepository,
swapTransactionRepository = swapTransactionRepository,
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
private const val SWAP_CATEGORY = "Swap"
private const val PROMO_CATEGORY = "Promo"
sealed class SwapEvents(
event: String,
@ -17,7 +18,7 @@ sealed class SwapEvents(
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(
event = "Choose Token Screen Opened",
@ -37,7 +38,7 @@ sealed class SwapEvents(
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(
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(
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(
event = "Provider Chosen",
@ -88,7 +89,7 @@ sealed class SwapEvents(
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(
event = "Notice - Not Enough Fee",
@ -110,4 +111,20 @@ sealed class SwapEvents(
"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
sealed class AdditionalBadge {
object BestTrade : AdditionalBadge()
object Empty : AdditionalBadge()
object PermissionRequired : AdditionalBadge()
data object BestTrade : AdditionalBadge()
data object Empty : AdditionalBadge()
data object PermissionRequired : AdditionalBadge()
data object Recommended : AdditionalBadge()
}
@Immutable
@ -54,6 +55,7 @@ sealed class ProviderState {
NONE, CLICK, SELECT
}
// Prefix will be disabled in 5.12 but mechanism is still implemented
@Immutable
enum class PrefixType {
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")
@Suppress("LongMethod")
@Composable
@ -142,16 +143,12 @@ private fun ProviderContentState(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
)
}
val badgeModifier = Modifier.padding(start = TangemTheme.dimens.spacing4)
when (state.additionalBadge) {
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(
Modifier.padding(start = TangemTheme.dimens.spacing4),
)
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(
Modifier.padding(start = TangemTheme.dimens.spacing4),
)
ProviderState.AdditionalBadge.Empty -> {
// no-op
}
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(badgeModifier)
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(badgeModifier)
ProviderState.AdditionalBadge.Recommended -> RecommendedItem(badgeModifier)
ProviderState.AdditionalBadge.Empty -> Unit
}
}
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
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)

View file

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

View file

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

View file

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

View file

@ -435,8 +435,11 @@ internal class SwapViewModel @Inject constructor(
if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) {
currentSelected
} else {
findBestQuoteProvider(consideredProviders.getLastLoadedSuccessStates())
?: consideredProviders.keys.first()
val successLoadedData = consideredProviders.getLastLoadedSuccessStates()
val recommendedProvider = successLoadedData.keys.firstOrNull { it.isRecommended }
val bestQuotesProvider = findBestQuoteProvider(successLoadedData)
triggerPromoProviderEvent(recommendedProvider, bestQuotesProvider)
recommendedProvider ?: bestQuotesProvider ?: consideredProviders.keys.first()
}
} else {
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 {
const val loggingTag = "SwapViewModel"
const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L
const val DEBOUNCE_AMOUNT_DELAY = 1000L
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.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
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.components.TokenDetailsNotification
@ -35,12 +37,15 @@ internal class TokenDetailsNotificationsAnalyticsSender(
-> TokenDetailsAnalyticsEvent.Notice.NotEnoughFee(
currency = cryptoCurrency,
)
is TokenDetailsNotification.SwapPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner(
programName = TokenSwapPromoAnalyticsEvent.ProgramName.OKX,
source = AnalyticsParam.ScreensSources.Token,
)
is TokenDetailsNotification.NetworksUnreachable,
is TokenDetailsNotification.ExistentialDeposit,
is TokenDetailsNotification.NetworksNoAccount,
is TokenDetailsNotification.TopUpWithoutReserve,
is TokenDetailsNotification.RentInfo,
is TokenDetailsNotification.SwapPromo,
is TokenDetailsNotification.NetworkShutdown,
is TokenDetailsNotification.HederaAssociateWarning,
is TokenDetailsNotification.KoinosMana,

View file

@ -2,7 +2,10 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.componen
import androidx.compose.runtime.Immutable
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.warnings.CryptoCurrencyWarning
import com.tangem.features.tokendetails.impl.R
@ -45,16 +48,9 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
val onCloseClick: () -> Unit,
) : TokenDetailsNotification(
config = NotificationConfig(
title = resourceReference(
id = R.string.token_swap_changelly_promotion_title,
formatArgs = wrappedList("0%"),
),
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,
title = resourceReference(id = R.string.swap_promo_title),
subtitle = resourceReference(id = R.string.swap_promo_text),
iconResId = R.drawable.img_okx_dex_logo,
onCloseClick = onCloseClick,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
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> {
if (status == null) return persistentListOf()
val isWaiting = status == ExchangeStatus.New || status == ExchangeStatus.Waiting
val isWaitingTxHash = status == ExchangeStatus.WaitingTxHash
val isConfirming = status == ExchangeStatus.Confirming
val isVerifying = status == ExchangeStatus.Verifying
val isExchanging = status == ExchangeStatus.Exchanging
@ -174,27 +175,35 @@ internal class TokenDetailsSwapTransactionsStateConverter(
val isExchangingDone = !isExchanging && isConfirmingDone
val isSendingDone = !isSending && !isVerifying && !isFailed && isExchangingDone
return if (status == ExchangeStatus.Cancelled) {
listOf(cancelledStep())
} else {
listOf(
waitStep(isWaiting, isWaitingDone),
confirmStep(isConfirming, isConfirmingDone),
exchangeStep(
isExchanging = isExchanging,
isExchangingDone = isExchangingDone,
isRefunded = isRefunded,
hasFailed = hasFailed,
isVerifying = isVerifying,
isFailed = isFailed,
),
sendStep(
isSending = isSending,
isSendingDone = isSendingDone,
isRefunded = isRefunded,
hasFailed = hasFailed,
),
)
return buildList {
when {
status == ExchangeStatus.Cancelled -> add(cancelledStep())
isWaitingTxHash -> add(waitTxStep())
// ExchangeStatus.Unknown is temporary added for 1inch
status == ExchangeStatus.Unknown -> add(unknownStateStep())
else -> {
add(waitStep(isWaiting, isWaitingDone))
add(confirmStep(isConfirming, isConfirmingDone))
add(
exchangeStep(
isExchanging = isExchanging,
isExchangingDone = isExchangingDone,
isRefunded = isRefunded,
hasFailed = hasFailed,
isVerifying = isVerifying,
isFailed = isFailed,
),
)
add(
sendStep(
isSending = isSending,
isSendingDone = isSendingDone,
isRefunded = isRefunded,
hasFailed = hasFailed,
),
)
}
}
}.toPersistentList()
}
@ -216,6 +225,20 @@ internal class TokenDetailsSwapTransactionsStateConverter(
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(
status = ExchangeStatus.Confirming,
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.MarketPriceBlockState
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.txHistoryItems
import com.tangem.core.ui.event.EventEffect
@ -116,7 +116,7 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) {
contentType = { it.config::class.java },
itemContent = {
if (it is TokenDetailsNotification.SwapPromo) {
NotificationWithBackground(
OkxPromoNotification(
config = it.config,
modifier = itemModifier.animateItemPlacement(),
)

View file

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

View file

@ -163,6 +163,7 @@ internal class ExchangeStatusFactory(
return when (status) {
ExchangeStatus.New,
ExchangeStatus.Waiting,
ExchangeStatus.WaitingTxHash,
ExchangeStatus.Sending,
ExchangeStatus.Confirming,
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.bundle.unbundle
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.global.BuyCurrencyDeepLink
import com.tangem.core.ui.clipboard.ClipboardManager
@ -565,7 +566,13 @@ internal class TokenDetailsViewModel @Inject constructor(
ifRight = { it },
)
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() {
viewModelScope.launch(dispatchers.main) {
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() {
viewModelScope.launch(dispatchers.main) {
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)
}

View file

@ -121,38 +121,4 @@ sealed class WalletScreenAnalyticsEvent {
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.models.AnalyticsEvent
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.state.model.WalletNotification
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.RateApp -> MainScreen.HowDoYouLikeTangem
is WalletNotification.Critical.BackupError -> MainScreen.BackupError
is WalletNotification.TravalaPromo -> WalletScreenAnalyticsEvent.Promotion.NoticePromotionBanner(
is WalletNotification.TravalaPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner(
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.Informational.NoAccount,
is WalletNotification.Warning.LowSignatures,
is WalletNotification.Warning.SomeNetworksUnreachable,
is WalletNotification.Warning.NetworksUnreachable,
is WalletNotification.SwapPromo,
-> null
}
}

View file

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

View file

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

View file

@ -47,6 +47,8 @@ internal object WalletImageResolver {
cardTypesResolver.isCOQWallet() -> userWallet.resolveCOQWallet()
cardTypesResolver.isCoinMetricaWallet() -> userWallet.resolveCoinMetricaWallet()
cardTypesResolver.isVoltInuWallet() -> userWallet.resolveVoltInuWallet()
cardTypesResolver.isVividWallet() -> userWallet.resolveVividWallet()
cardTypesResolver.isPastelWallet() -> userWallet.resolvePastelWallet()
cardTypesResolver.isWallet2() -> userWallet.resolveWallet2()
cardTypesResolver.isShibaWallet() -> userWallet.resolveShibaWallet()
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? {
return resolveWalletWithBackups { count ->
when (count) {

View file

@ -14,6 +14,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -36,6 +37,7 @@ internal class WalletStateController @Inject constructor() {
}
fun update(transformer: WalletScreenStateTransformer) {
Timber.d("Applying: ${transformer::class.simpleName}")
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 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.feature.wallet.impl.R
import org.joda.time.DateTime
@ -205,16 +208,9 @@ sealed class WalletNotification(val config: NotificationConfig) {
val onCloseClick: () -> Unit,
) : WalletNotification(
config = NotificationConfig(
title = resourceReference(
id = R.string.main_swap_changelly_promotion_title,
formatArgs = wrappedList("0%"),
),
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,
title = resourceReference(id = R.string.swap_promo_title),
subtitle = resourceReference(id = R.string.swap_promo_text),
iconResId = R.drawable.img_okx_dex_logo,
onCloseClick = onCloseClick,
),
)

View file

@ -1,22 +1,32 @@
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.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.WalletTokensListState
import timber.log.Timber
import java.math.BigDecimal
internal class SetTokenListErrorTransformer(
userWalletId: UserWalletId,
private val selectedWallet: UserWallet,
private val error: TokenListError,
) : WalletStateTransformer(userWalletId) {
private val appCurrency: AppCurrency,
) : WalletStateTransformer(selectedWallet.walletId) {
override fun transform(prevState: WalletState): WalletState {
return when (error) {
is TokenListError.EmptyTokens -> {
when (prevState) {
is WalletState.MultiCurrency.Content -> {
prevState.copy(tokensListState = WalletTokensListState.Empty)
prevState.copy(
walletCardState = prevState.walletCardState.toLoadedState(),
tokensListState = WalletTokensListState.Empty,
)
}
is WalletState.MultiCurrency.Locked -> {
Timber.w("Impossible to load tokens list for locked wallet")
@ -37,4 +47,21 @@ internal class SetTokenListErrorTransformer(
-> 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(),
transform = { maybeTokenList, maybeAppCurrency ->
val appCurrency = maybeAppCurrency.getOrElse { e ->
Timber.e("Failed to load app currency: $e")
AppCurrency.Default
}
val tokenList = maybeTokenList.getOrElse(
ifLoading = { maybeContent ->
val isRefreshing = stateHolder.getWalletState(userWallet.walletId)
@ -74,16 +79,17 @@ internal abstract class BasicTokenListSubscriber(
},
ifError = { e ->
Timber.e("Failed to load token list: $e")
SetTokenListErrorTransformer(userWallet.walletId, e)
stateHolder.update(
SetTokenListErrorTransformer(
selectedWallet = userWallet,
error = e,
appCurrency = appCurrency,
),
)
return@combine
},
)
val appCurrency = maybeAppCurrency.getOrElse { e ->
Timber.e("Failed to load app currency: $e")
AppCurrency.Default
}
updateContent(tokenList, appCurrency)
walletWithFundsChecker.check(tokenList)
},

View file

@ -5,7 +5,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
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.res.TangemTheme
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
when (it) {
is WalletNotification.SwapPromo -> {
NotificationWithBackground(
OkxPromoNotification(
config = it.config,
modifier = modifier.animateItemPlacement(),
)

View file

@ -7,6 +7,7 @@ import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.settings.*
import com.tangem.domain.wallets.models.UserWalletId
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.WalletEvent
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.transformers.*
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 walletDeepLinksHandler: WalletDeepLinksHandler,
private val walletNameMigrationUseCase: WalletNameMigrationUseCase,
private val refreshMultiCurrencyWalletQuotesUseCase: RefreshMultiCurrencyWalletQuotesUseCase,
private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase,
private val isFirstTimeAskingPermissionUseCase: IsFirstTimeAskingPermissionUseCase,
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
private fun subscribeToScreenBackgroundState() {
screenLifecycleProvider.isBackgroundState
@ -214,7 +215,7 @@ internal class WalletViewModel @Inject constructor(
refreshWalletJobHolder.cancel()
when {
isBackground -> needToRefreshTimer()
needToRefreshWallet && !isBackground -> triggerRefreshWallet()
needToRefreshWallet && !isBackground -> triggerRefreshWalletQuotes()
}
}
.launchIn(viewModelScope)
@ -227,13 +228,15 @@ internal class WalletViewModel @Inject constructor(
}.saveIn(refreshWalletJobHolder)
}
private fun triggerRefreshWallet() {
private fun triggerRefreshWalletQuotes() {
needToRefreshWallet = false
val state = stateHolder.uiState.value
val wallet = state.wallets.getOrNull(state.selectedWalletIndex) ?: return
wallet.pullToRefreshConfig.onRefresh.invoke(
WalletPullToRefreshConfig.ShowRefreshState(false),
)
viewModelScope.launch {
refreshMultiCurrencyWalletQuotesUseCase(wallet.walletCardState.id).getOrElse {
Timber.e("Failed to refreshMultiCurrencyWalletQuotesUseCase $it")
}
}.saveIn(refreshWalletJobHolder)
}
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.UnlockWallet -> unlockWallet(action)
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))
}
is WalletsUpdateActionResolver.Action.UpdateWalletName -> {

View file

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

View file

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