From 9275e7d178c4482eb91f11b5bd7fbd034c1e4b80 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 12 Feb 2026 19:25:31 +0300 Subject: [PATCH 01/17] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 2 +- gradle/tangem_dependencies.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index cdbd47714a..c04b7ea0fc 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -77,6 +77,6 @@ }, { "name": "WALLET_REORDER_FEATURE_ENABLED", - "version": "undefined" + "version": "5.34" } ] diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 6d72aefb0d..a5765e9809 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1425" +tangemBlockchainSdk = "releases-5.34-1428" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "develop-577" +tangemCardSdk = "releases-5.34-579" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 00974e5d3dd9ba51f675999b6a0a2ecf2ccf1982 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Feb 2026 09:28:22 +0100 Subject: [PATCH 02/17] Updated on 2026-08-14 --- .../core/ui/utils/DateTimeFormatters.kt | 19 ++- .../core/ui/utils/DateTimeFormattersTest.kt | 143 ++++++++++++++++++ .../feed/ui/utils/CreatedTimeFormatter.kt | 11 +- 3 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt index 95ffc567c2..f460a6f129 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt @@ -103,9 +103,9 @@ object DateTimeFormatters { */ val localFullDate: DateTimeFormatter by lazy { val locale = Locale.getDefault() - val datePattern = DateFormat.getBestDateTimePattern(locale, "d MMMM") + val datePattern = icuPatternToJodaPattern(DateFormat.getBestDateTimePattern(locale, "d MMMM")) val timeSkeleton = if (is12HourFormat) "h:mm a" else "HH:mm" - val timePattern = DateFormat.getBestDateTimePattern(locale, timeSkeleton) + val timePattern = icuPatternToJodaPattern(DateFormat.getBestDateTimePattern(locale, timeSkeleton)) val fullPattern = "$datePattern, $timePattern" DateTimeFormatterBuilder() .appendPattern(fullPattern) @@ -128,13 +128,26 @@ object DateTimeFormatters { */ fun getBestFormatterBySkeleton(skeleton: String): DateTimeFormatter { val skeletonWithLocale = skeleton.replaceHourLetters() + val icuPattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), skeletonWithLocale) + val jodaPattern = icuPatternToJodaPattern(icuPattern) return DateTimeFormatterBuilder() - .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), skeletonWithLocale)) + .appendPattern(jodaPattern) .toFormatter() .withLocale(Locale.getDefault()) } + /** + * Converts ICU date/time pattern (from [DateFormat.getBestDateTimePattern]) to Joda-Time compatible pattern. + */ + internal fun icuPatternToJodaPattern(icuPattern: String): String { + return icuPattern + .replace("LLLL", "MMMM") + .replace("LLL", "MMM") + .replace("LL", "MM") + .replace("L", "M") + } + private fun String.replaceHourLetters(): String { return if (is12HourFormat) { this.replace('H', 'h').replace('k', 'K') diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt new file mode 100644 index 0000000000..7fa8bcd9ce --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt @@ -0,0 +1,143 @@ +package com.tangem.core.ui.utils + +import com.google.common.truth.Truth +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Unit tests for [DateTimeFormatters], in particular for conversion of ICU date/time patterns + * to Joda-Time compatible patterns. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DateTimeFormattersTest { + + @Test + fun `converts LLLL to MMMM - full standalone month pattern that crashes on Chinese locale`() { + // Arrange + val icuPattern = "d LLLL" + + // Act + val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern) + + // Assert + Truth.assertThat(actual).isEqualTo("d MMMM") + } + + @Test + fun `converts LLL to MMM - short standalone month`() { + // Arrange + val icuPattern = "dd LLL yyyy" + + // Act + val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern) + + // Assert + Truth.assertThat(actual).isEqualTo("dd MMM yyyy") + } + + @Test + fun `converts LL to MM - numeric standalone month`() { + // Arrange + val icuPattern = "yyyy-MM-LL" + + // Act + val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern) + + // Assert + Truth.assertThat(actual).isEqualTo("yyyy-MM-MM") + } + + @Test + fun `converts single L to M`() { + // Arrange + val icuPattern = "d/L/yyyy" + + // Act + val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern) + + // Assert + Truth.assertThat(actual).isEqualTo("d/M/yyyy") + } + + @Test + fun `leaves pattern without L unchanged`() { + // Arrange + val icuPattern = "dd.MM.yyyy HH:mm" + + // Act + val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern) + + // Assert + Truth.assertThat(actual).isEqualTo("dd.MM.yyyy HH:mm") + } + + @Test + fun `leaves pattern with M unchanged`() { + // Arrange + val icuPattern = "d MMMM yyyy" + + // Act + val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern) + + // Assert + Truth.assertThat(actual).isEqualTo("d MMMM yyyy") + } + + @Test + fun `handles mixed ICU pattern as returned for Chinese locale - d MMMM`() { + // Arrange + val icuPatternWithStandaloneMonth = "d LLLL" + + // Act + val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPatternWithStandaloneMonth) + + // Assert — Joda-Time can parse and format this without IllegalArgumentException + Truth.assertThat(actual).isEqualTo("d MMMM") + } + + @Test + fun `handles empty string`() { + // Arrange + val icuPattern = "" + + // Act + val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern) + + // Assert + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `handles pattern with only literal characters`() { + // Arrange + val icuPattern = " 'at' " + + // Act + val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern) + + // Assert + Truth.assertThat(actual).isEqualTo(" 'at' ") + } + + @Test + fun `getBestFormatterBySkeleton with d MMMM skeleton produces formatter that does not throw on format`() { + // Arrange + val formatter = DateTimeFormatters.getBestFormatterBySkeleton("d MMMM") + val date = org.joda.time.DateTime(2025, 2, 13, 12, 0, 0, 0) + + // Act & Assert + val formatted = formatter.print(date) + Truth.assertThat(formatted).isNotEmpty() + } + + @Test + fun `getBestFormatterBySkeleton with dd MMMM skeleton produces formatter that does not throw on format`() { + // Arrange + val formatter = DateTimeFormatters.getBestFormatterBySkeleton("dd MMMM") + val date = org.joda.time.DateTime(2025, 2, 13, 12, 0, 0, 0) + + // Act & Assert + val formatted = formatter.print(date) + Truth.assertThat(formatted).isNotEmpty() + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt index b1052994f0..ef92d71b3c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt @@ -10,10 +10,13 @@ import com.tangem.utils.StringsSigns import org.joda.time.DateTime internal fun mapFormattedDate(createdAt: String): TextReference { - val formattedDate = getFormattedDate( - createdAt = createdAt, - now = DateTime.now(), - ) + val formattedDate = runCatching { + getFormattedDate( + createdAt = createdAt, + now = DateTime.now(), + ) + }.getOrElse { FormattedDate.FullDate("") } + return when (formattedDate) { is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) is FormattedDate.HoursAgo -> TextReference.PluralRes( From 2cc7fdc952b80ca97b42f2717811e44770ce72d7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Feb 2026 14:16:56 +0400 Subject: [PATCH 03/17] Updated on 2026-08-14 --- .../producer/WalletAccountListFlowFactory.kt | 3 +- .../WalletAccountListFlowFactoryTest.kt | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt index 2fe0b92a3f..fbc60cc6c6 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt @@ -47,8 +47,9 @@ internal class WalletAccountListFlowFactory @Inject constructor( return accountsResponseStoreFactory.create(userWallet.walletId).data .filterNotNull() + .filter { it.accounts.isNotEmpty() } .distinctUntilChanged() - .map(converter::convert) + .map { converter.convert(it) } } private fun createForSingleWallet(userWallet: UserWallet): AccountList { diff --git a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt index bcda235987..335516f9fd 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt @@ -169,4 +169,42 @@ class WalletAccountListFlowFactoryTest { accountListConverter.convert(any()) } } + + @Test + fun `create for multi wallet with empty accounts does not emit`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val userWalletsFlow = MutableStateFlow(listOf(userWallet)) + + every { userWalletsListRepository.userWallets } returns userWalletsFlow + + val accountsResponseWithEmptyAccounts = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = null, + sort = null, + totalAccounts = 0, + totalArchivedAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + accountsResponseStoreFlow.value = accountsResponseWithEmptyAccounts + + // Act + val actual = factory.create(userWalletId).let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() + + coVerify(inverse = true) { + accountListConverterFactory.create(any()) + accountListConverter.convert(any()) + } + } } \ No newline at end of file From 1d9d74b6a98377c1f28b7b83c0f5d39d6ddbed0a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 9 Feb 2026 17:12:27 +0300 Subject: [PATCH 04/17] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index a5d1a89425..2d18650f6d 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit a5d1a89425a95bc9c90c7a6fed3c578b0d324994 +Subproject commit 2d18650f6d4286353046ccb841745cef107c4fa6 From fe2bade1e49699ca493767754299a6e75319f22e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Feb 2026 14:07:02 +0300 Subject: [PATCH 05/17] Updated on 2026-08-14 --- features/onramp/impl/build.gradle.kts | 1 + .../swap/DefaultSwapSelectTokensComponent.kt | 31 ++++++ .../AvailableSwapPairsComponent.kt | 8 ++ .../DefaultAvailableSwapPairsComponent.kt | 8 ++ .../market/SwapMarketsListBatchFlowManager.kt | 7 ++ .../model/AddToPortfolioRoute.kt | 7 ++ .../model/AvailableSwapPairsModel.kt | 103 +++++++++++++++++- 7 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index d836fce188..f0a6cb16c5 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.features.swap.domain) implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) + implementation(projects.features.feed.api) /** Project - Core */ implementation(projects.core.analytics) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt index d9b66c559d..ee2dc2dd5b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt @@ -5,13 +5,20 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent import com.tangem.features.onramp.component.SwapSelectTokensComponent import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent +import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.swap.model.SwapSelectTokensModel import com.tangem.features.onramp.swap.ui.SwapSelectTokens import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent @@ -25,6 +32,7 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( tokenListComponentFactory: OnrampTokenListComponent.Factory, availableSwapPairsComponentFactory: AvailableSwapPairsComponent.Factory, analyticsEventHandler: AnalyticsEventHandler, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: SwapSelectTokensComponent.Params, ) : AppComponentContext by appComponentContext, SwapSelectTokensComponent { @@ -49,15 +57,36 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( ), ) + private val bottomSheetSlot = childSlot( + source = selectToTokenListComponent.bottomSheetNavigation, + serializer = AddToPortfolioRoute.serializer(), + key = "add_to_portfolio_bottom_sheet", + handleBackButton = false, + childFactory = { _, context -> bottomSheetChild(context) }, + ) + init { analyticsEventHandler.send(event = MainScreenAnalyticsEvent.SwapScreenOpened()) } + @Suppress("UnsafeCallOnNullableType") + private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent { + return addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = selectToTokenListComponent.addToPortfolioManager!!, + callback = selectToTokenListComponent.addToPortfolioCallback, + shouldSkipTokenActionsScreen = true, + ), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() val fromTokensState by selectFromTokenListComponent.uiState.collectAsStateWithLifecycle() val toTokensState by selectToTokenListComponent.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() SwapSelectTokens( state = state, @@ -67,6 +96,8 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( selectToTokenListState = toTokensState, modifier = modifier, ) + + bottomSheet.child?.instance?.BottomSheet() } @AssistedFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt index cb87a50dc2..bae0b5ba51 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt @@ -1,11 +1,15 @@ package com.tangem.features.onramp.swap.availablepairs import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.decompose.ComposableListContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.tokenlist.entity.TokenListUM import kotlinx.coroutines.flow.StateFlow @@ -13,6 +17,10 @@ import kotlinx.coroutines.flow.StateFlow @Stable internal interface AvailableSwapPairsComponent : ComposableListContentComponent { + val bottomSheetNavigation: SlotNavigation + val addToPortfolioManager: AddToPortfolioManager? + val addToPortfolioCallback: AddToPortfolioComponent.Callback + /** Component factory */ interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt index 456b6e8c30..27e528cd04 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt @@ -3,8 +3,12 @@ package com.tangem.features.onramp.swap.availablepairs import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier +import com.arkivanov.decompose.router.slot.SlotNavigation import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.ui.onrampSwapTokenList @@ -21,6 +25,10 @@ internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( private val model: AvailableSwapPairsModel = getOrCreateModel(params) + override val bottomSheetNavigation: SlotNavigation get() = model.bottomSheetNavigation + override val addToPortfolioManager: AddToPortfolioManager? get() = model.addToPortfolioManager + override val addToPortfolioCallback: AddToPortfolioComponent.Callback get() = model.addToPortfolioCallback + override val uiState: StateFlow get() = model.state diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt index 2aecafa4e4..8d9b34c5b8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt @@ -228,6 +228,13 @@ internal class SwapMarketsListBatchFlowManager( } } + fun getTokenMarketById(id: CryptoCurrency.RawID): TokenMarket? { + return batchFlow.state.value.data + .asSequence() + .flatMap { it.data } + .firstOrNull { it.id == id } + } + fun getBatchKeysByItemIds(ids: List): Set { val currentData = batchFlow.state.value.data diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt new file mode 100644 index 0000000000..559ef6eb09 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt @@ -0,0 +1,7 @@ +package com.tangem.features.onramp.swap.availablepairs.model + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal data object AddToPortfolioRoute : Route \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index 5466084880..7867772800 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -9,28 +9,36 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceLoading import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.GetTokenListUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.domain.GetAvailablePairsUseCase import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.feature.swap.domain.models.domain.SwapPairLeast +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent import com.tangem.features.swap.SwapFeatureToggles import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer @@ -48,9 +56,17 @@ import com.tangem.features.onramp.tokenlist.entity.transformer.* import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.lib.crypto.BlockchainUtils +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.coroutines.saveIn import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -71,7 +87,10 @@ internal class AvailableSwapPairsModel @Inject constructor( private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val accountsFeatureToggles: AccountsFeatureToggles, private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, - private val swapFeatureToggles: SwapFeatureToggles, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, + private val excludedBlockchains: ExcludedBlockchains, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + swapFeatureToggles: SwapFeatureToggles, getWalletsUseCase: GetWalletsUseCase, ) : Model() { @@ -79,6 +98,17 @@ internal class AvailableSwapPairsModel @Inject constructor( private val params: AvailableSwapPairsComponent.Params = paramsContainer.require() private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } + private val allUserWallets = getWalletsUseCase.invokeSync() + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + var addToPortfolioManager: AddToPortfolioManager? = null + val addToPortfolioCallback: AddToPortfolioComponent.Callback = object : AddToPortfolioComponent.Callback { + override fun onDismiss() = bottomSheetNavigation.dismiss() + override fun onSuccess(addedToken: CryptoCurrency) { + onTokenAddedToPortfolio(addedToken) + } + } + private val addToPortfolioJobHolder = JobHolder() private val tokenListFlow = getTokenListUseCaseFlow() private val accountListFlow = getAccountListUseCaseFlow() @@ -86,6 +116,7 @@ internal class AvailableSwapPairsModel @Inject constructor( private val selectedAppCurrencyFlow: StateFlow = getSelectedAppCurrencyUseCase.invokeOrDefault() .stateIn(scope = modelScope, started = SharingStarted.Eagerly, initialValue = AppCurrency.Default) + private val refreshPairsTrigger = MutableSharedFlow() private val searchQueryStateForMarkets = MutableStateFlow("") private val visibleMarketItemIds = MutableStateFlow>(emptyList()) @@ -374,8 +405,12 @@ internal class AvailableSwapPairsModel @Inject constructor( private fun subscribeOnAvailablePairsUpdates() { modelScope.launch { - params.selectedStatus - .filterNotNull() + combine( + params.selectedStatus.filterNotNull(), + refreshPairsTrigger + .onEach { availablePairsByNetworkFlow.value = emptyMap() } + .onStart { emit(Unit) }, + ) { status, _ -> status } .collectLatest { selectedStatus -> val networkInfo = selectedStatus.toLeastTokenInfo() @@ -542,7 +577,7 @@ internal class AvailableSwapPairsModel @Inject constructor( else -> SwapMarketState.Content( items = uiItems, loadMore = { searchMarketsListManager.loadMore() }, - onItemClick = {}, + onItemClick = { item -> addToPortfolioItem(item) }, visibleIdsChanged = { visibleMarketItemIds.value = it }, total = total ?: uiItems.size, ) @@ -556,6 +591,66 @@ internal class AvailableSwapPairsModel @Inject constructor( .launchIn(modelScope) } + private fun onTokenAddedToPortfolio(addedToken: CryptoCurrency) { + modelScope.launch { + bottomSheetNavigation.dismiss() + + // Trigger re-fetch of available pairs (clears cache + re-enters collectLatest) + refreshPairsTrigger.emit(Unit) + + // Wait for the added token status to become Loaded + val addedTokenStatus = getAccountCurrencyStatusUseCase(params.userWalletId, addedToken) + .firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded } + ?.status + ?: return@launch + + // Convert to TokenItemState and trigger token selection → navigates to swap + val converter = OnrampTokenItemStateConverterFactory.createAvailableItemConverter( + appCurrency = selectedAppCurrencyFlow.value, + onItemClick = params.onTokenClick, + ) + params.onTokenClick(converter.convert(addedTokenStatus), addedTokenStatus) + } + } + + private fun addToPortfolioItem(item: MarketsListItemUM) { + modelScope.launch { + val tokenMarket = searchMarketsListManager.getTokenMarketById(item.id) ?: return@launch + + val param = tokenMarket.toSerializableParam() + val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot } + + val networks = tokenMarket.networks?.filter { network -> + BlockchainUtils.isSupportedNetworkId( + blockchainId = network.networkId, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = hasOnlyHotWallets, + ) + }?.map { network -> + TokenMarketInfo.Network( + networkId = network.networkId, + isExchangeable = false, + contractAddress = network.contractAddress, + decimalCount = network.decimalCount, + ) + }.orEmpty() + + addToPortfolioManager = addToPortfolioManagerFactory + .create( + scope = modelScope, + token = param, + analyticsParams = null, + ).apply { + setTokenNetworks(networks) + } + + addToPortfolioManager?.state + ?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd } + ?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) } + }.saveIn(addToPortfolioJobHolder) + } + private fun subscribeOnVisibleMarketItems() { modelScope.launch { visibleMarketItemIds.mapNotNull { rawIds -> From 326d74753614e68505a9e06de26a5d2906d04a76 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 11 Feb 2026 15:48:18 +0300 Subject: [PATCH 06/17] Updated on 2026-08-14 --- .../market/SwapMarketsListBatchFlowManager.kt | 3 +- .../market/state/SwapMarketState.kt | 19 +- .../model/AvailableSwapPairsAnalyticsEvent.kt | 44 +++ .../model/AvailableSwapPairsModel.kt | 171 +++++++++-- .../availablepairs/ui/SwapMarketsListItems.kt | 3 +- .../onramp/tokenlist/ui/OnrampTokenList.kt | 16 +- .../feature/swap/analytics/SwapEvents.kt | 67 ++++- .../tangem/feature/swap/model/SwapModel.kt | 265 ++++++++++++++---- .../swap/model/SwapNotificationsFactory.kt | 6 + .../market/SwapMarketsListBatchFlowManager.kt | 6 +- .../models/market/state/SwapMarketState.kt | 19 +- .../swap/models/states/SwapNotificationUM.kt | 12 + .../tangem/feature/swap/ui/StateBuilder.kt | 55 ++++ .../feature/swap/ui/SwapSelectTokenScreen.kt | 22 +- .../ui/market/SwapMarketsListLazyColumn.kt | 3 +- .../preview/SwapSelectTokenPreviewProvider.kt | 4 + 16 files changed, 597 insertions(+), 118 deletions(-) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsAnalyticsEvent.kt diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt index 8d9b34c5b8..5df10fda71 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.* internal class SwapMarketsListBatchFlowManager( getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, + private val order: TokenMarketListConfig.Order, private val currentAppCurrency: Provider, private val currentSearchText: Provider, private val modelScope: CoroutineScope, @@ -183,7 +184,7 @@ internal class SwapMarketsListBatchFlowManager( searchText ?: currentSearchText() }, priceChangeInterval = TokenMarketListConfig.Interval.H24, - order = TokenMarketListConfig.Order.ByRating, + order = order, shouldNetworks = true, ), ), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt index bcd1c46b86..7e9abb3982 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt @@ -2,25 +2,40 @@ package com.tangem.features.onramp.swap.availablepairs.market.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrency import kotlinx.collections.immutable.ImmutableList @Immutable internal sealed class SwapMarketState { + abstract val marketsTitle: TextReference + abstract val shouldAssetsCount: Boolean + data class Content( val items: ImmutableList, val total: Int, val loadMore: () -> Unit, val onItemClick: (MarketsListItemUM) -> Unit, val visibleIdsChanged: (List) -> Unit, + override val marketsTitle: TextReference, + override val shouldAssetsCount: Boolean, ) : SwapMarketState() - data object Loading : SwapMarketState() + data class Loading( + override val marketsTitle: TextReference, + override val shouldAssetsCount: Boolean, + ) : SwapMarketState() data class LoadingError( val onRetryClicked: () -> Unit, + override val marketsTitle: TextReference, + override val shouldAssetsCount: Boolean, ) : SwapMarketState() - data object SearchNothingFound : SwapMarketState() + data object SearchNothingFound : SwapMarketState() { + override val marketsTitle: TextReference = TextReference.Res(R.string.markets_common_title) + override val shouldAssetsCount: Boolean = true + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsAnalyticsEvent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsAnalyticsEvent.kt new file mode 100644 index 0000000000..f5a57df2b5 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsAnalyticsEvent.kt @@ -0,0 +1,44 @@ +package com.tangem.features.onramp.swap.availablepairs.model + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM + +private const val SWAP_CATEGORY = "Swap" + +internal sealed class AvailableSwapPairsAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(SWAP_CATEGORY, event, params) { + + class TokenSelected( + val token: String, + val source: String, + val isSearched: Boolean, + ) : AvailableSwapPairsAnalyticsEvent( + event = "Token Selected", + params = mapOf( + TOKEN_PARAM to token, + SOURCE to source, + SEARCHED to if (isSearched) "True" else "False", + ), + ) { + companion object { + const val SOURCE = "Source" + const val SEARCHED = "Searched" + const val SOURCE_PORTFOLIO = "Portfolio" + const val SOURCE_MARKETS = "Markets" + } + } + + class TokenAdded( + val token: String, + val blockchain: String, + ) : AvailableSwapPairsAnalyticsEvent( + event = "Token Added", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), + ) +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index 7867772800..62822d4108 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -1,8 +1,12 @@ package com.tangem.features.onramp.swap.availablepairs.model +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.Model +import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.InputManager +import com.tangem.core.ui.R as CoreUiR +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.capitalize import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList @@ -22,6 +26,7 @@ import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceLoading import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketListConfig import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus @@ -66,6 +71,7 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.runSuspendCatching +import kotlinx.coroutines.ExperimentalCoroutinesApi import com.tangem.utils.coroutines.saveIn import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -77,6 +83,7 @@ private typealias AvailablePairsState = Lce> internal class AvailableSwapPairsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, private val getTokenListUseCase: GetTokenListUseCase, private val tokenListUMController: TokenListUMController, private val searchManager: InputManager, @@ -120,10 +127,23 @@ internal class AvailableSwapPairsModel @Inject constructor( private val searchQueryStateForMarkets = MutableStateFlow("") private val visibleMarketItemIds = MutableStateFlow>(emptyList()) + private val defaultMarketsListManager by lazy { + SwapMarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main, + order = TokenMarketListConfig.Order.Trending, + currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, + currentSearchText = Provider { null }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + private val searchMarketsListManager by lazy { SwapMarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, + order = TokenMarketListConfig.Order.ByRating, currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, currentSearchText = Provider { searchQueryStateForMarkets.value }, modelScope = modelScope, @@ -131,6 +151,8 @@ internal class AvailableSwapPairsModel @Inject constructor( ) } + private val visibleDefaultMarketItemIds = MutableStateFlow>(emptyList()) + init { if (accountsFeatureToggles.isFeatureEnabled) { subscribeOnUpdateStateV2() @@ -298,7 +320,7 @@ internal class AvailableSwapPairsModel @Inject constructor( } else { UpdateTokenItemsTransformer( appCurrency = appCurrency, - onItemClick = params.onTokenClick, + onItemClick = ::onPortfolioTokenClick, statuses = filterByQueryTokenList.filterByAvailability(availablePairs = availablePairs), isBalanceHidden = isBalanceHidden, unavailableTokensHeaderReference = resourceReference( @@ -357,7 +379,7 @@ internal class AvailableSwapPairsModel @Inject constructor( } else { UpdateAccountTokenListTransformer( appCurrency = appCurrency, - onItemClick = params.onTokenClick, + onItemClick = ::onPortfolioTokenClick, accountList = filterByQueryAccountList.filterByAvailability(availablePairs = availablePairs), isBalanceHidden = isBalanceHidden, unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), @@ -543,6 +565,17 @@ internal class AvailableSwapPairsModel @Inject constructor( } } + private fun onPortfolioTokenClick(tokenItem: TokenItemState, status: CryptoCurrencyStatus) { + analyticsEventHandler.send( + AvailableSwapPairsAnalyticsEvent.TokenSelected( + token = status.currency.symbol, + source = AvailableSwapPairsAnalyticsEvent.TokenSelected.SOURCE_PORTFOLIO, + isSearched = state.value.searchBarUM.query.isNotEmpty(), + ), + ) + params.onTokenClick(tokenItem, status) + } + private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo { return LeastTokenInfo( contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", @@ -550,50 +583,121 @@ internal class AvailableSwapPairsModel @Inject constructor( ) } - @Suppress("LongMethod") + @OptIn(ExperimentalCoroutinesApi::class) private fun subscribeOnMarketsUpdates() { - combine( - flow = searchQueryStateForMarkets - .onEach { searchQuery -> - if (searchQuery.isNotEmpty()) { - searchMarketsListManager.reload(searchQuery) - } - }, - flow2 = searchMarketsListManager.uiItems, - flow3 = searchMarketsListManager.isInInitialLoadingErrorState, - flow4 = searchMarketsListManager.isSearchNotFoundState, - flow5 = searchMarketsListManager.totalCount, - ) { searchQuery, uiItems, isError, isSearchNotFound, total -> - when { - searchQuery.isEmpty() -> { + searchQueryStateForMarkets + .map { it.isEmpty() } + .distinctUntilChanged() + .flatMapLatest { isDefaultMode -> + if (isDefaultMode) { visibleMarketItemIds.value = emptyList() - null + createDefaultMarketsFlow() + } else { + visibleDefaultMarketItemIds.value = emptyList() + createSearchMarketsFlow() } + } + .onEach { marketsState -> + tokenListUMController.update { it.copy(marketsState = marketsState) } + } + .flowOn(dispatchers.main) + .launchIn(modelScope) + + searchQueryStateForMarkets + .onEach { searchQuery -> + if (searchQuery.isNotEmpty()) { + searchMarketsListManager.reload(searchQuery) + } + } + .launchIn(modelScope) + + params.selectedStatus + .filterNotNull() + .take(1) + .onEach { defaultMarketsListManager.reload() } + .launchIn(modelScope) + } + + private fun createDefaultMarketsFlow(): Flow { + val marketsTitle = TextReference.Res(CoreUiR.string.feed_trending_now) + return combine( + defaultMarketsListManager.uiItems, + defaultMarketsListManager.isInInitialLoadingErrorState, + defaultMarketsListManager.totalCount, + ) { uiItems, isError, total -> + when { isError -> SwapMarketState.LoadingError( - onRetryClicked = { searchMarketsListManager.reload(searchQuery) }, + onRetryClicked = { defaultMarketsListManager.reload() }, + marketsTitle = marketsTitle, + shouldAssetsCount = false, + ) + uiItems.isEmpty() -> SwapMarketState.Loading( + marketsTitle = marketsTitle, + shouldAssetsCount = false, + ) + else -> SwapMarketState.Content( + items = uiItems, + loadMore = { defaultMarketsListManager.loadMore() }, + onItemClick = { item -> addToPortfolioItem(item) }, + visibleIdsChanged = { visibleDefaultMarketItemIds.value = it }, + total = total ?: uiItems.size, + marketsTitle = marketsTitle, + shouldAssetsCount = false, + ) + } + } + } + + private fun createSearchMarketsFlow(): Flow { + val marketsTitle = TextReference.Res(CoreUiR.string.markets_common_title) + return combine( + flow = searchMarketsListManager.uiItems, + flow2 = searchMarketsListManager.isInInitialLoadingErrorState, + flow3 = searchMarketsListManager.isSearchNotFoundState, + flow4 = searchMarketsListManager.totalCount, + ) { uiItems, isError, isSearchNotFound, total -> + when { + isError -> SwapMarketState.LoadingError( + onRetryClicked = { + searchMarketsListManager.reload(searchQueryStateForMarkets.value) + }, + marketsTitle = marketsTitle, + shouldAssetsCount = true, ) isSearchNotFound -> SwapMarketState.SearchNothingFound - uiItems.isEmpty() -> SwapMarketState.Loading + uiItems.isEmpty() -> SwapMarketState.Loading( + marketsTitle = marketsTitle, + shouldAssetsCount = true, + ) else -> SwapMarketState.Content( items = uiItems, loadMore = { searchMarketsListManager.loadMore() }, onItemClick = { item -> addToPortfolioItem(item) }, visibleIdsChanged = { visibleMarketItemIds.value = it }, total = total ?: uiItems.size, + marketsTitle = marketsTitle, + shouldAssetsCount = true, ) } } - .distinctUntilChanged() - .onEach { marketsState -> - tokenListUMController.update { it.copy(marketsState = marketsState) } - } - .flowOn(dispatchers.main) - .launchIn(modelScope) } private fun onTokenAddedToPortfolio(addedToken: CryptoCurrency) { modelScope.launch { bottomSheetNavigation.dismiss() + analyticsEventHandler.send( + AvailableSwapPairsAnalyticsEvent.TokenAdded( + token = addedToken.symbol, + blockchain = addedToken.network.name, + ), + ) + analyticsEventHandler.send( + AvailableSwapPairsAnalyticsEvent.TokenSelected( + token = addedToken.symbol, + source = AvailableSwapPairsAnalyticsEvent.TokenSelected.SOURCE_MARKETS, + isSearched = state.value.searchBarUM.query.isNotEmpty(), + ), + ) // Trigger re-fetch of available pairs (clears cache + re-enters collectLatest) refreshPairsTrigger.emit(Unit) @@ -615,7 +719,9 @@ internal class AvailableSwapPairsModel @Inject constructor( private fun addToPortfolioItem(item: MarketsListItemUM) { modelScope.launch { - val tokenMarket = searchMarketsListManager.getTokenMarketById(item.id) ?: return@launch + val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) + ?: searchMarketsListManager.getTokenMarketById(item.id) + ?: return@launch val param = tokenMarket.toSerializableParam() val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot } @@ -663,5 +769,16 @@ internal class AvailableSwapPairsModel @Inject constructor( searchMarketsListManager.loadCharts(visibleBatchKeys) } } + modelScope.launch { + visibleDefaultMarketItemIds.mapNotNull { rawIds -> + if (rawIds.isNotEmpty()) { + defaultMarketsListManager.getBatchKeysByItemIds(rawIds) + } else { + null + } + }.distinctUntilChanged().collectLatest { visibleBatchKeys -> + defaultMarketsListManager.loadCharts(visibleBatchKeys) + } + } } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt index b8e098fa31..18600da888 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt @@ -17,6 +17,7 @@ import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.core.ui.R import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.onramp.swap.availablepairs.market.state.SwapMarketState @@ -28,7 +29,7 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { val totalCount = (state as? SwapMarketState.Content)?.total Text( text = buildAnnotatedString { - append(stringResourceSafe(R.string.markets_common_title)) + append(state.marketsTitle.resolveReference()) if (totalCount != null) { withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { append(" $totalCount") diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt index 6cecf0ac0e..d92870b44f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt @@ -50,8 +50,7 @@ import kotlinx.collections.immutable.ImmutableList * */ internal fun LazyListScope.onrampSwapTokenList(state: TokenListUM) { - val isSearchMode = state.searchBarUM.query.isNotEmpty() && state.marketsState != null - if (isSearchMode) { + if (state.marketsState != null) { onrampTokenListWithMarkets(state = state) } else { onrampTokenList(state = state) @@ -91,7 +90,10 @@ private fun LazyListScope.onrampTokenListWithMarkets(state: TokenListUM) { state.tokensListData.totalTokensCount != 0 if (hasAssets) { - assetsTitle(count = state.tokensListData.totalTokensCount) + assetsTitle( + count = state.tokensListData.totalTokensCount, + showCount = state.marketsState?.shouldAssetsCount == true, + ) tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) @@ -159,13 +161,15 @@ private fun LazyListScope.searchBarItem(searchBarUM: SearchBarUM, modifier: Modi } } -private fun LazyListScope.assetsTitle(count: Int) { +private fun LazyListScope.assetsTitle(count: Int, showCount: Boolean) { item(key = "assets_title") { Text( text = buildAnnotatedString { append(stringResourceSafe(R.string.swap_your_assets_title)) - withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { - append(" $count") + if (showCount) { + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(" $count") + } } }, style = TangemTheme.typography.h3, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 98955f6373..cd44e2f95c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -25,7 +25,7 @@ sealed class SwapEvents( params: Map = emptyMap(), ) : AnalyticsEvent(SWAP_CATEGORY, event, params) { - data class SwapScreenOpened( + class SwapScreenOpened( val token: String, val blockchain: String, ) : SwapEvents( @@ -38,25 +38,47 @@ sealed class SwapEvents( class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked") - data class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents( + class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents( event = "Choose Token Screen Opened", params = mapOf("Available tokens" to if (hasAvailableTokens) "Yes" else "No"), ) - data class ChooseTokenScreenResult(val isTokenChosen: Boolean, val token: String? = null) : SwapEvents( + class ChooseTokenScreenResult( + val isTokenChosen: Boolean, + val token: String? = null, + val source: String? = null, + val isSearched: Boolean? = null, + ) : SwapEvents( event = "Choose Token Screen Result", params = buildMap { put("Token Chosen", if (isTokenChosen) "Yes" else "No") token?.let { put("Token", it) } + source?.let { put(TOKEN_SELECTED_SOURCE, it) } + isSearched?.let { put(SEARCHED, if (it) "True" else "False") } }, + ) { + companion object { + const val TOKEN_SELECTED_SOURCE = "Token Selected Source" + const val SEARCHED = "Searched" + const val SOURCE_PORTFOLIO = "Portfolio" + const val SOURCE_MARKETS = "Markets" + } + } + + class TokenAdded(val token: String, val blockchain: String) : SwapEvents( + event = "Token Added", + params = mapOf( + TOKEN_PARAM to token, + BLOCKCHAIN to blockchain, + ), ) - data class ButtonSwapClicked(val sendToken: String, val receiveToken: String) : SwapEvents( + class ButtonSwapClicked(val sendToken: String, val receiveToken: String) : SwapEvents( event = "Button - Swap", params = mapOf("Send Token" to sendToken, "Receive Token" to receiveToken), ) - data class ButtonGivePermissionClicked( + class ButtonGivePermissionClicked( val sendToken: String, val receiveToken: String, val provider: SwapProvider, @@ -69,7 +91,7 @@ sealed class SwapEvents( ), ) - data class ButtonPermissionApproveClicked( + class ButtonPermissionApproveClicked( val sendToken: String, val receiveToken: String, val approveType: ApproveType, @@ -88,8 +110,8 @@ sealed class SwapEvents( class ButtonSwipeClicked : SwapEvents(event = "Button - Swipe") - @Suppress("NullableToStringCall") - data class SwapInProgressScreen( + @Suppress("NullableToStringCall", "LongParameterList") + class SwapInProgressScreen( val provider: SwapProvider, val commission: FeeType, // Market / Fast val sendBlockchain: String, @@ -118,24 +140,39 @@ sealed class SwapEvents( class ProviderClicked : SwapEvents("Provider Clicked") - data class ProviderChosen(val provider: SwapProvider) : SwapEvents( + class ProviderChosen(val provider: SwapProvider) : SwapEvents( event = "Provider Chosen", params = mapOf("Provider" to provider.name), ) - data class ButtonStatus(val token: String) : SwapEvents( + class ButtonStatus(val token: String) : SwapEvents( event = "Button - Status", params = mapOf("Token" to token), ) - data class ButtonExplore(val token: String) : SwapEvents( + class ButtonExplore(val token: String) : SwapEvents( event = "Button - Explore", params = mapOf("Token" to token), ) class NoticeNoAvailableTokensToSwap : SwapEvents("Notice - No Available Tokens To Swap") - data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents( + class NoticeUnavailableToSwapPair( + val sendToken: String, + val receiveToken: String, + val sendBlockchain: String, + val receiveBlockchain: String, + ) : SwapEvents( + event = "Notice - Unavailable To Swap Pair", + params = mapOf( + SEND_TOKEN to sendToken, + RECEIVE_TOKEN to receiveToken, + "Send Blockchain" to sendBlockchain, + "Receive Blockchain" to receiveBlockchain, + ), + ) + + class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents( event = "Notice - Not Enough Fee", params = mapOf( "Token" to token, @@ -143,7 +180,7 @@ sealed class SwapEvents( ), ) - data class NoticeProviderError( + class NoticeProviderError( val sendToken: String, val receiveToken: String, val provider: SwapProvider, @@ -162,7 +199,7 @@ sealed class SwapEvents( // TODO parameters // region Promo activity - data class ChangellyActivity( + class ChangellyActivity( val promoState: PromoState, ) : AnalyticsEvent( category = PROMO_CATEGORY, @@ -177,7 +214,7 @@ sealed class SwapEvents( } } - data class NoticePermissionNeeded( + class NoticePermissionNeeded( val sendToken: String, val receiveToken: String, val provider: SwapProvider, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 35c5fd4fa3..a90bd3640a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue + import arrow.core.Either import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation @@ -44,6 +45,7 @@ import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketListConfig import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency @@ -88,7 +90,7 @@ import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.models.states.SwapNotificationUM -import com.tangem.feature.swap.presentation.R +import com.tangem.core.ui.R import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.router.SwapRouter import com.tangem.feature.swap.ui.StateBuilder @@ -105,6 +107,7 @@ import com.tangem.utils.Provider import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* import com.tangem.utils.isNullOrZero +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -120,6 +123,7 @@ import javax.inject.Inject typealias SuccessLoadedSwapData = Map +@OptIn(ExperimentalCoroutinesApi::class) @Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped @@ -154,7 +158,7 @@ internal class SwapModel @Inject constructor( private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val sendFeatureToggles: SendFeatureToggles, private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, - swapFeatureToggles: SwapFeatureToggles, + private val swapFeatureToggles: SwapFeatureToggles, private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val excludedBlockchains: ExcludedBlockchains, private val getUserWalletsUseCase: GetWalletsUseCase, @@ -258,10 +262,26 @@ internal class SwapModel @Inject constructor( private val searchQueryState = MutableStateFlow("") private val visibleMarketItemIds = MutableStateFlow>(emptyList()) + private val visibleDefaultMarketItemIds = MutableStateFlow>(emptyList()) + private var latestMarketsState: SwapMarketState? = null + + private val defaultMarketsListManager by lazy { + SwapMarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main, + order = TokenMarketListConfig.Order.Trending, + currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, + currentSearchText = Provider { null }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + private val searchMarketsListManager by lazy { SwapMarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, + order = TokenMarketListConfig.Order.ByRating, currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, currentSearchText = Provider { searchQueryState.value }, modelScope = modelScope, @@ -279,13 +299,21 @@ internal class SwapModel @Inject constructor( override fun onSuccess(addedToken: CryptoCurrency) { modelScope.launch { bottomSheetNavigation.dismiss() - uiState.selectTokenState?.let { currentSelectState -> - uiState = uiState.copy( - selectTokenState = currentSelectState.copy( - marketsState = null, - ), - ) - } + analyticsEventHandler.send( + SwapEvents.ChooseTokenScreenResult( + isTokenChosen = true, + token = addedToken.symbol, + source = SwapEvents.ChooseTokenScreenResult.SOURCE_MARKETS, + isSearched = searchQueryState.value.isNotEmpty(), + ), + ) + analyticsEventHandler.send( + SwapEvents.TokenAdded( + token = addedToken.symbol, + blockchain = addedToken.network.name, + ), + ) + searchQueryState.value = "" getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken) .firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded @@ -362,50 +390,7 @@ internal class SwapModel @Inject constructor( } .launchIn(modelScope) - if (swapFeatureToggles.isMarketListFeatureEnabled) { - combine( - flow = searchQueryState - .onEach { searchQuery -> - searchMarketsListManager.reload(searchQuery) - }, - flow2 = searchMarketsListManager.uiItems, - flow3 = searchMarketsListManager.isInInitialLoadingErrorState, - flow4 = searchMarketsListManager.isSearchNotFoundState, - flow5 = searchMarketsListManager.totalCount.filterNotNull(), - ) { searchQuery, uiItems, isError, isSearchNotFound, total -> - when { - searchQuery.isEmpty() -> { - visibleMarketItemIds.value = emptyList() - null - } - isError -> SwapMarketState.LoadingError( - onRetryClicked = { searchMarketsListManager.reload(searchQuery) }, - ) - isSearchNotFound -> SwapMarketState.SearchNothingFound - uiItems.isEmpty() -> SwapMarketState.Loading - else -> SwapMarketState.Content( - items = uiItems, - loadMore = { searchMarketsListManager.loadMore() }, - onItemClick = { item -> - addToPortfolioItem(item) - }, - visibleIdsChanged = { visibleMarketItemIds.value = it }, - total = total, - ) - } - } - .distinctUntilChanged() - .onEach { marketsState -> - uiState.selectTokenState?.let { currentSelectState -> - uiState = uiState.copy( - selectTokenState = currentSelectState.copy( - marketsState = marketsState, - ), - ) - } - } - .launchIn(modelScope) - } + subscribeMarketTokens() modelScope.launch { visibleMarketItemIds.mapNotNull { rawIDS -> @@ -418,6 +403,18 @@ internal class SwapModel @Inject constructor( searchMarketsListManager.loadCharts(visibleBatchKeys) } } + + modelScope.launch { + visibleDefaultMarketItemIds.mapNotNull { rawIds -> + if (rawIds.isNotEmpty()) { + defaultMarketsListManager.getBatchKeysByItemIds(rawIds) + } else { + null + } + }.distinctUntilChanged().collectLatest { visibleBatchKeys -> + defaultMarketsListManager.loadCharts(visibleBatchKeys) + } + } } fun onStart() { @@ -443,6 +440,41 @@ internal class SwapModel @Inject constructor( analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens)) } + private fun subscribeMarketTokens() { + if (swapFeatureToggles.isMarketListFeatureEnabled) { + // Switch between default and search market flows + searchQueryState + .map { it.isEmpty() } + .distinctUntilChanged() + .flatMapLatest { isDefaultMode -> + if (isDefaultMode) { + visibleMarketItemIds.value = emptyList() + createDefaultMarketsFlow() + } else { + visibleDefaultMarketItemIds.value = emptyList() + createSearchMarketsFlow() + } + } + .onEach { marketsState -> + latestMarketsState = marketsState + applyMarketsState(marketsState) + } + .launchIn(modelScope) + + // Reload search markets when query changes + searchQueryState + .onEach { searchQuery -> + if (searchQuery.isNotEmpty()) { + searchMarketsListManager.reload(searchQuery) + } + } + .launchIn(modelScope) + + // Initial load of default markets + defaultMarketsListManager.reload() + } + } + @Suppress("LongMethod") private fun initTokens(isReverseFromTo: Boolean) { modelScope.launch(dispatchers.main) { @@ -613,6 +645,25 @@ internal class SwapModel @Inject constructor( toAccount = toAccount, tokensDataState = state, ) + + if (!isTokenAvailableForSwap(state, selectedCurrency, isReverseFromTo)) { + analyticsEventHandler.send( + SwapEvents.NoticeUnavailableToSwapPair( + sendToken = fromCurrencyStatus.currency.symbol, + receiveToken = toCurrencyStatus.currency.symbol, + sendBlockchain = fromCurrencyStatus.currency.network.name, + receiveBlockchain = toCurrencyStatus.currency.network.name, + ), + ) + uiState = stateBuilder.createSwapNotSupportedState( + uiStateHolder = uiState, + fromToken = fromCurrencyStatus, + toToken = toCurrencyStatus, + toAccount = toAccount, + ) + return + } + startLoadingQuotes( fromToken = fromCurrencyStatus, fromAccount = fromAccount, @@ -640,6 +691,17 @@ internal class SwapModel @Inject constructor( fromToken = dataState.fromCryptoCurrency?.currency ?: initialCurrencyFrom, ) } + latestMarketsState?.let(::applyMarketsState) + } + + private fun applyMarketsState(marketsState: SwapMarketState) { + uiState.selectTokenState?.let { currentSelectState -> + uiState = uiState.copy( + selectTokenState = currentSelectState.copy( + marketsState = marketsState, + ), + ) + } } private fun startLoadingQuotes( @@ -1275,7 +1337,14 @@ internal class SwapModel @Inject constructor( val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id) foundToken?.currency?.symbol?.let { symbol -> - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = symbol)) + analyticsEventHandler.send( + SwapEvents.ChooseTokenScreenResult( + isTokenChosen = true, + token = symbol, + source = SwapEvents.ChooseTokenScreenResult.SOURCE_PORTFOLIO, + isSearched = searchQueryState.value.isNotEmpty(), + ), + ) } if (foundToken != null) { @@ -1871,6 +1940,28 @@ internal class SwapModel @Inject constructor( .orEmpty() } + private fun isTokenAvailableForSwap( + state: TokensDataStateExpress, + selectedCurrency: CryptoCurrencyStatus, + isReverseFromTo: Boolean, + ): Boolean { + val group = if (isReverseFromTo) state.fromGroup else state.toGroup + val idToFind = selectedCurrency.currency.id.value + + return if (accountsFeatureToggles.isFeatureEnabled) { + group.accountCurrencyList.any { (_, currencyList) -> + currencyList.any { accountSwapCurrency -> + idToFind == accountSwapCurrency.cryptoCurrencyStatus.currency.id.value && + accountSwapCurrency.isAvailable + } + } + } else { + group.available.any { swapAvailability -> + idToFind == swapAvailability.currencyStatus.currency.id.value + } + } + } + private fun List.filterForTangemPayWithdrawal(): List { return if (tangemPayInput?.isWithdrawal == true) { filter { it.type == ExchangeProviderType.CEX } @@ -2058,9 +2149,73 @@ internal class SwapModel @Inject constructor( } } + private fun createDefaultMarketsFlow(): Flow { + val marketsTitle = TextReference.Res(R.string.feed_trending_now) + return combine( + defaultMarketsListManager.uiItems, + defaultMarketsListManager.isInInitialLoadingErrorState, + defaultMarketsListManager.totalCount, + ) { uiItems, isError, total -> + when { + isError -> SwapMarketState.LoadingError( + onRetryClicked = { defaultMarketsListManager.reload() }, + marketsTitle = marketsTitle, + shouldAssetsCount = false, + ) + uiItems.isEmpty() -> SwapMarketState.Loading( + marketsTitle = marketsTitle, + shouldAssetsCount = false, + ) + else -> SwapMarketState.Content( + items = uiItems, + loadMore = { defaultMarketsListManager.loadMore() }, + onItemClick = { item -> addToPortfolioItem(item) }, + visibleIdsChanged = { visibleDefaultMarketItemIds.value = it }, + total = total ?: uiItems.size, + marketsTitle = marketsTitle, + shouldAssetsCount = false, + ) + } + } + } + + private fun createSearchMarketsFlow(): Flow { + val marketsTitle = TextReference.Res(R.string.markets_common_title) + return combine( + flow = searchMarketsListManager.uiItems, + flow2 = searchMarketsListManager.isInInitialLoadingErrorState, + flow3 = searchMarketsListManager.isSearchNotFoundState, + flow4 = searchMarketsListManager.totalCount, + ) { uiItems, isError, isSearchNotFound, total -> + when { + isError -> SwapMarketState.LoadingError( + onRetryClicked = { searchMarketsListManager.reload(searchQueryState.value) }, + marketsTitle = marketsTitle, + shouldAssetsCount = true, + ) + isSearchNotFound -> SwapMarketState.SearchNothingFound + uiItems.isEmpty() -> SwapMarketState.Loading( + marketsTitle = marketsTitle, + shouldAssetsCount = true, + ) + else -> SwapMarketState.Content( + items = uiItems, + loadMore = { searchMarketsListManager.loadMore() }, + onItemClick = { item -> addToPortfolioItem(item) }, + visibleIdsChanged = { visibleMarketItemIds.value = it }, + total = total ?: uiItems.size, + marketsTitle = marketsTitle, + shouldAssetsCount = true, + ) + } + } + } + private fun addToPortfolioItem(item: MarketsListItemUM) { modelScope.launch { - val tokenMarket = searchMarketsListManager.getTokenMarketById(item.id) ?: return@launch + val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) + ?: searchMarketsListManager.getTokenMarketById(item.id) + ?: return@launch val param = tokenMarket.toSerializableParam() val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 6c9bd0a484..fdb774f17d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -64,6 +64,12 @@ internal class SwapNotificationsFactory( ) } + fun getSwapNotSupportedNotifications(tokenName: String): ImmutableList { + return persistentListOf( + SwapNotificationUM.Warning.SwapNotSupported(tokenName), + ) + } + fun getQuotesErrorStateNotifications( expressDataError: ExpressDataError, fromToken: CryptoCurrency, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt index e8331efed3..77609fade7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/SwapMarketsListBatchFlowManager.kt @@ -22,12 +22,14 @@ import kotlinx.coroutines.flow.* internal class SwapMarketsListBatchFlowManager( getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, + private val order: TokenMarketListConfig.Order, private val currentAppCurrency: Provider, private val currentSearchText: Provider, private val modelScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, ) { - private val actionsFlow = MutableSharedFlow>() + private val actionsFlow = + MutableSharedFlow>(replay = 1) private val updateStateJob = JobHolder() private val batchFlow = getMarketsTokenListFlowUseCase( @@ -183,7 +185,7 @@ internal class SwapMarketsListBatchFlowManager( searchText ?: currentSearchText() }, priceChangeInterval = TokenMarketListConfig.Interval.H24, - order = TokenMarketListConfig.Order.ByRating, + order = order, shouldNetworks = true, ), ), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt index 50e6d14a74..8be842bcfd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt @@ -2,25 +2,40 @@ package com.tangem.feature.swap.models.market.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.core.ui.R import kotlinx.collections.immutable.ImmutableList @Immutable internal sealed class SwapMarketState { + abstract val marketsTitle: TextReference + abstract val shouldAssetsCount: Boolean + data class Content( val items: ImmutableList, val total: Int, val loadMore: () -> Unit, val onItemClick: (MarketsListItemUM) -> Unit, val visibleIdsChanged: (List) -> Unit, + override val marketsTitle: TextReference, + override val shouldAssetsCount: Boolean, ) : SwapMarketState() - data object Loading : SwapMarketState() + data class Loading( + override val marketsTitle: TextReference, + override val shouldAssetsCount: Boolean, + ) : SwapMarketState() data class LoadingError( val onRetryClicked: () -> Unit, + override val marketsTitle: TextReference, + override val shouldAssetsCount: Boolean, ) : SwapMarketState() - data object SearchNothingFound : SwapMarketState() + data object SearchNothingFound : SwapMarketState() { + override val marketsTitle: TextReference = TextReference.Res(R.string.markets_common_title) + override val shouldAssetsCount: Boolean = true + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index d140ef1ff0..7e5209dca5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -132,6 +132,18 @@ internal object SwapNotificationUM { ), ) + data class SwapNotSupported( + val tokenName: String, + ) : Warning( + title = resourceReference( + id = com.tangem.feature.swap.presentation.R.string.express_swap_not_supported_title, + formatArgs = wrappedList(tokenName), + ), + subtitle = resourceReference( + com.tangem.feature.swap.presentation.R.string.express_swap_not_supported_text, + ), + ) + data class NeedReserveToCreateAccount( val receiveAmount: String, val receiveToken: String, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index c3a85fe555..6c955e7c86 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -200,6 +200,60 @@ internal class StateBuilder( ) } + fun createSwapNotSupportedState( + uiStateHolder: SwapStateHolder, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + toAccount: Account.CryptoPortfolio?, + ): SwapStateHolder { + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + return uiStateHolder.copy( + sendCardData = SwapCardState.SwapCardData( + type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), + amountTextFieldValue = null, + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + token = fromToken, + tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, + coinId = uiStateHolder.sendCardData.coinId, + isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, + tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, + canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, + balance = fromToken.getFormattedAmount(isNeedSymbol = false), + networkIconRes = getActiveIconRes(fromToken.currency.network.rawId), + isBalanceHidden = isBalanceHiddenProvider(), + ), + receiveCardData = SwapCardState.SwapCardData( + type = TransactionCardType.ReadOnly( + accountTitleUM = getToCardAccountTitle(toAccount), + ), + amountTextFieldValue = TextFieldValue( + text = "0", + ), + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + token = toToken, + tokenIconUrl = toToken.currency.iconUrl, + coinId = toToken.currency.network.backendId, + isNotNativeToken = toToken.currency is CryptoCurrency.Token, + tokenCurrency = toToken.currency.symbol, + canSelectAnotherToken = true, + balance = toToken.getFormattedAmount(isNeedSymbol = false), + networkIconRes = getActiveIconRes(toToken.currency.network.rawId), + isBalanceHidden = isBalanceHiddenProvider(), + ), + notifications = notificationsFactory.getSwapNotSupportedNotifications(toToken.currency.name), + fee = FeeItemState.Empty, + swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(userWalletProvider()), + isEnabled = false, + isHoldToConfirm = isHoldToConfirmEnabled, + onClick = { }, + ), + changeCardsButtonState = ChangeCardsButtonState.DISABLED, + providerState = ProviderState.Empty(), + priceImpact = PriceImpact.Empty(), + ) + } + @Suppress("LongParameterList") fun createQuotesLoadingState( uiStateHolder: SwapStateHolder, @@ -438,6 +492,7 @@ internal class StateBuilder( notification is SwapNotificationUM.Warning.ExpressError || notification is SwapNotificationUM.Warning.ExpressGeneralError || notification is SwapNotificationUM.Warning.NoAvailableTokensToSwap || + notification is SwapNotificationUM.Warning.SwapNotSupported || notification is SwapNotificationUM.Warning.NeedReserveToCreateAccount || notification is SwapNotificationUM.Info.PermissionNeeded } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 2bc3cc5c61..a6ddf89de1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -189,7 +189,7 @@ private fun ListOfTokensWithMarkets( state = lazyListState, ) { if (state.tokensListData !is TokenListUMData.EmptyList) { - assetsTitle(count = state.tokensListData.totalTokensCount) + assetsTitle(count = state.tokensListData.totalTokensCount, showCount = marketsState.shouldAssetsCount) } tokensListItems( @@ -197,10 +197,18 @@ private fun ListOfTokensWithMarkets( isBalanceHidden = state.isBalanceHidden, ) - if (state.tokensListData is TokenListUMData.EmptyList) { + tokensToSelectItems(state.availableTokens, state.onTokenSelected) + if (state.unavailableTokens.isNotEmpty()) { item { SpacerH12() } - } else { + tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) + } + + val hasPortfolioContent = state.tokensListData !is TokenListUMData.EmptyList || + state.availableTokens.isNotEmpty() + if (hasPortfolioContent) { item { SpacerH32() } + } else { + item { SpacerH12() } } swapMarketsListItems(marketsState) @@ -242,13 +250,15 @@ private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapM } } -private fun LazyListScope.assetsTitle(count: Int) { +private fun LazyListScope.assetsTitle(count: Int, showCount: Boolean) { item(key = "assets_title") { Text( text = buildAnnotatedString { append(stringResourceSafe(R.string.swap_your_assets_title)) - withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { - append(" $count") + if (showCount) { + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(" $count") + } } }, style = TangemTheme.typography.h3, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt index 0f69e31f8b..ced4fbda3e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt @@ -14,6 +14,7 @@ import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.swap.models.market.state.SwapMarketState @@ -24,7 +25,7 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { val totalCount = (state as? SwapMarketState.Content)?.total Text( text = buildAnnotatedString { - append(stringResourceSafe(R.string.markets_common_title)) + append(state.marketsTitle.resolveReference()) if (totalCount != null) { withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { append(" $totalCount") diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt index b9a6ab2d40..8c03c69176 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt @@ -4,8 +4,10 @@ import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.core.ui.R import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.TokenBalanceData import com.tangem.feature.swap.models.TokenListUMData @@ -35,6 +37,8 @@ internal class SwapSelectTokenPreviewProvider { onItemClick = { }, visibleIdsChanged = { }, total = TOTAL_ITEMS, + marketsTitle = TextReference.Res(R.string.feed_trending_now), + shouldAssetsCount = false, ) private fun createPreviewMarketItems() = listOf( From 8b1fb82721a7005f618a737d28a60d51a87817ef Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Feb 2026 15:47:12 +0300 Subject: [PATCH 07/17] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index c04b7ea0fc..c4ccee2282 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -65,7 +65,7 @@ }, { "name": "SWAP_MARKET_LIST_ENABLED", - "version": "undefined" + "version": "5.34" }, { "name": "EARN_BLOCK_ENABLED", From 1cdece6ae7daa9c9c396b12d6d7a228ce2b62089 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Feb 2026 15:51:29 +0300 Subject: [PATCH 08/17] Updated on 2026-08-14 --- .../tap/domain/sdk/impl/DefaultTangemSdkManager.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 8639981110..11a3ffb59b 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -126,7 +126,13 @@ internal class DefaultTangemSdkManager( } if (awaitInitialization) { - awaitAuthenticationManagerInitialization().needEnrollBiometrics + val manager = awaitAuthenticationManagerInitialization() + + if (manager.isInitialized) { + manager.needEnrollBiometrics + } else { + false + } } else { throw e } @@ -145,7 +151,11 @@ internal class DefaultTangemSdkManager( if (awaitInitialization) { val manager = awaitAuthenticationManagerInitialization() - manager.canAuthenticate || manager.needEnrollBiometrics + if (manager.isInitialized) { + manager.canAuthenticate || manager.needEnrollBiometrics + } else { + false + } } else { throw e } From d7a120fc862ca038c1e36b3da301d62474a8ed19 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Feb 2026 19:18:15 +0400 Subject: [PATCH 09/17] Updated on 2026-08-14 --- .../tap/data/DefaultTangemPayStorage.kt | 27 +- .../api/pay/models/response/OrderResponse.kt | 1 + .../datasource/local/visa/TangemPayStorage.kt | 5 +- data/visa/build.gradle.kts | 1 + .../tangem/data/pay/di/TangemPayDataModule.kt | 2 +- .../data/pay/entity/WithdrawStoreData.kt | 19 ++ .../DefaultCustomerOrderRepository.kt | 26 +- .../DefaultTangemPaySwapRepository.kt | 99 ------ .../DefaultTangemPayWithdrawRepository.kt | 301 ++++++++++++++++++ .../DefaultTangemPayWithdrawUseCase.kt | 7 +- .../data/pay/util/WithdrawStateConverter.kt | 22 ++ .../pay/util/WithdrawStoreDataConverter.kt | 22 ++ domain/visa/build.gradle.kts | 1 + .../domain/pay/TangemPayWithdrawState.kt | 14 + .../com/tangem/domain/pay/model/OrderData.kt | 6 + .../pay/repository/CustomerOrderRepository.kt | 6 +- ...tory.kt => TangemPayWithdrawRepository.kt} | 9 +- .../TangemPayMainScreenCustomerInfoUseCase.kt | 12 +- .../tangempay/TangemPayWithdrawUseCase.kt | 2 + features/swap/domain/build.gradle.kts | 1 + .../feature/swap/domain/SwapInteractorImpl.kt | 9 + .../domain/models/ui/SwapTransactionState.kt | 2 + .../tangem/feature/swap/model/SwapModel.kt | 1 + .../tangempay/model/TangemPayDetailsModel.kt | 51 +-- 24 files changed, 486 insertions(+), 160 deletions(-) create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/entity/WithdrawStoreData.kt delete mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPaySwapRepository.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/util/WithdrawStateConverter.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/util/WithdrawStoreDataConverter.kt create mode 100644 domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayWithdrawState.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderData.kt rename domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/{TangemPaySwapRepository.kt => TangemPayWithdrawRepository.kt} (58%) diff --git a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt index 4dc9cefd1a..5708a79d8b 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -2,6 +2,9 @@ package com.tangem.tap.data import android.content.Context import com.squareup.moshi.Moshi +import com.tangem.data.pay.entity.WithdrawStoreData +import com.tangem.data.pay.util.WithdrawStateConverter +import com.tangem.data.pay.util.WithdrawStoreDataConverter import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys @@ -11,6 +14,7 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrNull import com.tangem.datasource.local.preferences.utils.store import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayWithdrawState import com.tangem.domain.visa.model.TangemPayAuthTokens import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -41,6 +45,8 @@ internal class DefaultTangemPayStorage @Inject constructor( } private val tokensAdapter by lazy { moshi.adapter(TangemPayAuthTokens::class.java) } + private val withdrawStoreDataConverter by lazy { WithdrawStoreDataConverter() } + private val withdrawStateConverter by lazy { WithdrawStateConverter() } override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) { withContext(dispatcherProvider.io) { @@ -131,10 +137,12 @@ internal class DefaultTangemPayStorage @Inject constructor( return appPreferencesStore.getSyncOrNull(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId)) } - override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, orderId: String) { + override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState) { appPreferencesStore.editData { mutablePreferences -> - val orders = mutablePreferences.getObjectMap(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY) - .plus(createWithdrawOrderIdKey(userWalletId) to orderId) + val orders = mutablePreferences.getObjectMap( + PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY, + ) + .plus(createWithdrawOrderIdKey(userWalletId) to withdrawStoreDataConverter.convert(data)) mutablePreferences.setObjectMap( key = PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY, value = orders, @@ -142,14 +150,19 @@ internal class DefaultTangemPayStorage @Inject constructor( } } - override suspend fun getWithdrawOrderId(userWalletId: UserWalletId): String? { - val orders = appPreferencesStore.getObjectMapSync(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY) - return orders[createWithdrawOrderIdKey(userWalletId)] + override suspend fun getWithdrawOrder(userWalletId: UserWalletId): TangemPayWithdrawState? { + val orders = appPreferencesStore.getObjectMapSync( + PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY, + ) + val data = orders[createWithdrawOrderIdKey(userWalletId)] ?: return null + return withdrawStateConverter.convert(data) } override suspend fun deleteWithdrawOrder(userWalletId: UserWalletId) { appPreferencesStore.editData { mutablePreferences -> - val orders = mutablePreferences.getObjectMap(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY) + val orders = mutablePreferences.getObjectMap( + PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY, + ) .minus(createWithdrawOrderIdKey(userWalletId)) mutablePreferences.setObjectMap( key = PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/OrderResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/OrderResponse.kt index a476b5dc1b..48cc8fc4fa 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/OrderResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/OrderResponse.kt @@ -27,6 +27,7 @@ data class OrderResponse( @Json(name = "emboss_name") val embossName: String?, @Json(name = "product_instance_id") val productInstanceId: String?, @Json(name = "payment_account_id") val paymentAccountId: String?, + @Json(name = "transaction_hash") val transactionHash: String?, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt index 591bc9f6be..bd29637e98 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.local.visa import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayWithdrawState import com.tangem.domain.visa.model.TangemPayAuthTokens @Suppress("TooManyFunctions") @@ -28,9 +29,9 @@ interface TangemPayStorage { suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? - suspend fun storeWithdrawOrder(userWalletId: UserWalletId, orderId: String) + suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState) - suspend fun getWithdrawOrderId(userWalletId: UserWalletId): String? + suspend fun getWithdrawOrder(userWalletId: UserWalletId): TangemPayWithdrawState? suspend fun deleteWithdrawOrder(userWalletId: UserWalletId) diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 3c9ef9ff07..67768d9958 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.domain.walletManager) implementation(projects.domain.quotes) implementation(projects.domain.common) + implementation(projects.features.swap.domain) /** Feature API - remove after removing [HotWalletFeatureToggles] */ implementation(projects.features.hotWallet.api) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 500e5fa50d..5868e4c0c6 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -45,7 +45,7 @@ internal interface TangemPayDataModule { @Binds @Singleton - fun bindTangemPaySwapRepository(repository: DefaultTangemPaySwapRepository): TangemPaySwapRepository + fun bindTangemPaySwapRepository(repository: DefaultTangemPayWithdrawRepository): TangemPayWithdrawRepository @Binds @Singleton diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/WithdrawStoreData.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/WithdrawStoreData.kt new file mode 100644 index 0000000000..cdad87d12b --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/WithdrawStoreData.kt @@ -0,0 +1,19 @@ +package com.tangem.data.pay.entity + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +data class WithdrawStoreData( + @Json(name = "orderId") val orderId: String, + @Json(name = "exchangeData") val exchangeData: ExchangeStoreData?, +) + +@JsonClass(generateAdapter = false) +data class ExchangeStoreData( + @Json(name = "txId") val txId: String, + @Json(name = "fromNetwork") val fromNetwork: String, + @Json(name = "fromAddress") val fromAddress: String, + @Json(name = "payInAddress") val payInAddress: String, + @Json(name = "payInExtraId") val payInExtraId: String?, +) \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt index 11afd970e8..f479794766 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt @@ -2,8 +2,8 @@ package com.tangem.data.pay.repository import arrow.core.Either import com.tangem.datasource.api.pay.TangemPayApi -import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.OrderData import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.repository.CustomerOrderRepository import com.tangem.domain.visa.error.VisaApiError @@ -12,35 +12,23 @@ import javax.inject.Inject internal class DefaultCustomerOrderRepository @Inject constructor( private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, - private val tangemPayStorage: TangemPayStorage, ) : CustomerOrderRepository { - override suspend fun getOrderStatus( - userWalletId: UserWalletId, - orderId: String, - ): Either { + override suspend fun getOrderData(userWalletId: UserWalletId, orderId: String): Either { return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId) }.map { response -> - when (response.result?.status) { + val status = when (response.result?.status) { null -> OrderStatus.UNKNOWN OrderStatus.NEW.apiName -> OrderStatus.NEW OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED else -> OrderStatus.CANCELED } + OrderData( + status = status, + withdrawTxHash = response.result?.data?.transactionHash?.ifEmpty { null }, + ) } } - - override suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean { - val orderId = tangemPayStorage.getWithdrawOrderId(userWalletId) - if (orderId == null) return false - - val status = getOrderStatus(userWalletId, orderId).getOrNull() - - val hasActiveOrder = status == OrderStatus.NEW || status == OrderStatus.PROCESSING - if (!hasActiveOrder) tangemPayStorage.deleteWithdrawOrder(userWalletId) - - return hasActiveOrder - } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPaySwapRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPaySwapRepository.kt deleted file mode 100644 index 6bc1774f28..0000000000 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPaySwapRepository.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.tangem.data.pay.repository - -import arrow.core.Either -import arrow.core.left -import com.tangem.core.error.UniversalError -import com.tangem.data.common.quote.QuotesFetcher -import com.tangem.datasource.api.pay.TangemPayApi -import com.tangem.datasource.api.pay.models.request.WithdrawDataRequest -import com.tangem.datasource.api.pay.models.request.WithdrawRequest -import com.tangem.datasource.local.visa.TangemPayStorage -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.pay.WithdrawalResult -import com.tangem.domain.pay.WithdrawalSignatureResult -import com.tangem.domain.pay.datasource.TangemPayAuthDataSource -import com.tangem.domain.pay.repository.TangemPaySwapRepository -import com.tangem.domain.visa.error.VisaApiError -import com.tangem.utils.extensions.addHexPrefix -import java.math.BigDecimal -import java.math.RoundingMode -import java.util.Currency -import java.util.Locale -import javax.inject.Inject - -@Suppress("LongParameterList") -internal class DefaultTangemPaySwapRepository @Inject constructor( - private val tangemPayApi: TangemPayApi, - private val requestHelper: TangemPayRequestPerformer, - private val authDataSource: TangemPayAuthDataSource, - private val quotesFetcher: QuotesFetcher, - private val tangemPayStorage: TangemPayStorage, -) : TangemPaySwapRepository { - - override suspend fun withdraw( - userWallet: UserWallet, - receiverAddress: String, - cryptoAmount: BigDecimal, - cryptoCurrencyId: CryptoCurrency.RawID, - ): Either { - val amountInCents = getAmountInCents(cryptoAmount, cryptoCurrencyId) - if (amountInCents.isNullOrEmpty()) return Either.Left(VisaApiError.WithdrawalDataError) - return requestHelper.performRequest(userWallet.walletId) { authHeader -> - val request = WithdrawDataRequest(amountInCents = amountInCents, recipientAddress = receiverAddress) - tangemPayApi.getWithdrawData(authHeader = authHeader, body = request) - }.map { data -> - val result = data.result ?: return VisaApiError.WithdrawalDataError.left() - val signatureResult = authDataSource.getWithdrawalSignature( - userWallet = userWallet, - hash = result.hash, - ).getOrNull() - - return when (signatureResult) { - is WithdrawalSignatureResult.Cancelled -> { - Either.Right(WithdrawalResult.Cancelled) - } - is WithdrawalSignatureResult.Success -> { - requestHelper.performRequest(userWallet.walletId) { authHeader -> - val request = WithdrawRequest( - amountInCents = amountInCents, - recipientAddress = receiverAddress, - adminSalt = result.salt, - senderAddress = result.senderAddress, - adminSignature = signatureResult.signature.addHexPrefix(), - ) - tangemPayApi.withdraw(authHeader = authHeader, body = request) - } - .mapLeft { return Either.Left(VisaApiError.WithdrawError) } - .map { response -> - val orderId = response.result?.orderId - if (orderId != null) tangemPayStorage.storeWithdrawOrder(userWallet.walletId, orderId) - WithdrawalResult.Success - } - } - null -> return Either.Left(VisaApiError.SignWithdrawError) - } - } - } - - private suspend fun getAmountInCents(cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID): String? { - val fiatRate = getFiatRate(cryptoCurrencyId) ?: return null - val amountInDollars = cryptoAmount.multiply(fiatRate) - val defaultFractionDigits = Currency.getInstance(Locale.US).defaultFractionDigits - return amountInDollars - .setScale(defaultFractionDigits, RoundingMode.HALF_UP) - .movePointRight(defaultFractionDigits) - .longValueExact() - .toString() - } - - private suspend fun getFiatRate(cryptoCurrencyId: CryptoCurrency.RawID): BigDecimal? { - val quotes = quotesFetcher.fetch( - fiatCurrencyId = Currency.getInstance(Locale.US).currencyCode, - currencyId = cryptoCurrencyId.value, - field = QuotesFetcher.Field.PRICE, - ).getOrNull() - - return quotes?.quotes[cryptoCurrencyId.value]?.price - } -} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt new file mode 100644 index 0000000000..cea8e61d60 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt @@ -0,0 +1,301 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.core.error.UniversalError +import com.tangem.data.common.quote.QuotesFetcher +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.request.WithdrawDataRequest +import com.tangem.datasource.api.pay.models.request.WithdrawRequest +import com.tangem.datasource.api.pay.models.response.WithdrawResponse +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.TangemPayWithdrawExchangeState +import com.tangem.domain.pay.TangemPayWithdrawState +import com.tangem.domain.pay.WithdrawalResult +import com.tangem.domain.pay.WithdrawalSignatureResult +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.pay.model.OrderData +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.repository.CustomerOrderRepository +import com.tangem.domain.pay.repository.TangemPayWithdrawRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.feature.swap.domain.api.SwapRepository +import com.tangem.feature.swap.domain.models.ExpressDataError +import com.tangem.utils.extensions.addHexPrefix +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.Currency +import java.util.Locale +import javax.inject.Inject +import kotlin.collections.set +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds + +private const val TAG = "TangemPaySwapRepository" + +@Suppress("LongParameterList") +internal class DefaultTangemPayWithdrawRepository @Inject constructor( + private val tangemPayApi: TangemPayApi, + private val requestHelper: TangemPayRequestPerformer, + private val authDataSource: TangemPayAuthDataSource, + private val quotesFetcher: QuotesFetcher, + private val tangemPayStorage: TangemPayStorage, + private val swapRepository: SwapRepository, + private val orderRepository: CustomerOrderRepository, +) : TangemPayWithdrawRepository { + + private val withdrawPollingScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val withdrawPollingJobs = mutableMapOf() + private val withdrawPollingMutex = Mutex() + + override suspend fun withdraw( + userWallet: UserWallet, + receiverAddress: String, + cryptoAmount: BigDecimal, + cryptoCurrencyId: CryptoCurrency.RawID, + exchangeData: TangemPayWithdrawExchangeState, + ): Either { + val amountInCents = getAmountInCents(cryptoAmount, cryptoCurrencyId) + if (amountInCents.isNullOrEmpty()) return Either.Left(VisaApiError.WithdrawalDataError) + return requestHelper.performRequest(userWallet.walletId) { authHeader -> + val request = WithdrawDataRequest(amountInCents = amountInCents, recipientAddress = receiverAddress) + tangemPayApi.getWithdrawData(authHeader = authHeader, body = request) + }.map { data -> + val result = data.result ?: return VisaApiError.WithdrawalDataError.left() + val signatureResult = authDataSource.getWithdrawalSignature( + userWallet = userWallet, + hash = result.hash, + ).getOrNull() + + return when (signatureResult) { + is WithdrawalSignatureResult.Cancelled -> { + Either.Right(WithdrawalResult.Cancelled) + } + is WithdrawalSignatureResult.Success -> { + requestHelper.performRequest(userWallet.walletId) { authHeader -> + val request = WithdrawRequest( + amountInCents = amountInCents, + recipientAddress = receiverAddress, + adminSalt = result.salt, + senderAddress = result.senderAddress, + adminSignature = signatureResult.signature.addHexPrefix(), + ) + tangemPayApi.withdraw(authHeader = authHeader, body = request) + } + .mapLeft { return Either.Left(VisaApiError.WithdrawError) } + .map { response -> + processWithdrawResult(response, userWallet, exchangeData) + WithdrawalResult.Success + } + } + null -> return Either.Left(VisaApiError.SignWithdrawError) + } + } + } + + private suspend fun processWithdrawResult( + response: WithdrawResponse, + userWallet: UserWallet, + exchangeData: TangemPayWithdrawExchangeState, + ) { + val orderId = response.result?.orderId + if (orderId != null) { + val orderData = orderRepository + .getOrderData(userWalletId = userWallet.walletId, orderId = orderId).getOrNull() + val withdrawTxHash = orderData?.withdrawTxHash + val storeData = TangemPayWithdrawState( + orderId = orderId, + exchangeData = exchangeData, + ) + if (orderData != null && !withdrawTxHash.isNullOrEmpty()) { + finalizeWithdraw( + userWallet = userWallet, + withdrawTxHash = withdrawTxHash, + orderId = orderId, + exchangeData = exchangeData, + order = orderData, + ).onLeft { + tangemPayStorage.storeWithdrawOrder(userWalletId = userWallet.walletId, data = storeData) + } + } else { + tangemPayStorage.storeWithdrawOrder(userWalletId = userWallet.walletId, data = storeData) + } + } + } + + private suspend fun finalizeWithdraw( + userWallet: UserWallet, + withdrawTxHash: String, + orderId: String, + exchangeData: TangemPayWithdrawExchangeState, + order: OrderData, + ): Either { + return swapRepository.exchangeSent( + userWallet = userWallet, + txId = exchangeData.txId, + fromNetwork = exchangeData.fromNetwork, + fromAddress = exchangeData.fromAddress, + payInAddress = exchangeData.payInAddress, + txHash = withdrawTxHash, + payInExtraId = exchangeData.payInExtraId, + ) + .onRight { + val isActive = order.status == OrderStatus.NEW || order.status == OrderStatus.PROCESSING + if (!isActive) { + tangemPayStorage.deleteWithdrawOrder(userWalletId = userWallet.walletId) + } else { + tangemPayStorage.storeWithdrawOrder( + userWalletId = userWallet.walletId, + data = TangemPayWithdrawState(orderId = orderId, exchangeData = null), + ) + } + } + .onLeft { error -> + Timber.tag(TAG).e(error.toString()) + } + } + + override suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean { + val orderExchangeData = tangemPayStorage.getWithdrawOrder(userWallet.walletId) + if (orderExchangeData == null) return false + + val exchangeData = orderExchangeData.exchangeData + val orderData = orderRepository + .getOrderData(userWallet.walletId, orderId = orderExchangeData.orderId).getOrNull() + val withdrawTxHash = orderData?.withdrawTxHash + + if (exchangeData != null && orderData != null && withdrawTxHash != null) { + finalizeWithdraw( + userWallet = userWallet, + withdrawTxHash = withdrawTxHash, + orderId = orderExchangeData.orderId, + exchangeData = exchangeData, + order = orderData, + ) + } + + return orderData?.status == OrderStatus.NEW || orderData?.status == OrderStatus.PROCESSING + } + + override suspend fun pollWithdrawOrderIfNeeds(userWallet: UserWallet): Either { + val storeData = tangemPayStorage.getWithdrawOrder(userWallet.walletId) ?: return Unit.right() + val exchangeData = storeData.exchangeData ?: return Unit.right() + + val orderId = storeData.orderId + val order = orderRepository + .getOrderData(userWalletId = userWallet.walletId, orderId = orderId).getOrNull() + ?: return Unit.right() + + val txHash = order.withdrawTxHash + + if (!txHash.isNullOrEmpty()) { + finalizeWithdraw( + userWallet = userWallet, + withdrawTxHash = txHash, + orderId = storeData.orderId, + exchangeData = exchangeData, + order = order, + ).onLeft { + startWithdrawOrderPolling( + userWallet = userWallet, + orderId = orderId, + storeData = storeData, + exchangeData = exchangeData, + ) + } + } else { + startWithdrawOrderPolling( + userWallet = userWallet, + orderId = orderId, + storeData = storeData, + exchangeData = exchangeData, + ) + } + + return Unit.right() + } + + private suspend fun startWithdrawOrderPolling( + userWallet: UserWallet, + orderId: String, + storeData: TangemPayWithdrawState, + exchangeData: TangemPayWithdrawExchangeState, + ) { + withdrawPollingMutex.withLock { + if (withdrawPollingJobs.containsKey(orderId)) return + + val pollingJob = withdrawPollingScope.launch { + try { + while (isActive && withdrawPollingJobs.containsKey(orderId)) { + delay(duration = 5.seconds) + + val orderData = orderRepository + .getOrderData(userWalletId = userWallet.walletId, orderId = orderId) + orderData.onRight { order -> + if (order.status != OrderStatus.NEW && order.status != OrderStatus.PROCESSING) { + tangemPayStorage.deleteWithdrawOrder(userWallet.walletId) + withdrawPollingJobs.remove(key = orderId) + return@launch + } + val txHash = order.withdrawTxHash + if (!txHash.isNullOrEmpty()) { + finalizeWithdraw( + userWallet = userWallet, + withdrawTxHash = txHash, + orderId = storeData.orderId, + exchangeData = exchangeData, + order = order, + ).onRight { + withdrawPollingJobs.remove(key = orderId) + return@launch + } + } + }.onLeft { error -> + Timber.tag(TAG).e("error ${error.errorCode}") + } + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.tag(TAG).e(exception) + withdrawPollingMutex.withLock { withdrawPollingJobs.remove(orderId) } + } + } + withdrawPollingJobs[orderId] = pollingJob + } + } + + private suspend fun getAmountInCents(cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID): String? { + val fiatRate = getFiatRate(cryptoCurrencyId) ?: return null + val amountInDollars = cryptoAmount.multiply(fiatRate) + val defaultFractionDigits = Currency.getInstance(Locale.US).defaultFractionDigits + return amountInDollars + .setScale(defaultFractionDigits, RoundingMode.HALF_UP) + .movePointRight(defaultFractionDigits) + .longValueExact() + .toString() + } + + private suspend fun getFiatRate(cryptoCurrencyId: CryptoCurrency.RawID): BigDecimal? { + val quotes = quotesFetcher.fetch( + fiatCurrencyId = Currency.getInstance(Locale.US).currencyCode, + currencyId = cryptoCurrencyId.value, + field = QuotesFetcher.Field.PRICE, + ).getOrNull() + + return quotes?.quotes[cryptoCurrencyId.value]?.price + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawUseCase.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawUseCase.kt index 36b85985ad..73f569a219 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawUseCase.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultTangemPayWithdrawUseCase.kt @@ -4,14 +4,15 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.pay.WithdrawalResult -import com.tangem.domain.pay.repository.TangemPaySwapRepository +import com.tangem.domain.pay.repository.TangemPayWithdrawRepository import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import java.math.BigDecimal import javax.inject.Inject internal class DefaultTangemPayWithdrawUseCase @Inject constructor( - private val repository: TangemPaySwapRepository, + private val repository: TangemPayWithdrawRepository, ) : TangemPayWithdrawUseCase { override suspend fun invoke( @@ -19,12 +20,14 @@ internal class DefaultTangemPayWithdrawUseCase @Inject constructor( cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID, receiverCexAddress: String, + exchangeData: TangemPayWithdrawExchangeState, ): Either { return repository.withdraw( userWallet = userWallet, cryptoAmount = cryptoAmount, receiverAddress = receiverCexAddress, cryptoCurrencyId = cryptoCurrencyId, + exchangeData = exchangeData, ) } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/WithdrawStateConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/WithdrawStateConverter.kt new file mode 100644 index 0000000000..5bcefd3b8e --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/WithdrawStateConverter.kt @@ -0,0 +1,22 @@ +package com.tangem.data.pay.util + +import com.tangem.data.pay.entity.WithdrawStoreData +import com.tangem.domain.pay.TangemPayWithdrawExchangeState +import com.tangem.domain.pay.TangemPayWithdrawState +import com.tangem.utils.converter.Converter + +class WithdrawStateConverter : Converter { + + override fun convert(value: WithdrawStoreData): TangemPayWithdrawState = TangemPayWithdrawState( + orderId = value.orderId, + exchangeData = value.exchangeData?.let { exchangeData -> + TangemPayWithdrawExchangeState( + txId = exchangeData.txId, + fromNetwork = exchangeData.fromNetwork, + fromAddress = exchangeData.fromAddress, + payInAddress = exchangeData.payInAddress, + payInExtraId = exchangeData.payInExtraId, + ) + }, + ) +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/WithdrawStoreDataConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/WithdrawStoreDataConverter.kt new file mode 100644 index 0000000000..e46044b735 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/WithdrawStoreDataConverter.kt @@ -0,0 +1,22 @@ +package com.tangem.data.pay.util + +import com.tangem.data.pay.entity.ExchangeStoreData +import com.tangem.data.pay.entity.WithdrawStoreData +import com.tangem.domain.pay.TangemPayWithdrawState +import com.tangem.utils.converter.Converter + +class WithdrawStoreDataConverter : Converter { + + override fun convert(value: TangemPayWithdrawState): WithdrawStoreData = WithdrawStoreData( + orderId = value.orderId, + exchangeData = value.exchangeData?.let { exchangeData -> + ExchangeStoreData( + txId = exchangeData.txId, + fromNetwork = exchangeData.fromNetwork, + fromAddress = exchangeData.fromAddress, + payInAddress = exchangeData.payInAddress, + payInExtraId = exchangeData.payInExtraId, + ) + }, + ) +} \ No newline at end of file diff --git a/domain/visa/build.gradle.kts b/domain/visa/build.gradle.kts index 3dfad027a7..559774d3e2 100644 --- a/domain/visa/build.gradle.kts +++ b/domain/visa/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.domain.core) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) + implementation(projects.features.swap.domain) /** Feature API - remove after removing [TangemPayFeatureToggles] */ implementation(projects.features.tangempay.details.api) diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayWithdrawState.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayWithdrawState.kt new file mode 100644 index 0000000000..a1424c3acd --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayWithdrawState.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.pay + +data class TangemPayWithdrawState( + val orderId: String, + val exchangeData: TangemPayWithdrawExchangeState?, +) + +data class TangemPayWithdrawExchangeState( + val txId: String, + val fromNetwork: String, + val fromAddress: String, + val payInAddress: String, + val payInExtraId: String?, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderData.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderData.kt new file mode 100644 index 0000000000..42b7001564 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderData.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.pay.model + +data class OrderData( + val status: OrderStatus, + val withdrawTxHash: String?, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOrderRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOrderRepository.kt index 9ae1fd414b..09b25edc3a 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOrderRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOrderRepository.kt @@ -2,12 +2,10 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.OrderData import com.tangem.domain.visa.error.VisaApiError interface CustomerOrderRepository { - suspend fun getOrderStatus(userWalletId: UserWalletId, orderId: String): Either - - suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean + suspend fun getOrderData(userWalletId: UserWalletId, orderId: String): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPaySwapRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt similarity index 58% rename from domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPaySwapRepository.kt rename to domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt index fed66aab7d..18b09d331e 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPaySwapRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt @@ -4,15 +4,22 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.pay.WithdrawalResult +import com.tangem.domain.visa.error.VisaApiError import java.math.BigDecimal -interface TangemPaySwapRepository { +interface TangemPayWithdrawRepository { suspend fun withdraw( userWallet: UserWallet, receiverAddress: String, cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID, + exchangeData: TangemPayWithdrawExchangeState, ): Either + + suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean + + suspend fun pollWithdrawOrderIfNeeds(userWallet: UserWallet): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt index 7ff408d1c1..647e6ad412 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt @@ -137,13 +137,13 @@ class TangemPayMainScreenCustomerInfoUseCase( userWalletId: UserWalletId, orderId: String, ): Either { - return customerOrderRepository.getOrderStatus(userWalletId, orderId = orderId) + return customerOrderRepository.getOrderData(userWalletId, orderId = orderId) .fold( ifLeft = { error -> error.mapErrorForCustomer().left() }, - ifRight = { orderStatus -> - when (orderStatus) { + ifRight = { orderData -> + when (orderData.status) { // Kyc is passed and user waits for order creation -> no need to get customer info OrderStatus.NEW, OrderStatus.PROCESSING, @@ -154,7 +154,7 @@ class TangemPayMainScreenCustomerInfoUseCase( kycStatus = CustomerInfo.KycStatus.APPROVED, cardInfo = null, ), - orderStatus = orderStatus, + orderStatus = orderData.status, ).right() // Order was created/cancelled -> clear order id and get customer info @@ -164,11 +164,11 @@ class TangemPayMainScreenCustomerInfoUseCase( -> { onboardingRepository.clearOrderId(userWalletId) // If order was cancelled -> start order creation - if (orderStatus == OrderStatus.CANCELED) onboardingRepository.createOrder(userWalletId) + if (orderData.status == OrderStatus.CANCELED) onboardingRepository.createOrder(userWalletId) onboardingRepository.getCustomerInfo(userWalletId = userWalletId) .mapLeft { it.mapErrorForCustomer() } .map { customerInfo -> - MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus) + MainScreenCustomerInfo(info = customerInfo, orderStatus = orderData.status) } } } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawUseCase.kt index e2abfd227f..d98264599f 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayWithdrawUseCase.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.pay.WithdrawalResult import java.math.BigDecimal @@ -14,5 +15,6 @@ interface TangemPayWithdrawUseCase { cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID, receiverCexAddress: String, + exchangeData: TangemPayWithdrawExchangeState, ): Either } \ No newline at end of file diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index b74d66f7e2..02e0992d2b 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(projects.domain.express.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.visa.models) implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 060390b7a6..cbaa2d6509 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -36,6 +36,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.tokens.* @@ -1030,6 +1031,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError if (isTangemPayWithdrawal) { + val networkAddress = currencyToSend.value.networkAddress return SwapTransactionState.TangemPayWithdrawalData( cryptoAmount = amount.value, cryptoCurrencyId = requireNotNull(currencyToSend.currency.id.rawCurrencyId), @@ -1056,6 +1058,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( txExternalId = exchangeDataCex.externalTxId, averageDuration = null, ), + exchangeData = TangemPayWithdrawExchangeState( + txId = exchangeDataCex.txId, + fromNetwork = currencyToSend.currency.network.backendId, + fromAddress = networkAddress?.defaultAddress?.value.orEmpty(), + payInAddress = exchangeData.transaction.txTo, + payInExtraId = exchangeDataCex.txExtraId, + ), ) } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt index 4619adf9ab..b1380f6a3a 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain.models.ui import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -31,6 +32,7 @@ sealed class SwapTransactionState { val toAmount: String?, val toAmountValue: BigDecimal?, val storeData: StoreTransactionData, + val exchangeData: TangemPayWithdrawExchangeState, ) : SwapTransactionState() { data class StoreTransactionData( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index a90bd3640a..fb4e74e1ff 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -1136,6 +1136,7 @@ internal class SwapModel @Inject constructor( cryptoAmount = swapTransactionState.cryptoAmount, cryptoCurrencyId = swapTransactionState.cryptoCurrencyId, receiverCexAddress = swapTransactionState.cexAddress, + exchangeData = swapTransactionState.exchangeData, ) .onLeft { startLoadingQuotesFromLastState() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index aa37f99d4a..793549861d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -26,8 +26,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayTopUpData -import com.tangem.domain.pay.repository.CustomerOrderRepository import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.TangemPayWithdrawRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.domain.visa.model.TangemPayTxHistoryItem @@ -79,7 +79,7 @@ internal class TangemPayDetailsModel @Inject constructor( private val cardDetailsEventListener: CardDetailsEventListener, private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener, private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory, - private val orderRepository: CustomerOrderRepository, + private val tangemPayWithdrawRepository: TangemPayWithdrawRepository, private val getUserWalletUseCase: GetUserWalletUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val expressTransactionsEventListener: ExpressTransactionsEventListener, @@ -124,6 +124,7 @@ internal class TangemPayDetailsModel @Inject constructor( modelScope.launch { expressTransactionsEventListener.send(ExpressTransactionsEvent.Update) } + subscribeToWithdrawOrder() } fun onPause() { @@ -148,6 +149,14 @@ internal class TangemPayDetailsModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeToWithdrawOrder() { + modelScope.launch { + val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull() + ?: return@launch + tangemPayWithdrawRepository.pollWithdrawOrderIfNeeds(userWallet) + } + } + override fun onClickPinCode() { analytics.send(TangemPayAnalyticsEvents.PinCodeClicked()) if (!params.config.isPinSet) { @@ -278,24 +287,28 @@ internal class TangemPayDetailsModel @Inject constructor( if (currentBalance == null || depositAddress == null) { showBottomSheetError(TangemPayDetailsErrorType.Withdraw) } else { - modelScope.launch { - val hasActiveWithdrawal = orderRepository.hasWithdrawOrder(userWalletId = params.userWalletId) - if (hasActiveWithdrawal) { - showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress) - } else { - val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull() - val currency = cryptoCurrency ?: userWallet?.let { - tangemPayCryptoCurrencyFactory.create(userWallet = userWallet, chainId = params.config.chainId) - .getOrNull() - } - if (currency != null) { - uiMessageSender.send( - message = TangemPayMessagesFactory.createWithdrawWarning( - onGotItClick = { onConfirmWithdrawal(currency, currentBalance, depositAddress) }, - ), - ) + val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull() + if (userWallet == null) { + showBottomSheetError(TangemPayDetailsErrorType.Withdraw) + } else { + modelScope.launch { + val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWallet = userWallet) + if (hasActiveWithdrawal) { + showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress) } else { - showBottomSheetError(TangemPayDetailsErrorType.Withdraw) + val currency = cryptoCurrency ?: tangemPayCryptoCurrencyFactory.create( + userWallet = userWallet, + chainId = params.config.chainId, + ).getOrNull() + if (currency != null) { + uiMessageSender.send( + message = TangemPayMessagesFactory.createWithdrawWarning( + onGotItClick = { onConfirmWithdrawal(currency, currentBalance, depositAddress) }, + ), + ) + } else { + showBottomSheetError(TangemPayDetailsErrorType.Withdraw) + } } } } From bd14f694c5afe474a33b468bbc57cbef2dd6ee15 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Feb 2026 11:25:36 +0400 Subject: [PATCH 10/17] Updated on 2026-08-14 --- .../SwapNotificationsComponent.kt | 6 +++++ .../model/SwapNotificationsModel.kt | 26 +++++++++++++++++++ .../confirm/SendWithSwapConfirmComponent.kt | 4 +++ .../confirm/model/SendWithSwapConfirmModel.kt | 4 +++ 4 files changed, 40 insertions(+) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt index 8de4dc6649..3634569cf9 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt @@ -7,6 +7,8 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.swap.v2.impl.notifications.model.SwapNotificationsModel import com.tangem.features.swap.v2.impl.notifications.ui.swapNotifications import kotlinx.collections.immutable.ImmutableList @@ -40,6 +42,10 @@ internal class SwapNotificationsComponent( data class SwapNotificationData( val expressError: ExpressError?, val fromCryptoCurrency: CryptoCurrency?, + val destinationAddress: String, + val memo: String? = null, + val toCryptoCurrencyStatus: CryptoCurrencyStatus? = null, + val userWalletId: UserWalletId? = null, ) } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt index e2be8113cd..89e743866c 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt @@ -1,5 +1,6 @@ package com.tangem.features.swap.v2.impl.notifications.model +import com.tangem.blockchain.common.BlockchainSdkError import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -7,6 +8,8 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase +import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger import com.tangem.features.swap.v2.impl.notifications.DefaultSwapNotificationsUpdateTrigger import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent @@ -14,6 +17,7 @@ import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsUpdateListener import com.tangem.features.swap.v2.impl.notifications.entity.SwapNotificationUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import java.math.BigDecimal import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -30,6 +34,7 @@ internal class SwapNotificationsModel @Inject constructor( private val swapNotificationsUpdateListener: SwapNotificationsUpdateListener, private val swapNotificationsUpdateTrigger: DefaultSwapNotificationsUpdateTrigger, private val swapAmountUpdateTrigger: SwapAmountUpdateTrigger, + private val validateTransactionUseCase: ValidateTransactionUseCase, paramsContainer: ParamsContainer, ) : Model() { @@ -61,12 +66,33 @@ internal class SwapNotificationsModel @Inject constructor( private suspend fun buildNotifications() { val notifications = buildList { addExpressErrorNotification() + addDestinationTagRequiredNotification() } swapNotificationsUpdateTrigger.callbackHasError(notifications.isNotEmpty()) uiState.value = notifications.toImmutableList() } + private suspend fun MutableList.addDestinationTagRequiredNotification() { + val toCryptoCurrencyStatus = notificationData.toCryptoCurrencyStatus ?: return + val userWalletId = notificationData.userWalletId ?: return + val destinationAddress = notificationData.destinationAddress + if (destinationAddress.isEmpty()) return + + val validationError = validateTransactionUseCase( + amount = BigDecimal.ZERO.convertToSdkAmount(toCryptoCurrencyStatus), + fee = null, + memo = notificationData.memo, + destination = destinationAddress, + userWalletId = userWalletId, + network = toCryptoCurrencyStatus.currency.network, + ).leftOrNull() + + if (validationError is BlockchainSdkError.DestinationTagRequired) { + add(NotificationUM.Error.DestinationTagRequired) + } + } + fun MutableList.addExpressErrorNotification() { val expressError = notificationData.expressError ?: return val fromCryptoCurrency = notificationData.fromCryptoCurrency ?: return diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index aac3bb92dd..59d34c0701 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -133,6 +133,10 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( swapNotificationData = SwapNotificationsComponent.Params.SwapNotificationData( expressError = (model.confirmData.quote as? SwapQuoteUM.Error)?.expressError, fromCryptoCurrency = model.confirmData.fromCryptoCurrencyStatus?.currency, + destinationAddress = model.confirmData.enteredDestination.orEmpty(), + memo = model.confirmData.enteredMemo, + toCryptoCurrencyStatus = model.confirmData.toCryptoCurrencyStatus, + userWalletId = params.userWallet.walletId, ), ), ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 6fdf6bdb46..271decfaa7 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -429,6 +429,10 @@ internal class SendWithSwapConfirmModel @Inject constructor( data = SwapNotificationData( expressError = (confirmData.quote as? SwapQuoteUM.Error)?.expressError, fromCryptoCurrency = confirmData.fromCryptoCurrencyStatus?.currency, + destinationAddress = confirmData.enteredDestination.orEmpty(), + memo = confirmData.enteredMemo, + toCryptoCurrencyStatus = confirmData.toCryptoCurrencyStatus, + userWalletId = params.userWallet.walletId, ), ) uiState.transformerUpdate( From 7559207f595107fcfe2826bd092171bbb1fd0b34 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Feb 2026 11:25:50 +0400 Subject: [PATCH 11/17] Updated on 2026-08-14 --- .../wallets/analytics/WalletSettingsAnalyticEvents.kt | 4 ++++ .../features/details/model/UserWalletListModel.kt | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt index be4e913124..850782d36a 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt @@ -171,6 +171,10 @@ sealed class WalletSettingsAnalyticEvents( event = "Wallet Upgraded", ), AppsFlyerIncludedEvent + class WalletsReorder : WalletSettingsAnalyticEvents( + event = "Longtap - Wallets Order", + ) + enum class RecoveryPhraseScreenAction(val value: String) { Upgrade("Upgrade"), Backup("Backup"), diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index 5c011e92c8..44af704d49 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.usecase.ApplyUserWalletListSortingUseCase import com.tangem.domain.wallets.usecase.UnlockWalletUseCase import com.tangem.features.details.entity.UserWalletListUM @@ -138,10 +139,11 @@ internal class UserWalletListModel @Inject constructor( val userWalletIds = state.value.userWallets.map { UserWalletId(it.id) } modelScope.launch { - applyUserWalletListSortingUseCase(userWalletIds) - .onLeft { error -> - Timber.e("Failed to apply wallet list sorting: $error") - } + applyUserWalletListSortingUseCase(userWalletIds).onRight { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.WalletsReorder()) + }.onLeft { error -> + Timber.e("Failed to apply wallet list sorting: $error") + } } } } \ No newline at end of file From 7f00a29920292af59d631e2bdf5caa2626e804c9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 16 Feb 2026 19:21:55 +0400 Subject: [PATCH 12/17] Updated on 2026-08-14 --- .../main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt | 2 +- .../main/java/com/tangem/feature/swap/DefaultSwapRepository.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index 20d6cbf213..b229533df3 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -248,7 +248,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( userWallet = userWallet, appPreferencesStore = appPreferencesStore, ), - toExtraId = toExtraId, + toExtraId = toExtraId?.ifEmpty { null }, ).getOrThrow() if (dataSignatureVerifier.verifySignature(response.signature, response.txDetailsJson)) { diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index d207e7992b..ef5dc2f371 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -312,7 +312,7 @@ internal class DefaultSwapRepository( userWallet = userWallet, appPreferencesStore = appPreferencesStore, ), - toExtraId = toExtraId, + toExtraId = toExtraId?.ifEmpty { null }, ).getOrThrow() if (dataSignatureVerifier.verifySignature(response.signature, response.txDetailsJson)) { val txDetails = parseTxDetails(response.txDetailsJson) From d0217583408efc22f681fcfd6c4918f663df2307 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 18 Feb 2026 14:58:21 +0700 Subject: [PATCH 13/17] Updated on 2026-08-14 --- .../wallet/presentation/wallet/domain/Wallet2CobrandImage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt index 27856a0954..ae764cf797 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt @@ -251,7 +251,7 @@ internal enum class Wallet2CobrandImage( Sakura( cards2ResId = R.drawable.ill_sakura_card2_120_106, cards3ResId = R.drawable.ill_sakura_card3_120_106, - batchIds = setOf("AF990029", "AF990030", "AF990031"), + batchIds = setOf("AF990029", "AF990030", "AF990031", "AF990071", "AF990072", "AF990073"), ), SatoshiFriends( From a71a0e9a65f751105399744e34f5f5e57c8c49aa Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Feb 2026 10:51:15 +0300 Subject: [PATCH 14/17] Updated on 2026-08-14 --- .../account/createedit/ui/AccountCreateEditContent.kt | 6 +++++- .../features/onramp/tokenlist/ui/OnrampTokenList.kt | 2 +- .../com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt | 2 +- .../presentation/account/ExpandedAccountsHolder.kt | 9 +++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt index 5d48231c07..792db8d675 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.vectorResource @@ -39,6 +40,7 @@ import com.tangem.core.ui.components.fields.AutoSizeTextField import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.features.account.createedit.entity.AccountCreateEditUM import com.tangem.features.account.createedit.entity.AccountCreateEditUM.Account @@ -53,11 +55,12 @@ internal fun AccountCreateEditContent( ) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current + val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection() + Column( modifier = modifier .background(color = TangemTheme.colors.background.secondary) .fillMaxSize() - .imePadding() .systemBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -65,6 +68,7 @@ internal fun AccountCreateEditContent( Column( modifier = Modifier + .nestedScroll(nestedScrollConnection) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) .weight(1f), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt index d92870b44f..e878df7dbd 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt @@ -213,7 +213,7 @@ internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portf portfolioItem( portfolio = portfolio, - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier, isBalanceHidden = isBalanceHidden, ) if (!isExpanded) return diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index a6ddf89de1..2a21dd13ec 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -322,7 +322,7 @@ internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portf portfolioItem( portfolio = portfolio, - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier, isBalanceHidden = isBalanceHidden, ) if (!isExpanded) return diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt index 59095ad0df..b1c48cff7e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt @@ -10,7 +10,9 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -43,10 +45,13 @@ internal class ExpandedAccountsHolder @Inject constructor( .toSet() // main state holder val expandedAccounts = MutableStateFlow(initExpandedState) + var debounceJob: Job? = null actionChannel .filter { (accountId, _) -> accountId.userWalletId == walletId } + .filter { debounceJob?.isActive != true } .onEach { (accountId, isExpand) -> + debounceJob = launch { delay(DEBOUNCE_MILLIS) } val newState = AccountExpandedState(accountId, isExpand) launch { accountsExpandedRepository.update(newState) } if (isExpand) { @@ -100,4 +105,8 @@ internal class ExpandedAccountsHolder @Inject constructor( } private fun walletAccounts(walletId: UserWalletId): Flow = singleAccountListSupplier(walletId) + + companion object { + private const val DEBOUNCE_MILLIS = 200L + } } \ No newline at end of file From 7b5b4753a7ef06b97e65d7e4dd35ce3eb85dea55 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 17 Feb 2026 12:54:20 +0300 Subject: [PATCH 15/17] Updated on 2026-08-14 --- .../add/impl/model/CheckCurrencyUnsupportedDelegate.kt | 2 +- .../add/impl/model/CheckCurrencyUnsupportedDelegate.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt index 8ba89fb021..feb3025f69 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt @@ -66,7 +66,7 @@ class CheckCurrencyUnsupportedDelegate @Inject constructor( formatArgs = wrappedList(unsupportedState.networkName), ) is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( - id = R.string.alert_manage_tokens_unsupported_curve_message, + id = R.string.alert_manage_tokens_unsupported_blockchain_by_card_message, formatArgs = wrappedList(unsupportedState.networkName), ) }, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt index a61f7cd842..f6d5e737b6 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt @@ -66,7 +66,7 @@ class CheckCurrencyUnsupportedDelegate @Inject constructor( formatArgs = wrappedList(unsupportedState.networkName), ) is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( - id = R.string.alert_manage_tokens_unsupported_curve_message, + id = R.string.alert_manage_tokens_unsupported_blockchain_by_card_message, formatArgs = wrappedList(unsupportedState.networkName), ) }, From 4e4e360ae184a93180d7531d2d8a82e4c9dc6d32 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 19 Feb 2026 11:10:14 +0400 Subject: [PATCH 16/17] Updated on 2026-08-14 --- .../DefaultWalletAddressServiceRepository.kt | 58 ++++++++----------- .../WalletAddressServiceRepository.kt | 2 +- .../usecase/ValidateWalletMemoUseCase.kt | 15 ++++- .../destination/model/SendDestinationModel.kt | 3 +- gradle/tangem_dependencies.toml | 2 +- 5 files changed, 39 insertions(+), 41 deletions(-) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultWalletAddressServiceRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultWalletAddressServiceRepository.kt index 49244f2da1..5c2bbe6654 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultWalletAddressServiceRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultWalletAddressServiceRepository.kt @@ -1,12 +1,14 @@ package com.tangem.data.transaction import android.net.Uri -import androidx.core.text.isDigitsOnly import com.tangem.blockchain.blockchains.near.NearWalletManager import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.NameResolver import com.tangem.blockchain.common.ResolveAddressResult import com.tangem.blockchain.common.ReverseResolveAddressResult +import com.tangem.blockchain.common.TransactionValidator +import com.tangem.blockchain.common.memo.MemoState +import com.tangem.blockchain.extensions.Result import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -16,7 +18,6 @@ import com.tangem.domain.wallets.models.ParsedQrCode import com.tangem.domain.wallets.models.errors.ParsedQrCodeErrors import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext -import java.math.BigInteger class DefaultWalletAddressServiceRepository( private val walletManagersFacade: WalletManagersFacade, @@ -95,19 +96,28 @@ class DefaultWalletAddressServiceRepository( } } - override fun validateMemo(network: Network, memo: String): Boolean { - if (memo.isEmpty()) return true - return when (network.rawId) { - Blockchain.XRP.id -> { - val tag = memo.toLongOrNull() - tag != null && tag <= XRP_TAG_MAX_NUMBER + override suspend fun validateMemo(userWalletId: UserWalletId, network: Network, memo: String): Boolean = + withContext(dispatchers.io) { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + network = network, + ) ?: return@withContext true + + val memoStateResult = (walletManager as? TransactionValidator)?.validateMemo(memo) + if (memoStateResult != null) { + when (memoStateResult) { + is Result.Success -> when (memoStateResult.data) { + MemoState.NotSupported, + MemoState.Valid, + -> true + MemoState.Invalid -> false + } + is Result.Failure -> true + } + } else { + true } - Blockchain.Stellar.id -> { - isAssignableXlmValue(memo) - } - else -> true } - } override suspend fun parseSharedAddress(input: String, network: Network): ParsedQrCode { val blockchain = network.toBlockchain() @@ -145,26 +155,4 @@ class DefaultWalletAddressServiceRepository( private fun Blockchain.isNear(): Boolean { return this == Blockchain.Near || this == Blockchain.NearTestnet } - - private fun isAssignableXlmValue(value: String): Boolean { - return when { - value.isNotEmpty() && value.isDigitsOnly() -> { - try { - // from com.tangem.blockchain.blockchains.stellar.StellarMemo.toStellarSdkMemo - value.toBigInteger() in BigInteger.ZERO..Long.MAX_VALUE.toBigInteger() * 2.toBigInteger() - } catch (ex: NumberFormatException) { - false - } - } - else -> { - // from org.stellar.sdk.MemoText - value.toByteArray().size <= XLM_MEMO_MAX_LENGTH - } - } - } - - companion object { - private const val XLM_MEMO_MAX_LENGTH = 28 - private const val XRP_TAG_MAX_NUMBER = 4294967295 - } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/WalletAddressServiceRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/WalletAddressServiceRepository.kt index f566a5a93b..e43d756df6 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/WalletAddressServiceRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/WalletAddressServiceRepository.kt @@ -23,7 +23,7 @@ interface WalletAddressServiceRepository { suspend fun validateAddress(userWalletId: UserWalletId, network: Network, address: String): Boolean - fun validateMemo(network: Network, memo: String): Boolean + suspend fun validateMemo(userWalletId: UserWalletId, network: Network, memo: String): Boolean suspend fun parseSharedAddress(input: String, network: Network): ParsedQrCode } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletMemoUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletMemoUseCase.kt index 364eef4916..eb69e7feff 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletMemoUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletMemoUseCase.kt @@ -3,7 +3,8 @@ package com.tangem.domain.transaction.usecase import arrow.core.Either import arrow.core.left import arrow.core.right -import com.tangem.domain.models.network.Network +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.error.ValidateMemoError @@ -14,9 +15,17 @@ class ValidateWalletMemoUseCase( private val walletAddressServiceRepository: WalletAddressServiceRepository, ) { - operator fun invoke(network: Network, memo: String): Either { + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + memo: String, + ): Either { return try { - val isValidMemo = walletAddressServiceRepository.validateMemo(network, memo) + val isValidMemo = walletAddressServiceRepository.validateMemo( + userWalletId = userWalletId, + network = cryptoCurrency.network, + memo = memo, + ) if (isValidMemo) { Unit.right() } else { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 95e67b5de3..96690cb647 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -323,8 +323,9 @@ internal class SendDestinationModel @Inject constructor( senderAddresses = senderAddresses.value, ) val memoValidationResult = validateWalletMemoUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, memo = memo.orEmpty(), - network = cryptoCurrency.network, ) if (type != null) { diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index a5765e9809..a0d719fc6b 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.34-1428" +tangemBlockchainSdk = "releases-5.34-1430" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.34-579" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 9adbde6e55b0b5683ae2e39e3721b07deb630e63 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 19 Feb 2026 13:00:40 +0500 Subject: [PATCH 17/17] Updated on 2026-08-14 --- .../tap/di/domain/HotWalletDomainModule.kt | 27 ++ .../local/preferences/PreferencesKeys.kt | 4 - .../hotwallet/DefaultHotWalletRepository.kt | 29 +- domain/hot-wallet/build.gradle.kts | 6 + .../CheckHotWalletUpgradeBannerUseCase.kt | 61 +++++ .../CloseHotWalletUpgradeBannerUseCase.kt | 20 ++ ...ShouldShowUpgradeHotWalletBannerUseCase.kt | 12 + .../repository/HotWalletRepository.kt | 10 +- .../CheckHotWalletUpgradeBannerUseCaseTest.kt | 257 ++++++++++++++++++ .../CloseHotWalletUpgradeBannerUseCaseTest.kt | 39 +++ .../intents/WalletWarningsClickIntents.kt | 16 +- .../domain/GetMultiWalletWarningsFactory.kt | 48 +--- 12 files changed, 454 insertions(+), 75 deletions(-) create mode 100644 domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/CheckHotWalletUpgradeBannerUseCase.kt create mode 100644 domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/CloseHotWalletUpgradeBannerUseCase.kt create mode 100644 domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/ShouldShowUpgradeHotWalletBannerUseCase.kt create mode 100644 domain/hot-wallet/src/test/kotlin/com/tangem/domain/hotwallet/CheckHotWalletUpgradeBannerUseCaseTest.kt create mode 100644 domain/hot-wallet/src/test/kotlin/com/tangem/domain/hotwallet/CloseHotWalletUpgradeBannerUseCaseTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/HotWalletDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/HotWalletDomainModule.kt index 8c8404b2d3..c99860711a 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/HotWalletDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/HotWalletDomainModule.kt @@ -1,9 +1,12 @@ package com.tangem.tap.di.domain +import com.tangem.domain.hotwallet.CheckHotWalletUpgradeBannerUseCase +import com.tangem.domain.hotwallet.CloseHotWalletUpgradeBannerUseCase import com.tangem.domain.hotwallet.GetAccessCodeSkippedUseCase import com.tangem.domain.hotwallet.IsHotWalletCreationSupported import com.tangem.domain.hotwallet.IsAccessCodeSimpleUseCase import com.tangem.domain.hotwallet.SetAccessCodeSkippedUseCase +import com.tangem.domain.hotwallet.ShouldShowUpgradeHotWalletBannerUseCase import com.tangem.domain.hotwallet.repository.HotWalletRepository import dagger.Module import dagger.Provides @@ -40,4 +43,28 @@ internal object HotWalletDomainModule { ): IsHotWalletCreationSupported { return IsHotWalletCreationSupported(hotWalletRepository) } + + @Provides + @Singleton + fun provideCheckHotWalletUpgradeBannerUseCase( + hotWalletRepository: HotWalletRepository, + ): CheckHotWalletUpgradeBannerUseCase { + return CheckHotWalletUpgradeBannerUseCase(hotWalletRepository) + } + + @Provides + @Singleton + fun provideCloseHotWalletUpgradeBannerUseCase( + hotWalletRepository: HotWalletRepository, + ): CloseHotWalletUpgradeBannerUseCase { + return CloseHotWalletUpgradeBannerUseCase(hotWalletRepository) + } + + @Provides + @Singleton + fun provideShouldShowUpgradeHotWalletBannerUseCase( + hotWalletRepository: HotWalletRepository, + ): ShouldShowUpgradeHotWalletBannerUseCase { + return ShouldShowUpgradeHotWalletBannerUseCase(hotWalletRepository) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index eae141d861..e0c785a2e8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -134,10 +134,6 @@ object PreferencesKeys { val SHOULD_SHOW_UPGRADE_BANNER_KEY by lazy { stringPreferencesKey(name = "shouldShowUpgradeBanner") } - val SHOULD_SHOW_NEXT_TIME_UPGRADE_BANNER_KEY by lazy { - stringPreferencesKey(name = "shouldShowNextTimeUpgradeBanner") - } - val UPGRADE_BANNER_CLOSURE_TIMESTAMP_KEY by lazy { stringPreferencesKey(name = "upgradeBannerClosureTimestamp") } val WALLET_CREATION_TIMESTAMP_KEY by lazy { stringPreferencesKey(name = "walletCreationTimestamp") } diff --git a/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/DefaultHotWalletRepository.kt b/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/DefaultHotWalletRepository.kt index 686f94579d..dbbe66db29 100644 --- a/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/DefaultHotWalletRepository.kt +++ b/data/hot-wallet/src/main/java/com/tangem/data/hotwallet/DefaultHotWalletRepository.kt @@ -10,11 +10,14 @@ import com.tangem.domain.hotwallet.repository.HotWalletRepository import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import java.util.concurrent.ConcurrentHashMap internal class DefaultHotWalletRepository( private val appPreferencesStore: AppPreferencesStore, ) : HotWalletRepository { + private val firstTopUpDetectedThisSession = ConcurrentHashMap() + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) override fun isWalletCreationSupported(): Boolean { return BuildConfig.DEBUG || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q @@ -50,28 +53,12 @@ internal class DefaultHotWalletRepository( } } - override fun shouldShowNextTimeUpgradeBanner(userWalletId: UserWalletId): Flow = appPreferencesStore - .getObjectMap(PreferencesKeys.SHOULD_SHOW_NEXT_TIME_UPGRADE_BANNER_KEY) - .map { it[userWalletId.stringValue] == true } - - override suspend fun setShouldShowNextTimeUpgradeBanner(userWalletId: UserWalletId, shouldShow: Boolean) { - appPreferencesStore.editData { mutablePreferences -> - mutablePreferences.setObjectMap( - key = PreferencesKeys.SHOULD_SHOW_NEXT_TIME_UPGRADE_BANNER_KEY, - value = mutablePreferences.getObjectMap( - PreferencesKeys.SHOULD_SHOW_NEXT_TIME_UPGRADE_BANNER_KEY, - ) - .plus(userWalletId.stringValue to shouldShow), - ) - } - } - override suspend fun getUpgradeBannerClosureTimestamp(userWalletId: UserWalletId): Long? { return appPreferencesStore .getObjectMapSync(PreferencesKeys.UPGRADE_BANNER_CLOSURE_TIMESTAMP_KEY)[userWalletId.stringValue] } - override suspend fun setUpgradeBannerClosureTimestamp(userWalletId: UserWalletId, timestamp: Long) { + override suspend fun setUpgradeBannerClosureTimestamp(userWalletId: UserWalletId, timestamp: Long?) { appPreferencesStore.editData { mutablePreferences -> mutablePreferences.setObjectMap( key = PreferencesKeys.UPGRADE_BANNER_CLOSURE_TIMESTAMP_KEY, @@ -110,4 +97,12 @@ internal class DefaultHotWalletRepository( ) } } + + override fun isFirstTopUpDetectedThisSession(userWalletId: UserWalletId): Boolean { + return firstTopUpDetectedThisSession.containsKey(userWalletId.stringValue) + } + + override fun markFirstTopUpDetectedThisSession(userWalletId: UserWalletId) { + firstTopUpDetectedThisSession[userWalletId.stringValue] = Unit + } } \ No newline at end of file diff --git a/domain/hot-wallet/build.gradle.kts b/domain/hot-wallet/build.gradle.kts index 471afb3452..621b607241 100644 --- a/domain/hot-wallet/build.gradle.kts +++ b/domain/hot-wallet/build.gradle.kts @@ -14,4 +14,10 @@ dependencies { implementation(projects.domain.wallets.models) implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + + testImplementation(deps.test.junit) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) } \ No newline at end of file diff --git a/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/CheckHotWalletUpgradeBannerUseCase.kt b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/CheckHotWalletUpgradeBannerUseCase.kt new file mode 100644 index 0000000000..1038773b7c --- /dev/null +++ b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/CheckHotWalletUpgradeBannerUseCase.kt @@ -0,0 +1,61 @@ +package com.tangem.domain.hotwallet + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.models.wallet.UserWalletId +import java.util.concurrent.TimeUnit + +class CheckHotWalletUpgradeBannerUseCase( + private val hotWalletRepository: HotWalletRepository, +) { + suspend operator fun invoke( + walletId: UserWalletId, + hasBalance: Boolean, + shouldShowUpgradeBanner: Boolean, + ): Either = try { + val currentTime = System.currentTimeMillis() + val creationTimestamp = hotWalletRepository.getWalletCreationTimestamp(walletId) + + val creationTimestampActual = if (creationTimestamp == null) { + // If creationTimestamp is null (wallet was created before this feature was released), + // store the current timestamp and use it below + hotWalletRepository.setWalletCreationTimestamp(walletId, currentTime) + currentTime + } else { + creationTimestamp + } + + val closureTimestamp = hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) + val hasHadFirstTopUp = hotWalletRepository.hasHadFirstTopUp(walletId) + + val daysSinceCreation = TimeUnit.MILLISECONDS.toDays(currentTime - creationTimestampActual) + val daysSinceClosure = closureTimestamp?.let { TimeUnit.MILLISECONDS.toDays(currentTime - it) } + + // Wallet balance is positive, but the first top-up hasn't been tracked yet + if (hasBalance && !hasHadFirstTopUp) { + hotWalletRepository.setHasHadFirstTopUp(walletId, true) + hotWalletRepository.setShouldShowUpgradeBanner(walletId, true) + hotWalletRepository.setUpgradeBannerClosureTimestamp(walletId, null) + hotWalletRepository.markFirstTopUpDetectedThisSession(walletId) + } + + val shouldShow = when { + // Banner should be shown (e.g., because of the first top-up in the previous session) + shouldShowUpgradeBanner -> !hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) + // Banner was closed; it happened more than BANNER_RESHOW_DAYS (30) days ago + closureTimestamp != null && daysSinceClosure != null && daysSinceClosure >= BANNER_RESHOW_DAYS -> true + // Banner hasn't been closed; wallet was created more than BANNER_RESHOW_DAYS (30) days ago + closureTimestamp == null && daysSinceCreation >= BANNER_RESHOW_DAYS -> true + else -> false + } + shouldShow.right() + } catch (e: Exception) { + e.left() + } + + companion object { + const val BANNER_RESHOW_DAYS = 30L + } +} \ No newline at end of file diff --git a/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/CloseHotWalletUpgradeBannerUseCase.kt b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/CloseHotWalletUpgradeBannerUseCase.kt new file mode 100644 index 0000000000..17591a2bb1 --- /dev/null +++ b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/CloseHotWalletUpgradeBannerUseCase.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.hotwallet + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.models.wallet.UserWalletId + +class CloseHotWalletUpgradeBannerUseCase( + private val hotWalletRepository: HotWalletRepository, +) { + suspend operator fun invoke(walletId: UserWalletId): Either = try { + val currentTime = System.currentTimeMillis() + hotWalletRepository.setShouldShowUpgradeBanner(walletId, false) + hotWalletRepository.setUpgradeBannerClosureTimestamp(walletId, currentTime) + Unit.right() + } catch (e: Exception) { + e.left() + } +} \ No newline at end of file diff --git a/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/ShouldShowUpgradeHotWalletBannerUseCase.kt b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/ShouldShowUpgradeHotWalletBannerUseCase.kt new file mode 100644 index 0000000000..49e17e6c7c --- /dev/null +++ b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/ShouldShowUpgradeHotWalletBannerUseCase.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.hotwallet + +import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +class ShouldShowUpgradeHotWalletBannerUseCase( + private val hotWalletRepository: HotWalletRepository, +) { + operator fun invoke(userWalletId: UserWalletId): Flow = + hotWalletRepository.shouldShowUpgradeBanner(userWalletId) +} \ No newline at end of file diff --git a/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/repository/HotWalletRepository.kt b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/repository/HotWalletRepository.kt index 2432bc3dc9..01229d8d3a 100644 --- a/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/repository/HotWalletRepository.kt +++ b/domain/hot-wallet/src/main/kotlin/com/tangem/domain/hotwallet/repository/HotWalletRepository.kt @@ -17,13 +17,9 @@ interface HotWalletRepository { suspend fun setShouldShowUpgradeBanner(userWalletId: UserWalletId, shouldShow: Boolean) - fun shouldShowNextTimeUpgradeBanner(userWalletId: UserWalletId): Flow - - suspend fun setShouldShowNextTimeUpgradeBanner(userWalletId: UserWalletId, shouldShow: Boolean) - suspend fun getUpgradeBannerClosureTimestamp(userWalletId: UserWalletId): Long? - suspend fun setUpgradeBannerClosureTimestamp(userWalletId: UserWalletId, timestamp: Long) + suspend fun setUpgradeBannerClosureTimestamp(userWalletId: UserWalletId, timestamp: Long?) suspend fun getWalletCreationTimestamp(userWalletId: UserWalletId): Long? @@ -32,4 +28,8 @@ interface HotWalletRepository { suspend fun hasHadFirstTopUp(userWalletId: UserWalletId): Boolean suspend fun setHasHadFirstTopUp(userWalletId: UserWalletId, hasTopUp: Boolean) + + fun isFirstTopUpDetectedThisSession(userWalletId: UserWalletId): Boolean + + fun markFirstTopUpDetectedThisSession(userWalletId: UserWalletId) } \ No newline at end of file diff --git a/domain/hot-wallet/src/test/kotlin/com/tangem/domain/hotwallet/CheckHotWalletUpgradeBannerUseCaseTest.kt b/domain/hot-wallet/src/test/kotlin/com/tangem/domain/hotwallet/CheckHotWalletUpgradeBannerUseCaseTest.kt new file mode 100644 index 0000000000..6e415efea4 --- /dev/null +++ b/domain/hot-wallet/src/test/kotlin/com/tangem/domain/hotwallet/CheckHotWalletUpgradeBannerUseCaseTest.kt @@ -0,0 +1,257 @@ +package com.tangem.domain.hotwallet + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.concurrent.TimeUnit + +class CheckHotWalletUpgradeBannerUseCaseTest { + + private val hotWalletRepository: HotWalletRepository = mockk(relaxed = true) + private val useCase = CheckHotWalletUpgradeBannerUseCase(hotWalletRepository) + + private val walletId = UserWalletId("0123456789ABCDEF") + + @Test + fun `GIVEN creation timestamp is null WHEN invoke THEN set timestamp and return false`() = runTest { + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns null + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result = useCase( + walletId = walletId, + hasBalance = false, + shouldShowUpgradeBanner = false, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isFalse() + coVerify { hotWalletRepository.setWalletCreationTimestamp(walletId, any()) } + } + + @Test + fun `GIVEN shouldShowUpgradeBanner is true and hasBalance WHEN invoke THEN return true`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result = useCase( + walletId = walletId, + hasBalance = true, + shouldShowUpgradeBanner = true, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isTrue() + } + + @Test + fun `GIVEN shouldShowUpgradeBanner is true WHEN invoke THEN return true regardless of balance`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result = useCase( + walletId = walletId, + hasBalance = false, + shouldShowUpgradeBanner = true, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isTrue() + } + + @Test + fun `GIVEN closure timestamp exists and 30 days since closure WHEN invoke THEN return true`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(60) + val closureTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns closureTimestamp + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result = useCase( + walletId = walletId, + hasBalance = true, + shouldShowUpgradeBanner = false, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isTrue() + } + + @Test + fun `GIVEN closure timestamp exists but less than 30 days since closure WHEN invoke THEN return false`() = + runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(60) + val closureTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(15) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns closureTimestamp + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result = useCase( + walletId = walletId, + hasBalance = true, + shouldShowUpgradeBanner = false, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isFalse() + } + + @Test + fun `GIVEN no flags set and no closure and 30 days since creation WHEN invoke THEN return true`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result = useCase( + walletId = walletId, + hasBalance = false, + shouldShowUpgradeBanner = false, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isTrue() + } + + @Test + fun `GIVEN no flags set but less than 30 days since creation WHEN invoke THEN return false`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(15) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result = useCase( + walletId = walletId, + hasBalance = false, + shouldShowUpgradeBanner = false, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isFalse() + } + + @Test + fun `GIVEN no flags set but closure timestamp exists WHEN invoke THEN return false`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(60) + val closureTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns closureTimestamp + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result = useCase( + walletId = walletId, + hasBalance = false, + shouldShowUpgradeBanner = false, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isFalse() + } + + @Test + fun `GIVEN first top-up detected WHEN invoke THEN return false and mark session`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result = useCase( + walletId = walletId, + hasBalance = true, + shouldShowUpgradeBanner = false, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isFalse() + coVerify { hotWalletRepository.setHasHadFirstTopUp(walletId, true) } + coVerify { hotWalletRepository.setShouldShowUpgradeBanner(walletId, true) } + coVerify { hotWalletRepository.setUpgradeBannerClosureTimestamp(walletId, null) } + verify { hotWalletRepository.markFirstTopUpDetectedThisSession(walletId) } + } + + @Test + fun `GIVEN first top-up detected this session WHEN invoke THEN return false`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns true + + val result = useCase( + walletId = walletId, + hasBalance = true, + shouldShowUpgradeBanner = true, + ) + + assertThat(result).isInstanceOf(Either.Right::class.java) + assertThat((result as Either.Right).value).isFalse() + } + + @Test + fun `GIVEN already had first top-up WHEN invoke with balance THEN do not set flags again`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + useCase( + walletId = walletId, + hasBalance = true, + shouldShowUpgradeBanner = true, + ) + + coVerify(exactly = 0) { hotWalletRepository.setHasHadFirstTopUp(any(), any()) } + coVerify(exactly = 0) { hotWalletRepository.setShouldShowUpgradeBanner(any(), any()) } + } + + @Test + fun `GIVEN multiple re-emissions with same state WHEN invoke THEN return same result`() = runTest { + val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31) + coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp + coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null + coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false + every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false + + val result1 = useCase( + walletId = walletId, + hasBalance = false, + shouldShowUpgradeBanner = false, + ) + val result2 = useCase( + walletId = walletId, + hasBalance = false, + shouldShowUpgradeBanner = false, + ) + val result3 = useCase( + walletId = walletId, + hasBalance = false, + shouldShowUpgradeBanner = false, + ) + + assertThat((result1 as Either.Right).value).isTrue() + assertThat((result2 as Either.Right).value).isTrue() + assertThat((result3 as Either.Right).value).isTrue() + } +} \ No newline at end of file diff --git a/domain/hot-wallet/src/test/kotlin/com/tangem/domain/hotwallet/CloseHotWalletUpgradeBannerUseCaseTest.kt b/domain/hot-wallet/src/test/kotlin/com/tangem/domain/hotwallet/CloseHotWalletUpgradeBannerUseCaseTest.kt new file mode 100644 index 0000000000..d961d07617 --- /dev/null +++ b/domain/hot-wallet/src/test/kotlin/com/tangem/domain/hotwallet/CloseHotWalletUpgradeBannerUseCaseTest.kt @@ -0,0 +1,39 @@ +package com.tangem.domain.hotwallet + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CloseHotWalletUpgradeBannerUseCaseTest { + + private val hotWalletRepository: HotWalletRepository = mockk(relaxed = true) + private val useCase = CloseHotWalletUpgradeBannerUseCase(hotWalletRepository) + + private val walletId = UserWalletId("0123456789ABCDEF") + + @Test + fun `WHEN invoke THEN set banner flag to false and closure timestamp`() = runTest { + val result = useCase(walletId) + + assertThat(result).isInstanceOf(Either.Right::class.java) + coVerify { hotWalletRepository.setShouldShowUpgradeBanner(walletId, false) } + coVerify { hotWalletRepository.setUpgradeBannerClosureTimestamp(walletId, any()) } + } + + @Test + fun `GIVEN repository throws exception WHEN invoke THEN return Either Left`() = runTest { + val exception = RuntimeException("Test error") + coEvery { hotWalletRepository.setShouldShowUpgradeBanner(walletId, false) } throws exception + + val result = useCase(walletId) + + assertThat(result).isInstanceOf(Either.Left::class.java) + assertThat((result as Either.Left).value).isEqualTo(exception) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index ca5a2d4caa..0cdab04aa3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -17,7 +17,7 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType -import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.hotwallet.CloseHotWalletUpgradeBannerUseCase import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -135,7 +135,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase, private val uiMessageSender: UiMessageSender, private val reviewManager: ReviewManager, - private val hotWalletRepository: HotWalletRepository, + private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase, ) : BaseWalletClickIntents(), WalletWarningsClickIntents { override fun onAddBackupCardClick() { @@ -554,8 +554,6 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( modelScope.launch(dispatchers.main) { val userWallet = getUserWalletUseCase(userWalletId).getOrNull() if (userWallet is UserWallet.Hot) { - hotWalletRepository.setShouldShowUpgradeBanner(userWalletId, false) - hotWalletRepository.setShouldShowNextTimeUpgradeBanner(userWalletId, false) appRouter.push(UpgradeWallet(userWalletId)) } } @@ -565,15 +563,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( modelScope.launch(dispatchers.main) { val userWallet = getUserWalletUseCase(userWalletId).getOrNull() if (userWallet is UserWallet.Hot) { - val hasHadFirstTopUp = hotWalletRepository.hasHadFirstTopUp(userWalletId) - val currentTime = System.currentTimeMillis() - - if (hasHadFirstTopUp) { - hotWalletRepository.setShouldShowUpgradeBanner(userWalletId, false) - hotWalletRepository.setUpgradeBannerClosureTimestamp(userWalletId, currentTime) - } else { - hotWalletRepository.setShouldShowUpgradeBanner(userWalletId, true) - } + closeHotWalletUpgradeBannerUseCase(userWalletId) } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 6f8e6b1be6..6218c6257a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -13,8 +13,9 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.hotwallet.CheckHotWalletUpgradeBannerUseCase import com.tangem.domain.hotwallet.GetAccessCodeSkippedUseCase -import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.hotwallet.ShouldShowUpgradeHotWalletBannerUseCase import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.currency.CryptoCurrency @@ -44,7 +45,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import java.util.concurrent.TimeUnit + import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -60,7 +61,8 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( private val notificationsRepository: NotificationsRepository, private val accountDependencies: AccountDependencies, private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase, - private val hotWalletRepository: HotWalletRepository, + private val shouldShowUpgradeHotWalletBannerUseCase: ShouldShowUpgradeHotWalletBannerUseCase, + private val checkHotWalletUpgradeBannerUseCase: CheckHotWalletUpgradeBannerUseCase, ) { @Suppress("UNCHECKED_CAST", "MagicNumber", "LongMethod") @@ -101,8 +103,8 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( getAccessCodeSkippedUseCase(userWallet.walletId).distinctUntilChanged(), shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.YieldPromo) .distinctUntilChanged(), - hotWalletRepository.shouldShowUpgradeBanner(userWallet.walletId).distinctUntilChanged(), - hotWalletRepository.shouldShowNextTimeUpgradeBanner(userWallet.walletId).distinctUntilChanged(), + shouldShowUpgradeHotWalletBannerUseCase.invoke(userWallet.walletId) + .distinctUntilChanged(), ) { array -> array } .combine(tokenListFlow()) { array, any: Any -> arrayOf(any).plus(elements = array) } .map { array -> @@ -117,7 +119,6 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( val shouldAccessCodeSkipped = array[6] as Boolean val shouldShowYieldPromo = array[7] as Boolean val shouldShowUpgradeBanner = array[8] as Boolean - val shouldShowNextTimeUpgradeBanner = array[9] as Boolean buildList { addUsedOutdatedDataNotification(totalFiatBalance) @@ -129,7 +130,6 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( flattenCurrencies = flattenCurrencies, clickIntents = clickIntents, shouldShowUpgradeBanner = shouldShowUpgradeBanner, - shouldShowNextTimeUpgradeBanner = shouldShowNextTimeUpgradeBanner, ) addFinishWalletActivationNotification( @@ -464,45 +464,22 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( ) } - @Suppress("CyclomaticComplexMethod") private suspend fun MutableList.addUpgradeHotWalletPromoNotification( userWallet: UserWallet, flattenCurrencies: Lce>, clickIntents: WalletClickIntents, shouldShowUpgradeBanner: Boolean, - shouldShowNextTimeUpgradeBanner: Boolean, ) { if (userWallet !is UserWallet.Hot) return - val currentTime = System.currentTimeMillis() - val creationTimestamp = hotWalletRepository.getWalletCreationTimestamp(userWallet.walletId) - val closureTimestamp = hotWalletRepository.getUpgradeBannerClosureTimestamp(userWallet.walletId) - val hasHadFirstTopUp = hotWalletRepository.hasHadFirstTopUp(userWallet.walletId) - - if (creationTimestamp == null) { - hotWalletRepository.setWalletCreationTimestamp(userWallet.walletId, currentTime) - return - } - - val daysSinceCreation = TimeUnit.MILLISECONDS.toDays(currentTime - creationTimestamp) - val daysSinceClosure = closureTimestamp?.let { TimeUnit.MILLISECONDS.toDays(currentTime - it) } - val currencies = flattenCurrencies.getOrNull(isPartialContentAccepted = true).orEmpty() val hasBalance = currencies.any { it.value.amount.orZero().isPositive() } - if (hasBalance && !hasHadFirstTopUp) { - hotWalletRepository.setHasHadFirstTopUp(userWallet.walletId, true) - } - - val shouldShow = when { - shouldShowUpgradeBanner && hasBalance -> true - shouldShowNextTimeUpgradeBanner && daysSinceClosure != null && daysSinceClosure >= UPGRADE_BANNER_RESHOW_DAYS -> true - !shouldShowUpgradeBanner && !shouldShowNextTimeUpgradeBanner && hasHadFirstTopUp && daysSinceCreation >= UPGRADE_BANNER_RESHOW_DAYS -> { - hotWalletRepository.setShouldShowNextTimeUpgradeBanner(userWallet.walletId, true) - true - } - else -> false - } + val shouldShow = checkHotWalletUpgradeBannerUseCase( + walletId = userWallet.walletId, + hasBalance = hasBalance, + shouldShowUpgradeBanner = shouldShowUpgradeBanner, + ).getOrNull() ?: return addIf( element = WalletNotification.UpgradeHotWalletPromo( @@ -515,6 +492,5 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( private companion object { const val MAX_REMAINING_SIGNATURES_COUNT = 10 - const val UPGRADE_BANNER_RESHOW_DAYS = 30 } } \ No newline at end of file