Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-06 16:58:32 +07:00
commit 6de53d32c1
129 changed files with 1419 additions and 514 deletions

@ -1 +1 @@
Subproject commit b55ca9ec9bc7ff88a8764f00656ee7a884df9125
Subproject commit a5d1a89425a95bc9c90c7a6fed3c578b0d324994

View file

@ -3,6 +3,7 @@ package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@ -18,6 +19,7 @@ import kotlin.contracts.contract
@Singleton
class AppsFlyerReferralParamsHandler @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase,
dispatchers: CoroutineDispatcherProvider,
) {
@ -69,6 +71,8 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private fun storeConversionData(refcode: String, campaign: String?) {
coroutineScope.launch {
mutex.withLock {
setShouldShowMobileWalletPromoUseCase()
.onLeft { Timber.e(it) }
appsFlyerStore.storeIfAbsent(
value = AppsFlyerConversionData(refcode = refcode, campaign = campaign),
)

View file

@ -1,8 +1,13 @@
package com.tangem.tap.di
import android.content.Context
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.domain.card.BuildConfig
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
@ -10,6 +15,7 @@ import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -30,6 +36,11 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
@GlobalUiMessageSender uiMessageSender: UiMessageSender,
appFinisher: AppFinisher,
sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
analyticsExceptionHandler: AnalyticsExceptionHandler,
dispatchers: CoroutineDispatcherProvider,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
MockTangemSdkManager(resources = context.resources)
@ -41,6 +52,11 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
uiMessageSender = uiMessageSender,
appFinisher = appFinisher,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
analyticsExceptionHandler = analyticsExceptionHandler,
dispatchers = dispatchers,
)
}
}

View file

@ -18,12 +18,21 @@ import com.tangem.common.extensions.hexToBytes
import com.tangem.common.services.secure.SecureStorage
import com.tangem.common.usersCode.UserCodeRepository
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.res.getStringSafe
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.crypto.bip39.DefaultMnemonic
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
@ -57,13 +66,14 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
import com.tangem.tap.domain.twins.FinalizeTwinTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.R
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
@Suppress("TooManyFunctions", "LargeClass")
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList")
internal class DefaultTangemSdkManager(
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val resources: Resources,
@ -71,6 +81,11 @@ internal class DefaultTangemSdkManager(
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val uiMessageSender: UiMessageSender,
private val appFinisher: AppFinisher,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
dispatchers: CoroutineDispatcherProvider,
) : TangemSdkManager {
private val awaitInitializationMutex = Mutex()
@ -99,6 +114,8 @@ internal class DefaultTangemSdkManager(
override val userCodeRequestPolicy: UserCodeRequestPolicy
get() = tangemSdk.config.userCodeRequestPolicy
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.io)
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean {
return try {
needEnrollBiometrics
@ -413,9 +430,16 @@ internal class DefaultTangemSdkManager(
break
} else {
if (attemps++ >= MAX_INITIALIZE_ATTEMPTS) {
error("Can't initialize authentication manager after $MAX_INITIALIZE_ATTEMPTS attempts")
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = IllegalStateException(
"Can't initialize authentication manager after $MAX_INITIALIZE_ATTEMPTS attempts",
),
),
)
showAlert()
} else {
delay(timeMillis = 200)
delay(timeMillis = 400)
}
}
} while (true)
@ -424,6 +448,28 @@ internal class DefaultTangemSdkManager(
}
}
private fun showAlert() {
uiMessageSender.send(
message = DialogMessage(
message = resourceReference(id = R.string.alert_authentication_error_message),
title = resourceReference(id = R.string.alert_authentication_error_title),
isDismissable = false,
dismissOnFirstAction = false,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.alert_button_request_support),
onClick = {
coroutineScope.launch {
sendFeedbackEmailUseCase(FeedbackEmailType.BiometricsAuthenticationFailed)
}
},
)
},
secondActionBuilder = { cancelAction(onClick = appFinisher::finish) },
),
)
}
// region Twin-specific
override suspend fun createFirstTwinWallet(

View file

@ -144,6 +144,7 @@ internal class ChildFactory @Inject constructor(
val source = when (route.source) {
AppRoute.ManageTokens.Source.SETTINGS -> ManageTokensSource.SETTINGS
AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES
AppRoute.ManageTokens.Source.ACCOUNT -> ManageTokensSource.ACCOUNT
}
val mode = when (val portfolio = route.portfolioId) {

View file

@ -3,9 +3,12 @@ package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.test.core.ProvideTestModels
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import arrow.core.right
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
@ -22,9 +25,13 @@ import org.junit.jupiter.params.ParameterizedTest
class AppsFlyerReferralParamsHandlerTest {
private val appsFlyerStore: AppsFlyerStore = mockk(relaxUnitFun = true)
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase = mockk {
coEvery { this@mockk.invoke() } returns Unit.right()
}
private val handler = AppsFlyerReferralParamsHandler(
appsFlyerStore = appsFlyerStore,
dispatchers = TestingCoroutineDispatcherProvider(),
setShouldShowMobileWalletPromoUseCase = setShouldShowMobileWalletPromoUseCase,
)
@AfterEach
@ -43,6 +50,7 @@ class AppsFlyerReferralParamsHandlerTest {
if (model.shouldStore) {
val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN)
coVerify { appsFlyerStore.storeIfAbsent(value = value) }
} else {
coVerify(inverse = true) { appsFlyerStore.storeIfAbsent(value = any()) }

View file

@ -138,6 +138,7 @@ sealed class AppRoute(val path: String) : Route {
enum class Source {
STORIES,
SETTINGS,
ACCOUNT,
}
}

View file

@ -181,7 +181,7 @@ private fun RowScope.TokenRatingPlace(ratingPosition: String?) {
.alignByBaseline()
.heightIn(min = TangemTheme.dimens.size16)
.background(
color = TangemTheme.colors.field.primary,
color = TangemTheme.colors.button.secondary,
shape = TangemTheme.shapes.roundedCornersSmall2,
)
.padding(horizontal = TangemTheme.dimens.spacing5),

View file

@ -1,5 +1,8 @@
package com.tangem.core.analytics.models
import com.tangem.core.analytics.models.AnalyticsParam.Key.REFERRAL
import com.tangem.core.analytics.models.AnalyticsParam.Key.REFERRAL_ID
const val IS_NOT_HTTP_ERROR = "Is not http error"
sealed class AnalyticsParam {
@ -284,6 +287,15 @@ sealed class AnalyticsParam {
const val ENS = "ENS"
const val ENS_ADDRESS = "ENS Address"
const val ACCOUNT_DERIVATION_FROM = "Account Derivation (from)"
const val ACCOUNT_DERIVATION_TO = "Account Derivation (to)"
const val FEE_TOKEN = "Fee Token"
const val ACCOUNT_DERIVATION = "Account Derivation"
const val REFERRAL = "Referral"
const val REFERRAL_ID = "Referral_ID"
}
}
}
fun getReferralParams(referralId: String?): List<Pair<String, String>> = listOf(
REFERRAL to (!referralId.isNullOrBlank()).toString().replaceFirstChar(Char::titlecase),
REFERRAL_ID to (referralId ?: "Empty"),
)

View file

@ -1,22 +1,32 @@
package com.tangem.core.analytics.models
/**
* Marker interface for analytics events that should be sent only once per session.
* Marker interface for analytics events that can be throttled by session and/or time.
*
* Events implementing this interface will be tracked by their [oneTimeEventId] to ensure
* they are not sent multiple times during the same application session. Once an event
* with a specific [oneTimeEventId] has been sent, subsequent attempts to send an event
* with the same ID will be ignored.
* Events implementing this interface will be tracked by their [oneTimeEventId].
*
* Behavior depends on [throttleSeconds]:
* - **null** (default): One time per session event is sent only once during the application session.
* - **non-null**: Time-based throttling event is not sent if less than [throttleSeconds] seconds
* have passed since the last send for this [oneTimeEventId].
*
* @see Analytics.send
*/
interface OneTimePerSessionEvent {
/**
* Unique identifier for the one-time event.
* Unique identifier for the throttled event.
*
* This ID is used to track whether the event has already been sent in the current session.
* Events with the same [oneTimeEventId] will only be sent once, even if they are
* different instances of the same event class.
* This ID is used to track whether and when the event was last sent.
* Events with the same [oneTimeEventId] share the same throttling state.
*/
val oneTimeEventId: String
/**
* Minimum interval in seconds between sends for this event.
*
* - **null**: One time per session only. Event is sent at most once per session.
* - **non-null**: Don't send if less than this many seconds have passed since the last send.
*/
val throttleSeconds: Long?
get() = null
}

View file

@ -3,6 +3,7 @@ package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
sealed class OnboardingAnalyticsEvent(
category: String,
@ -55,6 +56,7 @@ sealed class OnboardingAnalyticsEvent(
creationType: WalletCreationType = WalletCreationType.NewSeed,
seedPhraseLength: Int? = null,
passPhraseState: AnalyticsParam.EmptyFull,
referralId: String?,
) : CreateWallet(
event = "Wallet Created Successfully",
params = buildMap {
@ -64,6 +66,7 @@ sealed class OnboardingAnalyticsEvent(
if (seedPhraseLength != null) {
put("Seed Phrase Length", seedPhraseLength.toString())
}
putAll(getReferralParams(referralId))
},
), AppsFlyerIncludedEvent

View file

@ -13,6 +13,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
[REDACTED_AUTHOR]
@ -32,7 +33,7 @@ object Analytics : GlobalAnalyticsEventHandler {
private val handlers = mutableMapOf<String, AnalyticsHandler>()
private val paramsInterceptors = ConcurrentHashMap<String, ParamsInterceptor>()
private val oneEventsPerSession = ConcurrentHashMap<String, Boolean>()
private val throttledEventsState = ConcurrentHashMap<String, Long>()
private val analyticsFilters = mutableSetOf<AnalyticsEventFilter>()
private val analyticsMutex = Mutex()
@ -87,9 +88,7 @@ object Analytics : GlobalAnalyticsEventHandler {
override fun send(event: AnalyticsEvent) {
analyticsScope.launch {
if (event is OneTimePerSessionEvent &&
oneEventsPerSession.putIfAbsent(event.oneTimeEventId, true) != null
) {
if (event is OneTimePerSessionEvent && !shouldSendThrottledEvent(event)) {
return@launch
}
event.params = applyParamsInterceptors(event)
@ -137,6 +136,21 @@ object Analytics : GlobalAnalyticsEventHandler {
return interceptedParams
}
private fun shouldSendThrottledEvent(event: OneTimePerSessionEvent): Boolean {
val now = System.currentTimeMillis()
val id = event.oneTimeEventId
return when (val throttleMs = event.throttleSeconds?.let(TimeUnit.SECONDS::toMillis)) {
null -> throttledEventsState.putIfAbsent(id, now) == null
else -> throttledEventsState.compute(id) { _, lastSendTime ->
when {
lastSendTime == null -> now
now - lastSendTime >= throttleMs -> now
else -> lastSendTime
}
} == now
}
}
private fun createScope(): CoroutineScope {
val name = "Analytics"
val dispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()

View file

@ -7,4 +7,5 @@ import com.squareup.moshi.JsonClass
data class PromocodeActivationBody(
@Json(name = "promoCode") val promoCode: String,
@Json(name = "address") val address: String,
@Json(name = "walletId") val walletId: String,
)

View file

@ -86,6 +86,8 @@
<string name="address_qr_code_message_format">Sende nur %1$s ( %2$s ) vom %3$s -Netzwerk an diese Adresse. Die Verwendung anderer Token und Netzwerke kann zum Verlust von Geldern führen.</string>
<string name="address_type_default">Standard</string>
<string name="address_type_legacy">Altbestand</string>
<string name="alert_authentication_error_message">Bei der biometrischen Authentifizierung ist ein Fehler aufgetreten. Bitte setzen Sie die Biometrie auf Ihrem Gerät zurück oder kontaktieren Sie den Support.</string>
<string name="alert_authentication_error_title">Authentifizierungsfehler</string>
<string name="alert_button_how_to_scan">So scannt man</string>
<string name="alert_button_request_support">Hilfe anfordern</string>
<string name="alert_button_try_again">Erneut versuchen</string>
@ -299,6 +301,7 @@
<string name="common_go_to_token">Zum Token</string>
<string name="common_got_it">Verstanden</string>
<string name="common_hide">Ausblenden</string>
<string name="common_hold_to">Halten bis %s</string>
<string name="common_hour">Stunde</string>
<string name="common_import">Importieren</string>
<string name="common_in_progress">In Arbeit</string>
@ -374,6 +377,7 @@
<string name="common_swap">Tauschen</string>
<string name="common_tangem">Tangem</string>
<string name="common_tangem_wallet">Tangem Wallet</string>
<string name="common_tap_and_hold_hint">Tippen und halten</string>
<string name="common_terms_and_conditions">Allgemeine Geschäftsbedingungen</string>
<string name="common_terms_of_use">Nutzungsbedingungen</string>
<string name="common_to">An</string>
@ -463,6 +467,14 @@
<string name="domain_receive_assets_onboarding_description">Das Senden von Vermögenswerten in anderen Netzwerken führt zu dauerhaftem Verlust.</string>
<string name="domain_receive_assets_onboarding_network_name">%s Netzwerk</string>
<string name="domain_receive_assets_onboarding_title">Sende Geld nur mit</string>
<string name="earn_best_opportunities">Beste Gelegenheiten</string>
<string name="earn_clear_filter">Filter löschen</string>
<string name="earn_filter_all_networks">Alle Netzwerke</string>
<string name="earn_filter_all_types">Alle Arten</string>
<string name="earn_filter_by">Filtern nach</string>
<string name="earn_mostly_used">Meist verwendet</string>
<string name="earn_no_results">Keine Ergebnisse</string>
<string name="earn_title">Verdienen</string>
<string name="email_preface_wc_error">Hallo Support-Team, ich habe einen Fehler mit dem Code %s festgestellt.</string>
<string name="email_subject_wc_error">WalletConnect-Fehler</string>
<string name="error_wrong_wallet_tapped">Du hast eine Karte oder Ring aus einer anderen Wallet verwendet. Tippe auf die Karte oder Ring, die dieser Wallet zugeordnet ist.</string>
@ -613,6 +625,7 @@
<string name="hw_backup_need_title">Zuerst die Sicherung abschließen</string>
<string name="hw_backup_no_backup">Unvollständig</string>
<string name="hw_backup_section_other_title">Andere Methoden</string>
<string name="hw_backup_seed_code_description">Bewahre Deine Wiederherstellungsphrase an einem sicheren Ort auf und halte diese geheim, um Dein Guthaben zu schützen. Richte außerdem einen 8 stelligen Zugangscode für zusätzliche Sicherheit ein.</string>
<string name="hw_backup_seed_description">Speicher Deinen Wiederherstellungssatz an einem sicheren Ort und halte diesen stets geheim, um Dein Geld zu schützen.</string>
<string name="hw_backup_seed_title">Wiederherstellungs-Phrase</string>
<string name="hw_backup_to_secure_description">Um Deine Wallet mit einem Zugangscode zu sichern, schließe den Sicherungsvorgang ab.</string>
@ -1463,6 +1476,9 @@
<string name="tangem_pay_freeze_card_freeze">Einfrieren</string>
<string name="tangem_pay_freeze_card_success">Ihre Karte ist eingefroren.</string>
<string name="tangem_pay_get_help">Hilfe erhalten</string>
<string name="tangem_pay_history_item_spend_mc_declined_reason">Grund: %s</string>
<string name="tangem_pay_history_item_spend_mc_title_format" formatted="false">%s · %s</string>
<string name="tangem_pay_history_item_spend_mcc">MCC %s</string>
<string name="tangem_pay_other">Andere</string>
<string name="tangem_pay_rooted_device_subtitle">Nicht nutzbar auf gerooteten Geräten</string>
<string name="tangem_pay_status_completed">Abgeschlossen</string>
@ -1834,7 +1850,18 @@
<string name="warning_button_ok">OK, habe ich verstanden!</string>
<string name="warning_button_really_cool">Echt toll!</string>
<string name="warning_button_refresh">Aktualisieren</string>
<string name="warning_clore_migration_button">Migration starten</string>
<string name="warning_clore_migration_copy_button">Kopie</string>
<string name="warning_clore_migration_description">Um weiterhin Zugriff auf Deine Gelder zu haben, beginne die Migration gemäß den offiziellen Clore-Richtlinien.</string>
<string name="warning_clore_migration_error_signing_not_supported">Die Nachrichtensignatur wird für dieses Netzwerk nicht unterstützt.</string>
<string name="warning_clore_migration_error_wallet_manager_not_found">Nachricht konnte nicht signiert werden. Bitte versuche es erneut.</string>
<string name="warning_clore_migration_message">Laut der offiziellen Dokumentation von Clore werden alle Münzen, die vor dem 21. Dezember erhalten wurden, in Clore (ERC-20 Token) migriert; Münzen, die nach diesem Datum erhalten wurden, nicht. Eine Lösung für den Transfer ist in Arbeit — bleibt dran.</string>
<string name="warning_clore_migration_message_label">Nachricht</string>
<string name="warning_clore_migration_open_portal_button">Claim-Portal öffnen</string>
<string name="warning_clore_migration_sheet_description">Um Deine Clore-Token weiterhin nutzen zu können, musst Du die Token-Migration gemäß den Informationen im Claim Portal durchführen.</string>
<string name="warning_clore_migration_sheet_title">Clore-Netzwerkmigration</string>
<string name="warning_clore_migration_sign_button">signieren</string>
<string name="warning_clore_migration_signature_label">Unterschrift</string>
<string name="warning_clore_migration_title">Migration des Clore-Netzwerks</string>
<string name="warning_demo_mode_message">Du befindest sich derzeit im Demo-Modus</string>
<string name="warning_demo_mode_title">Demo-Modus aktiv</string>

View file

@ -86,6 +86,8 @@
<string name="address_qr_code_message_format">Envíe solo %1$s (%2$s) desde redes como %3$s a esta dirección. Usar otros tokens y redes puede resultar en la pérdida de fondos.</string>
<string name="address_type_default">Por defecto</string>
<string name="address_type_legacy">Legacy</string>
<string name="alert_authentication_error_message">Algo salió mal con la biometría. Por favor, intente restablecer la biometría en su dispositivo o contacte con soporte.</string>
<string name="alert_authentication_error_title">Error de inicio de sesión</string>
<string name="alert_button_how_to_scan">Cómo escanear</string>
<string name="alert_button_request_support">Solicitar soporte</string>
<string name="alert_button_try_again">Inténtelo de nuevo</string>
@ -836,7 +838,7 @@
<item quantity="other">Hace %d minutos</item>
</plurals>
<string name="news_quick_recap">Resumen rápido</string>
<string name="news_related_news">Noticias relacionadas</string>
<string name="news_related_news">Noticias</string>
<string name="news_related_tokens">Tokens relacionados</string>
<string name="news_sources">Fuentes</string>
<string name="news_stay_in_the_loop">Manténgase informado</string>

View file

@ -86,6 +86,8 @@
<string name="address_qr_code_message_format">Envoyez uniquement %1$s (%2$s) depuis les réseaux %3$s à cette adresse. L\'utilisation d\'autres jetons et réseaux peut entraîner une perte de fonds.</string>
<string name="address_type_default">Défaut</string>
<string name="address_type_legacy">Héritage</string>
<string name="alert_authentication_error_message">Quelque chose sest mal passé avec la biométrie. Veuillez réinitialiser la biométrie sur votre appareil ou contacter le support.</string>
<string name="alert_authentication_error_title">Erreur dauthentification</string>
<string name="alert_button_how_to_scan">Comment scanner</string>
<string name="alert_button_request_support">Demander de l\'aide</string>
<string name="alert_button_try_again">Réessayez</string>
@ -466,9 +468,14 @@
<string name="domain_receive_assets_onboarding_network_name">%s réseau</string>
<string name="domain_receive_assets_onboarding_title">Envoyez des fonds en utilisant uniquement</string>
<string name="earn_best_opportunities">Meilleures opportunités</string>
<string name="earn_clear_filter">Effacer le filtre</string>
<string name="earn_filter_all_networks">Tous les réseaux</string>
<string name="earn_filter_all_types">Tous les types</string>
<string name="earn_filter_by">Filtrer par</string>
<string name="earn_filter_my_networks">Mes réseaux</string>
<string name="earn_filter_networks">Réseaux</string>
<string name="earn_mostly_used">Principalement utilisé</string>
<string name="earn_no_results">Pas de résultat</string>
<string name="earn_title">Gagner</string>
<string name="email_preface_wc_error">Bonjour équipe de support, jai rencontré une erreur avec le code : %s</string>
<string name="email_subject_wc_error">Erreur WalletConnect</string>
@ -821,6 +828,7 @@
<string name="markets_token_details_volume">Volume</string>
<string name="markets_tooltip_message">Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché</string>
<string name="markets_tooltip_title">Ajouter des jetons</string>
<string name="markets_tooltip_v2_title">Ajouter plus de tokens</string>
<string name="markets_yield_supply_banner_description">Optimisez vos actifs tout en leur fournissant un accès instantané. %s</string>
<string name="markets_yield_supply_banner_title">Activer le mode rendement</string>
<string name="mobile_wallet_requires_min_os_warning_body">Vous devez effectuer la mise à jour %1$s afin de créer un portefeuille mobile.</string>
@ -1845,7 +1853,18 @@
<string name="warning_button_ok">Ok, compris!</string>
<string name="warning_button_really_cool">Vraiment cool !</string>
<string name="warning_button_refresh">Rafraîchir</string>
<string name="warning_clore_migration_button">Lancer la migration</string>
<string name="warning_clore_migration_copy_button">Copier</string>
<string name="warning_clore_migration_description">Pour conserver l\'accès à vos fonds, lancez la migration conformément aux directives officielles de Clore.</string>
<string name="warning_clore_migration_error_signing_not_supported">La signature des messages n\'est pas prise en charge pour ce réseau.</string>
<string name="warning_clore_migration_error_wallet_manager_not_found">Impossible de signer le message. Veuillez réessayer.</string>
<string name="warning_clore_migration_message">Selon la documentation officielle de Clore, toutes les pièces reçues avant le 21 décembre seront migrées vers Clore (token ERC-20) ; les pièces reçues après cette date ne le seront pas. Une solution de transfert arrive — restez à l\'écoute.</string>
<string name="warning_clore_migration_message_label">Message</string>
<string name="warning_clore_migration_open_portal_button">Ouvrir le portail des réclamations</string>
<string name="warning_clore_migration_sheet_description">Pour continuer à utiliser vos jetons Clore, vous devez effectuer la migration des jetons conformément aux informations fournies sur le portail de réclamation.</string>
<string name="warning_clore_migration_sheet_title">Migration du réseau Clore</string>
<string name="warning_clore_migration_sign_button">Signer</string>
<string name="warning_clore_migration_signature_label">Signature</string>
<string name="warning_clore_migration_title">Migration du réseau Clore</string>
<string name="warning_demo_mode_message">Vous êtes actuellement en mode démo</string>
<string name="warning_demo_mode_title">Mode démo actif</string>

View file

@ -86,6 +86,8 @@
<string name="address_qr_code_message_format">このアドレスには%3$s ネットワークから%1$s (%2$s) のみを送信してください。他のトークンやネットワークを使用すると、資金を失う可能性があります。</string>
<string name="address_type_default">デフォルト</string>
<string name="address_type_legacy">レガシー</string>
<string name="alert_authentication_error_message">デバイスの生体認証をリセットするか、サポートにお問い合わせください</string>
<string name="alert_authentication_error_title">認証エラー</string>
<string name="alert_button_how_to_scan">スキャン方法</string>
<string name="alert_button_request_support">サポートをリクエストする</string>
<string name="alert_button_try_again">もう一度やり直してください</string>
@ -523,7 +525,7 @@
<string name="express_provider">プロバイダー</string>
<string name="express_provider_best_rate">ベストレート</string>
<string name="express_provider_fca_warning_list">FCA警告リスト</string>
<string name="express_provider_great_rate">最適な選択</string>
<string name="express_provider_great_rate">お得なレート</string>
<string name="express_provider_in_fca_warning_list">FCA警告リストに掲載されたプロバイダー</string>
<string name="express_provider_max_amount">最大 %s まで使用可能</string>
<string name="express_provider_min_amount">%s 以上で利用可能</string>
@ -724,7 +726,7 @@
<string name="markets_loading_no_data_title">データなし</string>
<string name="markets_pulse_common_title">マーケット動向</string>
<string name="markets_quick_actions">クイックアクション</string>
<string name="markets_search_header_title">マーケットから探す</string>
<string name="markets_search_header_title">トークンを探す</string>
<string name="markets_search_result_title">結果</string>
<string name="markets_search_see_tokens_under_100k">時価総額10万ドル以下のトークンを見る</string>
<string name="markets_search_show_tokens">トークンを表示</string>
@ -741,8 +743,8 @@
<string name="markets_sort_by_experienced_buyers_title">経験豊富な買い手</string>
<string name="markets_sort_by_rating_title">時価総額</string>
<string name="markets_sort_by_title">並べ替え</string>
<string name="markets_sort_by_top_gainers_title">上昇率上位</string>
<string name="markets_sort_by_top_losers_title">下落率上位</string>
<string name="markets_sort_by_top_gainers_title">値上がり</string>
<string name="markets_sort_by_top_losers_title">値下がり</string>
<string name="markets_sort_by_trending_title">トレンド</string>
<string name="markets_sort_by_yield_mode_title">利息モード</string>
<string name="markets_staking_banner_description_placeholder">ステーキングは暗号資産で報酬を受け取る最も簡単な方法です。 %s</string>
@ -770,7 +772,7 @@
<string name="markets_token_details_exchanges_title">取引所</string>
<string name="markets_token_details_experienced_buyers">経験豊富な買い手</string>
<string name="markets_token_details_experienced_buyers_description">少なくとも100の発信取引を持つネット・バイヤー</string>
<string name="markets_token_details_experienced_buyers_full">経験豊富な買い手</string>
<string name="markets_token_details_experienced_buyers_full">アクティブ投資家数</string>
<string name="markets_token_details_fully_diluted_valuation">完全希薄化後評価額</string>
<string name="markets_token_details_fully_diluted_valuation_description">現在流通していないものも含め、存在する可能性のあるすべてのコインが流通している場合の暗号資産の理論上の合計価値</string>
<string name="markets_token_details_fully_diluted_valuation_full">完全希薄化後評価額</string>
@ -895,7 +897,7 @@
<string name="notification_visa_waitlist_promo_text">事前申し込み受付中。他にない特別なカードを、いち早く体験しよう。</string>
<string name="notification_visa_waitlist_promo_title">Tangem Visaカード</string>
<string name="notification_yield_promo_button">利用規約</string>
<string name="notification_yield_promo_text">100ドル以上を入金し、30日間保持すると、10ドルを受け取れます。</string>
<string name="notification_yield_promo_text">$100以上を入金して30日間保有すると、$10を受け取れます</string>
<string name="notification_yield_promo_title">Yield Mode キャンペーンに参加しよう!</string>
<string name="onboarding_access_code_feature_1_description">すべてのデバイスを保護するには、単一のアクセスコードを設定してください。</string>
<string name="onboarding_access_code_feature_1_title">保護する</string>
@ -1011,7 +1013,7 @@
<string name="onramp_max_amount_restriction">買付金額は%s以下にしてください</string>
<string name="onramp_min_amount_restriction">買付金額は少なくとも%sである必要があります</string>
<string name="onramp_no_available_providers">この通貨で利用可能なプロバイダーはありません</string>
<string name="onramp_offer_type_fastet"></string>
<string name="onramp_offer_type_fastet">短処理</string>
<string name="onramp_pay_with">支払う</string>
<string name="onramp_payment_method_subtitle">支払方法</string>
<string name="onramp_provider_max_amount">最大 %s まで使用可能</string>
@ -1598,7 +1600,7 @@
<string name="token_swap_changelly_promotion_message">このトークンは、2月%2$s-%3$s の間、%1$s のサービス手数料で別のトークンと交換できます。</string>
<string name="token_swap_changelly_promotion_title">Changellyでスワップ、手数料%s</string>
<string name="token_swap_promotion_button">今すぐスワップ</string>
<string name="tokens_list_hot_crypto_header">ホットな暗号資産🔥</string>
<string name="tokens_list_hot_crypto_header">市場トレンド🔥</string>
<string name="tokens_list_unavailable_to_purchase_header">買付できません</string>
<string name="tokens_list_unavailable_to_sell_header">売却できません</string>
<string name="tokens_list_unavailable_to_swap_header">%sからのスワップは利用できません</string>
@ -1825,7 +1827,18 @@
<string name="warning_button_ok">はい、わかりました!</string>
<string name="warning_button_really_cool">すごくクールだ!</string>
<string name="warning_button_refresh">リフレッシュ</string>
<string name="warning_clore_migration_button">移行を開始</string>
<string name="warning_clore_migration_copy_button">コピー</string>
<string name="warning_clore_migration_description">資金へのアクセスを維持するため、Cloreの公式ガイドラインに従って移行を開始してください。</string>
<string name="warning_clore_migration_error_signing_not_supported">このネットワークではメッセージ署名はサポートされていません</string>
<string name="warning_clore_migration_error_wallet_manager_not_found">メッセージに署名できません。もう一度お試しください。</string>
<string name="warning_clore_migration_message">Cloreの公式ドキュメントによると、12月21日以前に受け取ったすべてのコインは CloreERC-20トークンへ移行されますが、同日以降に受け取ったコインは移行されません。送金移行ソリューションは現在準備中です。続報をお待ちください。</string>
<string name="warning_clore_migration_message_label">メッセージ</string>
<string name="warning_clore_migration_open_portal_button">Claim Portalを開く</string>
<string name="warning_clore_migration_sheet_description">Cloreトークンを引き続き使用するには、Claim Portalの案内に従ってトークン移行を完了する必要があります。</string>
<string name="warning_clore_migration_sheet_title">Cloreネットワーク移行</string>
<string name="warning_clore_migration_sign_button">署名する</string>
<string name="warning_clore_migration_signature_label">署名</string>
<string name="warning_clore_migration_title">Cloreネットワークの移行</string>
<string name="warning_demo_mode_message">現在デモモードです</string>
<string name="warning_demo_mode_title">デモモードが有効になっています</string>

View file

@ -86,6 +86,8 @@
<string name="address_qr_code_message_format">Отправляйте только %1$s (%2$s) в сети %3$s на этот адрес. Использование другой сети может привести к утрате средств.</string>
<string name="address_type_default">По умолчанию</string>
<string name="address_type_legacy">Устаревший</string>
<string name="alert_authentication_error_message">Произошла ошибка при работе с биометрией. Пожалуйста, попробуйте обновить биометрию на вашем устройстве или обратитесь в службу поддержки.</string>
<string name="alert_authentication_error_title">Ошибка аутентификации</string>
<string name="alert_button_how_to_scan">Как сканировать</string>
<string name="alert_button_request_support">Обратиться в поддержку</string>
<string name="alert_button_try_again">Попробовать снова</string>
@ -308,6 +310,7 @@
<string name="common_go_to_token">Перейти в токен</string>
<string name="common_got_it">Понятно</string>
<string name="common_hide">Скрыть</string>
<string name="common_hold_to">Удерживайте, чтобы %s</string>
<string name="common_hour">час</string>
<string name="common_import">Импортировать</string>
<string name="common_in_progress">В процессе</string>
@ -385,6 +388,7 @@
<string name="common_swap">Обменять</string>
<string name="common_tangem">Tangem</string>
<string name="common_tangem_wallet">Tangem Wallet</string>
<string name="common_tap_and_hold_hint">Нажмите и удерживайте</string>
<string name="common_terms_and_conditions">условия участия</string>
<string name="common_terms_of_use">Условиями использования</string>
<string name="common_to">На</string>
@ -476,6 +480,14 @@
<string name="domain_receive_assets_onboarding_description">Отправка средств в другой сети может повлечь потерю средств.</string>
<string name="domain_receive_assets_onboarding_network_name">%s сеть</string>
<string name="domain_receive_assets_onboarding_title">Отправляйте средства, используя только</string>
<string name="earn_best_opportunities">Лучшие возможности</string>
<string name="earn_clear_filter">Очистить фильтр</string>
<string name="earn_filter_all_networks">Все сети</string>
<string name="earn_filter_all_types">Все типы</string>
<string name="earn_filter_by">Фильтровать по</string>
<string name="earn_filter_networks">Сети</string>
<string name="earn_mostly_used">Часто используемые</string>
<string name="earn_no_results">Нет результата</string>
<string name="email_preface_wc_error">Привет, команда поддержки, у меня возникла ошибка с кодом: %s</string>
<string name="email_subject_wc_error">Ошибка WalletConnect</string>
<string name="error_wrong_wallet_tapped">Вы использовали карту или кольцо от другого кошелька. Приложите карту или кольцо, связанную с этим кошельком.</string>
@ -850,7 +862,7 @@
<item quantity="other">%dмин назад</item>
</plurals>
<string name="news_quick_recap">Резюме</string>
<string name="news_related_news">Связанные новости</string>
<string name="news_related_news">Новости</string>
<string name="news_related_tokens">Связанные токены</string>
<string name="news_sources">Связанные новости</string>
<string name="news_stay_in_the_loop">Будьте в курсе</string>
@ -1805,6 +1817,7 @@
<string name="warning_button_refresh">Обновить</string>
<string name="warning_clore_migration_button">Начать миграцию</string>
<string name="warning_clore_migration_copy_button">Копировать</string>
<string name="warning_clore_migration_description">Чтобы сохранить доступ к своим средствам, начните миграцию в соответствии с официальными рекомендациями Clore.</string>
<string name="warning_clore_migration_error_signing_not_supported">Подписание сообщений не поддерживается в этой сети</string>
<string name="warning_clore_migration_error_wallet_manager_not_found">Невозможно подписать сообщение. Пожалуйста, попробуй позже.</string>
<string name="warning_clore_migration_message">Согласно официальной документации Clore, все монеты, полученные до 21 декабря, будут мигрированы в токен Clore (ERC-20); монеты, полученные после этой даты, — нет. Решение для перевода находится в разработке — следите за обновлениями.</string>

View file

@ -86,6 +86,8 @@
<string name="address_qr_code_message_format">Надсилайте тільки %1$s (%2$s) в мережі %3$s на цю адресу. Використання іншої мережі може призвести до втрати коштів.</string>
<string name="address_type_default">За замовчуванням</string>
<string name="address_type_legacy">Застарілий</string>
<string name="alert_authentication_error_message">Сталася помилка під час роботи з біометрією. Будь ласка, спробуйте оновити біометрію на вашому пристрої або зверніться до служби підтримки.</string>
<string name="alert_authentication_error_title">Помилка автентифікації</string>
<string name="alert_button_how_to_scan">Як сканувати</string>
<string name="alert_button_request_support">Звернутися в підтримку</string>
<string name="alert_button_try_again">Спробуйте ще раз</string>

View file

@ -86,7 +86,7 @@
<string name="address_qr_code_message_format">Send only %1$s (%2$s) from %3$s network to this address. Using other tokens and networks may result in loss of funds.</string>
<string name="address_type_default">Default</string>
<string name="address_type_legacy">Legacy</string>
<string name="alert_authentication_error_message">Try to reset biometrics on your device or contact support</string>
<string name="alert_authentication_error_message">Something went wrong with biometrics. Please try resetting biometrics on your device or contact support.</string>
<string name="alert_authentication_error_title">Authentication error</string>
<string name="alert_button_how_to_scan">How to scan</string>
<string name="alert_button_request_support">Request support</string>
@ -468,15 +468,15 @@
<string name="domain_receive_assets_onboarding_network_name">%s network</string>
<string name="domain_receive_assets_onboarding_title">Send funds using only</string>
<string name="earn_best_opportunities">Best opportunities</string>
<string name="earn_filter_all_networks">All networks</string>
<string name="earn_filter_my_networks">My networks</string>
<string name="earn_filter_all_types">All types</string>
<string name="earn_filter_networks">Networks</string>
<string name="earn_clear_filter">Clear filter</string>
<string name="earn_filter_by">Filter by</string>
<string name="earn_no_results">No results</string>
<string name="earn_empty">The list is temporarily empty as its being refreshed. Check back in a moment.</string>
<string name="earn_filter_all_networks">All networks</string>
<string name="earn_filter_all_types">All types</string>
<string name="earn_filter_by">Filter by</string>
<string name="earn_filter_my_networks">My networks</string>
<string name="earn_filter_networks">Networks</string>
<string name="earn_mostly_used">Mostly used</string>
<string name="earn_no_results">No results</string>
<string name="earn_title">Earn</string>
<string name="email_preface_wc_error">Hi support team, I\'ve encountered an error with code: %s</string>
<string name="email_subject_wc_error">WalletConnect error</string>
@ -830,6 +830,7 @@
<string name="markets_token_details_volume">Volume</string>
<string name="markets_tooltip_message">Pull this up or tap the search bar to add tokens directly from the market</string>
<string name="markets_tooltip_title">Add tokens</string>
<string name="markets_tooltip_v2_title">Add more tokens</string>
<string name="markets_yield_supply_banner_description">Power up your assets while supplying them with instant access. %s</string>
<string name="markets_yield_supply_banner_title">Activate Yield Mode</string>
<string name="mobile_wallet_requires_min_os_warning_body">You must update to %1$s before creating a mobile wallet</string>
@ -845,7 +846,7 @@
<item quantity="other">%d minutes ago</item>
</plurals>
<string name="news_quick_recap">Quick recap</string>
<string name="news_related_news">Related News</string>
<string name="news_related_news">News</string>
<string name="news_related_tokens">Related tokens</string>
<string name="news_sources">Related news</string>
<string name="news_stay_in_the_loop">Stay in the loop</string>

View file

@ -1,201 +1,284 @@
package com.tangem.core.ui.components.pager
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
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.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
// six - cause the central indicator has width multiplied twice
private const val TOTAL_MAX_INDICATORS = 6
private const val SPACER_COUNT_BETWEEN_INDICATORS = 4
private const val ANIMATION_DURATION = 300
private const val MAX_VISIBLE_DOTS = 5
private const val MIN_HIDDEN_FOR_SMALL_DOT = 2
private const val MIN_DISTANCE_FOR_SMALL_DOT = 3
private const val MIN_DISTANCE_FOR_HINT_DOT = 2
/**
* Horizontal pager indicator
*
* @param pagerState state of pager
* @param indicatorCount counter of visible indicator items
*/
private val SPACING = 4.dp
private val BACKGROUND_SIZE = DpSize(92.dp, 32.dp)
private val CURRENT_DOT_SIZE = DpSize(16.dp, 8.dp)
private val NORMAL_DOT_SIZE = DpSize(8.dp, 8.dp)
private val HINT_DOT_SIZE = DpSize(6.dp, 6.dp)
private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier, indicatorCount: Int = 5) {
if (pagerState.pageCount == 0) return
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier) {
val totalPages = pagerState.pageCount
val currentIndex = pagerState.currentPage
val listState = rememberLazyListState()
if (totalPages == 0) return
val indicatorColor = TangemTheme.colors.control.key
val overlayColor = TangemTheme.colors.overlay.secondary
val inactiveIndicatorColor = TangemTheme.colors.text.tertiary
val inactiveIndicatorColor = remember(indicatorColor) {
indicatorColor.copy(alpha = 0.5f)
}
val density = LocalDensity.current
val baseIndicatorSize = 8.dp
val spacing = 4.dp
val (targetLower, targetUpper) = getWindowBounds(totalPages, currentIndex)
val indicatorState by remember(pagerState, indicatorCount) {
derivedStateOf {
val count = pagerState.pageCount
val current = pagerState.currentPage
var displayLower by remember { mutableIntStateOf(targetLower) }
var displayUpper by remember { mutableIntStateOf(targetUpper) }
var prevTargetLower by remember { mutableIntStateOf(targetLower) }
val winSize = min(indicatorCount, count)
val centerPosition = winSize / 2
val slideOffset = remember { Animatable(0f) }
var isSliding by remember { mutableStateOf(false) }
var slideDirection by remember { mutableIntStateOf(0) }
val fadeProgress = remember { Animatable(0f) }
var fadeJob by remember { mutableStateOf<Job?>(null) }
val start = when {
count <= winSize -> 0
current <= centerPosition -> 0
current >= count - centerPosition - 1 -> count - winSize
else -> current - centerPosition
LaunchedEffect(targetLower) {
if (targetLower != prevTargetLower && totalPages > MAX_VISIBLE_DOTS) {
fadeJob?.cancel()
slideOffset.stop()
fadeProgress.stop()
val dir = if (targetLower > prevTargetLower) 1 else -1
val edgeDotSize = with(density) { (HINT_DOT_SIZE.width + SPACING).toPx() }
val halfEdge = edgeDotSize / 2
isSliding = true
slideDirection = dir
fadeProgress.snapTo(0f)
if (dir > 0) {
displayLower = prevTargetLower
displayUpper = targetUpper
slideOffset.snapTo(halfEdge)
} else {
displayLower = targetLower
displayUpper = prevTargetLower + MAX_VISIBLE_DOTS
slideOffset.snapTo(-halfEdge)
}
Triple(count, winSize, start)
prevTargetLower = targetLower
fadeJob = launch {
fadeProgress.animateTo(1f, tween(ANIMATION_DURATION))
}
slideOffset.animateTo(
if (dir > 0) -halfEdge else halfEdge,
tween(ANIMATION_DURATION),
)
displayLower = targetLower
displayUpper = targetUpper
slideOffset.snapTo(0f)
isSliding = false
slideDirection = 0
}
}
val (itemCount, windowSize, windowStart) = indicatorState
val currentItem by remember { derivedStateOf { pagerState.currentPage } }
LaunchedEffect(currentItem, windowStart) {
if (itemCount > windowSize) {
listState.animateScrollToItem(windowStart.coerceIn(0, itemCount - 1))
}
}
val maxContainerWidth = remember(baseIndicatorSize, spacing) {
baseIndicatorSize * TOTAL_MAX_INDICATORS + spacing * SPACER_COUNT_BETWEEN_INDICATORS
}
val visibleIndices = (displayLower until displayUpper).toList()
Box(
modifier = modifier
.height(32.dp)
.width(maxContainerWidth + 32.dp)
.width(BACKGROUND_SIZE.width)
.height(BACKGROUND_SIZE.height)
.background(
color = overlayColor,
shape = CircleShape,
)
.padding(horizontal = 16.dp, vertical = 12.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
LazyRow(
modifier = Modifier.wrapContentWidth(),
state = listState,
Row(
modifier = Modifier.offset {
IntOffset(slideOffset.value.roundToInt(), 0)
},
horizontalArrangement = Arrangement.spacedBy(SPACING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing),
userScrollEnabled = false,
) {
indicatorItems(
itemCount = itemCount,
currentItem = currentItem,
activeColor = indicatorColor,
inActiveColor = inactiveIndicatorColor,
baseSize = baseIndicatorSize,
windowSize = windowSize,
windowStart = windowStart,
)
visibleIndices.forEach { index ->
val dotAlpha = when {
!isSliding -> 1f
slideDirection > 0 && index == displayLower -> 1f - fadeProgress.value
slideDirection > 0 && index == displayUpper - 1 -> fadeProgress.value
slideDirection < 0 && index == displayUpper - 1 -> 1f - fadeProgress.value
slideDirection < 0 && index == displayLower -> fadeProgress.value
else -> 1f
}
key(index) {
Dot(
index = index,
currentIndex = currentIndex,
totalPages = totalPages,
activeColor = indicatorColor,
inactiveColor = inactiveIndicatorColor,
modifier = Modifier.graphicsLayer { alpha = dotAlpha },
)
}
}
}
}
}
@Suppress("MagicNumber", "CyclomaticComplexMethod")
private fun calculateIndicatorHeight(position: Int, currentPosition: Int, baseSize: Dp, windowSize: Int): Dp {
val distance = abs(position - currentPosition)
val mediumSize = 6.dp
val smallSize = 4.dp
if (windowSize < 5) {
return when {
distance <= 1 -> baseSize
distance == 2 -> mediumSize
else -> smallSize
}
private fun getWindowBounds(totalPages: Int, currentIndex: Int): Pair<Int, Int> {
if (totalPages <= MAX_VISIBLE_DOTS) {
return 0 to totalPages
}
val isEdgeFocus = currentPosition == 0 || currentPosition == windowSize - 1
val isNearEdgeFocus = currentPosition == 1 || currentPosition == windowSize - 2
return when {
isEdgeFocus -> when {
distance <= 2 -> baseSize
distance == 3 -> mediumSize
else -> smallSize
}
isNearEdgeFocus -> when {
distance <= 1 -> baseSize
distance == 2 -> mediumSize
else -> smallSize
}
else -> when {
distance <= 1 -> baseSize
else -> mediumSize
}
val lowerBound = when {
currentIndex <= 1 -> 0
currentIndex >= totalPages - 2 -> totalPages - MAX_VISIBLE_DOTS
else -> currentIndex - 2
}
val upperBound = min(lowerBound + MAX_VISIBLE_DOTS, totalPages)
return lowerBound to upperBound
}
@Suppress("LongParameterList")
private fun LazyListScope.indicatorItems(
itemCount: Int,
currentItem: Int,
activeColor: Color,
inActiveColor: Color,
baseSize: Dp,
windowSize: Int,
windowStart: Int,
private fun getDotSize(index: Int, currentIndex: Int, totalPages: Int): DpSize {
if (index == currentIndex) {
return CURRENT_DOT_SIZE
}
if (totalPages <= MAX_VISIBLE_DOTS) {
return NORMAL_DOT_SIZE
}
val params = DotSizeParams.create(index, currentIndex, totalPages)
return params.calculateSize()
}
private class DotSizeParams private constructor(
val posInWindow: Int,
val currentPosInWindow: Int,
val hiddenLeft: Int,
val hiddenRight: Int,
val distanceFromCurrent: Int,
) {
val safeWindowSize = min(windowSize, itemCount)
if (safeWindowSize <= 0) return
private val lastPos = MAX_VISIBLE_DOTS - 1
private val isCentered = currentPosInWindow == 2 && hiddenLeft >= 1 && hiddenRight >= 1
val windowEnd = windowStart + safeWindowSize
val currentPosInWindow = (currentItem - windowStart).coerceIn(0, safeWindowSize - 1)
items(itemCount) { pageIndex ->
val isInWindow = pageIndex in windowStart until windowEnd
val positionInWindow = (pageIndex - windowStart).coerceIn(0, safeWindowSize - 1)
val isSelected = pageIndex == currentItem
val refinedHeight = if (isInWindow) {
calculateIndicatorHeight(
position = positionInWindow,
currentPosition = currentPosInWindow,
baseSize = baseSize,
windowSize = safeWindowSize,
)
} else {
0.dp
}
val targetWidth = if (isSelected) refinedHeight * 2 else refinedHeight
val targetShape = if (isSelected) RoundedCornerShape(16.dp) else CircleShape
val animatedWidth by animateDpAsState(targetValue = targetWidth, label = "width")
val animatedHeight by animateDpAsState(targetValue = refinedHeight, label = "height")
Box(
modifier = Modifier
.padding(vertical = (baseSize - animatedHeight) / 2)
.clip(targetShape)
.width(animatedWidth)
.height(animatedHeight)
.background(
if (isSelected) activeColor else inActiveColor,
targetShape,
),
)
fun calculateSize(): DpSize = when {
isCentered -> getCenteredSize()
hiddenRight >= 1 -> getRightEdgeSize()
hiddenLeft >= 1 -> getLeftEdgeSize()
else -> NORMAL_DOT_SIZE
}
private fun getCenteredSize(): DpSize = when (posInWindow) {
0, lastPos -> HINT_DOT_SIZE
else -> NORMAL_DOT_SIZE
}
private fun getRightEdgeSize(): DpSize {
val isLastPos = posInWindow == lastPos
val isSecondToLast = posInWindow == lastPos - 1
val hasExtraHidden = hiddenRight >= MIN_HIDDEN_FOR_SMALL_DOT
val isFarFromCurrent = distanceFromCurrent >= MIN_DISTANCE_FOR_SMALL_DOT
val isModerateDistance = distanceFromCurrent >= MIN_DISTANCE_FOR_HINT_DOT
return when {
isLastPos && hasExtraHidden && isFarFromCurrent -> SMALL_DOT_SIZE
isLastPos && isModerateDistance -> HINT_DOT_SIZE
isSecondToLast && hasExtraHidden && isModerateDistance -> HINT_DOT_SIZE
else -> NORMAL_DOT_SIZE
}
}
private fun getLeftEdgeSize(): DpSize {
val isFirstPos = posInWindow == 0
val isSecondPos = posInWindow == 1
val hasExtraHidden = hiddenLeft >= MIN_HIDDEN_FOR_SMALL_DOT
val isFarFromCurrent = distanceFromCurrent >= MIN_DISTANCE_FOR_SMALL_DOT
val isModerateDistance = distanceFromCurrent >= MIN_DISTANCE_FOR_HINT_DOT
return when {
isFirstPos && hasExtraHidden && isFarFromCurrent -> SMALL_DOT_SIZE
isFirstPos && isModerateDistance -> HINT_DOT_SIZE
isSecondPos && hasExtraHidden && isModerateDistance -> HINT_DOT_SIZE
else -> NORMAL_DOT_SIZE
}
}
companion object {
fun create(index: Int, currentIndex: Int, totalPages: Int): DotSizeParams {
val (windowStart, windowEnd) = getWindowBounds(totalPages, currentIndex)
val posInWindow = index - windowStart
val currentPosInWindow = currentIndex - windowStart
return DotSizeParams(
posInWindow = posInWindow,
currentPosInWindow = currentPosInWindow,
hiddenLeft = windowStart,
hiddenRight = totalPages - windowEnd,
distanceFromCurrent = abs(posInWindow - currentPosInWindow),
)
}
}
}
@Composable
private fun Dot(
index: Int,
currentIndex: Int,
totalPages: Int,
activeColor: Color,
inactiveColor: Color,
modifier: Modifier = Modifier,
) {
val isActive = index == currentIndex
val size = getDotSize(index, currentIndex, totalPages)
val animSpec = tween<Dp>(ANIMATION_DURATION)
val colorSpec = tween<Color>(ANIMATION_DURATION)
val animatedWidth by animateDpAsState(size.width, animSpec, label = "w$index")
val animatedHeight by animateDpAsState(size.height, animSpec, label = "h$index")
val animatedColor by animateColorAsState(
targetValue = if (isActive) activeColor else inactiveColor,
animationSpec = colorSpec,
label = "c$index",
)
val shape = RoundedCornerShape(animatedHeight / 2)
Box(
modifier = modifier
.width(animatedWidth)
.height(animatedHeight)
.background(animatedColor, shape),
)
}
@Preview(showBackground = true)
@ -203,28 +286,82 @@ private fun LazyListScope.indicatorItems(
private fun PagerIndicatorPreview() {
TangemThemePreview {
Column(
modifier = Modifier
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
val pagerState = rememberPagerState(
initialPage = 2,
pageCount = { 10 },
)
PagerIndicator(pagerState = pagerState)
listOf(0, 1, 2, 3, 4).forEach { page ->
PagerIndicator(rememberPagerState(page) { 5 })
}
}
}
}
val pagerState1 = rememberPagerState(
initialPage = 0,
pageCount = { 3 },
)
PagerIndicator(pagerState = pagerState1)
@Preview(showBackground = true)
@Composable
private fun PagerIndicator6ItemsPreview() {
TangemThemePreview {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4, 5).forEach { page ->
PagerIndicator(rememberPagerState(page) { 6 })
}
}
}
}
val pagerState2 = rememberPagerState(
initialPage = 0,
pageCount = { 1 },
)
PagerIndicator(pagerState = pagerState2)
@Preview(showBackground = true)
@Composable
private fun PagerIndicator7ItemsPreview() {
TangemThemePreview {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4, 5, 6).forEach { page ->
PagerIndicator(rememberPagerState(page) { 7 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicator10ItemsPreview() {
TangemThemePreview {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).forEach { page ->
PagerIndicator(rememberPagerState(page) { 10 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicatorSmallCountsPreview() {
TangemThemePreview {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
PagerIndicator(rememberPagerState(0) { 1 })
PagerIndicator(rememberPagerState(1) { 2 })
PagerIndicator(rememberPagerState(1) { 3 })
}
}
}

View file

@ -57,6 +57,8 @@ internal class DefaultCustomTokensRepository(
Blockchain.Binance,
Blockchain.BinanceTestnet,
Blockchain.Kaspa,
Blockchain.TerraV1,
Blockchain.TerraV2,
-> true
Blockchain.Cardano,
Blockchain.Sui,

View file

@ -3,6 +3,7 @@ package com.tangem.data.pay
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.pay.TangemPayEligibilityManager
@ -36,11 +37,21 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
}
}
override suspend fun getPossibleWalletsIds(shouldExcludePaeraCustomers: Boolean): List<UserWalletId> {
return getPossibleWalletsForTangemPay().addPaeraCustomersData().mapNotNull {
if (!it.isPaeraCustomer || !shouldExcludePaeraCustomers) it.userWallet.walletId else null
}
}
override suspend fun getTangemPayAvailability(): Boolean {
return onboardingRepository.checkCustomerEligibility()
.also { isEligible -> if (!isEligible) reset() }
}
override suspend fun isPaeraCustomerForAnyWallet(): Boolean {
return getUserWalletsData().any { it.isPaeraCustomer }
}
private suspend fun getUserWalletsData(): List<UserWalletData> {
cachedEligibleWallets?.let { return it }
@ -50,9 +61,13 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
coroutineScope {
val deferred = async {
getPossibleWalletsForTangemPay()
.addPaeraCustomersData()
.also { cachedEligibleWallets = it }
if (!checkTangemPayEligibility()) {
emptyList()
} else {
getPossibleWalletsForTangemPay()
.addPaeraCustomersData()
.also { cachedEligibleWallets = it }
}
}
eligibleWalletsDeferred = deferred
try {

View file

@ -4,6 +4,7 @@ import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.repository.*
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
@ -11,6 +12,7 @@ import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.security.DeviceSecurityInfoProvider
@ -65,6 +67,10 @@ internal interface TangemPayDataModule {
@Singleton
fun bindTangemPayWithdrawUseCase(impl: DefaultTangemPayWithdrawUseCase): TangemPayWithdrawUseCase
@Binds
@Singleton
fun bindGetTangemPayCustomerIdUseCase(impl: DefaultGetTangemPayCustomerIdUseCase): GetTangemPayCustomerIdUseCase
@Binds
@Singleton
fun bindTangemPayEligibilityManager(impl: DefaultTangemPayEligibilityManager): TangemPayEligibilityManager

View file

@ -0,0 +1,25 @@
package com.tangem.data.pay.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
internal class DefaultGetTangemPayCustomerIdUseCase @Inject constructor(
private val tangemPayOnboardingRepository: OnboardingRepository,
) : GetTangemPayCustomerIdUseCase {
override fun invoke(userWalletId: UserWalletId): Either<UniversalError, String> {
val customerId = tangemPayOnboardingRepository.getSavedCustomerInfo(userWalletId)?.customerId
return if (customerId.isNullOrEmpty()) {
VisaApiError.CustomerIdUnavailable.left()
} else {
customerId.right()
}
}
}

View file

@ -69,7 +69,7 @@ internal class WcEthAddNetworkUseCase @AssistedInject constructor(
?: return illegalState()
// find and add all derivation
val networkToAddCAIP10 = networksConverter
.allAddressForChain(networkToAddCAIP2.raw, wallet)
.allAddressForChain(networkToAddCAIP2.raw, wallet, session.account)
.map { address -> CAIP10(networkToAddCAIP2, address).raw }
val newNamespaces = namespaces.copy(
chains = namespaces.chains.plus(networkToAddCAIP2.raw),
@ -134,7 +134,11 @@ internal class WcEthAddSwitchCommonDelegate @AssistedInject constructor(
if (generalNetwork == null) {
return HandleMethodError.TangemUnsupportedNetwork(caip2.raw).left()
}
val addedNetwork = networksConverter.mainOrAnyWalletNetworkForRequest(caip2.raw, wallet)
val addedNetwork = networksConverter.mainOrAnyWalletNetworkForRequest(
rawChainId = caip2.raw,
wallet = wallet,
account = context.session.account,
)
if (addedNetwork == null) {
return HandleMethodError.NotAddedNetwork(generalNetwork.name).left()
}

View file

@ -39,12 +39,17 @@ internal class WcEthNetwork(
val name = toWcMethodName(request) ?: return error("Unknown method name")
val session = sessionsManager.findSessionByTopic(request.topic)
?: return HandleMethodError.UnknownSession.left()
val account = session.account
val wallet = session.wallet
val chainId = request.chainId.orEmpty()
val method: WcEthMethod = name.toMethod(request)
.getOrElse { return error(it.message.orEmpty()) }
?: return error("Failed to parse $name")
suspend fun anyExistNetwork() = networksConverter.mainOrAnyWalletNetworkForRequest(chainId, wallet)
suspend fun anyExistNetwork() = networksConverter.mainOrAnyWalletNetworkForRequest(
rawChainId = chainId,
wallet = wallet,
account = account,
)
val accountAddress = when (method) {
is WcEthMethod.MessageSign -> method.account
@ -69,12 +74,13 @@ internal class WcEthNetwork(
-> anyExistNetwork()
} ?: return error("Failed to find walletNetwork for accountAddress $accountAddress")
val networkDerivationsCount = networksConverter.filterWalletNetworkForRequest(chainId, wallet, account).size
val context = WcMethodUseCaseContext(
session = session,
rawSdkRequest = request,
network = walletNetwork,
accountAddress = accountAddress,
networkDerivationsCount = networksConverter.filterWalletNetworkForRequest(chainId, wallet).size,
networkDerivationsCount = networkDerivationsCount,
)
return when (method) {
is WcEthMethod.MessageSign -> factories.messageSign.create(context, method)

View file

@ -47,8 +47,9 @@ internal class WcSolanaNetwork(
val session = sessionsManager.findSessionByTopic(request.topic)
?: return HandleMethodError.UnknownSession.left()
val wallet = session.wallet
val account = session.account
val chainId = request.chainId.orEmpty()
suspend fun anyExistNetwork() = networksConverter.mainOrAnyWalletNetworkForRequest(chainId, wallet)
suspend fun anyExistNetwork() = networksConverter.mainOrAnyWalletNetworkForRequest(chainId, wallet, account)
suspend fun anyAddress() = anyExistNetwork()
?.let { network -> networksConverter.getAddressForWC(wallet.walletId, network).orEmpty() }
.orEmpty()
@ -63,12 +64,13 @@ internal class WcSolanaNetwork(
?: anyExistNetwork()
?: return error("Failed to find walletNetwork for accountAddress $accountAddress")
val networkDerivationsCount = networksConverter.filterWalletNetworkForRequest(chainId, wallet, account).size
val context = WcMethodUseCaseContext(
session = session,
rawSdkRequest = request,
network = walletNetwork,
accountAddress = accountAddress,
networkDerivationsCount = networksConverter.filterWalletNetworkForRequest(chainId, wallet).size,
networkDerivationsCount = networkDerivationsCount,
)
return when (method) {
is WcSolanaMethod.SignMessage -> factories.messageSign.create(context, method)

View file

@ -5,6 +5,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.sign.SignStateConverter.toPreSign
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning
import com.tangem.domain.models.account.derivationIndex
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.model.WcRequestError
import com.tangem.domain.walletconnect.model.WcRequestError.Companion.code
@ -71,6 +72,7 @@ internal class WcSignUseCaseDelegate<MiddleAction, SignModel>(
network = context.network,
errorCode = error.code(),
errorMessage = errorMessage,
accountDerivation = context.session.account?.derivationIndex?.value,
)
analytics.send(event)
}

View file

@ -44,7 +44,11 @@ internal class WcNetworksConverter @Inject constructor(
requestAddress: String,
): Network? {
val wallet = session.wallet
val allCoinNetwork = filterWalletNetworkForRequest(request.chainId.orEmpty(), session.wallet)
val allCoinNetwork = filterWalletNetworkForRequest(
rawChainId = request.chainId.orEmpty(),
wallet = session.wallet,
account = session.account,
)
val requestNetwork = allCoinNetwork.find { network ->
val address = getAddressForWC(wallet.walletId, network)
@ -56,13 +60,13 @@ internal class WcNetworksConverter @Inject constructor(
/**
* return network with not custom derivationPath or first custom or any
*/
suspend fun mainOrAnyWalletNetworkForRequest(rawChainId: String, wallet: UserWallet): Network? {
val networks = filterWalletNetworkForRequest(rawChainId, wallet)
suspend fun mainOrAnyWalletNetworkForRequest(rawChainId: String, wallet: UserWallet, account: Account?): Network? {
val networks = filterWalletNetworkForRequest(rawChainId, wallet, account)
return networks.firstOrNull { !isCustomCoin(it) } ?: networks.firstOrNull()
}
suspend fun allAddressForChain(rawChainId: String, wallet: UserWallet): List<String> {
return filterWalletNetworkForRequest(rawChainId, wallet)
suspend fun allAddressForChain(rawChainId: String, wallet: UserWallet, account: Account?): List<String> {
return filterWalletNetworkForRequest(rawChainId, wallet, account)
.mapNotNull { getAddressForWC(wallet.walletId, it)?.lowercase() }
}
@ -80,13 +84,18 @@ internal class WcNetworksConverter @Inject constructor(
/**
* return all exist derivation networks
*/
suspend fun filterWalletNetworkForRequest(rawChainId: String, wallet: UserWallet): List<Network> {
val walletNetworks = getWalletNetworks(wallet.walletId)
suspend fun filterWalletNetworkForRequest(
rawChainId: String,
wallet: UserWallet,
account: Account?,
): List<Network> {
val portfolioNetworks = account?.let { getAccountNetworks(it.accountId) }
?: getWalletNetworks(wallet.walletId)
val blockchain = namespaceConverters
.firstNotNullOfOrNull { it.toBlockchain(rawChainId) } ?: return listOf()
val allCoinNetwork = walletNetworks.filter { it.rawId == blockchain.id }
val allCoinNetwork = portfolioNetworks.filter { it.rawId == blockchain.id }
return allCoinNetwork
}

View file

@ -423,6 +423,7 @@ internal class DefaultWalletsRepository(
}
override suspend fun activatePromoCode(
userWalletId: UserWalletId,
promoCode: String,
bitcoinAddress: String,
): Either<ActivatePromoCodeError, String> = withContext(dispatchers.io) {
@ -430,6 +431,7 @@ internal class DefaultWalletsRepository(
body = PromocodeActivationBody(
promoCode = promoCode,
address = bitcoinAddress,
walletId = userWalletId.stringValue,
),
).fold(
onSuccess = { it.status.right() },

View file

@ -268,6 +268,7 @@ class DefaultWalletsRepositoryTest {
@Test
fun `GIVEN valid data WHEN activatePromoCode THEN returns Right with status and calls API`() = runTest {
// GIVEN
val walletId = UserWalletId("1234567890abcdef")
val promoCode = "PROMO123"
val address = "bc1qexampleaddress"
coEvery { tangemTechApi.activatePromoCode(any()) } returns ApiResponse.Success(
@ -275,7 +276,11 @@ class DefaultWalletsRepositoryTest {
)
// WHEN
val result = repository.activatePromoCode(promoCode = promoCode, bitcoinAddress = address)
val result = repository.activatePromoCode(
userWalletId = walletId,
promoCode = promoCode,
bitcoinAddress = address
)
// THEN
var right: String? = null
@ -294,13 +299,14 @@ class DefaultWalletsRepositoryTest {
@Test
fun `GIVEN NOT_FOUND error WHEN activatePromoCode THEN returns Left InvalidPromoCode`() = runTest {
// GIVEN
val walletId = UserWalletId("1234567890abcdef")
coEvery { tangemTechApi.activatePromoCode(any()) } returns
ApiResponse.Error(
HttpException(code = HttpException.Code.NOT_FOUND, message = null, errorBody = null),
) as ApiResponse<PromocodeActivationResponse>
// WHEN
val result = repository.activatePromoCode(promoCode = "PROMO", bitcoinAddress = "addr")
val result = repository.activatePromoCode(userWalletId = walletId, promoCode = "PROMO", bitcoinAddress = "addr")
// THEN
var error: ActivatePromoCodeError? = null
@ -311,13 +317,14 @@ class DefaultWalletsRepositoryTest {
@Test
fun `GIVEN CONFLICT error WHEN activatePromoCode THEN returns Left PromocodeAlreadyUsed`() = runTest {
// GIVEN
val walletId = UserWalletId("1234567890abcdef")
coEvery { tangemTechApi.activatePromoCode(any()) } returns
ApiResponse.Error(
HttpException(code = HttpException.Code.CONFLICT, message = null, errorBody = null),
) as ApiResponse<PromocodeActivationResponse>
// WHEN
val result = repository.activatePromoCode(promoCode = "PROMO", bitcoinAddress = "addr")
val result = repository.activatePromoCode(userWalletId = walletId, promoCode = "PROMO", bitcoinAddress = "addr")
// THEN
var error: ActivatePromoCodeError? = null

View file

@ -2,6 +2,7 @@ package com.tangem.domain.card.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.getReferralParams
sealed class IntroductionProcess(
event: String,
@ -13,7 +14,14 @@ sealed class IntroductionProcess(
class ButtonBuyCards : IntroductionProcess("Button - Buy Cards")
class ButtonScanCardLegacy : IntroductionProcess("Button - Scan Card")
class CreateWalletIntroScreenOpened : IntroductionProcess("Create Wallet Intro Screen Opened")
class CreateWalletIntroScreenOpened(
referralId: String?,
) : IntroductionProcess(
event = "Create Wallet Intro Screen Opened",
params = buildMap {
putAll(getReferralParams(referralId))
},
)
class ButtonScanCard(
val source: AnalyticsParam.ScreensSources,

View file

@ -59,32 +59,55 @@ sealed interface FeedbackEmailType {
override val walletMetaInfo: WalletMetaInfo? = null
}
data object BiometricsAuthenticationFailed : FeedbackEmailType {
override val walletMetaInfo: WalletMetaInfo? = null
}
sealed class Visa : FeedbackEmailType {
abstract val customerId: String
data class DirectUserRequest(override val walletMetaInfo: WalletMetaInfo) : Visa()
data class DirectUserRequest(
override val walletMetaInfo: WalletMetaInfo,
override val customerId: String,
) : Visa()
data class Activation(override val walletMetaInfo: WalletMetaInfo) : Visa()
data class Activation(
override val walletMetaInfo: WalletMetaInfo,
override val customerId: String,
) : Visa()
data class Dispute(
val visaTxDetails: VisaTxDetails,
override val walletMetaInfo: WalletMetaInfo,
override val customerId: String,
) : Visa()
data class FailedIssueCard(override val walletMetaInfo: WalletMetaInfo) : Visa()
data class FailedIssueCard(
override val walletMetaInfo: WalletMetaInfo,
override val customerId: String,
) : Visa()
data class DisputeV2(
val item: TangemPayTxHistoryItem,
override val walletMetaInfo: WalletMetaInfo,
override val customerId: String,
) : Visa()
data class Withdrawal(
override val walletMetaInfo: WalletMetaInfo,
override val customerId: String,
val providerName: String,
val txId: String,
) : Visa()
data class FeatureIsBeta(override val walletMetaInfo: WalletMetaInfo) : Visa()
data class FeatureIsBeta(
override val walletMetaInfo: WalletMetaInfo,
override val customerId: String,
) : Visa()
data class KycRejected(override val walletMetaInfo: WalletMetaInfo, val customerId: String) : Visa()
data class KycRejected(
override val walletMetaInfo: WalletMetaInfo,
override val customerId: String,
) : Visa()
}
}

View file

@ -55,7 +55,7 @@ internal class FeedbackDataBuilder {
}
fun addCustomerId(customerId: String) {
builder.appendKeyValue("ID: ", customerId)
builder.appendKeyValue("Tangem Pay Customer ID", customerId)
}
fun addUserWalletMetaInfo(walletMetaInfo: WalletMetaInfo) {

View file

@ -93,6 +93,7 @@ class SendFeedbackEmailUseCase(
is FeedbackEmailType.CurrencyDescriptionError,
is FeedbackEmailType.PreActivatedWallet,
is FeedbackEmailType.CardAttestationFailed,
is FeedbackEmailType.BiometricsAuthenticationFailed,
is FeedbackEmailType.Visa.Dispute,
is FeedbackEmailType.Visa.DisputeV2,
is FeedbackEmailType.Visa.FeatureIsBeta,

View file

@ -31,10 +31,15 @@ class EmailMessageBodyResolver(
is FeedbackEmailType.BackupProblem -> addUserRequestBody(type.walletMetaInfo)
is FeedbackEmailType.ScanningProblem,
is FeedbackEmailType.CardAttestationFailed,
is FeedbackEmailType.BiometricsAuthenticationFailed,
-> addPhoneInfoBody()
is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.walletMetaInfo)
is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.walletMetaInfo)
is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.walletMetaInfo, type.visaTxDetails)
is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(
walletMetaInfo = type.walletMetaInfo,
customerId = type.customerId,
visaTxDetails = type.visaTxDetails,
)
is FeedbackEmailType.Visa.FailedIssueCard -> addTangemPayFailedIssuingCardBody(type)
is FeedbackEmailType.Visa.DisputeV2 -> addTangemPayDisputeRequestBody(type)
is FeedbackEmailType.Visa.Withdrawal -> addTangemPayWithdrawalRequestBody(type)
@ -48,6 +53,7 @@ class EmailMessageBodyResolver(
private fun FeedbackDataBuilder.addTangemPayFailedIssuingCardBody(type: FeedbackEmailType.Visa.FailedIssueCard) {
addTangemPayPhoneInfoBody(type)
addDelimiter()
addCustomerId(customerId = type.customerId)
type.walletMetaInfo.userWalletId?.let { userWalletId ->
addUserWalletId(userWalletId = userWalletId.stringValue)
}
@ -58,6 +64,7 @@ class EmailMessageBodyResolver(
addDelimiter()
addTangemPayTxInfo(type.item)
addDelimiter()
addCustomerId(customerId = type.customerId)
type.walletMetaInfo.userWalletId?.let { userWalletId ->
addUserWalletId(userWalletId = userWalletId.stringValue)
}
@ -66,6 +73,7 @@ class EmailMessageBodyResolver(
private fun FeedbackDataBuilder.addTangemPayBetaRequestBody(type: FeedbackEmailType.Visa) {
addTangemPayPhoneInfoBody(type)
addDelimiter()
addCustomerId(customerId = type.customerId)
type.walletMetaInfo?.userWalletId?.let { userWalletId ->
addUserWalletId(userWalletId = userWalletId.stringValue)
}
@ -82,6 +90,7 @@ class EmailMessageBodyResolver(
) {
addUserWalletMetaInfo(type.walletMetaInfo)
addDelimiter()
addCustomerId(customerId = type.customerId)
val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" }
val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId)
@ -107,9 +116,11 @@ class EmailMessageBodyResolver(
private suspend fun FeedbackDataBuilder.addVisaRequestBody(
walletMetaInfo: WalletMetaInfo,
visaTxDetails: VisaTxDetails,
customerId: String,
) {
addUserRequestBody(walletMetaInfo)
addDelimiter()
addCustomerId(customerId = customerId)
addVisaTxInfo(visaTxDetails)
}

View file

@ -36,6 +36,7 @@ internal class EmailMessageTitleResolver(private val resources: Resources) {
is FeedbackEmailType.TransactionSendingProblem,
is FeedbackEmailType.StakingProblem,
is FeedbackEmailType.SwapProblem,
is FeedbackEmailType.BiometricsAuthenticationFailed,
-> R.string.feedback_preface_tx_failed
}

View file

@ -39,6 +39,7 @@ internal class EmailSubjectResolver(private val resources: Resources) {
resources.getStringSafe(R.string.feedback_token_description_error)
}
FeedbackEmailType.CardAttestationFailed -> "Card attestation failed"
FeedbackEmailType.BiometricsAuthenticationFailed -> "Biometrics authentication failed"
is FeedbackEmailType.Visa.Activation -> "[Visa] [Activation] {auto-filled subject}"
is FeedbackEmailType.Visa.DirectUserRequest -> "[Visa] {auto-filled subject}"
is FeedbackEmailType.Visa.FailedIssueCard -> "[Visa] {auto-filled subject}"

View file

@ -185,4 +185,10 @@ sealed interface Account {
error("Not yet implemented")
}
}
}
}
val Account.derivationIndex: DerivationIndex?
get() = when (this) {
is Account.CryptoPortfolio -> derivationIndex
is Account.Payment -> TODO("[REDACTED_JIRA]")
}

View file

@ -282,9 +282,12 @@ class CreateAndSendGaslessTransactionUseCase(
val feeInTokenCurrency = txFee.transactionFee.normal as? Fee.Ethereum.TokenCurrency
?: error("Fee must be in token currency")
val maxTokenFeeAmount = feeInTokenCurrency.amount
val maxTokenFee = maxTokenFeeAmount.value?.movePointRight(maxTokenFeeAmount.decimals)?.toBigInteger()
?: error("Max token fee amount is null")
return GaslessTransactionData.Fee(
feeToken = tokenForFee.contractAddress,
maxTokenFee = feeInTokenCurrency.gasLimit,
maxTokenFee = maxTokenFee,
coinPriceInToken = feeInTokenCurrency.coinPriceInToken,
feeTransferGasLimit = feeInTokenCurrency.feeTransferGasLimit,
baseGas = feeInTokenCurrency.baseGas,

View file

@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class TangemPayDetailsConfig(
val customerId: String,
val cardId: String,
val isPinSet: Boolean,
val cardFrozenState: TangemPayCardFrozenState,

View file

@ -66,6 +66,7 @@ sealed class VisaApiError(
data object SignWithdrawError : VisaApiError(104004004)
data object WithdrawError : VisaApiError(104004005)
data object ServerUnavailable : VisaApiError(104004006)
data object CustomerIdUnavailable : VisaApiError(104004007)
companion object {
fun fromBackendError(backendErrorCode: Int): VisaApiError {

View file

@ -1,10 +1,21 @@
package com.tangem.domain.pay
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
interface TangemPayEligibilityManager {
suspend fun getEligibleWallets(shouldExcludePaeraCustomers: Boolean): List<UserWallet>
/**
* Returns all compatible user wallets without checking Tangem Pay eligibility, only used when opening deeplink
* Remove after removing [TangemPayOnboardingComponent.Params.Deeplink]
* */
suspend fun getPossibleWalletsIds(shouldExcludePaeraCustomers: Boolean): List<UserWalletId>
suspend fun getTangemPayAvailability(): Boolean
suspend fun isPaeraCustomerForAnyWallet(): Boolean
fun reset()
}

View file

@ -7,6 +7,7 @@ sealed class MainCustomerInfoContentState {
object Loading : MainCustomerInfoContentState()
object OnboardingBanner : MainCustomerInfoContentState()
data class Content(val info: MainScreenCustomerInfo) : MainCustomerInfoContentState()
object Empty : MainCustomerInfoContentState()
}
data class MainScreenCustomerInfo(

View file

@ -68,17 +68,21 @@ class TangemPayMainScreenCustomerInfoUseCase(
}
private suspend fun showOnboardingBannerIfEligible(userWalletId: UserWalletId) {
if (eligibilityManager.isPaeraCustomerForAnyWallet()) {
updateState(userWalletId, MainCustomerInfoContentState.Empty.right())
return
}
val isEligible = eligibilityManager
.getEligibleWallets(shouldExcludePaeraCustomers = false)
.any { it.walletId == userWalletId }
if (isEligible) {
if (onboardingRepository.getHideMainOnboardingBanner(userWalletId)) {
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
updateState(userWalletId, MainCustomerInfoContentState.Empty.right())
} else {
updateState(userWalletId, MainCustomerInfoContentState.OnboardingBanner.right())
}
} else {
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
updateState(userWalletId, MainCustomerInfoContentState.Empty.right())
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.domain.tangempay
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.wallet.UserWalletId
interface GetTangemPayCustomerIdUseCase {
operator fun invoke(userWalletId: UserWalletId): Either<UniversalError, String>
}

View file

@ -34,8 +34,17 @@ sealed class WcAnalyticEvents(
),
)
class PairButtonConnect : WcAnalyticEvents(
class PairButtonConnect(
dAppName: String,
accountDerivation: Int?,
) : WcAnalyticEvents(
event = "Button - Connect",
params = buildMap {
put(AnalyticsParam.DAPP_NAME, dAppName)
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)
class PairRequested(
@ -114,6 +123,7 @@ sealed class WcAnalyticEvents(
rawRequest: WcSdkSessionRequest,
network: Network,
emulationStatus: EmulationStatus?,
accountDerivation: Int?,
securityStatus: CheckDAppResult,
) : WcAnalyticEvents(
event = "Signature Request Received",
@ -124,6 +134,7 @@ sealed class WcAnalyticEvents(
AnalyticsParam.BLOCKCHAIN to network.name,
AnalyticsParam.EMULATION_STATUS to emulationStatus?.status,
AnalyticsParam.TYPE to securityStatus.toAnalyticVerificationStatus(),
AnalyticsParam.ACCOUNT_DERIVATION to accountDerivation?.toString(),
).mapNotNullValues { it.value },
) {
enum class EmulationStatus(val status: String) {
@ -137,15 +148,19 @@ sealed class WcAnalyticEvents(
rawRequest: WcSdkSessionRequest,
network: Network,
securityStatus: CheckDAppResult,
accountDerivation: Int?,
) : WcAnalyticEvents(
event = "Signature Request Handled",
params = mapOf(
AnalyticsParam.DAPP_NAME to rawRequest.dAppMetaData.name,
AnalyticsParam.DAPP_URL to rawRequest.dAppMetaData.url,
AnalyticsParam.METHOD_NAME to rawRequest.request.method,
AnalyticsParam.BLOCKCHAIN to network.name,
AnalyticsParam.TYPE to securityStatus.toAnalyticVerificationStatus(),
),
params = buildMap {
put(AnalyticsParam.DAPP_NAME, rawRequest.dAppMetaData.name)
put(AnalyticsParam.DAPP_URL, rawRequest.dAppMetaData.url)
put(AnalyticsParam.METHOD_NAME, rawRequest.request.method)
put(AnalyticsParam.BLOCKCHAIN, network.name)
put(AnalyticsParam.TYPE, securityStatus.toAnalyticVerificationStatus())
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
), AppsFlyerIncludedEvent
class SignatureRequestFailed(
@ -153,16 +168,18 @@ sealed class WcAnalyticEvents(
network: Network,
errorCode: String,
errorMessage: String,
accountDerivation: Int?,
) : WcAnalyticEvents(
event = "Signature Request Failed",
params = mapOf(
AnalyticsParam.DAPP_NAME to rawRequest.dAppMetaData.name,
AnalyticsParam.DAPP_URL to rawRequest.dAppMetaData.url,
AnalyticsParam.METHOD_NAME to rawRequest.request.method,
AnalyticsParam.BLOCKCHAIN to network.name,
AnalyticsParam.ERROR_CODE to errorCode,
AnalyticsParam.ERROR_DESCRIPTION to errorMessage,
),
params = buildMap {
put(AnalyticsParam.DAPP_NAME, rawRequest.dAppMetaData.name)
put(AnalyticsParam.DAPP_URL, rawRequest.dAppMetaData.url)
put(AnalyticsParam.METHOD_NAME, rawRequest.request.method)
put(AnalyticsParam.BLOCKCHAIN, network.name)
put(AnalyticsParam.ERROR_CODE, errorCode)
put(AnalyticsParam.ERROR_DESCRIPTION, errorMessage)
accountDerivation?.let { put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString()) }
},
)
class SignatureRequestReceivedFailed(
@ -279,23 +296,4 @@ fun CheckDAppResult.toAnalyticVerificationStatus(): String = when (this) {
SAFE -> DAppVerificationStatus.Verified
UNSAFE -> DAppVerificationStatus.Risky
FAILED_TO_VERIFY -> DAppVerificationStatus.Unknown
}.status
sealed class WcAnalyticAccountEvents(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = WC_CATEGORY_ACCOUNT_NAME, event = event, params = params) {
data class PairButtonConnect(
private val accountDerivation: Int,
) : WcAnalyticAccountEvents(
event = "Button - Connect",
params = mapOf(
"Account Derivation" to accountDerivation.toString(),
),
)
companion object {
const val WC_CATEGORY_ACCOUNT_NAME = "WalletConnect / Account"
}
}
}.status

View file

@ -66,5 +66,9 @@ interface WalletsRepository {
@Throws
suspend fun associateWallets(applicationId: String, wallets: List<UserWallet>)
suspend fun activatePromoCode(promoCode: String, bitcoinAddress: String): Either<ActivatePromoCodeError, String>
suspend fun activatePromoCode(
userWalletId: UserWalletId,
promoCode: String,
bitcoinAddress: String,
): Either<ActivatePromoCodeError, String>
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError
import com.tangem.domain.wallets.repository.WalletsRepository
import javax.inject.Inject
@ -9,6 +10,13 @@ class ActivateBitcoinPromocodeUseCase @Inject constructor(
private val walletsRepository: WalletsRepository,
) {
suspend operator fun invoke(address: String, promoCode: String): Either<ActivatePromoCodeError, String> =
walletsRepository.activatePromoCode(promoCode = promoCode, bitcoinAddress = address)
suspend operator fun invoke(
userWalletId: UserWalletId,
address: String,
promoCode: String,
): Either<ActivatePromoCodeError, String> = walletsRepository.activatePromoCode(
userWalletId = userWalletId,
promoCode = promoCode,
bitcoinAddress = address,
)
}

View file

@ -2,14 +2,22 @@ package com.tangem.domain.wallets.usecase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
class GetSavedWalletsCountUseCase(
private val userWalletsListRepository: UserWalletsListRepository,
) {
@OptIn(ExperimentalCoroutinesApi::class)
operator fun invoke(): Flow<List<UserWallet>> {
return userWalletsListRepository.userWallets.map { requireNotNull(it) }
return flowOf(Unit)
.flatMapLatest {
userWalletsListRepository.load()
userWalletsListRepository.userWallets.map { wallets -> requireNotNull(wallets) }
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.account.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.account.AccountCreateEditComponent
@ -15,20 +16,48 @@ sealed class AccountSettingsAnalyticEvents(
event = "Account Settings Screen Opened",
)
class ButtonManageTokens : AccountSettingsAnalyticEvents(
class ButtonManageTokens(
accountDerivation: Int?,
) : AccountSettingsAnalyticEvents(
event = "Button - Manage Tokens",
params = buildMap {
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)
class ButtonArchiveAccount : AccountSettingsAnalyticEvents(
class ButtonArchiveAccount(
accountDerivation: Int?,
) : AccountSettingsAnalyticEvents(
event = "Button - Archive Account",
params = buildMap {
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)
class ButtonArchiveAccountConfirmation : AccountSettingsAnalyticEvents(
class ButtonArchiveAccountConfirmation(
accountDerivation: Int?,
) : AccountSettingsAnalyticEvents(
event = "Button - Archive Account Confirmation",
params = buildMap {
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)
class ButtonCancelAccountArchivation : AccountSettingsAnalyticEvents(
class ButtonCancelAccountArchivation(
accountDerivation: Int?,
) : AccountSettingsAnalyticEvents(
event = "Button - Cancel Account Archivation",
params = buildMap {
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)
class AccountArchived : AccountSettingsAnalyticEvents(
@ -39,13 +68,21 @@ sealed class AccountSettingsAnalyticEvents(
event = "Button - Edit",
)
class AccountEditScreenOpened : AccountSettingsAnalyticEvents(
class AccountEditScreenOpened(
accountDerivation: Int?,
) : AccountSettingsAnalyticEvents(
event = "Account Edit Screen Opened",
params = buildMap {
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)
class ButtonSave(
val name: AccountName,
val icon: CryptoPortfolioIcon,
accountDerivation: Int?,
) : AccountSettingsAnalyticEvents(
event = "Button - Save",
params = buildMap {
@ -56,13 +93,16 @@ sealed class AccountSettingsAnalyticEvents(
put("Name", accountName)
put("Color", icon.color.name)
put("Icon", icon.value.name)
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)
class ButtonAddNewAccount(
val name: AccountName,
val icon: CryptoPortfolioIcon,
val derivationIndex: Int,
accountDerivation: Int?,
) : AccountSettingsAnalyticEvents(
event = "Button - Add New Account",
params = buildMap {
@ -73,18 +113,24 @@ sealed class AccountSettingsAnalyticEvents(
put("Name", accountName)
put("Color", icon.color.name)
put("Icon", icon.value.name)
put("Derivation", derivationIndex.toString())
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)
class AccountError(
val source: Source,
val error: String,
accountDerivation: Int?,
) : AccountSettingsAnalyticEvents(
event = "Account Error",
params = buildMap {
put("Error", error)
put("Source", source.value)
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)

View file

@ -1,6 +1,7 @@
package com.tangem.features.account.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed class WalletSettingsAccountAnalyticEvents(
category: String = "Settings / Wallet Settings",
@ -16,11 +17,23 @@ sealed class WalletSettingsAccountAnalyticEvents(
event = "Account Recovered",
)
class ArchivedAccountsScreenOpened : WalletSettingsAccountAnalyticEvents(
class ArchivedAccountsScreenOpened(
private val accountsCount: Int,
) : WalletSettingsAccountAnalyticEvents(
event = "Archived Accounts Screen Opened",
params = buildMap {
put("Accounts Count", accountsCount.toString())
},
)
class ButtonRecoverAccount : WalletSettingsAccountAnalyticEvents(
class ButtonRecoverAccount(
accountDerivation: Int?,
) : WalletSettingsAccountAnalyticEvents(
event = "Button - Recover Account",
params = buildMap {
accountDerivation?.let {
put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString())
}
},
)
}

View file

@ -13,9 +13,9 @@ import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase
import com.tangem.domain.models.account.AccountId
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.account.analytics.WalletSettingsAccountAnalyticEvents
import com.tangem.features.account.archived.entity.AccountArchivedUM
@ -53,7 +53,6 @@ internal class ArchivedAccountListModel @Inject constructor(
private val getArchivedAccountsJob = JobHolder()
init {
analyticsEventHandler.send(WalletSettingsAccountAnalyticEvents.ArchivedAccountsScreenOpened())
getArchivedAccounts()
}
@ -69,14 +68,16 @@ internal class ArchivedAccountListModel @Inject constructor(
umBuilder.mapContent(
accounts = content,
onCloseClick = onCloseClick,
onRecoverClick = { recoverCryptoPortfolio(accountId = it.accountId) },
onRecoverClick = { recoverCryptoPortfolio(account = it) },
)
},
ifContent = { content ->
val event = WalletSettingsAccountAnalyticEvents.ArchivedAccountsScreenOpened(content.size)
analyticsEventHandler.send(event)
umBuilder.mapContent(
accounts = content,
onCloseClick = onCloseClick,
onRecoverClick = { recoverCryptoPortfolio(accountId = it.accountId) },
onRecoverClick = { recoverCryptoPortfolio(account = it) },
)
},
ifError = { error ->
@ -97,13 +98,15 @@ internal class ArchivedAccountListModel @Inject constructor(
.saveIn(getArchivedAccountsJob)
}
private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch {
analyticsEventHandler.send(WalletSettingsAccountAnalyticEvents.ButtonRecoverAccount())
uiState.update { it.toggleProgress(accountId, isLoading = true) }
private fun recoverCryptoPortfolio(account: ArchivedAccount) = modelScope.launch {
analyticsEventHandler.send(
WalletSettingsAccountAnalyticEvents.ButtonRecoverAccount(account.derivationIndex.value),
)
uiState.update { it.toggleProgress(account.accountId, isLoading = true) }
val result = withContext(dispatchers.default) {
recoverCryptoPortfolioUseCase(accountId)
recoverCryptoPortfolioUseCase(account.accountId)
}
uiState.update { it.toggleProgress(accountId, isLoading = false) }
uiState.update { it.toggleProgress(account.accountId, isLoading = false) }
result
.onLeft(::handleRecoverError)
.onRight {

View file

@ -25,6 +25,7 @@ import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase
import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.account.derivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.analytics.AccountSettingsAnalyticEvents
@ -69,10 +70,13 @@ internal class AccountCreateEditModel @Inject constructor(
field = MutableStateFlow(value = getInitialState())
init {
if (params is AccountCreateEditComponent.Params.Create) {
updateDerivationInfo(userWalletId = params.userWalletId)
} else {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.AccountEditScreenOpened())
when (params) {
is AccountCreateEditComponent.Params.Create -> updateDerivationInfo(userWalletId = params.userWalletId)
is AccountCreateEditComponent.Params.Edit -> {
val derivationIndex = params.account.derivationIndex?.value
val event = AccountSettingsAnalyticEvents.AccountEditScreenOpened(derivationIndex)
analyticsEventHandler.send(event)
}
}
}
@ -116,7 +120,7 @@ internal class AccountCreateEditModel @Inject constructor(
val event = AccountSettingsAnalyticEvents.ButtonAddNewAccount(
name = name,
icon = icon,
derivationIndex = derivationIndex.value,
accountDerivation = derivationIndex.value,
)
analyticsEventHandler.send(event)
@ -130,7 +134,7 @@ internal class AccountCreateEditModel @Inject constructor(
uiState.value = uiState.value.toggleProgress(showProgress = false)
result
.onLeft(::handleAddAccountError)
.onLeft { error -> handleAddAccountError(error, derivationIndex.value) }
.onRight {
analyticsEventHandler.send(WalletSettingsAccountAnalyticEvents.AccountCreated())
showMessage(R.string.account_create_success_message)
@ -138,9 +142,10 @@ internal class AccountCreateEditModel @Inject constructor(
}
}
private fun handleAddAccountError(error: AddCryptoPortfolioUseCase.Error) {
private fun handleAddAccountError(error: AddCryptoPortfolioUseCase.Error, derivationIndex: Int) {
val event = AccountSettingsAnalyticEvents.AccountError(
source = params.toAnalyticSource(),
accountDerivation = derivationIndex,
error = when (error) {
is AddCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet -> error.cause.tag
is AddCryptoPortfolioUseCase.Error.DataOperationFailed -> error.cause.message.orEmpty()
@ -165,7 +170,8 @@ internal class AccountCreateEditModel @Inject constructor(
val icon = CryptoPortfolioIconConverter.convertBack(state.account.portfolioIcon)
val isNewName = name != params.account.accountName
val isNewIcon = icon != params.account.portfolioIcon
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonSave(name, icon))
val derivationIndex = params.account.derivationIndex?.value
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonSave(name, icon, derivationIndex))
uiState.value = uiState.value.toggleProgress(showProgress = true)
val result = updateCryptoPortfolioUseCase(
@ -176,16 +182,17 @@ internal class AccountCreateEditModel @Inject constructor(
uiState.value = uiState.value.toggleProgress(showProgress = false)
result
.onLeft(::handleEditAccountError)
.onLeft { error -> handleEditAccountError(error, params.account.derivationIndex?.value) }
.onRight {
showMessage(R.string.account_edit_success_message)
router.pop()
}
}
private fun handleEditAccountError(error: UpdateCryptoPortfolioUseCase.Error) {
private fun handleEditAccountError(error: UpdateCryptoPortfolioUseCase.Error, derivationIndex: Int?) {
val event = AccountSettingsAnalyticEvents.AccountError(
source = params.toAnalyticSource(),
accountDerivation = derivationIndex,
error = when (error) {
is UpdateCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet -> error.cause.tag
is UpdateCryptoPortfolioUseCase.Error.DataOperationFailed -> error.cause.message.orEmpty()

View file

@ -19,6 +19,7 @@ import com.tangem.domain.account.status.usecase.ArchiveCryptoPortfolioUseCase
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.models.PortfolioId
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.derivationIndex
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.account.AccountDetailsComponent
@ -65,15 +66,19 @@ internal class AccountDetailsModel @Inject constructor(
private fun onManageTokensClick(account: Account) {
val route = AppRoute.ManageTokens(
source = AppRoute.ManageTokens.Source.SETTINGS,
source = AppRoute.ManageTokens.Source.ACCOUNT,
portfolioId = PortfolioId(account.accountId),
)
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonManageTokens())
analyticsEventHandler.send(
AccountSettingsAnalyticEvents.ButtonManageTokens(account.derivationIndex?.value),
)
router.push(route)
}
private fun onArchiveAccountClick() {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonArchiveAccount())
val accountDerivation = params.account.derivationIndex?.value
val event = AccountSettingsAnalyticEvents.ButtonArchiveAccount(accountDerivation)
analyticsEventHandler.send(event)
confirmArchiveDialog()
}
@ -81,7 +86,9 @@ internal class AccountDetailsModel @Inject constructor(
val secondAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonCancelAccountArchivation())
val accountDerivation = params.account.derivationIndex?.value
val event = AccountSettingsAnalyticEvents.ButtonCancelAccountArchivation(accountDerivation)
analyticsEventHandler.send(event)
},
)
val firstAction = EventMessageAction(
@ -100,7 +107,9 @@ internal class AccountDetailsModel @Inject constructor(
}
private fun archiveCryptoPortfolio() = modelScope.launch {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonArchiveAccountConfirmation())
val accountDerivation = params.account.derivationIndex?.value
val event = AccountSettingsAnalyticEvents.ButtonArchiveAccountConfirmation(accountDerivation)
analyticsEventHandler.send(event)
uiState.update { it.toggleProgress(true) }
archiveCryptoPortfolioUseCase(accountId)
.onLeft { error ->
@ -119,6 +128,7 @@ internal class AccountDetailsModel @Inject constructor(
val event = AccountSettingsAnalyticEvents.AccountError(
source = AccountSettingsAnalyticEvents.Source.ARCHIVE,
error = error.tag,
accountDerivation = params.account.derivationIndex?.value,
)
analyticsEventHandler.send(event)
val titleRes: Int

View file

@ -22,6 +22,7 @@ dependencies {
implementation(projects.domain.wallets)
implementation(projects.domain.models)
implementation(projects.domain.hotWallet)
implementation(projects.domain.wallets.models)
/** Core modules */
implementation(projects.core.configToggles)

View file

@ -20,6 +20,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.dialog.Dialogs.hotWalletCreationNotSupportedDialog
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.repository.CardSdkConfigRepository
@ -63,6 +64,7 @@ internal class CreateWalletStartModel @Inject constructor(
private val urlOpener: UrlOpener,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appsFlyerStore: AppsFlyerStore,
) : Model() {
private val params = paramsContainer.require<CreateWalletStartComponent.Params>()
@ -130,9 +132,13 @@ internal class CreateWalletStartModel @Inject constructor(
)
init {
analyticsEventHandler.send(
event = IntroductionProcess.CreateWalletIntroScreenOpened(),
)
modelScope.launch {
analyticsEventHandler.send(
event = IntroductionProcess.CreateWalletIntroScreenOpened(
referralId = appsFlyerStore.get()?.refcode,
),
)
}
}
private fun onScanClick() {

View file

@ -22,6 +22,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
@ -60,6 +61,7 @@ internal class DetailsModel @Inject constructor(
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val appStateHolder: ReduxStateHolder,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
override val dispatchers: CoroutineDispatcherProvider,
@ -130,14 +132,20 @@ internal class DetailsModel @Inject constructor(
?: error("Selected wallet is null")
val metaInfo = getWalletMetaInfoUseCase(selectedUserWallet.walletId).getOrNull() ?: return@launch
val visaCustomerId = getTangemPayCustomerIdUseCase(selectedUserWallet.walletId).getOrNull()
val feedbackType = when {
userWallets.all { it is UserWallet.Cold && it.scanResponse.card.isVisa } ->
FeedbackEmailType.Visa.DirectUserRequest(metaInfo)
userWallets.all {
it is UserWallet.Cold && it.scanResponse.card.isVisa && !visaCustomerId.isNullOrEmpty()
} ->
FeedbackEmailType.Visa.DirectUserRequest(
walletMetaInfo = metaInfo,
customerId = requireNotNull(visaCustomerId),
)
userWallets.all { it !is UserWallet.Cold || it.scanResponse.card.isVisa.not() } ->
FeedbackEmailType.DirectUserRequest(metaInfo)
else -> {
showFeedbackEmailTypeOptionBS(metaInfo)
showFeedbackEmailTypeOptionBS(selectedWalletMetaInfo = metaInfo, visaCustomerId = visaCustomerId)
return@launch
}
}
@ -154,16 +162,16 @@ internal class DetailsModel @Inject constructor(
}
}
private fun showFeedbackEmailTypeOptionBS(selectedWalletMetaInfo: WalletMetaInfo) {
state.update {
it.copy(
private fun showFeedbackEmailTypeOptionBS(selectedWalletMetaInfo: WalletMetaInfo, visaCustomerId: String?) {
state.update { current ->
current.copy(
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {
state.update {
it.copy(
current.copy(
selectFeedbackEmailTypeBSConfig =
it.selectFeedbackEmailTypeBSConfig.copy(isShown = false),
current.selectFeedbackEmailTypeBSConfig.copy(isShown = false),
)
}
},
@ -172,6 +180,7 @@ internal class DetailsModel @Inject constructor(
onEmailFeedbackTypeOptionSelected(
selectedWalletMetaInfo = selectedWalletMetaInfo,
option = option,
visaCustomerId = visaCustomerId,
)
state.update {
@ -190,6 +199,7 @@ internal class DetailsModel @Inject constructor(
private fun onEmailFeedbackTypeOptionSelected(
selectedWalletMetaInfo: WalletMetaInfo,
option: SelectEmailFeedbackTypeBS.Option,
visaCustomerId: String?,
) {
modelScope.launch {
val feedbackType = when (option) {
@ -207,14 +217,15 @@ internal class DetailsModel @Inject constructor(
}
}
SelectEmailFeedbackTypeBS.Option.Visa -> {
if (selectedWalletMetaInfo.isVisa == true) {
FeedbackEmailType.Visa.DirectUserRequest(selectedWalletMetaInfo)
if (selectedWalletMetaInfo.isVisa == true && !visaCustomerId.isNullOrEmpty()) {
FeedbackEmailType.Visa.DirectUserRequest(selectedWalletMetaInfo, visaCustomerId)
} else {
val userWallet = getWalletsUseCase.invokeSync()
.firstOrNull { it is UserWallet.Cold && it.scanResponse.card.isVisa }
?: return@launch
val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch
FeedbackEmailType.Visa.DirectUserRequest(metaInfo)
val customerId = getTangemPayCustomerIdUseCase(userWallet.walletId).getOrNull() ?: return@launch
FeedbackEmailType.Visa.DirectUserRequest(metaInfo, customerId)
}
}
}

View file

@ -96,11 +96,11 @@ internal class AddTokenModel @Inject constructor(
return@launch
}
val status = getAccountCurrencyStatusUseCase.invokeSync(
val status = getAccountCurrencyStatusUseCase(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
).getOrNull()
).firstOrNull()
if (status == null) {
processError(error = null)
} else {

View file

@ -108,9 +108,9 @@ internal class NewsDetailsModel @Inject constructor(
private val stateFactory by lazy(LazyThreadSafetyMode.NONE) {
NewsDetailsStateFactory(
currentStateProvider = Provider { _state.value },
shareManager = shareManager,
onStateUpdate = { newState -> _state.update { newState } },
onRetryClick = ::onRetryClicked,
onShareClick = ::onShareClick,
)
}
@ -149,6 +149,11 @@ internal class NewsDetailsModel @Inject constructor(
urlOpener.openUrl(relatedArticle.url)
}
private fun onShareClick(article: ArticleUM) {
shareManager.shareText(article.newsUrl)
analyticsEventHandler.send(NewsDetailsAnalyticsEvent.NewsShareButtonClick(article.id))
}
private fun onArticleIndexChanged(newIndex: Int) {
stateFactory.updateSelectedArticleIndex(newIndex)
val currentArticle = when (state.value.articlesStateUM) {

View file

@ -70,4 +70,20 @@ internal sealed class NewsDetailsAnalyticsEvent(
ERROR_MESSAGE to message,
),
)
data class NewsShareButtonClick(
private val newsId: Int,
) : NewsDetailsAnalyticsEvent(
event = "News Share Button Clicked",
params = mapOf(
"News Id" to newsId.toString(),
),
), OneTimePerSessionEvent {
override val oneTimeEventId: String = event
override val throttleSeconds: Long = THROTTLE_SECONDS
}
private companion object {
const val THROTTLE_SECONDS = 10L
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.features.feed.model.news.details.factory
import com.tangem.core.navigation.share.ShareManager
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
@ -10,7 +9,7 @@ import kotlinx.collections.immutable.toImmutableList
internal class NewsDetailsStateFactory(
private val currentStateProvider: Provider<NewsDetailsUM>,
private val shareManager: ShareManager,
private val onShareClick: (ArticleUM) -> Unit,
private val onStateUpdate: (NewsDetailsUM) -> Unit,
private val onRetryClick: () -> Unit,
) {
@ -23,11 +22,7 @@ internal class NewsDetailsStateFactory(
articles = articles.toImmutableList(),
articlesStateUM = ArticlesStateUM.Content,
selectedArticleIndex = selectedIndex,
onShareClick = {
currentArticle?.let {
shareManager.shareText(it.newsUrl)
}
},
onShareClick = { currentArticle?.let(onShareClick) },
),
)
}
@ -38,11 +33,7 @@ internal class NewsDetailsStateFactory(
onStateUpdate(
currentState.copy(
selectedArticleIndex = newIndex,
onShareClick = {
currentArticle?.let {
shareManager.shareText(it.newsUrl)
}
},
onShareClick = { currentArticle?.let(onShareClick) },
),
)
}

View file

@ -27,6 +27,7 @@ dependencies {
implementation(projects.common.routing)
/** Domain */
implementation(projects.domain.common)
implementation(projects.domain.models)
implementation(projects.domain.core)
implementation(projects.domain.card)
@ -37,6 +38,10 @@ dependencies {
implementation(projects.domain.legacy)
implementation(projects.domain.feedback)
implementation(projects.domain.feedback.models)
implementation(projects.domain.referral)
/** Referral */
implementation(projects.features.referral.domain)
/** AndroidX libraries */
implementation(deps.androidx.activity.compose)

View file

@ -41,7 +41,9 @@ import com.tangem.features.home.api.HomeComponent
import com.tangem.features.home.impl.ui.state.HomeUM
import com.tangem.features.home.impl.ui.state.Stories
import com.tangem.features.home.impl.ui.state.getRestrictedStories
import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@ -70,9 +72,12 @@ internal class HomeModel @Inject constructor(
private val urlOpener: UrlOpener,
private val userWalletsListRepository: UserWalletsListRepository,
private val reduxStateHolder: ReduxStateHolder,
private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
private val debouncer = Debouncer()
val params = paramsContainer.require<HomeComponent.Params>()
private val _uiState = MutableStateFlow(
@ -141,7 +146,16 @@ internal class HomeModel @Inject constructor(
}
private fun onGetStartedClick() {
router.push(AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.ColdWallet))
debouncer.debounce(modelScope) {
modelScope.launch {
val mode = if (shouldShowMobileWalletPromoUseCase()) {
AppRoute.CreateWalletStart.Mode.HotWallet
} else {
AppRoute.CreateWalletStart.Mode.ColdWallet
}
router.push(AppRoute.CreateWalletStart(mode = mode))
}
}
}
private fun scanCard() {

View file

@ -248,7 +248,7 @@ internal class AccessCodeModel @Inject constructor(
auth = HotAuth.Password(accessCode.toCharArray()),
)
if (walletsRepository.requireAccessCode().not()) {
if (walletsRepository.requireAccessCode().not() && canUseBiometryUseCase()) {
updatedHotWalletId = tangemHotSdk.changeAuth(
unlockHotWallet = unlockHotWallet,
auth = HotAuth.Biometry,

View file

@ -14,6 +14,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.crypto.bip39.Mnemonic
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
@ -41,6 +42,7 @@ internal class AddExistingWalletImportModel @Inject constructor(
private val saveUserWalletUseCase: SaveWalletUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appsFlyerStore: AppsFlyerStore,
) : Model() {
private val params: AddExistingWalletImportComponent.Params = paramsContainer.require()
@ -122,6 +124,7 @@ internal class AddExistingWalletImportModel @Inject constructor(
} else {
AnalyticsParam.EmptyFull.Full
},
referralId = appsFlyerStore.get()?.refcode,
),
)
params.callbacks.onWalletImported(userWallet.walletId)

View file

@ -11,6 +11,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.message.dialog.Dialogs.hotWalletCreationNotSupportedDialog
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
@ -44,6 +45,7 @@ internal class CreateMobileWalletModel @Inject constructor(
private val isHotWalletCreationSupported: IsHotWalletCreationSupported,
private val uiMessageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appsFlyerStore: AppsFlyerStore,
) : Model() {
private val params: CreateMobileWalletComponent.Params = paramsContainer.require()
@ -100,6 +102,7 @@ internal class CreateMobileWalletModel @Inject constructor(
creationType = OnboardingAnalyticsEvent.CreateWallet.WalletCreationType.NewSeed,
seedPhraseLength = SEED_PHRASE_LENGTH,
passPhraseState = AnalyticsParam.EmptyFull.Empty,
referralId = appsFlyerStore.get()?.refcode,
),
)

View file

@ -6,7 +6,8 @@ import com.tangem.domain.models.wallet.UserWalletId
enum class ManageTokensSource(val analyticsName: String) {
STORIES(analyticsName = "Stories"),
ONBOARDING(analyticsName = "Onboarding"),
SETTINGS(analyticsName = "Settings"),
SETTINGS(analyticsName = "Wallet Settings"),
ACCOUNT(analyticsName = "Account"),
SEND_VIA_SWAP(analyticsName = "SendViaSwap"),
}

View file

@ -8,7 +8,7 @@ internal sealed class CustomTokenAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(
category = "Manage Tokens / Custom",
category = "Manage Tokens / Custom Token",
event = event,
params = params,
) {

View file

@ -55,7 +55,8 @@ internal class CustomTokenFormModel @Inject constructor(
private val params: CustomTokenFormComponent.Params = paramsContainer.require()
private var createdCurrency: CryptoCurrency? = null
private var useCasesFacade: CustomTokenFormUseCasesFacade = customTokenFormUseCasesFacadeFactory.create(params.mode)
private val useCasesFacade: CustomTokenFormUseCasesFacade =
customTokenFormUseCasesFacadeFactory.create(params.mode.userWalletId)
private val customCurrencyValidator = CustomCurrencyValidator(
userWalletId = params.mode.userWalletId,
useCasesFacade = useCasesFacade,

View file

@ -18,9 +18,9 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase
import com.tangem.features.managetokens.component.AddCustomTokenMode
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -29,7 +29,7 @@ import timber.log.Timber
@Suppress("LongParameterList")
internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
@Assisted private val mode: AddCustomTokenMode,
@Assisted private val userWalletId: UserWalletId,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase,
@ -39,26 +39,24 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
private val accountsFeatureToggles: AccountsFeatureToggles,
) {
suspend fun addCryptoCurrenciesUseCase(currency: CryptoCurrency): Either<Throwable, Unit> = when (mode) {
is AddCustomTokenMode.Account -> either {
val accountId = getAccountId(currency)
suspend fun addCryptoCurrenciesUseCase(currency: CryptoCurrency): Either<Throwable, Unit> {
return if (accountsFeatureToggles.isFeatureEnabled) {
either {
val accountId = getAccountId(currency)
manageCryptoCurrenciesUseCase(accountId = accountId, add = currency).bind()
}
is AddCustomTokenMode.Wallet -> {
addCryptoCurrenciesUseCase.invoke(
userWalletId = mode.userWalletId,
currency = currency,
)
manageCryptoCurrenciesUseCase(accountId = accountId, add = currency).bind()
}
} else {
addCryptoCurrenciesUseCase.invoke(userWalletId = userWalletId, currency = currency)
}
}
suspend fun derivePublicKeysUseCase(currencies: List<CryptoCurrency>): Either<Throwable, Unit> = when (mode) {
is AddCustomTokenMode.Account -> Unit.right()
is AddCustomTokenMode.Wallet -> derivePublicKeysUseCase.invoke(
userWalletId = mode.userWalletId,
currencies = currencies,
)
suspend fun derivePublicKeysUseCase(currencies: List<CryptoCurrency>): Either<Throwable, Unit> {
return if (accountsFeatureToggles.isFeatureEnabled) {
Unit.right()
} else {
derivePublicKeysUseCase.invoke(userWalletId = userWalletId, currencies = currencies)
}
}
suspend fun checkIsCurrencyNotAddedUseCase(
@ -67,7 +65,7 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
contractAddress: String?,
): Either<Throwable, Boolean> = if (accountsFeatureToggles.isFeatureEnabled) {
getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = mode.userWalletId,
userWalletId = userWalletId,
networkId = networkId,
derivationPath = derivationPath,
contractAddress = contractAddress,
@ -76,7 +74,7 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
.right()
} else {
checkIsCurrencyNotAddedUseCase.invoke(
userWalletId = mode.userWalletId,
userWalletId = userWalletId,
networkId = networkId,
derivationPath = derivationPath,
contractAddress = contractAddress,
@ -85,15 +83,15 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
private suspend fun Raise<Throwable>.getAccountId(currency: CryptoCurrency): AccountId {
val accountList = singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = mode.userWalletId),
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
ensureNotNull(accountList) {
IllegalStateException("Account list not found: ${mode.userWalletId}")
IllegalStateException("Account list not found: $userWalletId")
}
if (accountList.activeAccounts == 1) {
return AccountId.forMainCryptoPortfolio(userWalletId = mode.userWalletId)
return AccountId.forMainCryptoPortfolio(userWalletId = userWalletId)
}
val currencyAccountIndex = currency.getAccountIndex().bind()
@ -104,7 +102,7 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
cryptoPortfolioAccount?.derivationIndex?.value == currencyAccountIndex
}
return account?.accountId ?: AccountId.forMainCryptoPortfolio(userWalletId = mode.userWalletId)
return account?.accountId ?: AccountId.forMainCryptoPortfolio(userWalletId = userWalletId)
}
private fun CryptoCurrency.getAccountIndex(): Either<Throwable, Int> = either {
@ -140,6 +138,6 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
@AssistedFactory
interface Factory {
fun create(mode: AddCustomTokenMode): CustomTokenFormUseCasesFacade
fun create(userWalletId: UserWalletId): CustomTokenFormUseCasesFacade
}
}

View file

@ -96,11 +96,11 @@ internal class AddTokenModel @Inject constructor(
return@launch
}
val status = getAccountCurrencyStatusUseCase.invokeSync(
val status = getAccountCurrencyStatusUseCase(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
).getOrNull()
).firstOrNull()
if (status == null) {
processError(error = null)
} else {

View file

@ -3,6 +3,8 @@ package com.tangem.features.onboarding.v2.common.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
import kotlin.collections.putAll
sealed class OnboardingEvent(
category: String,
@ -25,6 +27,7 @@ sealed class OnboardingEvent(
creationType: WalletCreationType = WalletCreationType.PrivateKey,
seedPhraseLength: Int? = null,
passPhraseState: AnalyticsParam.EmptyFull,
referralId: String?,
) : CreateWallet(
event = "Wallet Created Successfully",
params = buildMap {
@ -33,6 +36,7 @@ sealed class OnboardingEvent(
if (seedPhraseLength != null) {
put("Seed Phrase Length", seedPhraseLength.toString())
}
putAll(getReferralParams(referralId))
},
), AppsFlyerIncludedEvent

View file

@ -9,13 +9,14 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.impl.R
@ -45,6 +46,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
private val analyticsHandler: AnalyticsEventHandler,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val appsFlyerStore: AppsFlyerStore,
) : Model() {
private val params = paramsContainer.require<MultiWalletChildParams>()
@ -109,6 +111,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
analyticsHandler.send(
event = OnboardingEvent.CreateWallet.WalletCreatedSuccessfully(
passPhraseState = AnalyticsParam.EmptyFull.Empty,
referralId = appsFlyerStore.get()?.refcode,
),
)

View file

@ -18,6 +18,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.crypto.bip39.Mnemonic
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
@ -64,6 +65,7 @@ internal class MultiWalletSeedPhraseModel @Inject constructor(
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appsFlyerStore: AppsFlyerStore,
) : Model() {
private val params = paramsContainer.require<MultiWalletChildParams>()
@ -248,6 +250,7 @@ internal class MultiWalletSeedPhraseModel @Inject constructor(
} else {
AnalyticsParam.EmptyFull.Full
},
referralId = appsFlyerStore.get()?.refcode,
),
)

View file

@ -7,6 +7,7 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.artwork.ArtworkUM
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
@ -33,6 +34,7 @@ internal class OnboardingNoteCreateWalletModel @Inject constructor(
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appsFlyerStore: AppsFlyerStore,
) : Model() {
private val params = paramsContainer.require<OnboardingNoteCreateWalletComponent.Params>()
@ -68,6 +70,7 @@ internal class OnboardingNoteCreateWalletModel @Inject constructor(
analyticsEventHandler.send(
event = OnboardingEvent.CreateWallet.WalletCreatedSuccessfully(
passPhraseState = AnalyticsParam.EmptyFull.Empty,
referralId = appsFlyerStore.get()?.refcode,
),
)
createWalletAndNavigateBackWithDone(scanResponse.copy(card = result.data.card))

View file

@ -13,6 +13,7 @@ import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.features.onboarding.v2.stepper.api.OnboardingStepperComponent
import com.tangem.features.onboarding.v2.stepper.impl.ui.OnboardingStepper
import dagger.assisted.Assisted
@ -27,6 +28,7 @@ internal class DefaultOnboardingStepperComponent @AssistedInject constructor(
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val analyticsHandler: AnalyticsEventHandler,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
) : OnboardingStepperComponent, AppComponentContext by context {
override val state = instanceKeeper.getOrCreateSimple { MutableStateFlow(params.initState) }
@ -41,11 +43,13 @@ internal class DefaultOnboardingStepperComponent @AssistedInject constructor(
componentScope.launch {
val cardInfo = getWalletMetaInfoUseCase(params.scanResponse).getOrNull() ?: return@launch
val userWalletId = cardInfo.userWalletId ?: return@launch
val visaCustomerId = getTangemPayCustomerIdUseCase(userWalletId).getOrNull()
sendFeedbackEmailUseCase(
if (params.scanResponse.card.isVisa) {
FeedbackEmailType.Visa.Activation(cardInfo)
if (params.scanResponse.card.isVisa && !visaCustomerId.isNullOrEmpty()) {
FeedbackEmailType.Visa.Activation(walletMetaInfo = cardInfo, customerId = visaCustomerId)
} else {
FeedbackEmailType.DirectUserRequest(cardInfo)
FeedbackEmailType.DirectUserRequest(walletMetaInfo = cardInfo)
},
)
}

View file

@ -15,6 +15,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.toWrappedList
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.domain.card.common.TwinCardNumber
import com.tangem.domain.card.common.getTwinCardNumber
@ -63,6 +64,7 @@ internal class OnboardingTwinModel @Inject constructor(
private val cardRepository: CardRepository,
private val uiMessageSender: UiMessageSender,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val appsFlyerStore: AppsFlyerStore,
) : Model() {
private val params = paramsContainer.require<OnboardingTwinComponent.Params>()
@ -174,6 +176,7 @@ internal class OnboardingTwinModel @Inject constructor(
analyticsEventHandler.send(
event = OnboardingEvent.CreateWallet.WalletCreatedSuccessfully(
passPhraseState = AnalyticsParam.EmptyFull.Empty,
referralId = appsFlyerStore.get()?.refcode,
),
)

View file

@ -75,11 +75,11 @@ internal class OnrampAddTokenModel @Inject constructor(
return@launch
}
val status = getAccountCurrencyStatusUseCase.invokeSync(
val status = getAccountCurrencyStatusUseCase(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
).getOrNull()
).firstOrNull()
if (status == null) {
processError(error = null)
} else {

View file

@ -18,8 +18,10 @@ dependencies {
/** Data modules */
implementation(projects.data.common)
implementation(deps.androidx.datastore)
/** Domain modules */
implementation(projects.domain.common)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
implementation(projects.domain.models)

View file

@ -0,0 +1,26 @@
package com.tangem.feature.referral.data
import androidx.datastore.preferences.core.booleanPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
import javax.inject.Inject
internal class DefaultMobileWalletPromoRepository @Inject constructor(
private val appPreferencesStore: AppPreferencesStore,
) : MobileWalletPromoRepository {
override suspend fun shouldShowMobileWalletPromo(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = SHOULD_SHOW_MOBILE_WALLET_PROMO_KEY, default = false)
}
override suspend fun setShouldShowMobileWalletPromo(value: Boolean) {
appPreferencesStore.editData { preferences ->
preferences[SHOULD_SHOW_MOBILE_WALLET_PROMO_KEY] = value
}
}
private companion object {
val SHOULD_SHOW_MOBILE_WALLET_PROMO_KEY = booleanPreferencesKey("should_show_mobile_wallet_promo")
}
}

View file

@ -2,10 +2,13 @@ package com.tangem.feature.referral.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.feature.referral.converters.ReferralConverter
import com.tangem.feature.referral.data.DefaultMobileWalletPromoRepository
import com.tangem.feature.referral.data.ExternalReferralRepository
import com.tangem.feature.referral.data.ReferralRepositoryImpl
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
import com.tangem.feature.referral.domain.ReferralRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -53,4 +56,11 @@ class ReferralRepositoryModule {
excludedBlockchains = excludedBlockchains,
)
}
@Provides
@Singleton
fun provideMobileWalletPromoRepository(appPreferencesStore: AppPreferencesStore): MobileWalletPromoRepository =
DefaultMobileWalletPromoRepository(
appPreferencesStore = appPreferencesStore,
)
}

View file

@ -21,6 +21,7 @@ dependencies {
/** Domain modules */
implementation(projects.domain.account.status)
implementation(projects.domain.card)
implementation(projects.domain.common)
implementation(projects.domain.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)

View file

@ -0,0 +1,8 @@
package com.tangem.feature.referral.domain
interface MobileWalletPromoRepository {
suspend fun shouldShowMobileWalletPromo(): Boolean
suspend fun setShouldShowMobileWalletPromo(value: Boolean)
}

View file

@ -0,0 +1,18 @@
package com.tangem.feature.referral.domain
import arrow.core.Either
import com.tangem.domain.common.wallets.UserWalletsListRepository
import javax.inject.Inject
class SetShouldShowMobileWalletPromoUseCase @Inject constructor(
private val mobileWalletPromoRepository: MobileWalletPromoRepository,
private val userWalletsListRepository: UserWalletsListRepository,
) {
suspend operator fun invoke(): Either<Throwable, Unit> = Either.catch {
val wallets = userWalletsListRepository.userWallets.value
if (wallets.isNullOrEmpty()) {
mobileWalletPromoRepository.setShouldShowMobileWalletPromo(true)
}
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.feature.referral.domain
import javax.inject.Inject
class ShouldShowMobileWalletPromoUseCase @Inject constructor(
private val mobileWalletPromoRepository: MobileWalletPromoRepository,
) {
suspend operator fun invoke(): Boolean {
return mobileWalletPromoRepository.shouldShowMobileWalletPromo()
}
}

View file

@ -23,6 +23,7 @@ sealed class FeeSelectorParams {
abstract val feeDisplaySource: FeeDisplaySource
abstract val analyticsCategoryName: String
abstract val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource
abstract val shouldShowOnlySpeedOption: Boolean
data class FeeSelectorBlockParams(
override val state: FeeSelectorUM,
@ -37,6 +38,7 @@ sealed class FeeSelectorParams {
override val feeDisplaySource: FeeDisplaySource,
override val analyticsCategoryName: String,
override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
override val shouldShowOnlySpeedOption: Boolean = false,
val bottomSheetShown: (Boolean) -> Unit = {},
) : FeeSelectorParams()
@ -53,6 +55,7 @@ sealed class FeeSelectorParams {
override val feeDisplaySource: FeeDisplaySource,
override val analyticsCategoryName: String,
override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
override val shouldShowOnlySpeedOption: Boolean = false,
val callback: FeeSelectorModelCallback,
) : FeeSelectorParams()

View file

@ -56,6 +56,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
analyticsCategoryName = params.analyticsCategoryName,
analyticsSendSource = params.analyticsSendSource,
userWalletId = params.userWalletId,
shouldShowOnlySpeedOption = model.shouldShowOnlySpeedOption,
),
onDismiss = {
model.feeSelectorBottomSheet.dismiss()

View file

@ -38,6 +38,8 @@ internal class FeeSelectorBlockModel @Inject constructor(
modelScope = modelScope,
)
val shouldShowOnlySpeedOption: Boolean
get() = feeSelectorLogic.shouldShowOnlySpeedOption.value
val feeSelectorBottomSheet = SlotNavigation<Unit>()
val uiState: StateFlow<FeeSelectorUM>
field = feeSelectorLogic.uiState

View file

@ -37,6 +37,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -68,6 +69,9 @@ internal class FeeSelectorLogic @AssistedInject constructor(
isGaslessFeeSupportedForNetwork(params.feeCryptoCurrencyStatus.currency.network) &&
params.cryptoCurrencyStatus.currency is CryptoCurrency.Token
val shouldShowOnlySpeedOption: StateFlow<Boolean>
field = MutableStateFlow(params.shouldShowOnlySpeedOption)
init {
initAppCurrency()
subscribeOnFeeReloadTriggerUpdates()
@ -253,12 +257,14 @@ internal class FeeSelectorLogic @AssistedInject constructor(
is GetFeeError.GaslessError.NotEnoughFunds -> error.left()
is GetFeeError.GaslessError -> {
// Something wrong with gasless fee, fallback to basic fee
shouldShowOnlySpeedOption.value = true
params.onLoadFee().map { LoadedFeeResult.Basic(it) }
}
else -> error.left()
}
},
ifRight = { fee ->
shouldShowOnlySpeedOption.value = false
populateExtendedFee(fee)
},
)

View file

@ -130,6 +130,7 @@ internal class FeeSelectorModel @Inject constructor(
fun getInitialRoute(): FeeSelectorRoute {
return when {
params.shouldShowOnlySpeedOption -> FeeSelectorRoute.ChooseSpeed
feeSelectorLogic.isGaslessEnabled -> FeeSelectorRoute.NetworkFee
else -> FeeSelectorRoute.ChooseSpeed
}

View file

@ -8,4 +8,6 @@ interface SwapAmountUpdateTrigger {
suspend fun triggerUpdateAmount(amountValue: String, isEnterInFiatSelected: Boolean)
suspend fun triggerQuoteReload()
suspend fun triggerAutoUpdateEnabled(isEnabled: Boolean)
}

View file

@ -17,6 +17,8 @@ interface SwapAmountUpdateListener {
val updateAmountTriggerFlow: Flow<Pair<String, Boolean>>
val reloadQuotesTriggerFlow: Flow<Unit>
val autoUpdateTriggerFlow: Flow<Boolean>
}
/**
@ -59,6 +61,9 @@ internal class DefaultSwapAmountUpdateTrigger @Inject constructor() :
override val reloadQuotesTriggerFlow: Flow<Unit>
field = MutableSharedFlow<Unit>()
override val autoUpdateTriggerFlow: Flow<Boolean>
field = MutableSharedFlow<Boolean>()
override suspend fun triggerUpdateAmount(amountValue: String, isEnterInFiatSelected: Boolean) {
updateAmountTriggerFlow.emit(amountValue to isEnterInFiatSelected)
}
@ -67,6 +72,10 @@ internal class DefaultSwapAmountUpdateTrigger @Inject constructor() :
reloadQuotesTriggerFlow.emit(Unit)
}
override suspend fun triggerAutoUpdateEnabled(isEnabled: Boolean) {
autoUpdateTriggerFlow.emit(isEnabled)
}
override suspend fun triggerReduceBy(reduceBy: ReduceByData) {
reduceByTriggerFlow.emit(reduceBy)
}

View file

@ -58,6 +58,7 @@ import com.tangem.utils.coroutines.SingleTaskScheduler
import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
@ -110,6 +111,8 @@ internal class SwapAmountModel @Inject constructor(
private var isShowBestRateAnimation: Boolean = false
private var autoUpdateSubscriberJob: Job? = null
val uiState: StateFlow<SwapAmountUM>
field = MutableStateFlow(params.amountUM)
@ -139,10 +142,12 @@ internal class SwapAmountModel @Inject constructor(
scope = modelScope,
task = loadQuotesTask(),
)
subscribeOnAutoupdateEnabling()
}
fun onStop() {
quoteTaskScheduler.cancelTask()
autoUpdateSubscriberJob?.cancel()
}
override fun onDestroy() {
@ -482,6 +487,19 @@ internal class SwapAmountModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeOnAutoupdateEnabling() {
autoUpdateSubscriberJob = swapAmountUpdateListener.autoUpdateTriggerFlow
.distinctUntilChanged()
.onEach { isEnabled ->
if (isEnabled) {
startLoadingQuotesTask(isSilentReload = true)
} else {
quoteTaskScheduler.cancelTask()
}
}
.launchIn(modelScope)
}
private fun observeChooseSelectToken() {
swapChooseTokenNetworkListener.swapChooseTokenNetworkResultFlow
.onEach { data ->

View file

@ -2,6 +2,7 @@ package com.tangem.features.swap.v2.impl.sendviaswap.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_BLOCKCHAIN
@ -22,16 +23,18 @@ internal sealed class SendWithSwapAnalyticEvents(
val feeType: AnalyticsParam.FeeType,
val fromToken: CryptoCurrency,
val toToken: CryptoCurrency,
val fromDerivationIndex: Int?,
) : SendWithSwapAnalyticEvents(
event = "Send With Swap In Progress Screen Opened",
params = mapOf(
PROVIDER to providerName,
FEE_TYPE to if (feeType is AnalyticsParam.FeeType.Normal) "Market" else "Fast",
SEND_TOKEN to fromToken.symbol,
RECEIVE_TOKEN to toToken.symbol,
SEND_BLOCKCHAIN to fromToken.network.name,
RECEIVE_BLOCKCHAIN to toToken.network.name,
),
params = buildMap {
put(PROVIDER, providerName)
put(FEE_TYPE, if (feeType is AnalyticsParam.FeeType.Normal) "Market" else "Fast")
put(SEND_TOKEN, fromToken.symbol)
put(RECEIVE_TOKEN, toToken.symbol)
put(SEND_BLOCKCHAIN, fromToken.network.name)
put(RECEIVE_BLOCKCHAIN, toToken.network.name)
if (fromDerivationIndex != null) put(ACCOUNT_DERIVATION_FROM, fromDerivationIndex.toString())
},
), AppsFlyerIncludedEvent
data class NoticeCanNotSwapToken(

View file

@ -98,6 +98,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
analyticsCategoryName = params.analyticsCategoryName,
analyticsSendSource = params.analyticsSendSource,
userWalletId = params.userWallet.walletId,
bottomSheetShown = model::onFeeBottomSheetShown,
),
onResult = model::onFeeResult,
)

View file

@ -45,6 +45,7 @@ import com.tangem.features.send.v2.api.subcomponents.destination.entity.Destinat
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceTrigger
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
@ -90,6 +91,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
private val sendNotificationsUpdateListener: SendNotificationsUpdateListener,
private val swapNotificationsUpdateListener: SwapNotificationsUpdateListener,
private val swapAmountReduceTrigger: SwapAmountReduceTrigger,
private val swapAmountUpdateTrigger: SwapAmountUpdateTrigger,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val swapAlertFactory: SwapAlertFactory,
private val appRouter: AppRouter,
@ -211,6 +213,12 @@ internal class SendWithSwapConfirmModel @Inject constructor(
}
}
fun onFeeBottomSheetShown(isShown: Boolean) {
modelScope.launch {
swapAmountUpdateTrigger.triggerAutoUpdateEnabled(isEnabled = !isShown)
}
}
fun showEditAmount() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.ScreenReopened(
@ -448,6 +456,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
val toCurrency = confirmData.toCryptoCurrencyStatus?.currency ?: return
val feeSelectorUM = uiState.value.feeSelectorUM as? FeeSelectorUM.Content ?: return
val feeType = feeSelectorUM.toAnalyticType()
val fromDerivationIndex = confirmData.fromAccount?.derivationIndex?.value
analyticsEventHandler.send(
SendWithSwapAnalyticEvents.TransactionScreenOpened(
@ -455,6 +464,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
feeType = feeType,
fromToken = fromCurrency,
toToken = toCurrency,
fromDerivationIndex = fromDerivationIndex,
),
)
analyticsEventHandler.send(

View file

@ -1648,7 +1648,14 @@ internal class SwapInteractorImpl @AssistedInject constructor(
}
}
} else {
IncludeFeeInAmount.Excluded
val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO
getIncludeFeeInAmountForNative(
networkId = networkId,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
fromToken = fromToken.currency,
feeValue = fee,
)
}
}
is TxFeeSealedState.Legacy -> {

View file

@ -16,6 +16,7 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.navigation)
implementation(projects.core.utils)
implementation(projects.core.ui)

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