Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-09 14:33:14 +03:00
commit 8eb1a28f29
236 changed files with 3852 additions and 2068 deletions

View file

@ -0,0 +1,20 @@
package com.tangem.common.utils
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
fun getClipboardText(context: Context): String? {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
return if (clipboard.hasPrimaryClip()) {
clipboard.primaryClip?.getItemAt(0)?.text?.toString()
} else {
null
}
}
fun setClipboardText(context: Context, text: String?) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("label", text)
clipboard.setPrimaryClip(clip)
}

View file

@ -85,4 +85,13 @@ fun BaseTestCase.openDeviceSettingsScreen() {
step("Click on 'Device settings' button") {
onWalletSettingsScreen { deviceSettingsButton.clickWithAssertion() }
}
}
fun BaseTestCase.openWalletConnectScreen() {
step("Click 'More' button on TopBar") {
onTopBar { moreButton.clickWithAssertion() }
}
step("Click on 'Wallet Connect' button") {
onDetailsScreen { walletConnectButton.clickWithAssertion() }
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.screens.onWalletConnectScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.checkWalletConnectBottomSheet() {
waitForIdle()
step("Assert 'Wallet Connect' bottom sheet title is displayed") {
onWalletConnectBottomSheet { title.assertIsDisplayed() }
}
@ -60,34 +61,64 @@ fun BaseTestCase.checkWalletConnectBottomSheet() {
}
}
fun BaseTestCase.checkWalletConnectScreen() {
fun BaseTestCase.checkWalletConnectScreen(withConnections: Boolean) {
waitForIdle()
step("Assert 'Wallet Connect' title is displayed") {
onWalletConnectScreen { title.assertIsDisplayed() }
}
step("Assert 'More' button is displayed") {
onWalletConnectScreen { moreButton.assertIsDisplayed() }
}
step("Assert wallet name is displayed") {
onWalletConnectScreen { walletName.assertIsDisplayed() }
}
step("Assert app icon is displayed") {
onWalletConnectScreen { appIcon.assertIsDisplayed() }
}
step("Assert app name is displayed") {
onWalletConnectScreen { appName.assertIsDisplayed() }
}
step("Assert approve icon is displayed") {
onWalletConnectScreen { approveIcon.assertIsDisplayed() }
}
step("Assert app URL is displayed") {
onWalletConnectScreen { appUrl.assertIsDisplayed() }
}
step("Assert 'New Connection' button is displayed") {
onWalletConnectScreen { newConnectionButton.assertIsDisplayed() }
}
if (withConnections) {
step("Assert 'More' button is displayed") {
onWalletConnectScreen { moreButton.assertIsDisplayed() }
}
step("Assert wallet name is displayed") {
onWalletConnectScreen { walletName.assertIsDisplayed() }
}
step("Assert app icon is displayed") {
onWalletConnectScreen { appIcon.assertIsDisplayed() }
}
step("Assert app name is displayed") {
onWalletConnectScreen { appName.assertIsDisplayed() }
}
step("Assert approve icon is displayed") {
onWalletConnectScreen { approveIcon.assertIsDisplayed() }
}
step("Assert app URL is displayed") {
onWalletConnectScreen { appUrl.assertIsDisplayed() }
}
} else {
step("Assert wallet name is not displayed") {
onWalletConnectScreen { walletName.assertIsNotDisplayed() }
}
step("Assert app icon is not displayed") {
onWalletConnectScreen { appIcon.assertIsNotDisplayed() }
}
step("Assert app name is not displayed") {
onWalletConnectScreen { appName.assertIsNotDisplayed() }
}
step("Assert approve icon is not displayed") {
onWalletConnectScreen { approveIcon.assertIsNotDisplayed() }
}
step("Assert app URL is not displayed") {
onWalletConnectScreen { appUrl.assertIsNotDisplayed() }
}
step("Assert 'Wallet Connect' image is displayed") {
onWalletConnectScreen { walletConnectImage.assertIsDisplayed() }
}
step("Assert 'No session' title is displayed") {
onWalletConnectScreen { noSessionTitle.assertIsDisplayed() }
}
step("Assert 'No session' text is displayed") {
onWalletConnectScreen { noSessionText.assertIsDisplayed() }
}
}
}
fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) {
waitForIdle()
step("Assert connection details title is displayed") {
onWalletConnectDetailsBottomSheet { title.assertIsDisplayed() }
}
@ -128,7 +159,7 @@ fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) {
onWalletConnectDetailsBottomSheet { connectedNetworkIcon.assertIsDisplayed() }
}
step("Assert connected dApp name: '$dAppName'") {
onWalletConnectDetailsBottomSheet { connectedNetworkName.assertTextContains(dAppName) }
onWalletConnectDetailsBottomSheet { appName.assertTextContains(dAppName) }
}
step("Assert connected network symbol is displayed") {
onWalletConnectDetailsBottomSheet { connectedNetworkSymbol.assertIsDisplayed() }

View file

@ -9,13 +9,11 @@ import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import com.tangem.features.walletconnect.impl.R as WalletConnectImplR
class WalletConnectBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<WalletConnectBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasText(getResourceString(WalletConnectImplR.string.wc_wallet_connect))
hasTestTag(WalletConnectBottomSheetTestTags.TITLE)
useUnmergedTree = true
}

View file

@ -53,6 +53,21 @@ class WalletConnectPageObject(semanticsProvider: SemanticsNodeInteractionsProvid
hasText(getResourceString(R.string.wc_new_connection))
useUnmergedTree = true
}
val walletConnectImage: KNode = child {
hasTestTag(WalletConnectScreenTestTags.WALLET_CONNECT_IMAGE)
useUnmergedTree = true
}
val noSessionTitle: KNode = child {
hasText(getResourceString(R.string.wc_no_sessions_title))
useUnmergedTree = true
}
val noSessionText: KNode = child {
hasText(getResourceString(R.string.wc_no_sessions_desc))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onWalletConnectScreen(function: WalletConnectPageObject.() -> Unit) =

View file

@ -0,0 +1,23 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseButtonTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class WalletConnectScanQrPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<WalletConnectScanQrPageObject>(semanticsProvider = semanticsProvider) {
val pasteFromClipboardButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.wallet_connect_paste_from_clipboard))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onWalletConnectScanQrScreen(function: WalletConnectScanQrPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -2,12 +2,13 @@ package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.TOTAL_BALANCE
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.utils.getWcUri
import com.tangem.common.utils.setClipboardText
import com.tangem.scenarios.*
import com.tangem.screens.*
import com.tangem.wallet.BuildConfig
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
@ -21,7 +22,7 @@ class WalletConnectTest : BaseTestCase() {
@DisplayName("WC (React App): open session from deeplink on main screen")
@Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work")
@Test
fun openWalletConnectSessionOnMainScreen() {
fun openWalletConnectSessionOnMainScreenTest() {
val balance = TOTAL_BALANCE
val dAppName = "React App"
val deepLinkUri = getWcUri()
@ -37,19 +38,28 @@ class WalletConnectTest : BaseTestCase() {
openAppByDeepLink(deepLinkUri)
}
step("Check 'Wallet Connect' bottom sheet") {
checkWalletConnectBottomSheet()
flakySafely(WAIT_UNTIL_TIMEOUT) {
checkWalletConnectBottomSheet()
}
}
step("Assert 'Connect' button is enabled") {
onWalletConnectBottomSheet { connectButton.assertIsEnabled() }
}
step("Click on 'Connect' button") {
waitForIdle()
onWalletConnectBottomSheet { connectButton.performClick() }
}
step("Click 'More' button on TopBar") {
onTopBar { moreButton.clickWithAssertion() }
step("Assert 'Connect' button is not displayed") {
waitForIdle()
onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() }
}
step("Click on 'Wallet Connect' button") {
onDetailsScreen { walletConnectButton.clickWithAssertion() }
step("Open 'Wallet Connect' screen") {
openWalletConnectScreen()
}
step("Check 'Wallet Connect' screen") {
checkWalletConnectScreen()
step("Check 'Wallet Connect' screen with connections") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
checkWalletConnectScreen(withConnections = true)
}
}
step("Click on app icon") {
onWalletConnectScreen { appIcon.performClick() }
@ -57,11 +67,11 @@ class WalletConnectTest : BaseTestCase() {
step("Check 'Wallet Connect' details bottom sheet") {
checkWalletConnectDetailsBottomSheet(dAppName)
}
step("Click on 'Disconnect button' is displayed") {
step("Click on 'Disconnect' button") {
onWalletConnectDetailsBottomSheet { disconnectButton.performClick() }
}
step("Assert connection is not displayed") {
onWalletConnectScreen { appName.assertIsNotDisplayed() }
step("Check 'Wallet Connect' screen without connections") {
checkWalletConnectScreen(withConnections = false)
}
}
}
@ -70,7 +80,7 @@ class WalletConnectTest : BaseTestCase() {
@DisplayName("WC (React App): open session from deeplink not on main screen")
@Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work")
@Test
fun openWalletConnectSessionNotOnMainScreen() {
fun openWalletConnectSessionNotOnMainScreenTest() {
val balance = TOTAL_BALANCE
val dAppName = "React App"
val deepLinkUri = getWcUri()
@ -82,41 +92,44 @@ class WalletConnectTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses(balance)
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Open 'Wallet Connect' screen") {
openWalletConnectScreen()
checkWalletConnectScreen(false)
}
step("Create WC session buy deeplink") {
openAppByDeepLink(deepLinkUri)
}
step("Check 'Wallet Connect' bottom sheet") {
checkWalletConnectBottomSheet()
flakySafely(WAIT_UNTIL_TIMEOUT) {
checkWalletConnectBottomSheet()
}
}
step("Click on 'Connect' button") {
waitForIdle()
onWalletConnectBottomSheet { connectButton.performClick() }
}
step("Click 'More' button on TopBar") {
onTopBar { moreButton.clickWithAssertion() }
step("Assert 'Connect' button is not displayed") {
waitForIdle()
onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() }
}
step("Click on 'Wallet Connect' button") {
onDetailsScreen { walletConnectButton.clickWithAssertion() }
}
step("Assert 'Wallet Connect' bottom sheet is displayed") {
onWalletConnectBottomSheet { connectButton.clickWithAssertion() }
}
step("Check 'Wallet Connect' screen") {
checkWalletConnectScreen()
step("Check 'Wallet Connect' screen with connections") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
checkWalletConnectScreen(withConnections = true)
}
}
step("Click on app icon") {
onWalletConnectScreen { appIcon.performClick() }
}
step("Check 'Wallet Connect' details bottom sheet") {
checkWalletConnectDetailsBottomSheet(dAppName)
flakySafely(WAIT_UNTIL_TIMEOUT) {
checkWalletConnectDetailsBottomSheet(dAppName)
}
}
step("Click on 'Disconnect button' is displayed") {
step("Click on 'Disconnect' button") {
onWalletConnectDetailsBottomSheet { disconnectButton.performClick() }
}
step("Assert connection is not displayed") {
onWalletConnectScreen { appName.assertIsNotDisplayed() }
step("Check 'Wallet Connect' screen without connections") {
checkWalletConnectScreen(withConnections = false)
}
}
}
@ -125,9 +138,10 @@ class WalletConnectTest : BaseTestCase() {
@DisplayName("WC (React App): open session from deeplink ")
@Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work")
@Test
fun openWalletConnectSession() {
fun openWalletConnectSessionTest() {
val balance = TOTAL_BALANCE
val dAppName = "React App"
val packageName = BuildConfig.APPLICATION_ID
val deepLinkUri = getWcUri()
setupHooks().run {
@ -137,32 +151,28 @@ class WalletConnectTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses(balance)
}
step("Open recent apps") {
device.uiDevice.pressRecentApps()
}
step("Stop app by swipe") {
swipeVertical(SwipeDirection.UP, startHeightRatio = 0.8f)
step("Kill app") {
device.apps.kill(packageName)
}
step("Create WC session buy deeplink") {
openAppByDeepLink(deepLinkUri)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Check 'Wallet Connect' bottom sheet") {
checkWalletConnectBottomSheet()
flakySafely(WAIT_UNTIL_TIMEOUT) {
checkWalletConnectBottomSheet()
}
}
step("Click on 'Connect' button") {
onWalletConnectBottomSheet { connectButton.performClick() }
}
step("Click 'More' button on TopBar") {
onTopBar { moreButton.clickWithAssertion() }
step("Assert 'Connect' button is not displayed") {
onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() }
}
step("Click on 'Wallet Connect' button") {
onDetailsScreen { walletConnectButton.clickWithAssertion() }
step("Open 'Wallet Connect' screen") {
openWalletConnectScreen()
}
step("Check 'Wallet Connect' screen") {
checkWalletConnectScreen()
step("Check 'Wallet Connect' screen with connections") {
checkWalletConnectScreen(withConnections = true)
}
step("Click on app icon") {
onWalletConnectScreen { appIcon.performClick() }
@ -170,11 +180,72 @@ class WalletConnectTest : BaseTestCase() {
step("Check 'Wallet Connect' details bottom sheet") {
checkWalletConnectDetailsBottomSheet(dAppName)
}
step("Click on 'Disconnect button' is displayed") {
step("Click on 'Disconnect' button") {
onWalletConnectDetailsBottomSheet { disconnectButton.performClick() }
}
step("Assert connection is not displayed") {
onWalletConnectScreen { appName.assertIsNotDisplayed() }
step("Check 'Wallet Connect' screen without connections") {
checkWalletConnectScreen(withConnections = false)
}
}
}
@AllureId("887")
@DisplayName("WC: open session by 'Paste from clipboard' button")
@Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work")
@Test
fun openWalletConnectSessionByClipboardLinkTest() {
val balance = TOTAL_BALANCE
val dAppName = "React App"
val context = device.context
val deepLinkUri = getWcUri()
setupHooks().run {
step("Set URI to clipboard") {
setClipboardText(context, deepLinkUri)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses(balance)
}
step("Open 'Wallet Connect' screen") {
openWalletConnectScreen()
}
step("Click 'New connection' button") {
onWalletConnectScreen { newConnectionButton.performClick() }
}
step("CLick 'Paste from clipboard' button") {
onWalletConnectScanQrScreen { pasteFromClipboardButton.clickWithAssertion() }
}
step("Check 'Wallet Connect' bottom sheet") {
waitForIdle()
flakySafely(WAIT_UNTIL_TIMEOUT) {
checkWalletConnectBottomSheet()
}
}
step("Click on 'Connect' button") {
waitForIdle()
onWalletConnectBottomSheet { connectButton.performClick() }
}
step("Assert 'Connect' button is not displayed") {
waitForIdle()
onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() }
}
step("Check 'Wallet Connect' screen with connections") {
checkWalletConnectScreen(withConnections = true)
}
step("Click on app icon") {
onWalletConnectScreen { appIcon.performClick() }
}
step("Check 'Wallet Connect' details bottom sheet") {
checkWalletConnectDetailsBottomSheet(dAppName)
}
step("Click on 'Disconnect' button") {
onWalletConnectDetailsBottomSheet { disconnectButton.performClick() }
}
step("Check 'Wallet Connect' screen without connections") {
checkWalletConnectScreen(withConnections = false)
}
}
}

View file

@ -67,7 +67,11 @@ internal class DefaultTangemPayStorage @Inject constructor(
secureStorage.get(createOrderIdKey(customerWalletAddress))?.decodeToString(throwOnInvalidSequence = true)
}
override suspend fun clear(customerWalletAddress: String) = withContext(dispatcherProvider.io) {
override suspend fun clearOrderId(customerWalletAddress: String) = withContext(dispatcherProvider.io) {
secureStorage.delete(createOrderIdKey(customerWalletAddress))
}
override suspend fun clearAll(customerWalletAddress: String) = withContext(dispatcherProvider.io) {
secureStorage.delete(createKey(customerWalletAddress))
secureStorage.delete(createOrderIdKey(customerWalletAddress))
}

View file

@ -16,7 +16,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.*
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase
@ -454,21 +454,17 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun provideYieldSupplyApyFlowUseCase(
yieldSupplyMarketRepository: YieldSupplyMarketRepository,
): YieldSupplyApyFlowUseCase {
fun provideYieldSupplyApyFlowUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyApyFlowUseCase {
return YieldSupplyApyFlowUseCase(
yieldSupplyMarketRepository = yieldSupplyMarketRepository,
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyApyUpdateUseCase(
yieldSupplyMarketRepository: YieldSupplyMarketRepository,
): YieldSupplyApyUpdateUseCase {
fun provideYieldSupplyApyUpdateUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyApyUpdateUseCase {
return YieldSupplyApyUpdateUseCase(
yieldSupplyMarketRepository = yieldSupplyMarketRepository,
yieldSupplyRepository = yieldSupplyRepository,
)
}
}

View file

@ -4,7 +4,7 @@ import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.error.FeeErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
import com.tangem.domain.yield.supply.usecase.*
import dagger.Module
@ -82,30 +82,54 @@ internal object YieldSupplyDomainModule {
@Provides
@Singleton
fun provideYieldSupplyGetTokenStatusUseCase(
yieldSupplyMarketRepository: YieldSupplyMarketRepository,
yieldSupplyRepository: YieldSupplyRepository,
): YieldSupplyGetTokenStatusUseCase {
return YieldSupplyGetTokenStatusUseCase(
yieldSupplyMarketRepository = yieldSupplyMarketRepository,
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetApyUseCase(
yieldSupplyMarketRepository: YieldSupplyMarketRepository,
): YieldSupplyGetApyUseCase {
fun provideYieldSupplyGetApyUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyGetApyUseCase {
return YieldSupplyGetApyUseCase(
yieldSupplyMarketRepository = yieldSupplyMarketRepository,
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetChartUseCase(
yieldSupplyMarketRepository: YieldSupplyMarketRepository,
): YieldSupplyGetChartUseCase {
fun provideYieldSupplyGetChartUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyGetChartUseCase {
return YieldSupplyGetChartUseCase(
yieldSupplyMarketRepository = yieldSupplyMarketRepository,
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyIsAvailableUseCase(
yieldSupplyRepository: YieldSupplyRepository,
): YieldSupplyIsAvailableUseCase {
return YieldSupplyIsAvailableUseCase(
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyActivateUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyActivateUseCase {
return YieldSupplyActivateUseCase(
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyDeactivateUseCase(
yieldSupplyRepository: YieldSupplyRepository,
): YieldSupplyDeactivateUseCase {
return YieldSupplyDeactivateUseCase(
yieldSupplyRepository = yieldSupplyRepository,
)
}
}

View file

@ -160,4 +160,5 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency?
Pepecoin, PepecoinTestnet -> null
Hyperliquid, HyperliquidTestnet -> null
Quai, QuaiTestnet -> null
Linea, LineaTestnet -> null
}

View file

@ -2,6 +2,7 @@ package com.tangem.tap.routing.utils
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.models.PortfolioId
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.QrScanningComponent
import com.tangem.feature.referral.api.ReferralComponent
@ -19,6 +20,7 @@ import com.tangem.features.hotwallet.*
import com.tangem.features.kyc.KycComponent
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.tokenlist.MarketsTokenListComponent
@ -140,9 +142,15 @@ internal class ChildFactory @Inject constructor(
AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES
}
val mode = when (val portfolio = route.portfolioId) {
is PortfolioId.Account -> ManageTokensMode.Account(portfolio.accountId)
is PortfolioId.Wallet -> ManageTokensMode.Wallet(portfolio.userWalletId)
null -> ManageTokensMode.None
}
createComponentChild(
context = context,
params = ManageTokensComponent.Params(route.userWalletId, source),
params = ManageTokensComponent.Params(mode, source),
componentFactory = manageTokensComponentFactory,
)
}
@ -200,7 +208,7 @@ internal class ChildFactory @Inject constructor(
createComponentChild(
context = context,
params = OnrampComponent.Params(
userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param,
userWalletId = route.userWalletId,
cryptoCurrency = route.currency,
source = route.source,
shouldLaunchSepa = route.shouldLaunchSepa,
@ -274,7 +282,7 @@ internal class ChildFactory @Inject constructor(
createComponentChild(
context = context,
params = TokenDetailsComponent.Params(
userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param
userWalletId = route.userWalletId,
currency = route.currency,
),
componentFactory = tokenDetailsComponentFactory,
@ -284,7 +292,7 @@ internal class ChildFactory @Inject constructor(
createComponentChild(
context = context,
params = StakingComponent.Params(
userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param,
userWalletId = route.userWalletId,
cryptoCurrencyId = route.cryptoCurrencyId,
yieldId = route.yieldId,
),
@ -297,7 +305,7 @@ internal class ChildFactory @Inject constructor(
params = SwapComponent.Params(
currencyFrom = route.currencyFrom,
currencyTo = route.currencyTo,
userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param,
userWalletId = route.userWalletId,
isInitialReverseOrder = route.isInitialReverseOrder,
screenSource = route.screenSource,
),
@ -308,7 +316,7 @@ internal class ChildFactory @Inject constructor(
createComponentChild(
context = context,
params = SendComponent.Params(
userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param,
userWalletId = route.userWalletId,
currency = route.currency,
transactionId = route.transactionId,
amount = route.amount,

View file

@ -11,7 +11,6 @@ import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.PortfolioId
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.scan.ScanResponse
@ -51,50 +50,25 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class CurrencyDetails(
val portfolioId: PortfolioId,
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
) : AppRoute(path = "/currency_details/${portfolioId.stringValue}/${currency.id.value}") {
companion object {
operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) = CurrencyDetails(
portfolioId = PortfolioId(userWalletId),
currency = currency,
)
}
}
) : AppRoute(path = "/currency_details/${userWalletId.stringValue}/${currency.id.value}")
@Serializable
data class Send(
val portfolioId: PortfolioId,
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val transactionId: String? = null,
val amount: String? = null,
val tag: String? = null,
val destinationAddress: String? = null,
) : AppRoute(
path = "/send/${portfolioId.stringValue}/${currency.id.value}?" +
path = "/send/${userWalletId.stringValue}/${currency.id.value}?" +
"&$transactionId" +
"&$amount" +
"&$tag" +
"&$destinationAddress",
) {
companion object {
operator fun invoke(
userWalletId: UserWalletId,
currency: CryptoCurrency,
transactionId: String? = null,
amount: String? = null,
tag: String? = null,
destinationAddress: String? = null,
) = Send(
portfolioId = PortfolioId(userWalletId),
currency = currency,
transactionId = transactionId,
amount = amount,
tag = tag,
destinationAddress = destinationAddress,
)
}
}
)
@Serializable
data class Details(
@ -147,8 +121,8 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class ManageTokens(
val source: Source,
val userWalletId: UserWalletId? = null,
) : AppRoute(path = "${source.name.lowercase()}/manage_tokens/$userWalletId") {
val portfolioId: PortfolioId? = null,
) : AppRoute(path = "${source.name.lowercase()}/manage_tokens/${portfolioId?.stringValue}") {
enum class Source {
STORIES,
@ -199,51 +173,26 @@ sealed class AppRoute(val path: String) : Route {
data class Swap(
val currencyFrom: CryptoCurrency,
val currencyTo: CryptoCurrency? = null,
val portfolioId: PortfolioId,
val userWalletId: UserWalletId,
val isInitialReverseOrder: Boolean = false,
val screenSource: String,
) : AppRoute(
path = "/swap" +
"/${currencyFrom.id.value}" +
"/${currencyTo?.id?.value}" +
"/${portfolioId.stringValue}" +
"/${userWalletId.stringValue}" +
"/$isInitialReverseOrder",
) {
companion object {
operator fun invoke(
userWalletId: UserWalletId,
currencyFrom: CryptoCurrency,
currencyTo: CryptoCurrency? = null,
isInitialReverseOrder: Boolean = false,
screenSource: String,
) = Swap(
portfolioId = PortfolioId(userWalletId),
currencyFrom = currencyFrom,
currencyTo = currencyTo,
isInitialReverseOrder = isInitialReverseOrder,
screenSource = screenSource,
)
}
}
)
@Serializable
data object AppCurrencySelector : AppRoute(path = "/app_currency_selector")
@Serializable
data class Staking(
val portfolioId: PortfolioId,
val userWalletId: UserWalletId,
val cryptoCurrencyId: CryptoCurrency.ID,
val yieldId: String,
) : AppRoute(path = "/staking/${portfolioId.stringValue}/${cryptoCurrencyId.value}/$yieldId") {
companion object {
operator fun invoke(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, yieldId: String) =
Staking(
portfolioId = PortfolioId(userWalletId),
cryptoCurrencyId = cryptoCurrencyId,
yieldId = yieldId,
)
}
}
) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrencyId.value}/$yieldId")
@Serializable
data class PushNotification(
@ -287,25 +236,11 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class Onramp(
val source: OnrampSource,
val portfolioId: PortfolioId,
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val shouldLaunchSepa: Boolean = false,
) : AppRoute(path = "/onramp/${portfolioId.stringValue}/${currency.symbol}"), RouteBundleParams {
) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
operator fun invoke(
source: OnrampSource,
userWalletId: UserWalletId,
currency: CryptoCurrency,
launchSepa: Boolean = false,
) = Onramp(
source = source,
portfolioId = PortfolioId(userWalletId),
currency = currency,
shouldLaunchSepa = launchSepa,
)
}
}
@Serializable

View file

@ -0,0 +1,53 @@
package com.tangem.common.ui.account
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
/**
* Displays account name with icon
*
* @param name account name
* @param icon portfolio account icon model
* @param iconSize portfolio account icon size
* @param nameStyle account name style
* @param nameColor account name color
* @see AccountIcon
*/
@Composable
fun AccountLabel(
name: TextReference,
icon: CryptoPortfolioIconUM,
iconSize: AccountIconSize,
modifier: Modifier = Modifier,
nameStyle: TextStyle = TangemTheme.typography.subtitle2,
nameColor: Color = TangemTheme.colors.text.tertiary,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AccountIcon(
name = name,
icon = icon,
size = iconSize,
)
Text(
text = name.resolveReference(),
style = nameStyle,
color = nameColor,
maxLines = 1,
)
}
}

View file

@ -13,21 +13,17 @@ import androidx.compose.ui.unit.dp
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountScreenClickIntentsStub
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.common.ui.amountScreen.ui.amountField
import com.tangem.common.ui.amountScreen.ui.amountFieldV2
import com.tangem.common.ui.amountScreen.ui.buttons
import com.tangem.core.ui.res.TangemThemePreview
/**
* Amount screen with field
* @param amountState amount state
* @param isBalanceHidden flag hidden balances
* @param clickIntents amount screen clicks
*/
@Composable
fun AmountScreenContent(
amountState: AmountState,
isBalanceHidden: Boolean,
clickIntents: AmountScreenClickIntents,
modifier: Modifier = Modifier,
extraContent: (@Composable () -> Unit)? = null,
@ -38,32 +34,17 @@ fun AmountScreenContent(
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (amountState.isRedesignEnabled) {
amountFieldV2(
amountState = amountState,
onValueChange = clickIntents::onAmountValueChange,
onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss,
onCurrencyChange = clickIntents::onCurrencyChangeClick,
onMaxAmountClick = clickIntents::onMaxValueClick,
)
if (extraContent != null) {
item("EXTRA_CONTENT_KEY") {
extraContent()
}
amountFieldV2(
amountState = amountState,
onValueChange = clickIntents::onAmountValueChange,
onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss,
onCurrencyChange = clickIntents::onCurrencyChangeClick,
onMaxAmountClick = clickIntents::onMaxValueClick,
)
if (extraContent != null) {
item("EXTRA_CONTENT_KEY") {
extraContent()
}
} else if (amountState is AmountState.Data) {
amountField(
amountState = amountState,
isBalanceHidden = isBalanceHidden,
onValueChange = clickIntents::onAmountValueChange,
onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss,
)
buttons(
segmentedButtonConfig = amountState.segmentedButtonConfig,
clickIntents = clickIntents,
isSegmentedButtonsEnabled = amountState.isSegmentedButtonsEnabled,
selectedButton = amountState.selectedButton,
)
}
}
}
@ -78,7 +59,6 @@ private fun SendAmountContentPreview(
TangemThemePreview {
AmountScreenContent(
amountState = amountState,
isBalanceHidden = false,
clickIntents = AmountScreenClickIntentsStub,
)
}

View file

@ -38,7 +38,6 @@ class AmountCurrencyTransformer(
keyboardType = KeyboardType.Number,
),
),
selectedButton = prevState.segmentedButtonConfig.indexOfFirst { it.isFiat == value },
)
}
}

View file

@ -70,10 +70,9 @@ class AmountReduceByTransformer(
error = when {
isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance)
isLessThanMinimumIfProvided -> {
val minimumAmount = minimumTransactionAmount
?.amount
?.format { crypto(cryptoCurrencyStatus.currency) }
.orEmpty()
val minimumAmount = minimumTransactionAmount.amount.format {
crypto(cryptoCurrencyStatus.currency)
}
resourceReference(
R.string.transfer_notification_invalid_minimum_transaction_amount_text,
wrappedList(minimumAmount, minimumAmount),

View file

@ -64,10 +64,9 @@ class AmountReduceToTransformer(
error = when {
isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance)
isLessThanMinimumIfProvided -> {
val minimumAmount = minimumTransactionAmount
?.amount
?.format { crypto(cryptoCurrencyStatus.currency) }
.orEmpty()
val minimumAmount = minimumTransactionAmount.amount.format {
crypto(cryptoCurrencyStatus.currency)
}
resourceReference(
R.string.transfer_notification_invalid_minimum_transaction_amount_text,
wrappedList(minimumAmount, minimumAmount),

View file

@ -1,104 +1,35 @@
package com.tangem.common.ui.amountScreen.converters
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverterV2
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.DOT
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.persistentListOf
/**
* Converts initial [String] to [AmountState]
*
* @property clickIntents amount screen clicks
* @property appCurrencyProvider selected app currency provider
* @property maxEnterAmount max enter amount data
* @property cryptoCurrencyStatusProvider current cryptocurrency status provider
* @property iconStateConverter currency icon converter
*/
@Deprecated("Use AmountStateConverterV2")
class AmountStateConverter(
private val clickIntents: AmountScreenClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val maxEnterAmount: EnterAmountBoundary,
private val iconStateConverter: CryptoCurrencyToIconStateConverter,
) : Converter<AmountParameters, AmountState> {
private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) {
AmountFieldConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
appCurrencyProvider = appCurrencyProvider,
)
}
override fun convert(value: AmountParameters): AmountState {
val appCurrency = appCurrencyProvider()
val status = cryptoCurrencyStatusProvider()
val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) }
val crypto = maxEnterAmount.amount.format { crypto(status.currency) }
val hasNoFeeRate = status.value.fiatRate.isNullOrZero()
return AmountState.Data(
title = value.title,
availableBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)),
availableBalanceCrypto = stringReference(crypto),
availableBalanceFiat = stringReference(fiat),
tokenName = stringReference(status.currency.name),
tokenIconState = iconStateConverter.convert(status),
amountTextField = amountFieldConverter.convert(value.value),
isPrimaryButtonEnabled = false,
appCurrency = appCurrency,
segmentedButtonConfig = persistentListOf(
AmountSegmentedButtonsConfig(
title = stringReference(status.currency.symbol),
iconState = iconStateConverter.convertCustom(
value = status,
forceGrayscale = hasNoFeeRate,
showCustomTokenBadge = false,
),
isFiat = false,
),
AmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconUrl = appCurrency.iconSmallUrl,
isFiat = true,
),
),
isSegmentedButtonsEnabled = !hasNoFeeRate,
selectedButton = 0,
isRedesignEnabled = false,
)
}
}
/**
* Converts initial [String] to [AmountState]
*
* @property clickIntents amount screen clicks
* @property appCurrency selected app currency
* @property maxEnterAmount max enter amount data
* @property cryptoCurrencyStatus current cryptocurrency status
* @property maxEnterAmount max enter amount data
* @property iconStateConverter currency icon converter
* @property isBalanceHidden is balance hidden status
*/
@Suppress("LongParameterList")
class AmountStateConverterV2(
class AmountStateConverter(
private val clickIntents: AmountScreenClickIntents,
private val appCurrency: AppCurrency,
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
@ -108,7 +39,7 @@ class AmountStateConverterV2(
) : Converter<AmountParameters, AmountState> {
private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) {
AmountFieldConverterV2(
AmountFieldConverter(
clickIntents = clickIntents,
cryptoCurrencyStatus = cryptoCurrencyStatus,
appCurrency = appCurrency,
@ -118,19 +49,13 @@ class AmountStateConverterV2(
override fun convert(value: AmountParameters): AmountState {
val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) }
val crypto = maxEnterAmount.amount.format { crypto(cryptoCurrencyStatus.currency) }
val noFeeRate = cryptoCurrencyStatus.value.fiatRate.isNullOrZero()
if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) {
return AmountState.Empty(isRedesignEnabled = true)
return AmountState.Empty
}
return AmountState.Data(
title = value.title,
availableBalance = combinedReference(
stringReference(crypto),
stringReference(" $DOT "),
stringReference(fiat),
).orMaskWithStars(isBalanceHidden),
availableBalanceCrypto = stringReference(crypto).orMaskWithStars(isBalanceHidden),
availableBalanceFiat = if (isBalanceHidden) {
TextReference.EMPTY
@ -145,25 +70,6 @@ class AmountStateConverterV2(
amountTextField = amountFieldConverter.convert(value.value),
isPrimaryButtonEnabled = false,
appCurrency = appCurrency,
segmentedButtonConfig = persistentListOf(
AmountSegmentedButtonsConfig(
title = stringReference(cryptoCurrencyStatus.currency.symbol),
iconState = iconStateConverter.convertCustom(
value = cryptoCurrencyStatus,
forceGrayscale = noFeeRate,
showCustomTokenBadge = false,
),
isFiat = false,
),
AmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconUrl = appCurrency.iconSmallUrl,
isFiat = true,
),
),
isSegmentedButtonsEnabled = !noFeeRate,
selectedButton = 0,
isRedesignEnabled = true,
)
}
}

View file

@ -2,7 +2,10 @@ package com.tangem.common.ui.amountScreen.converters.field
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
@ -32,14 +35,7 @@ class AmountBoundaryUpdateTransformer(
val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) }
val crypto = maxEnterAmount.amount.format { crypto(cryptoCurrencyStatus.currency) }
val availableBalance = combinedReference(
stringReference(crypto),
stringReference(" $DOT "),
stringReference(fiat),
)
return prevState.copy(
availableBalance = availableBalance.orMaskWithStars(isBalanceHidden),
availableBalanceCrypto = stringReference(crypto).orMaskWithStars(isBalanceHidden),
availableBalanceFiat = if (isBalanceHidden) {
TextReference.EMPTY

View file

@ -13,75 +13,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.tokens.model.convertToAmount
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import java.math.BigDecimal
/**
* Converts initial [String] to [AmountFieldModel]
*
* @property clickIntents amount screen clicks
* @property appCurrencyProvider selected app currency provider
* @property cryptoCurrencyStatusProvider current cryptocurrency status provider
*/
@Deprecated("Use AmountFieldConverterV2")
class AmountFieldConverter(
private val clickIntents: AmountScreenClickIntents,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
) : Converter<String, AmountFieldModel> {
override fun convert(value: String): AmountFieldModel {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoDecimal = value.toBigDecimalOrNull() ?: BigDecimal.ZERO
val cryptoAmount = cryptoDecimal.convertToAmount(cryptoCurrencyStatus.currency)
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val (fiatValue, fiatDecimal) = when {
fiatRate.isNullOrZero() -> "" to null
value.isEmpty() -> "" to BigDecimal.ZERO
else -> {
val fiatDecimal = fiatRate?.multiply(cryptoDecimal)
val fiatValue = fiatDecimal?.parseBigDecimal(FIAT_DECIMALS).orEmpty()
fiatValue to fiatDecimal
}
}
val isDoneActionEnabled = !cryptoDecimal.isNullOrZero()
return AmountFieldModel(
value = value,
fiatValue = fiatValue,
onValueChange = clickIntents::onAmountValueChange,
keyboardOptions = KeyboardOptions(
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(
onDone = { clickIntents.onAmountNext() },
),
isFiatValue = false,
cryptoAmount = cryptoAmount,
fiatAmount = getAppCurrencyAmount(fiatDecimal, appCurrencyProvider()),
isError = false,
isWarning = false,
error = TextReference.EMPTY,
isFiatUnavailable = fiatRate == null,
isValuePasted = false,
onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss,
)
}
private fun getAppCurrencyAmount(fiatValue: BigDecimal?, appCurrency: AppCurrency) = Amount(
currencySymbol = appCurrency.symbol,
value = fiatValue,
decimals = FIAT_DECIMALS,
type = AmountType.FiatType(appCurrency.code),
)
private companion object {
private const val FIAT_DECIMALS = 2
}
}
/**
* Converts initial [String] to [AmountFieldModel]
*
@ -89,7 +24,7 @@ class AmountFieldConverter(
* @property appCurrency selected app currency
* @property cryptoCurrencyStatus current cryptocurrency status
*/
class AmountFieldConverterV2(
class AmountFieldConverter(
private val clickIntents: AmountScreenClickIntents,
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,

View file

@ -54,7 +54,7 @@ class AmountFieldSetMaxAmountTransformer(
isError = isLessThanMinimumIfProvided,
error = when {
isLessThanMinimumIfProvided -> {
val minimumAmount = minAmount?.amount.format { crypto(cryptoCurrencyStatus.currency) }
val minimumAmount = minAmount.amount.format { crypto(cryptoCurrencyStatus.currency) }
resourceReference(
R.string.transfer_notification_invalid_minimum_transaction_amount_text,
wrappedList(minimumAmount, minimumAmount),

View file

@ -1,21 +0,0 @@
package com.tangem.common.ui.amountScreen.models
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
/**
* Segmented buttons config
*
* @param title button title
* @param iconState currency icon state
* @param iconUrl currency icon url
* @param isFiat is fiat currency
*/
@Immutable
data class AmountSegmentedButtonsConfig(
val title: TextReference,
val iconState: CurrencyIconState? = null,
val iconUrl: String? = null,
val isFiat: Boolean,
)

View file

@ -4,7 +4,6 @@ import androidx.compose.runtime.Stable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import kotlinx.collections.immutable.PersistentList
import java.math.BigDecimal
/** Model for amount state */
@ -12,18 +11,13 @@ import java.math.BigDecimal
sealed class AmountState {
abstract val isPrimaryButtonEnabled: Boolean
abstract val isRedesignEnabled: Boolean
/**
* @param isPrimaryButtonEnabled indicates if next state button enabled
* @param title title
* @param availableBalance user crypto currency balance with fiat balance
* @param availableBalanceCrypto user crypto currency balance in crypto
* @param availableBalanceFiat user crypto currency balance in fiat
* @param tokenIconState crypto currency icon state
* @param segmentedButtonConfig currency switcher config
* @param selectedButton selected currency index
* @param isSegmentedButtonsEnabled indicates if currency switches is enabled
* @param amountTextField amount field state
* @param appCurrency app currency
* @param isEditingDisabled indicated whether amount is editable
@ -32,17 +26,11 @@ sealed class AmountState {
*/
data class Data(
override val isPrimaryButtonEnabled: Boolean,
override val isRedesignEnabled: Boolean,
val title: TextReference,
@Deprecated("Remove with SEND_REDESIGNED toggle")
val availableBalance: TextReference,
val availableBalanceCrypto: TextReference,
val availableBalanceFiat: TextReference,
val tokenName: TextReference,
val tokenIconState: CurrencyIconState,
val segmentedButtonConfig: PersistentList<AmountSegmentedButtonsConfig>,
val selectedButton: Int,
val isSegmentedButtonsEnabled: Boolean,
val amountTextField: AmountFieldModel,
val appCurrency: AppCurrency,
val isEditingDisabled: Boolean = false,
@ -50,8 +38,7 @@ sealed class AmountState {
val isIgnoreReduce: Boolean = false,
) : AmountState()
data class Empty(
override val isPrimaryButtonEnabled: Boolean = false,
override val isRedesignEnabled: Boolean,
) : AmountState()
data object Empty : AmountState() {
override val isPrimaryButtonEnabled: Boolean = false
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.common.ui.amountScreen.preview
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
@ -12,31 +11,18 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
object AmountStatePreviewData {
val emptyState = AmountState.Empty(isRedesignEnabled = true)
val emptyState = AmountState.Empty
val amountState = AmountState.Data(
isPrimaryButtonEnabled = false,
title = stringReference("Family Wallet"),
availableBalance = stringReference("2 130,81231238 USDT • 2 129,12 \$)"),
availableBalanceCrypto = stringReference("2 130,81231238 USDT"),
availableBalanceFiat = stringReference("1 232 129,12 \$"),
tokenIconState = CurrencyIconState.Loading,
segmentedButtonConfig = persistentListOf(
AmountSegmentedButtonsConfig(
title = stringReference("USDT"),
iconState = CurrencyIconState.Locked,
isFiat = false,
),
AmountSegmentedButtonsConfig(
title = stringReference("USD"),
isFiat = true,
),
),
appCurrency = AppCurrency.Default,
tokenName = stringReference("Tether"),
amountTextField = AmountFieldModel(
@ -65,12 +51,9 @@ object AmountStatePreviewData {
isValuePasted = false,
onValuePastedTriggerDismiss = {},
),
isSegmentedButtonsEnabled = true,
selectedButton = 0,
isRedesignEnabled = false,
)
val amountWithValueState = amountState.copy(
private val amountWithValueState = amountState.copy(
amountTextField = amountState.amountTextField.copy(
value = "100.00",
cryptoAmount = amountState.amountTextField.cryptoAmount.copy(
@ -84,16 +67,10 @@ object AmountStatePreviewData {
)
val amountStateV2 = amountState.copy(
isRedesignEnabled = true,
availableBalance = stringReference("2 130,81231238 USDT • 2 129,12 \$)"),
availableBalanceCrypto = stringReference("2 130,81231238 USDT"),
availableBalanceFiat = stringReference(" ${StringsSigns.DOT} 1 232 129,12 $"),
)
val amountWithValueFiatState = amountWithValueState.copy(
amountTextField = amountWithValueState.amountTextField.copy(isFiatValue = false),
)
val amountStateV2WithoutRates = amountState.copy(
amountTextField = amountState.amountTextField.copy(
fiatAmount = amountState.amountTextField.fiatAmount.copy(

View file

@ -16,10 +16,13 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
@ -59,7 +62,16 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis
.clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing16),
) {
CurrencyIcon(state = amountState.tokenIconState)
Text(
text = amountState.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
SpacerH(20.dp)
CurrencyIcon(
state = amountState.tokenIconState,
iconSize = 40.dp,
)
ResizableText(
text = firstAmount,
style = TangemTheme.typography.h2,

View file

@ -1,126 +0,0 @@
package com.tangem.common.ui.amountScreen.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.currency.fiaticon.FiatIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.SendScreenTestTags
import kotlinx.collections.immutable.PersistentList
private const val AMOUNT_BUTTONS_KEY = "amountButtonsKey"
internal fun LazyListScope.buttons(
segmentedButtonConfig: PersistentList<AmountSegmentedButtonsConfig>,
clickIntents: AmountScreenClickIntents,
isSegmentedButtonsEnabled: Boolean,
selectedButton: Int,
) {
item(
key = AMOUNT_BUTTONS_KEY,
) {
val hapticFeedback = LocalHapticFeedback.current
Row {
if (segmentedButtonConfig.isNotEmpty()) {
SegmentedButtons(
modifier = Modifier
.weight(1f)
.height(TangemTheme.dimens.size40),
config = segmentedButtonConfig,
showIndication = false,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
clickIntents.onCurrencyChangeClick(it.isFiat)
},
initialSelectedItem = segmentedButtonConfig.getOrNull(selectedButton),
isEnabled = isSegmentedButtonsEnabled,
) {
AmountCurrencyButton(
button = it,
isSegmentedButtonsEnabled = isSegmentedButtonsEnabled,
)
}
} else {
SpacerWMax()
}
Text(
text = stringResourceSafe(R.string.send_max_amount),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing8)
.height(TangemTheme.dimens.size40)
.clip(shape = RoundedCornerShape(TangemTheme.dimens.radius26))
.background(TangemTheme.colors.button.secondary)
.clickable {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
clickIntents.onMaxValueClick()
}
.padding(
vertical = TangemTheme.dimens.spacing10,
horizontal = TangemTheme.dimens.spacing34,
)
.testTag(SendScreenTestTags.MAX_BUTTON),
)
}
}
}
@Composable
private fun AmountCurrencyButton(button: AmountSegmentedButtonsConfig, isSegmentedButtonsEnabled: Boolean) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(
horizontal = TangemTheme.dimens.spacing10,
)
.testTag(SendScreenTestTags.CURRENCY_BUTTON),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
val iconModifier = Modifier
.size(TangemTheme.dimens.size18)
.padding(horizontal = TangemTheme.dimens.spacing1)
if (button.isFiat) {
FiatIcon(
url = button.iconUrl,
size = TangemTheme.dimens.size18,
isGrayscale = !isSegmentedButtonsEnabled,
modifier = iconModifier.testTag(SendScreenTestTags.FIAT_ICON),
)
} else if (button.iconState != null) {
CurrencyIcon(
state = button.iconState,
shouldDisplayNetwork = false,
modifier = iconModifier.testTag(SendScreenTestTags.CURRENCY_ICON),
)
}
Text(
text = button.title.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.button,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing8,
),
)
}
}

View file

@ -1,160 +0,0 @@
package com.tangem.common.ui.amountScreen.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.BottomCenter
import androidx.compose.ui.Alignment.Companion.TopCenter
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.SendScreenTestTags
import com.tangem.core.ui.utils.rememberDecimalFormat
import kotlinx.coroutines.delay
@Composable
internal fun AmountField(
amountField: AmountFieldModel,
appCurrencyCode: String,
onValueChange: (String) -> Unit,
onValuePastedTriggerDismiss: () -> Unit,
) {
val decimalFormat = rememberDecimalFormat()
val isFiatValue = amountField.isFiatValue
val currencyCode = if (isFiatValue) appCurrencyCode else null
val (primaryAmount, primaryValue) = if (isFiatValue) {
amountField.fiatAmount to amountField.fiatValue
} else {
amountField.cryptoAmount to amountField.value
}
val requester = remember { FocusRequester() }
val symbolColor = if (primaryValue.isBlank()) TangemTheme.colors.text.disabled else TangemTheme.colors.text.primary1
AmountTextField(
value = primaryValue,
decimals = primaryAmount.decimals,
visualTransformation = AmountVisualTransformation(
decimals = primaryAmount.decimals,
symbol = primaryAmount.currencySymbol,
currencyCode = currencyCode,
decimalFormat = decimalFormat,
symbolColor = symbolColor,
),
onValueChange = onValueChange,
keyboardOptions = amountField.keyboardOptions,
keyboardActions = amountField.keyboardActions,
textStyle = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
isAutoResize = true,
isValuePasted = amountField.isValuePasted,
onValuePastedTriggerDismiss = onValuePastedTriggerDismiss,
modifier = Modifier
.focusRequester(requester)
.padding(
top = TangemTheme.dimens.spacing24,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)
.requiredHeightIn(min = TangemTheme.dimens.size32),
)
LaunchedEffect(key1 = Unit) {
delay(timeMillis = 200)
requester.requestFocus()
}
AmountSecondary(amountField, appCurrencyCode)
}
@Composable
private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: String) {
val secondaryAmount = if (amountField.isFiatValue) amountField.cryptoAmount else amountField.fiatAmount
Box(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
.padding(
top = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
) {
val text = if (amountField.isFiatValue) {
secondaryAmount.value.format { crypto(secondaryAmount.currencySymbol, secondaryAmount.decimals) }
} else {
secondaryAmount.value.format {
fiat(
fiatCurrencySymbol = secondaryAmount.currencySymbol,
fiatCurrencyCode = appCurrencyCode,
)
}
}
Text(
text = text,
style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.align(TopCenter)
.padding(bottom = TangemTheme.dimens.spacing32)
.testTag(SendScreenTestTags.SECONDARY_AMOUNT),
)
AmountFieldError(
isError = amountField.isError,
isWarning = amountField.isWarning,
error = amountField.error,
modifier = Modifier
.align(BottomCenter)
.padding(
top = TangemTheme.dimens.spacing20,
bottom = TangemTheme.dimens.spacing12,
),
)
}
}
@Composable
private fun AmountFieldError(
isError: Boolean,
isWarning: Boolean,
error: TextReference,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = isError || isWarning,
enter = fadeIn(),
exit = fadeOut(),
modifier = modifier,
) {
val errorText = remember(this, error) { error }
val color = if (isError) TangemTheme.colors.text.warning else TangemTheme.colors.text.attention
Text(
text = errorText.resolveReference(),
style = TangemTheme.typography.caption2,
color = color,
textAlign = TextAlign.Center,
)
}
}

View file

@ -15,8 +15,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.models.AmountState
@ -25,68 +23,12 @@ import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.SendScreenTestTags
private const val AMOUNT_FIELD_KEY = "amountFieldKey"
internal fun LazyListScope.amountField(
amountState: AmountState.Data,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
onValueChange: (String) -> Unit,
onValuePastedTriggerDismiss: () -> Unit,
) {
item(key = AMOUNT_FIELD_KEY) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action),
) {
Text(
text = amountState.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing14)
.testTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE),
)
val balance = amountState.availableBalance.orMaskWithStars(isBalanceHidden).resolveReference()
AnimatedContent(
targetState = balance,
label = "Hide Balance Animation",
) {
Text(
text = it,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing2)
.testTag(SendScreenTestTags.AMOUNT_CONTAINER_TEXT),
)
}
CurrencyIcon(
state = amountState.tokenIconState,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing32),
)
AmountField(
amountField = amountState.amountTextField,
appCurrencyCode = amountState.appCurrency.code,
onValueChange = onValueChange,
onValuePastedTriggerDismiss = onValuePastedTriggerDismiss,
)
}
}
}
internal fun LazyListScope.amountFieldV2(
amountState: AmountState,
modifier: Modifier = Modifier,
@ -183,46 +125,49 @@ private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modi
@Composable
private fun AmountInfoMain(amountUM: AmountState, modifier: Modifier = Modifier) {
AnimatedContent(
targetState = amountUM !is AmountState.Data,
targetState = amountUM,
modifier = modifier,
) { isContent ->
if (isContent) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
TextShimmer(
style = TangemTheme.typography.subtitle2,
modifier = Modifier.width(56.dp),
)
TextShimmer(
style = TangemTheme.typography.caption2,
modifier = Modifier.width(72.dp),
)
}
} else {
val amountUM = amountUM as AmountState.Data
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = amountUM.tokenName.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
)
Row {
EllipsisText(
text = amountUM.availableBalanceCrypto.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(amountUM.amountTextField.cryptoAmount.currencySymbol.length),
modifier = Modifier.weight(1f, fill = false),
) { currentAmount ->
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
when (currentAmount) {
is AmountState.Data -> {
val amountUM = amountUM as AmountState.Data
Text(
text = amountUM.tokenName.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
)
EllipsisText(
text = amountUM.availableBalanceFiat.resolveReference(),
Row {
EllipsisText(
text = amountUM.availableBalanceCrypto.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(
amountUM.amountTextField.cryptoAmount.currencySymbol.length,
),
modifier = Modifier.weight(1f, fill = false),
)
EllipsisText(
text = amountUM.availableBalanceFiat.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(
amountUM.amountTextField.fiatAmount.currencySymbol.length,
),
)
}
}
AmountState.Empty -> {
TextShimmer(
style = TangemTheme.typography.subtitle2,
modifier = Modifier.width(56.dp),
)
TextShimmer(
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(amountUM.amountTextField.fiatAmount.currencySymbol.length),
modifier = Modifier.width(72.dp),
)
}
}

View file

@ -55,7 +55,6 @@ fun NavigationButtonsBlock(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
PreviousButton(state?.prevButton)
NavigationPrimaryButton(state?.primaryButton, modifier = Modifier.weight(1f))
}
}

View file

@ -8,7 +8,6 @@ sealed class NavigationButtonsState {
data class Data(
val primaryButton: NavigationButton?,
val prevButton: NavigationButton?,
val extraButtons: Pair<NavigationButton, NavigationButton>?,
val txUrl: String? = null,
val onTextClick: (String) -> Unit,

View file

@ -3,7 +3,6 @@ package com.tangem.common.ui.navigationButtons.preview
import com.tangem.common.ui.R
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
internal object NavigationButtonsPreview {
@ -26,16 +25,6 @@ internal object NavigationButtonsPreview {
onClick = {},
)
private val prev = NavigationButton(
textReference = TextReference.EMPTY,
iconRes = R.drawable.ic_back_24,
isSecondary = true,
isIconVisible = true,
shouldShowProgress = false,
isEnabled = true,
onClick = {},
)
private val finished = NavigationButton(
textReference = resourceReference(R.string.common_close),
isSecondary = false,
@ -47,7 +36,6 @@ internal object NavigationButtonsPreview {
val allButtons = NavigationButtonsState.Data(
primaryButton = finished,
prevButton = prev,
extraButtons = extraButtons,
txUrl = "https://tangem.com",
onTextClick = {},

View file

@ -15,7 +15,7 @@ import retrofit2.http.Query
interface YieldSupplyApi {
@GET("api/v1/yield/markets")
suspend fun getYieldMarkets(@Query("chainId") chainId: Int? = null): ApiResponse<YieldMarketsResponse>
suspend fun getYieldMarkets(@Query("chainId") chainId: String? = null): ApiResponse<YieldMarketsResponse>
@GET("api/v1/yield/token/{chainId}/{tokenAddress}")
suspend fun getYieldTokenStatus(

View file

@ -2,8 +2,37 @@ package com.tangem.datasource.api.tangemTech.models.account
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.utils.SerializeNulls
@JsonClass(generateAdapter = true)
data class SaveWalletAccountsResponse(
@Json(name = "accounts") val accounts: List<WalletAccountDTO>,
)
@Json(name = "accounts") val accounts: List<AccountDTO>,
) {
@SerializeNulls
@JsonClass(generateAdapter = true)
data class AccountDTO(
@Json(name = "id") val id: String,
@Json(name = "name") val name: String?,
@Json(name = "derivation") val derivationIndex: Int,
@Json(name = "icon") val icon: String,
@Json(name = "iconColor") val iconColor: String,
)
companion object {
operator fun invoke(accounts: List<WalletAccountDTO>): SaveWalletAccountsResponse {
return SaveWalletAccountsResponse(
accounts = accounts.map { accountDto ->
AccountDTO(
id = accountDto.id,
name = accountDto.name,
derivationIndex = accountDto.derivationIndex,
icon = accountDto.icon,
iconColor = accountDto.iconColor,
)
},
)
}
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.common.json.MoshiJsonConverter
import com.tangem.datasource.api.common.adapter.*
import com.tangem.datasource.local.config.providers.models.ProviderModel
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.utils.SerializeNullsFactory
import com.tangem.domain.models.scan.serialization.*
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardActivationStatus
@ -28,6 +29,7 @@ class MoshiModule {
@NetworkMoshi
fun provideNetworkMoshi(): Moshi {
return Moshi.Builder()
.add(SerializeNullsFactory)
.add(
PolymorphicJsonAdapterFactory.of(ProviderModel::class.java, "type")
.withSubtype(ProviderModel.Public::class.java, "public")

View file

@ -4,11 +4,11 @@ import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse
import com.tangem.datasource.local.yieldsupply.DefaultYieldMarketsStore
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -34,7 +34,7 @@ object YieldSupplyModule {
persistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = listTypes<YieldMarketToken>(),
types = listTypes<YieldMarketsResponse.MarketDto>(),
defaultValue = emptyList(),
),
produceFile = { context.dataStoreFile(fileName = "yield_markets_cache") },

View file

@ -12,5 +12,7 @@ interface TangemPayStorage {
suspend fun getOrderId(customerWalletAddress: String): String?
suspend fun clear(customerWalletAddress: String)
suspend fun clearOrderId(customerWalletAddress: String)
suspend fun clearAll(customerWalletAddress: String)
}

View file

@ -1,21 +1,21 @@
package com.tangem.datasource.local.yieldsupply
import androidx.datastore.core.DataStore
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
internal class DefaultYieldMarketsStore(
private val persistenceStore: DataStore<List<YieldMarketToken>>,
private val persistenceStore: DataStore<List<YieldMarketsResponse.MarketDto>>,
) : YieldMarketsStore {
override fun get(): Flow<List<YieldMarketToken>> = persistenceStore.data
override fun get(): Flow<List<YieldMarketsResponse.MarketDto>> = persistenceStore.data
override suspend fun getSyncOrNull(): List<YieldMarketToken>? {
override suspend fun getSyncOrNull(): List<YieldMarketsResponse.MarketDto>? {
return persistenceStore.data.firstOrNull()
}
override suspend fun store(items: List<YieldMarketToken>) {
override suspend fun store(items: List<YieldMarketsResponse.MarketDto>) {
persistenceStore.updateData { _ -> items }
}
}

View file

@ -1,13 +1,13 @@
package com.tangem.datasource.local.yieldsupply
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse
import kotlinx.coroutines.flow.Flow
interface YieldMarketsStore {
fun get(): Flow<List<YieldMarketToken>>
fun get(): Flow<List<YieldMarketsResponse.MarketDto>>
suspend fun getSyncOrNull(): List<YieldMarketToken>?
suspend fun getSyncOrNull(): List<YieldMarketsResponse.MarketDto>?
suspend fun store(items: List<YieldMarketToken>)
suspend fun store(items: List<YieldMarketsResponse.MarketDto>)
}

View file

@ -0,0 +1,5 @@
package com.tangem.datasource.utils
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class SerializeNulls

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.utils
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.lang.reflect.Type
/**
* Factory to serialize nulls in Moshi if the class is annotated with [SerializeNulls].
*
[REDACTED_AUTHOR]
*/
internal object SerializeNullsFactory : JsonAdapter.Factory {
override fun create(type: Type, annotations: MutableSet<out Annotation>, moshi: Moshi): JsonAdapter<*>? {
val rawType = Types.getRawType(type)
if (!rawType.isAnnotationPresent(SerializeNulls::class.java)) {
return null
}
val nextAdapter: JsonAdapter<Any> = moshi.nextAdapter(this, type, annotations)
return nextAdapter.serializeNulls()
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.datasource.utils
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
// --- DTO ---
@SerializeNulls
@JsonClass(generateAdapter = true)
data class UserWithNulls(val id: String?, val name: String?)
@JsonClass(generateAdapter = true)
data class UserWithoutNulls(val id: String?, val name: String?)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SerializeNullsFactoryTest {
private val moshi = Moshi.Builder()
.add(SerializeNullsFactory)
.build()
@Test
fun `should serialize nulls for annotated class`() {
val adapter = moshi.adapter(UserWithNulls::class.java)
val json = adapter.toJson(UserWithNulls(id = null, name = "John"))
assertThat(json).isEqualTo("""{"id":null,"name":"John"}""")
}
@Test
fun `should skip nulls for non-annotated class`() {
val adapter = moshi.adapter(UserWithoutNulls::class.java)
val json = adapter.toJson(UserWithoutNulls(id = null, name = "John"))
assertThat(json).isEqualTo("""{"name":"John"}""")
}
@Test
fun `should deserialize annotated class correctly`() {
val adapter = moshi.adapter(UserWithNulls::class.java)
val json = """{"id":null,"name":"Jane"}"""
val result = adapter.fromJson(json)
assertThat(result).isEqualTo(UserWithNulls(id = null, name = "Jane"))
}
}

View file

@ -1688,6 +1688,7 @@
<string name="yield_module_approve_sheet_title">承認を確定する</string>
<string name="yield_module_balance_info_sheet_subtitle">資産残高に関するテキスト [プレースホルダー]</string>
<string name="yield_module_balance_info_sheet_title">あなたの%sはAaveに預けられています</string>
<string name="yield_module_chart_loading_error">チャートを読み込めません・・</string>
<string name="yield_module_deposit_error_notification_title">受け取った金額%1$s %2$sはAaveに入金されませんでした。</string>
<string name="yield_module_earn_badge">%1$s%% を獲得</string>
<string name="yield_module_earn_sheet_available_title">利用可能</string>
@ -1705,6 +1706,7 @@
<string name="yield_module_fee_policy_sheet_max_fee_title">最大手数料</string>
<string name="yield_module_fee_policy_sheet_title">手数料ポリシー</string>
<string name="yield_module_high_fee_error">ネットワーク手数料が現在高すぎます。設定した上限を下回るまで待機しています。</string>
<string name="yield_module_historical_returns">過去のリターン</string>
<string name="yield_module_main_view_approve_notification_description">ここに説明を入力してください。1〜3行が理想的です。[プレースホルダー]</string>
<string name="yield_module_main_view_approve_notification_title">トークン承認が必要</string>
<string name="yield_module_network_fee_unreachable_notification_description">ネットワーク接続を確認してください</string>

View file

@ -80,7 +80,7 @@
<string name="biometric_unavailable_warning">Вы отключили биометрическую аутентификацию на вашем телефоне и не сможете сохранять кошельки в приложении. Для сохранения кошельков, пожалуйста, включите функцию биометрической аутентификации в настройках телефона.</string>
<string name="bitcoin_promo_activation_error">При обработке промокода произошла ошибка. Пожалуйста, попробуйте позже.</string>
<string name="bitcoin_promo_activation_error_title">Ошибка активации</string>
<string name="bitcoin_promo_activation_success">Ваш промокод был успешно активирован. Награда будет зачислена на ваш счёт в течение 14 дней.</string>
<string name="bitcoin_promo_activation_success">Ваш промокод успешно активирован. Бонус 10 USDT в Bitcoin будет зачислен через 14 дней.</string>
<string name="bitcoin_promo_activation_success_title">Промокод активирован</string>
<string name="bitcoin_promo_already_activated">Этот промокод уже был использован и не может быть активирован повторно.</string>
<string name="bitcoin_promo_already_activated_title">Код недоступен</string>
@ -132,7 +132,7 @@
<string name="common_analytics">Аналитика</string>
<string name="common_apply">Применить</string>
<string name="common_approval">Одобрение</string>
<string name="common_approve">Разрешить</string>
<string name="common_approve">Подтвердить</string>
<string name="common_attention">Внимание</string>
<string name="common_available_networks">Доступные сети</string>
<string name="common_balance">Баланс: %s</string>
@ -1202,7 +1202,7 @@
<string name="swapping_alert_dex_description_with_slippage">В сумму включена комиссия провайдера сервиса. \n\nПроскальзывание провайдера составляет до %s</string>
<string name="swapping_alert_title">Информация</string>
<string name="swapping_approve_information_text">Подтверждения считаются отраслевым стандартом для всех децентрализованных бирж и защищают ваш кошелек от доступа со стороны смарт-контракта без вашего разрешения. По замыслу смарт-контракты не могут получить доступ к вашим токенам, если вы не одобрите доступ со своей стороны. «Разблокируя» свои токены, вы даете смарт-контракту 1inch разрешение тратить ваши активы. Майнеры сети получают компенсацию за газ (оплачиваемый вами) за запись этого действия в блокчейне. Как только разрешение будет предоставлено, вы сможете обменять свой токен.</string>
<string name="swapping_approve_information_title">Разрешение</string>
<string name="swapping_approve_information_title">Подтвердить</string>
<string name="swapping_fee_estimation_error_text">Ошибка расчета комиссии. Пожалуйста, отправьте информацию в поддержку.</string>
<string name="swapping_from_title">Вы отправляете</string>
<string name="swapping_high_price_impact_description">Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму.</string>
@ -1559,7 +1559,6 @@
<string name="xtz_withdrawal_message_ignore">Нет, отправить все</string>
<string name="xtz_withdrawal_message_reduce">Уменьшить на %s XTZ</string>
<string name="xtz_withdrawal_message_warning">Чтобы не платить повышенную комиссию при следующем пополнении кошелька, уменьшите сумму на %s XTZ</string>
<string name="yield_module_approve_needed_notification_cta">Выдать разрешение</string>
<string name="yield_module_chart_loading_error">Невозможно загрузить график</string>
<string name="yield_module_deposit_error_notification_title">Полученная сумма, %1$s %2$s, не была зачислена на Aave.</string>
<string name="yield_module_high_fee_error">Сетевая комиссия сейчас слишком высокая. Ожидаем, пока она упадёт ниже вашего лимита.</string>

View file

@ -135,7 +135,7 @@
<string name="biometric_unavailable_warning">You have disabled biometric authentication on your phone and will not be able to save wallets in the app. To save wallets, please enable the biometric authentication function in your phone settings.</string>
<string name="bitcoin_promo_activation_error">An error occurred while processing your promo code. Please try again later.</string>
<string name="bitcoin_promo_activation_error_title">Activation error</string>
<string name="bitcoin_promo_activation_success">Your promo code was successfully activated. A reward will be credited to your account within 14 days.</string>
<string name="bitcoin_promo_activation_success">Your promo code was successfully activated. A bonus of 10 USDT in Bitcoin will be credited to your account within 14 days.</string>
<string name="bitcoin_promo_activation_success_title">Promo Code Activated</string>
<string name="bitcoin_promo_already_activated">This promo code has already been used and cannot be activated again.</string>
<string name="bitcoin_promo_already_activated_title">Code unavailable</string>
@ -1117,8 +1117,8 @@
<string name="settings_forget_wallet_footer">This will remove the wallet from the application. The wallet itself can be added again.</string>
<string name="settings_wallet_name_title">Name</string>
<string name="stake_token_description">Put your token to work</string>
<string name="staking_account_initialization_footer">A network fee is a small payment required to process and confirm your transaction on the blockchain.</string>
<string name="staking_account_initialization_message">To start staking, your TON account must be activated with a self-transaction of 1 TON. The funds stay in your wallet — this step only enables your account for staking.</string>
<string name="staking_account_initialization_footer">A network fee is a small payment to process and confirm your transaction on the blockchain.</string>
<string name="staking_account_initialization_message">To start staking, your TON account must be activated with a self-transaction of 1 TON. The funds stay in your wallet — this step only enables your account for staking.</string>
<string name="staking_account_initialization_title">Account activation</string>
<string name="staking_amount_requirement_error">The amount to stake must be at least %s</string>
<string name="staking_amount_tron_integer_error">Staking amount will be rounded to %1$s TRX due to network rules.</string>
@ -1731,20 +1731,13 @@
<string name="wc_uri_already_used_title">URI already used</string>
<string name="wc_wallet_connect">WalletConnect</string>
<string name="wc_warning_transaction">Suspicious transaction</string>
<string name="welcome_create_wallet_already_have">Already have Tangem?</string>
<string name="welcome_create_wallet_feature_assets">Thousands of assets</string>
<string name="welcome_create_wallet_feature_class">Best in class hardware wallet</string>
<string name="welcome_create_wallet_feature_delivery">Fast delivery</string>
<string name="welcome_create_wallet_feature_one_tap">Start in one tap</string>
<string name="welcome_create_wallet_feature_seamless">Seamless and secure</string>
<string name="welcome_create_wallet_feature_use">Simple to use</string>
<string name="welcome_create_wallet_hardware_description">Create a hardware wallet with Tangem. Slim as a bank card, secure as a bank vault.</string>
<string name="welcome_create_wallet_mobile_description">Create or import a software wallet</string>
<string name="welcome_create_wallet_mobile_description_full">Create or import a software wallet on your phone </string>
<string name="welcome_create_wallet_mobile_title">Start with Mobile Wallet</string>
<string name="welcome_create_wallet_other_method">Other method</string>
<string name="welcome_create_wallet_use_hardware_description">Use Tangem Hardware Wallet</string>
<string name="welcome_create_wallet_use_hardware_title">Learn more &amp; buy</string>
<string name="welcome_interrupted_backup_alert_discard">Discard</string>
<string name="welcome_interrupted_backup_alert_message">You have an interrupted backup. Do you want to resume?</string>
<string name="welcome_interrupted_backup_alert_resume">Yes, resume</string>

View file

@ -60,10 +60,10 @@ fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: M
@Composable
fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
if (state.isExpanded) {
ExpandedPortfolioHeader(state.state, modifier)
ExpandedPortfolioHeader(state = state.tokenItemUM, isCollapsable = state.isCollapsable, modifier = modifier)
} else {
TokenItem(
state = state.state,
state = state.tokenItemUM,
isBalanceHidden = isBalanceHidden,
modifier = modifier,
)
@ -83,7 +83,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B
}
@Composable
private fun ExpandedPortfolioHeader(state: TokenItemState, modifier: Modifier = Modifier) {
private fun ExpandedPortfolioHeader(state: TokenItemState, isCollapsable: Boolean, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
@ -130,11 +130,13 @@ private fun ExpandedPortfolioHeader(state: TokenItemState, modifier: Modifier =
)
}
Icon(
modifier = Modifier.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_minimize_24),
tint = TangemTheme.colors.icon.inactive,
contentDescription = null,
)
if (isCollapsable) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_minimize_24),
tint = TangemTheme.colors.icon.inactive,
contentDescription = null,
)
}
}
}

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
/** Tokens list item state */
@Immutable
@ -41,11 +42,12 @@ sealed interface TokensListItemUM {
}
data class Portfolio(
val state: TokenItemState,
val tokenItemUM: TokenItemState,
val isExpanded: Boolean,
val tokens: List<PortfolioTokensListItemUM>,
val isCollapsable: Boolean,
val tokens: ImmutableList<PortfolioTokensListItemUM>,
) : TokensListItemUM {
override val id: String = state.id
override val id: String = tokenItemUM.id
}
data class Text(override val id: Any, val text: TextReference) : TokensListItemUM

View file

@ -0,0 +1,26 @@
package com.tangem.core.ui.decompose
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@Stable
interface ComposableListContentComponent<T> {
val uiState: StateFlow<T>
fun LazyListScope.content(uiState: T, modifier: Modifier)
companion object {
val EMPTY = EmptyComposableListContentComponent
}
}
object EmptyComposableListContentComponent : ComposableListContentComponent<Unit> {
override val uiState: StateFlow<Unit> = MutableStateFlow(Unit)
override fun LazyListScope.content(uiState: Unit, modifier: Modifier) { /* no-op */
}
}

View file

@ -96,6 +96,7 @@ fun getActiveIconRes(blockchainId: String): Int {
"pepecoin", "pepecoin/test" -> R.drawable.img_pepecoin_22
"hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22
"quai", "quai/test" -> R.drawable.img_quai_22
"linea", "linea/test" -> R.drawable.img_linea_22
else -> R.drawable.ic_alert_24
}
}
@ -190,6 +191,7 @@ fun getActiveIconResByCoinId(coinId: String): Int {
"pepecoin-network", "pepecoin-network/test" -> R.drawable.img_pepecoin_22
"hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22
"quai", "quai/test" -> R.drawable.img_quai_22
"linea", "linea/test" -> R.drawable.img_linea_22
else -> R.drawable.ic_alert_24
}
}
@ -287,6 +289,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int {
"pepecoin", "pepecoin/test" -> R.drawable.ic_pepecoin_22
"hyperliquid", "hyperliquid/test" -> R.drawable.ic_hyperliquid_22
"quai", "quai/test" -> R.drawable.ic_quai_22
"linea", "linea/test" -> R.drawable.ic_linea_22
else -> R.drawable.ic_alert_24
}
}

View file

@ -7,4 +7,5 @@ object WalletConnectScreenTestTags {
const val APP_NAME = "WALLET_CONNECT_SCREEN_APP_NAME"
const val APPROVE_ICON = "WALLET_CONNECT_SCREEN_APPROVE_ICON"
const val APP_URL = "WALLET_CONNECT_SCREEN_APP_URL"
const val WALLET_CONNECT_IMAGE = "WALLET_CONNECT_SCREEN_WALLET_CONNECT_IMAGE"
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<group>
<clip-path android:pathData="M15.573,6H6V16H15.573V6Z" />
<path
android:fillColor="#121212"
android:pathData="M13.951,16H6V7.623H7.819V14.376H13.951V15.999V16Z" />
<path
android:fillColor="#121212"
android:pathData="M13.95,9.245C14.847,9.245 15.573,8.519 15.573,7.623C15.573,6.727 14.847,6 13.95,6C13.054,6 12.328,6.727 12.328,7.623C12.328,8.519 13.054,9.245 13.95,9.245Z" />
</group>
</vector>

View file

@ -0,0 +1,26 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<group>
<clip-path
android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z"/>
<path
android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M0,0h22v22h-22z"
android:fillColor="#61DFFF"/>
<group>
<clip-path
android:pathData="M15.573,6H6V16H15.573V6Z"/>
<path
android:pathData="M13.951,16H6V7.623H7.819V14.376H13.951V15.999V16Z"
android:fillColor="#121212"/>
<path
android:pathData="M13.95,9.245C14.847,9.245 15.573,8.519 15.573,7.623C15.573,6.727 14.847,6 13.95,6C13.054,6 12.328,6.727 12.328,7.623C12.328,8.519 13.054,9.245 13.95,9.245Z"
android:fillColor="#121212"/>
</group>
</group>
</vector>

View file

@ -24,6 +24,7 @@ dependencies {
// region Project - Domain
api(projects.domain.account)
api(projects.domain.card)
api(projects.domain.common)
api(projects.domain.models)
// endregion

View file

@ -28,7 +28,7 @@ internal class AccountListConverter @AssistedInject constructor(
override fun convert(value: GetWalletAccountsResponse): AccountList {
return AccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(),
totalAccounts = value.wallet.totalAccounts,
sortType = TokensSortTypeConverter.convert(value.wallet.sort),

View file

@ -1,7 +1,6 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.utils.converter.Converter
@ -21,8 +20,8 @@ internal object SaveWalletAccountsResponseConverter : Converter<AccountList, Sav
)
}
private fun toDTO(account: Account.CryptoPortfolio): WalletAccountDTO {
return WalletAccountDTO(
private fun toDTO(account: Account.CryptoPortfolio): SaveWalletAccountsResponse.AccountDTO {
return SaveWalletAccountsResponse.AccountDTO(
id = account.accountId.value,
name = AccountNameConverter.convert(value = account.accountName),
derivationIndex = account.derivationIndex.value,

View file

@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.assignTokens
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.account.WalletAccountsFetcher
@ -14,6 +15,7 @@ import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
@ -31,17 +33,20 @@ import javax.inject.Singleton
* @property accountsResponseStoreFactory factory to create [AccountsResponseStore]
* @property userTokensSaver saves user tokens to the database
* @property fetchWalletAccountsErrorHandler handles errors during fetching wallet accounts
* @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse]
* @property eTagsStore store for ETags to manage caching
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
@Singleton
internal class DefaultWalletAccountsFetcher @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val userTokensSaver: UserTokensSaver,
private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler,
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
private val eTagsStore: ETagsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : WalletAccountsFetcher, WalletAccountsSaver {
@ -49,9 +54,11 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
override suspend fun fetch(userWalletId: UserWalletId) {
val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull()
val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse)
val unassignedTokens = accountsResponse?.unassignedTokens
?: return
if (!unassignedTokens.isNullOrEmpty()) {
if (accountsResponse.accounts.isEmpty()) {
initializeAccounts(userWalletId, accountsResponse)
} else if (accountsResponse.unassignedTokens.isNotEmpty()) {
assignTokens(userWalletId, accountsResponse)
}
}
@ -86,11 +93,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
body = body.copy(
accounts = body.accounts.map {
it.copy(tokens = null, totalTokens = null, totalNetworks = null)
},
),
body = body,
)
}
@ -135,12 +138,23 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
pushWalletAccounts = ::push,
storeWalletAccounts = ::store,
)
null
},
)
}
private suspend fun initializeAccounts(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) {
val response = defaultWalletAccountsResponseFactory.create(
userWalletId = userWalletId,
userTokensResponse = UserTokensResponse(
group = accountsResponse.wallet.group,
sort = accountsResponse.wallet.sort,
tokens = accountsResponse.unassignedTokens,
),
)
pushAndStore(userWalletId, response)
}
private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) {
val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId)

View file

@ -1,10 +1,8 @@
package com.tangem.data.account.fetcher
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.utils.assignTokens
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
@ -13,10 +11,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import timber.log.Timber
import javax.inject.Inject
@ -24,12 +18,9 @@ import javax.inject.Inject
/**
* Handles errors that occur during the fetching of wallet accounts
*
* @property userTokensSaver saves user tokens to the storage
* @property userWalletsStore provides access to user wallet data
* @property userTokensResponseStore provides access to user token responses.
* @property cryptoPortfolioCF factory for converting crypto portfolios
* @property userTokensResponseFactory factory for creating user token responses
* @property cardCryptoCurrencyFactory factory for creating default cryptocurrencies for multi-currency wallets
* @property userTokensSaver saves user tokens to the storage
* @property userTokensResponseStore provides access to user token responses.
* @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse]
*
* @see DefaultWalletAccountsFetcher
*
@ -37,11 +28,8 @@ import javax.inject.Inject
*/
internal class FetchWalletAccountsErrorHandler @Inject constructor(
private val userTokensSaver: UserTokensSaver,
private val userWalletsStore: UserWalletsStore,
private val userTokensResponseStore: UserTokensResponseStore,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userTokensResponseFactory: UserTokensResponseFactory,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
) {
/**
@ -61,20 +49,19 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
savedAccountsResponse: GetWalletAccountsResponse?,
pushWalletAccounts: suspend (userWalletId: UserWalletId, accounts: List<WalletAccountDTO>) -> Unit,
storeWalletAccounts: suspend (userWalletId: UserWalletId, response: GetWalletAccountsResponse) -> Unit,
) {
): GetWalletAccountsResponse? {
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
if (isResponseUpToDate) {
Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId")
return
return savedAccountsResponse
}
val (accountDTOs, userTokensResponse) = if (savedAccountsResponse == null) {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
val response = savedAccountsResponse ?: defaultWalletAccountsResponseFactory.create(
userWalletId = userWalletId,
userTokensResponse = getFromLegacyStore(userWalletId),
)
createDefaultAccountDTOs(userWallet) to getFromLegacyStore(userWalletId).orDefault(userWallet)
} else {
savedAccountsResponse.accounts to savedAccountsResponse.toUserTokensResponse()
}
val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse()
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isNotFoundError) {
@ -82,49 +69,18 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse)
}
val response = savedAccountsResponse.orDefault(userWalletId, accountDTOs, userTokensResponse)
storeWalletAccounts(userWalletId, response)
}
private fun createDefaultAccountDTOs(userWallet: UserWallet): List<WalletAccountDTO> {
val accounts = AccountList.empty(userWallet).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val converter = cryptoPortfolioCF.create(userWallet = userWallet)
return converter.convertListBack(input = accounts)
return response
}
private suspend fun getFromLegacyStore(userWalletId: UserWalletId): UserTokensResponse? {
return userTokensResponseStore.getSyncOrNull(userWalletId)
?.let {
it.copy(
tokens = UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, tokens = it.tokens),
)
}
.also { userTokensResponseStore.clear(userWalletId) }
}
private fun UserTokensResponse?.orDefault(userWallet: UserWallet): UserTokensResponse {
if (this != null) return this
return userTokensResponseFactory.createUserTokensResponse(
currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet = userWallet),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
private fun GetWalletAccountsResponse?.orDefault(
userWalletId: UserWalletId,
accountDTOs: List<WalletAccountDTO>,
userTokensResponse: UserTokensResponse,
): GetWalletAccountsResponse {
if (this != null) return this
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = userTokensResponse.group,
sort = userTokensResponse.sort,
totalAccounts = accountDTOs.size,
),
accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = userTokensResponse.tokens),
unassignedTokens = emptyList(),
)
}
}

View file

@ -5,6 +5,7 @@ import arrow.core.some
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.MultiAccountListProducer
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -35,10 +36,11 @@ internal class DefaultMultiAccountListProducer @AssistedInject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
override fun produce(): Flow<List<AccountList>> {
return userWalletsStore.userWallets
.map { it.map(UserWallet::walletId) }
.distinctUntilChanged()
.flatMapLatest { userWallets ->
.flatMapLatest { ids ->
combine(
flows = userWallets.map(walletAccountListFlowFactory::create),
flows = ids.map(walletAccountListFlowFactory::create),
transform = ::listOf,
)
}

View file

@ -2,7 +2,6 @@ package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.none
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -11,16 +10,13 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
/**
* Default implementation of [SingleAccountListProducer].
* Produces a list of [AccountList] for a specific user wallet.
*
* @property params params containing the user wallet ID
* @property userWalletsStore store that provides user wallets
* @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet
* @property dispatchers coroutine dispatchers provider
*
@ -28,7 +24,6 @@ import kotlinx.coroutines.flow.mapNotNull
*/
internal class DefaultSingleAccountListProducer @AssistedInject constructor(
@Assisted val params: SingleAccountListProducer.Params,
private val userWalletsStore: UserWalletsStore,
private val walletAccountListFlowFactory: WalletAccountListFlowFactory,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleAccountListProducer {
@ -37,11 +32,7 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
override fun produce(): Flow<AccountList> {
return userWalletsStore.userWallets
.mapNotNull { userWallets ->
userWallets.firstOrNull { it.walletId == params.userWalletId }
}
.flatMapLatest(walletAccountListFlowFactory::create)
return walletAccountListFlowFactory.create(userWalletId = params.userWalletId)
.flowOn(dispatchers.default)
}

View file

@ -4,9 +4,11 @@ import com.tangem.data.account.converter.AccountListConverter
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.models.wallet.requireColdWallet
import kotlinx.coroutines.flow.*
@ -22,12 +24,15 @@ import javax.inject.Inject
[REDACTED_AUTHOR]
*/
internal class WalletAccountListFlowFactory @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val accountListConverterFactory: AccountListConverter.Factory,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
) {
fun create(userWallet: UserWallet): Flow<AccountList> {
fun create(userWalletId: UserWalletId): Flow<AccountList> {
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
return if (userWallet.isMultiCurrency) {
createForMultiWallet(userWallet)
} else {
@ -53,6 +58,6 @@ internal class WalletAccountListFlowFactory @Inject constructor(
setOf(cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet))
}
return AccountList.empty(userWallet = userWallet, cryptoCurrencies = currencies)
return AccountList.empty(userWalletId = userWallet.walletId, cryptoCurrencies = currencies)
}
}

View file

@ -101,12 +101,12 @@ internal class DefaultAccountsCRUDRepository(
}
override suspend fun saveAccounts(accountList: AccountList) {
val userWalletId = accountList.userWallet.walletId
val userWallet = userWalletsStore.getSyncStrict(accountList.userWalletId)
val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = accountList.userWallet)
val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet)
val accountsResponse = converter.convert(value = accountList)
walletAccountsSaver.pushAndStore(userWalletId = userWalletId, response = accountsResponse)
walletAccountsSaver.pushAndStore(userWalletId = userWallet.walletId, response = accountsResponse)
}
override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option<Int> = option {

View file

@ -0,0 +1,68 @@
package com.tangem.data.account.utils
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
/**
* Factory to create default [GetWalletAccountsResponse].
*
* @property userWalletsListRepository repository to get user wallet information
* @property cryptoPortfolioCF converter factory to convert crypto portfolio accounts
* @property userTokensResponseFactory factory to create [UserTokensResponse]
* @property cardCryptoCurrencyFactory factory to get default coins for multi-currency wallet
*
[REDACTED_AUTHOR]
*/
internal class DefaultWalletAccountsResponseFactory @Inject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userTokensResponseFactory: UserTokensResponseFactory,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
) {
suspend fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse {
val userWallet = userWalletsListRepository.userWalletsSync().firstOrNull { it.walletId == userWalletId }
val accountDTOs = userWallet?.let(::createDefaultAccountDTOs).orEmpty()
val response = userTokensResponse.orDefault(userWallet = userWallet)
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = response.group,
sort = response.sort,
totalAccounts = accountDTOs.size,
),
accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = response.tokens),
unassignedTokens = emptyList(),
)
}
private fun createDefaultAccountDTOs(userWallet: UserWallet): List<WalletAccountDTO> {
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val converter = cryptoPortfolioCF.create(userWallet = userWallet)
return converter.convertListBack(input = accounts)
}
private fun UserTokensResponse?.orDefault(userWallet: UserWallet?): UserTokensResponse {
if (this != null) return this
return userTokensResponseFactory.createUserTokensResponse(
currencies = userWallet?.let(cardCryptoCurrencyFactory::createDefaultCoinsForMultiCurrencyWallet).orEmpty(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
}

View file

@ -8,7 +8,6 @@ import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
internal fun createWalletAccountDTO(
@ -72,13 +71,13 @@ internal fun createGetWalletAccountsResponse(
}
internal fun createAccountList(
userWallet: UserWallet,
userWalletId: UserWalletId,
sortType: TokensSortType = TokensSortType.BALANCE,
groupType: TokensGroupType = TokensGroupType.NETWORK,
): AccountList {
return AccountList(
userWallet = userWallet,
accounts = setOf(createCryptoPortfolio(userWallet.walletId)),
userWalletId = userWalletId,
accounts = setOf(createCryptoPortfolio(userWalletId)),
totalAccounts = 1,
sortType = sortType,
groupType = groupType,

View file

@ -96,7 +96,7 @@ class AccountListConverterTest {
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
@ -110,7 +110,7 @@ class AccountListConverterTest {
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
@ -124,7 +124,7 @@ class AccountListConverterTest {
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),

View file

@ -46,7 +46,7 @@ class GetWalletAccountsResponseConverterTest {
@Test
fun `cryptoPortfolioConverter throws exception`() {
// Arrange
val domain = createAccountList(userWallet = userWallet)
val domain = createAccountList(userWalletId = userWallet.walletId)
val exception = IllegalStateException("Test exception")
every { cryptoPortfolioConverter.convertBack(any()) } throws exception
@ -92,7 +92,7 @@ class GetWalletAccountsResponseConverterTest {
return listOf(
ConvertModel(
value = createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
@ -106,7 +106,7 @@ class GetWalletAccountsResponseConverterTest {
),
ConvertModel(
value = createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),

View file

@ -2,14 +2,10 @@ package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@ -19,13 +15,11 @@ class SaveWalletAccountsResponseConverterTest {
@Test
fun convert() {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns UserWalletId("011")
}
val userWalletId = UserWalletId("011")
val accountList = AccountList(
userWallet = userWallet,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)),
userWalletId = userWalletId,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)),
totalAccounts = 1,
)
.getOrNull()!!
@ -36,7 +30,7 @@ class SaveWalletAccountsResponseConverterTest {
// Assert
val expected = SaveWalletAccountsResponse(
accounts = listOf(
WalletAccountDTO(
SaveWalletAccountsResponse.AccountDTO(
id = accountList.mainAccount.accountId.value,
name = (accountList.mainAccount.accountName as? AccountName.Custom)?.value,
derivationIndex = accountList.mainAccount.derivationIndex.value,

View file

@ -5,6 +5,7 @@ import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
@ -36,6 +37,7 @@ class DefaultWalletAccountsFetcherTest {
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true)
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true)
private val fetcher: DefaultWalletAccountsFetcher = DefaultWalletAccountsFetcher(
@ -43,6 +45,7 @@ class DefaultWalletAccountsFetcherTest {
accountsResponseStoreFactory = accountsResponseStoreFactory,
userTokensSaver = userTokensSaver,
fetchWalletAccountsErrorHandler = fetchWalletAccountsErrorHandler,
defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory,
eTagsStore = eTagsStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@ -205,6 +208,16 @@ class DefaultWalletAccountsFetcherTest {
tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag)
} returns apiError as ApiResponse<GetWalletAccountsResponse>
coEvery {
fetchWalletAccountsErrorHandler.handle(
error = apiError.cause,
userWalletId = userWalletId,
savedAccountsResponse = null,
pushWalletAccounts = any(),
storeWalletAccounts = any(),
)
} returns savedAccountsResponse
// Act
fetcher.fetch(userWalletId)

View file

@ -1,9 +1,7 @@
package com.tangem.data.account.fetcher
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
@ -11,12 +9,11 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -29,35 +26,21 @@ import org.junit.jupiter.api.TestInstance
class FetchWalletAccountsErrorHandlerTest {
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val userWalletsStore: UserWalletsStore = mockk()
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory = mockk()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val userTokensResponseFactory: UserTokensResponseFactory = mockk()
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
private val handler = FetchWalletAccountsErrorHandler(
userTokensSaver = userTokensSaver,
userWalletsStore = userWalletsStore,
userTokensResponseStore = userTokensResponseStore,
cryptoPortfolioCF = cryptoPortfolioCF,
userTokensResponseFactory = userTokensResponseFactory,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory,
)
private val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
@BeforeEach
fun setupEach() {
clearMocks(
userTokensSaver,
userWalletsStore,
userTokensResponseStore,
cryptoPortfolioCF,
cryptoPortfolioConverter,
cardCryptoCurrencyFactory,
defaultWalletAccountsResponseFactory,
)
}
@ -84,12 +67,8 @@ class FetchWalletAccountsErrorHandlerTest {
// Assert
coVerify(inverse = true) {
userWalletsStore.getSyncStrict(key = any())
userTokensResponseStore.getSyncOrNull(userWalletId = any())
userTokensResponseFactory.createUserTokensResponse(any(), any(), any())
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any())
cryptoPortfolioCF.create(any())
cryptoPortfolioConverter.convertListBack(any())
defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any())
pushWalletAccounts(any(), any())
userTokensSaver.push(userWalletId = any(), response = any())
storeWalletAccounts(any(), any())
@ -146,12 +125,8 @@ class FetchWalletAccountsErrorHandlerTest {
}
coVerify(inverse = true) {
userWalletsStore.getSyncStrict(key = any())
userTokensResponseStore.getSyncOrNull(userWalletId = any())
userTokensResponseFactory.createUserTokensResponse(any(), any(), any())
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any())
cryptoPortfolioCF.create(any())
cryptoPortfolioConverter.convertListBack(any())
defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any())
}
}
@ -160,9 +135,6 @@ class FetchWalletAccountsErrorHandlerTest {
// Arrange
val error = ApiResponseError.TimeoutException()
val accounts = AccountList.empty(userWallet).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val accountDTO = WalletAccountDTO(
id = "nibh",
name = "Michael Dotson",
@ -186,18 +158,10 @@ class FetchWalletAccountsErrorHandlerTest {
val userTokensResponse = savedAccountsResponse.toUserTokensResponse()
every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet
every { cryptoPortfolioCF.create(userWallet) } returns cryptoPortfolioConverter
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountDTO)
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns null
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns userTokensResponse
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList()
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns userTokensResponse
coEvery {
defaultWalletAccountsResponseFactory.create(userWalletId, userTokensResponse)
} returns savedAccountsResponse
val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> Unit = mockk(relaxed = true)
val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true)
@ -213,16 +177,8 @@ class FetchWalletAccountsErrorHandlerTest {
// Assert
coVerify {
userWalletsStore.getSyncStrict(userWalletId)
cryptoPortfolioCF.create(userWallet)
cryptoPortfolioConverter.convertListBack(accounts)
userTokensResponseStore.getSyncOrNull(userWalletId)
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet)
defaultWalletAccountsResponseFactory.create(userWalletId, userTokensResponse)
storeWalletAccounts(userWalletId, any())
}

View file

@ -51,8 +51,8 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList)
val accountList = AccountList.empty(userWalletId)
every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList)
// Act
val actual = producer.produce().let(::getEmittedValues)
@ -63,7 +63,7 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -73,11 +73,11 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.NONE)
val factoryFlow = MutableStateFlow<AccountList?>(null)
every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull()
every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull()
// Act (first emission)
factoryFlow.value = accountList
@ -95,9 +95,9 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -107,10 +107,10 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val factoryFlow = MutableStateFlow<AccountList?>(null)
every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull()
every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull()
// Act (first emission)
factoryFlow.value = accountList
@ -128,9 +128,9 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -141,7 +141,7 @@ class DefaultMultiAccountListProducerTest {
every { userWalletsStore.userWallets } returns userWalletsFlow
val exception = RuntimeException("Converter error")
every { walletAccountListFlowFactory.create(userWallet) } throws exception
every { walletAccountListFlowFactory.create(userWalletId) } throws exception
// Act
val actual = producer.produceWithFallback().let(::getEmittedValues)
@ -152,7 +152,7 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -178,7 +178,7 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
every { walletAccountListFlowFactory.create(userWallet) } returns emptyFlow()
every { walletAccountListFlowFactory.create(userWalletId) } returns emptyFlow()
// Act
val actual = producer.produce().let(::getEmittedValues)
@ -188,7 +188,7 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -203,9 +203,9 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(listOf(userWallet, userWallet2))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList)
every { walletAccountListFlowFactory.create(userWallet2) } returns emptyFlow()
val accountList = AccountList.empty(userWalletId)
every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList)
every { walletAccountListFlowFactory.create(userWalletId2) } returns emptyFlow()
// Act
val actual = producer.produce().let(::getEmittedValues)
@ -215,8 +215,8 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWallet2)
walletAccountListFlowFactory.create(userWalletId)
walletAccountListFlowFactory.create(userWalletId2)
}
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.data.account.producer
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.models.TokensSortType
@ -11,7 +10,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@ -26,7 +24,6 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultSingleAccountListProducerTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk()
private val userWalletId = UserWalletId("011")
@ -36,24 +33,22 @@ class DefaultSingleAccountListProducerTest {
private val producer = DefaultSingleAccountListProducer(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
userWalletsStore = userWalletsStore,
walletAccountListFlowFactory = walletAccountListFlowFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@AfterEach
fun tearDownEach() {
clearMocks(userWalletsStore, walletAccountListFlowFactory)
clearMocks(walletAccountListFlowFactory)
}
@Test
fun produce() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
MutableStateFlow(listOf(userWallet))
val accountList = AccountList.empty(userWallet)
every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList)
val accountList = AccountList.empty(userWalletId)
every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList)
// Act
val actual = producer.produce().let(::getEmittedValues)
@ -63,22 +58,18 @@ class DefaultSingleAccountListProducerTest {
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@Test
fun `flow will updated if factoryFlow is updated`() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.NONE)
val factoryFlow = MutableStateFlow<AccountList?>(null)
every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull()
every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull()
// Act (first emission)
factoryFlow.value = accountList
@ -95,23 +86,18 @@ class DefaultSingleAccountListProducerTest {
Truth.assertThat(secondEmission).containsExactly(updatedAccountList)
coVerifyOrder {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
walletAccountListFlowFactory.create(userWalletId)
}
}
@Test
fun `flow is filtered the same response`() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val factoryFlow = MutableStateFlow<AccountList?>(null)
every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull()
every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull()
// Act (first emission)
factoryFlow.value = accountList
@ -128,71 +114,8 @@ class DefaultSingleAccountListProducerTest {
Truth.assertThat(secondEmission).containsExactly(accountList)
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
walletAccountListFlowFactory.create(userWalletId)
}
}
@Test
fun `flow is empty if factory throws exception`() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val exception = RuntimeException("Converter error")
every { walletAccountListFlowFactory.create(userWallet) } throws exception
// Act
val actual = producer.produceWithFallback().let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty() // no emissions
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
}
}
@Test
fun `flow is empty if userWalletsFlow returns empty flow`() = runTest {
// Arrange
val userWalletsFlow = emptyFlow<List<UserWallet>>()
every { userWalletsStore.userWallets } returns userWalletsFlow
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty() // no emissions
coVerify(exactly = 1) { userWalletsStore.userWallets }
coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) }
}
@Test
fun `flow is empty if userWalletsFlow doesn't contains userWalletId from params`() = runTest {
// Arrange
val unknownId = UserWalletId("012")
val unknownWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns unknownId
}
val userWalletsFlow = MutableStateFlow(listOf(unknownWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty() // no emissions
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
}
coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) }
}
}

View file

@ -10,6 +10,7 @@ import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -28,6 +29,7 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class WalletAccountListFlowFactoryTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk()
private val accountsResponseStore: AccountsResponseStore = mockk()
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
@ -38,6 +40,7 @@ class WalletAccountListFlowFactoryTest {
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
private val factory = WalletAccountListFlowFactory(
userWalletsStore = userWalletsStore,
accountsResponseStoreFactory = accountsResponseStoreFactory,
accountListConverterFactory = accountListConverterFactory,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
@ -49,6 +52,7 @@ class WalletAccountListFlowFactoryTest {
@AfterEach
fun tearDownEach() {
clearMocks(
userWalletsStore,
accountsResponseStoreFactory,
accountsResponseStore,
accountListConverterFactory,
@ -66,17 +70,19 @@ class WalletAccountListFlowFactoryTest {
every { this@mockk.isMultiCurrency } returns true
}
every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet
val accountsResponse = createGetWalletAccountsResponse(userWalletId)
every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore
every { accountsResponseStore.data } returns accountsResponseStoreFlow
accountsResponseStoreFlow.value = accountsResponse
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
every { accountListConverterFactory.create(userWallet) } returns accountListConverter
every { accountListConverter.convert(accountsResponse) } returns accountList
// Act
val actual = factory.create(userWallet).let(::getEmittedValues)
val actual = factory.create(userWalletId).let(::getEmittedValues)
// Assert
val expected = accountList
@ -99,14 +105,16 @@ class WalletAccountListFlowFactoryTest {
fun `create for single wallet`() = runTest {
val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false)
every { userWalletsStore.getSyncStrict(userWallet.walletId) } returns userWallet
val currency = cryptoCurrencyFactory.ethereum
every { cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) } returns currency
// Act
val actual = factory.create(userWallet).let(::getEmittedValues)
val actual = factory.create(userWallet.walletId).let(::getEmittedValues)
// Assert
val expected = AccountList.empty(userWallet = userWallet, cryptoCurrencies = setOf(currency))
val expected = AccountList.empty(userWalletId = userWallet.walletId, cryptoCurrencies = setOf(currency))
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
@ -126,16 +134,18 @@ class WalletAccountListFlowFactoryTest {
fun `flow is created for single wallet with token`() = runTest {
val nodl = MockUserWalletFactory.createSingleWalletWithToken()
every { userWalletsStore.getSyncStrict(nodl.walletId) } returns nodl
val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet()
every {
cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl)
} returns currencies.toList()
// Act
val actual = factory.create(nodl).let(::getEmittedValues)
val actual = factory.create(nodl.walletId).let(::getEmittedValues)
// Assert
val expected = AccountList.empty(userWallet = nodl, cryptoCurrencies = currencies)
val expected = AccountList.empty(userWalletId = nodl.walletId, cryptoCurrencies = currencies)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {

View file

@ -584,7 +584,7 @@ class DefaultAccountsCRUDRepositoryTest {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountsResponse = mockk<GetWalletAccountsResponse>()
accountsResponseStoreFlow.value = accountsResponse
@ -593,6 +593,8 @@ class DefaultAccountsCRUDRepositoryTest {
every { this@mockk.convert(accountList) } returns accountsResponse
}
every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet
every {
convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet)
} returns converter
@ -617,7 +619,7 @@ class DefaultAccountsCRUDRepositoryTest {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountsResponse = mockk<GetWalletAccountsResponse>()
accountsResponseStoreFlow.value = accountsResponse
@ -626,6 +628,8 @@ class DefaultAccountsCRUDRepositoryTest {
every { this@mockk.convert(accountList) } returns accountsResponse
}
every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet
every {
convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet)
} returns converter

View file

@ -0,0 +1,240 @@
package com.tangem.data.account.utils
import com.google.common.truth.Truth
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultWalletAccountsResponseFactoryTest {
private val userWalletsListRepository = mockk<UserWalletsListRepository>()
private val cryptoPortfolioCF = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val userTokensResponseFactory = mockk<UserTokensResponseFactory>()
private val cardCryptoCurrencyFactory = mockk<CardCryptoCurrencyFactory>()
private val factory = DefaultWalletAccountsResponseFactory(
userWalletsListRepository = userWalletsListRepository,
cryptoPortfolioCF = cryptoPortfolioCF,
userTokensResponseFactory = userTokensResponseFactory,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
)
private val userWalletId = UserWalletId("011")
@BeforeEach
fun setUpEach() {
every { cryptoPortfolioCF.create(any()) } returns cryptoPortfolioConverter
}
@AfterEach
fun tearDownEach() {
clearMocks(
userWalletsListRepository,
cryptoPortfolioCF,
cryptoPortfolioConverter,
userTokensResponseFactory,
cardCryptoCurrencyFactory,
)
}
@Test
fun `create returns empty accounts when user wallet not found`() = runTest {
// Arrange
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns userTokensResponse
// Act
val actual = factory.create(userWalletId = userWalletId, userTokensResponse = null)
// Assert
val expected = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
totalAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
userWalletsListRepository.userWalletsSync()
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
}
@Test
fun `create returns response with default tokens when userTokensResponse is null`() = runTest {
// Arrange
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
val defaultCoins = listOf(mockk<CryptoCurrency.Coin>())
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns defaultCoins
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = listOf(mockk(relaxed = true)),
)
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = defaultCoins,
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns defaultResponse
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val accountsDTO = createWalletAccountDTO(userWalletId)
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO)
// Act
val actual = factory.create(userWalletId, null)
// Assert
val expected = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = defaultResponse.group,
sort = defaultResponse.sort,
totalAccounts = 1,
),
accounts = listOf(accountsDTO),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
userWalletsListRepository.userWalletsSync()
cryptoPortfolioConverter.convertListBack(accounts)
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet)
userTokensResponseFactory.createUserTokensResponse(
currencies = defaultCoins,
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
}
@Test
fun `create returns response with default tokens when userTokensResponse is null and no default coins`() = runTest {
// Arrange
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList()
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns defaultResponse
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList()
// Act
val actual = factory.create(userWalletId, null)
// Assert
val expected = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = defaultResponse.group,
sort = defaultResponse.sort,
totalAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `create returns response with assigned tokens`() = runTest {
// Arrange
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
val assignedTokens = listOf(mockk<CryptoCurrency.Token>(), mockk<CryptoCurrency.Token>())
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = listOf(mockk(relaxed = true)),
)
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = assignedTokens,
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns userTokensResponse
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val accountsDTO = createWalletAccountDTO(userWalletId)
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO)
// Act
val actual = factory.create(userWalletId, userTokensResponse)
// Assert
val expected = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = userTokensResponse.group,
sort = userTokensResponse.sort,
totalAccounts = 1,
),
accounts = listOf(accountsDTO),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
}
}

View file

@ -326,6 +326,7 @@ class NetworkFactory @Inject constructor(
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
Blockchain.Quai, Blockchain.QuaiTestnet,
Blockchain.Linea, Blockchain.LineaTestnet,
-> Network.TransactionExtrasType.NONE
// endregion
}

View file

@ -160,5 +160,6 @@ public val Blockchain.mercuryoNetwork: String?
Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> null
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> null
Blockchain.Quai, Blockchain.QuaiTestnet -> null
Blockchain.Linea, Blockchain.LineaTestnet -> null
}
}

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject
private const val VALID_STATUS = "valid"
private const val APPROVED_KYC_STATUS = "APPROVED"
private const val TAG = "TangemPay: OnboardingRepository"
internal class DefaultOnboardingRepository @Inject constructor(
@ -47,13 +48,36 @@ internal class DefaultOnboardingRepository @Inject constructor(
override suspend fun getMainScreenCustomerInfo(): Either<UniversalError, MainScreenCustomerInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.requestWithPersistedToken { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
val customerWalletAddress = requestHelper.getCustomerWalletAddress()
val orderStatus = getOrderStatus().getOrNull() ?: error("Order status is null")
when (val orderId = tangemPayStorage.getOrderId(customerWalletAddress)) {
// If order id wasn't saved -> get customer info
null -> {
MainScreenCustomerInfo(
info = getCustomerInfoWithPersistedToken(),
orderStatus = OrderStatus.UNKNOWN,
)
}
// If order id was saved -> check its status
else -> {
val orderStatus = getOrderStatus(orderId)
val customerInfo = when (orderStatus) {
// Kyc is passed and user waits for order creation -> no need to get customer info
OrderStatus.NEW,
OrderStatus.PROCESSING,
-> CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null)
MainScreenCustomerInfo(info = getCustomerInfo(result), orderStatus = orderStatus)
// Order was created/cancelled -> clear order id and get customer info
OrderStatus.UNKNOWN,
OrderStatus.COMPLETED,
OrderStatus.CANCELED,
-> getCustomerInfoWithPersistedToken().also {
tangemPayStorage.clearOrderId(customerWalletAddress)
}
}
MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus)
}
}
}
}
@ -84,27 +108,28 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
return CustomerInfo(
productInstance = response?.productInstance?.let { ProductInstance(id = it.id, status = it.status) },
kycStatus = response?.kyc?.status,
isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS,
cardInfo = cardInfo,
)
}
private suspend fun getOrderStatus(): Either<UniversalError, OrderStatus> {
return requestHelper.runWithErrorLogs(TAG) {
val walletAddress = requestHelper.getCustomerWalletAddress()
val orderId: String = tangemPayStorage.getOrderId(walletAddress)
?: return@runWithErrorLogs OrderStatus.NOT_ISSUED
private suspend fun getOrderStatus(orderId: String): OrderStatus {
val result = requestHelper.request { authHeader ->
tangemPayApi.getOrder(authHeader, orderId)
}.result ?: error("Order result is null")
val result = requestHelper.request { authHeader ->
tangemPayApi.getOrder(authHeader, orderId)
}.result ?: error("Order result is null")
when (result.status) {
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
}
return when (result.status) {
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
}
}
private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo {
val result = requestHelper.requestWithPersistedToken { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
return getCustomerInfo(result)
}
}

View file

@ -86,6 +86,6 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
}.result
val items = TangemPayTxHistoryItemConverter.convertList(result.transactions).filterNotNull()
txHistoryItemsStore.store(key = customerWalletAddress, cursor = cursor ?: INITIAL_CURSOR, value = items)
}
}.onLeft { error(it.toString()) }
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.data.yield.supply
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.yieldsupply.YieldSupplyProvider
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
@ -9,8 +11,11 @@ import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.data.yield.supply.converters.YieldTokenStatusConverter
import com.tangem.data.yield.supply.converters.YieldTokenChartConverter
import com.tangem.datasource.api.tangemTech.models.YieldSupplyChangeTokenStatusBody
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
@ -20,25 +25,29 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlin.collections.map
internal class DefaultYieldSupplyMarketRepository(
internal class DefaultYieldSupplyRepository(
private val yieldSupplyApi: YieldSupplyApi,
private val store: YieldMarketsStore,
private val walletManagersFacade: WalletManagersFacade,
private val dispatchers: CoroutineDispatcherProvider,
) : YieldSupplyMarketRepository {
) : YieldSupplyRepository {
override suspend fun getCachedMarkets(): List<YieldMarketToken>? = withContext(dispatchers.io) {
store.getSyncOrNull()?.enrichNetworkIds()
val cache = store.getSyncOrNull().orEmpty()
val domain = cache.map(YieldMarketTokenConverter::convert)
domain.enrichNetworkIds()
}
override suspend fun updateMarkets(): List<YieldMarketToken> = withContext(dispatchers.io) {
val response = yieldSupplyApi.getYieldMarkets().getOrThrow()
val chains = Blockchain.yieldSupplySupportedBlockchains().map { it.getChainId() }.joinToString(",")
val response = yieldSupplyApi.getYieldMarkets(chainId = chains).getOrThrow()
val domain = response.marketDtos.map(YieldMarketTokenConverter::convert)
store.store(domain)
store.store(response.marketDtos)
domain
}
override fun getMarketsFlow(): Flow<List<YieldMarketToken>> = store.get().map {
it.enrichNetworkIds()
it.map(YieldMarketTokenConverter::convert).enrichNetworkIds()
}
override suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketTokenStatus {
@ -55,6 +64,41 @@ internal class DefaultYieldSupplyMarketRepository(
return YieldTokenChartConverter.convert(response)
}
override suspend fun isYieldSupplySupported(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean =
withContext(dispatchers.io) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = cryptoCurrency.network.toBlockchain(),
derivationPath = cryptoCurrency.network.derivationPath.value,
) ?: error("Wallet manager not found")
(walletManager as? YieldSupplyProvider)?.isSupported() ?: false
}
override suspend fun activateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean =
withContext(dispatchers.io) {
val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId()
?: error("Chain id is required for evm's")
yieldSupplyApi.activateYieldModule(
YieldSupplyChangeTokenStatusBody(
tokenAddress = cryptoCurrencyToken.contractAddress,
chainId = chainId,
),
).getOrThrow().isActive
}
override suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean =
withContext(dispatchers.io) {
val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId()
?: error("Chain id is required for evm's")
yieldSupplyApi.deactivateYieldModule(
YieldSupplyChangeTokenStatusBody(
tokenAddress = cryptoCurrencyToken.contractAddress,
chainId = chainId,
),
).getOrThrow().isActive
}
private fun List<YieldMarketToken>.enrichNetworkIds(): List<YieldMarketToken> {
val chainIdMap = Blockchain.entries.associate { it.getChainId() to it.toNetworkId() }
return this.map { token ->

View file

@ -1,12 +1,12 @@
package com.tangem.data.yield.supply.di
import com.tangem.data.yield.supply.DefaultYieldSupplyMarketRepository
import com.tangem.data.yield.supply.DefaultYieldSupplyRepository
import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver
import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -37,12 +37,14 @@ internal object YieldSupplyDataModule {
fun provideYieldSupplyMarketRepository(
yieldSupplyApi: YieldSupplyApi,
store: YieldMarketsStore,
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): YieldSupplyMarketRepository {
return DefaultYieldSupplyMarketRepository(
): YieldSupplyRepository {
return DefaultYieldSupplyRepository(
yieldSupplyApi = yieldSupplyApi,
store = store,
dispatchers = dispatchers,
walletManagersFacade = walletManagersFacade,
)
}

View file

@ -8,14 +8,14 @@ import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import kotlinx.serialization.Serializable
/**
* Represents a list of accounts associated with a user wallet
*
* @property userWallet the user wallet associated with the account list
* @property userWalletId the user wallet id associated with the account list
* @property accounts a set of accounts belonging to the user wallet
* @property totalAccounts the total number of accounts
*
@ -23,7 +23,7 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class AccountList private constructor(
val userWallet: UserWallet,
val userWalletId: UserWalletId,
val accounts: Set<Account>,
val totalAccounts: Int,
val sortType: TokensSortType,
@ -51,7 +51,7 @@ data class AccountList private constructor(
val accounts = this.accounts.addOrReplace(other) { it.accountId == other.accountId }
return invoke(
userWallet = this.userWallet,
userWalletId = this.userWalletId,
accounts = accounts,
totalAccounts = this.totalAccounts + if (isNewAccount) 1 else 0,
sortType = this.sortType,
@ -73,7 +73,7 @@ data class AccountList private constructor(
}
return invoke(
userWallet = this.userWallet,
userWalletId = this.userWalletId,
accounts = accounts,
totalAccounts = this.totalAccounts - if (isExistingAccount) 1 else 0,
sortType = this.sortType,
@ -134,12 +134,12 @@ data class AccountList private constructor(
* Factory method to create an `AccountList` instance.
* Validates the input to ensure the accounts list is not empty and contains exactly one main account.
*
* @param userWallet the user wallet associated with the account list
* @param userWalletId the user wallet id associated with the account list
* @param accounts a set of accounts belonging to the user wallet
* @param totalAccounts the total number of accounts
*/
operator fun invoke(
userWallet: UserWallet,
userWalletId: UserWalletId,
accounts: Set<Account>,
totalAccounts: Int,
sortType: TokensSortType = TokensSortType.NONE,
@ -169,7 +169,7 @@ data class AccountList private constructor(
}
AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = accounts,
totalAccounts = totalAccounts,
sortType = sortType,
@ -180,19 +180,19 @@ data class AccountList private constructor(
/**
* Factory method to create an empty [AccountList] with a main crypto portfolio account
*
* @param userWallet the user wallet associated with the account list
* @param userWalletId the user wallet id associated with the account list
*/
fun empty(
userWallet: UserWallet,
userWalletId: UserWalletId,
cryptoCurrencies: Set<CryptoCurrency> = emptySet(),
sortType: TokensSortType = TokensSortType.NONE,
groupType: TokensGroupType = TokensGroupType.NONE,
): AccountList {
return AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(
Account.CryptoPortfolio.createMainAccount(
userWalletId = userWallet.walletId,
userWalletId = userWalletId,
cryptoCurrencies = cryptoCurrencies,
),
),

View file

@ -3,13 +3,13 @@ package com.tangem.domain.account.models
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
/**
* Represents a list of account statuses associated with a user wallet
*
* @property userWallet the user wallet to which the account statuses belong
* @property userWalletId the user wallet id to which the account statuses belong
* @property accountStatuses a set of account statuses associated with the user wallet
* @property totalAccounts the total number of accounts (including archived ones)
* @property totalFiatBalance the total fiat balance across all accounts
@ -18,7 +18,7 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class AccountStatusList(
val userWallet: UserWallet,
val userWalletId: UserWalletId,
val accountStatuses: Set<AccountStatus>,
val totalAccounts: Int,
val totalFiatBalance: TotalFiatBalance,

View file

@ -8,11 +8,7 @@ import com.tangem.domain.account.utils.createAccounts
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@ -31,7 +27,7 @@ class AccountListTest {
val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
val accountList = AccountList(
userWallet = mockk(),
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
)
@ -49,13 +45,13 @@ class AccountListTest {
fun canAddMoreAccounts() {
// Arrange
val accountList = AccountList(
userWallet = mockk(),
userWalletId = userWalletId,
accounts = createAccounts(userWalletId = userWalletId, count = 2),
totalAccounts = 2,
).getOrNull()!!
val fullAccountList = AccountList(
userWallet = mockk(),
userWalletId = userWalletId,
accounts = createAccounts(userWalletId = userWalletId, count = 20),
totalAccounts = 20,
).getOrNull()!!
@ -67,16 +63,13 @@ class AccountListTest {
@Test
fun empty() {
// Arrange
val userWallet = mockk<UserWallet>(relaxed = true)
// Act
val actual = AccountList.empty(userWallet)
val actual = AccountList.empty(userWalletId)
// Assert
val expected = AccountList(
userWallet = userWallet,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)),
userWalletId = userWalletId,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)),
totalAccounts = 1,
).getOrNull()!!
@ -87,19 +80,12 @@ class AccountListTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Create {
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(userWallet)
}
@ParameterizedTest
@MethodSource("provideTestModels")
fun invoke(model: CreateTestModel) {
// Act
val actual = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = model.accounts,
totalAccounts = model.accounts.size,
)
@ -131,13 +117,13 @@ class AccountListTest {
createAccounts(userWalletId = userWalletId, count = 1).let {
CreateTestModel(
accounts = it,
expected = AccountList(userWallet = userWallet, accounts = it, totalAccounts = 1),
expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 1),
)
},
createAccounts(userWalletId = userWalletId, count = 20).let {
CreateTestModel(
accounts = it,
expected = AccountList(userWallet = userWallet, accounts = it, totalAccounts = 20),
expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 20),
)
},
CreateTestModel(
@ -171,8 +157,6 @@ class AccountListTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Plus {
private val userWallet = mockk<UserWallet>()
@ParameterizedTest
@MethodSource("provideTestModels")
fun invoke(model: PlusTestModel) {
@ -191,13 +175,13 @@ class AccountListTest {
PlusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
).getOrNull()!!,
toAdd = newAccount,
expected = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount, newAccount),
totalAccounts = 2,
),
@ -211,13 +195,13 @@ class AccountListTest {
PlusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
).getOrNull()!!,
toAdd = newAccount,
expected = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(newAccount),
totalAccounts = 1,
),
@ -226,7 +210,7 @@ class AccountListTest {
// endregion
PlusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = createAccounts(userWalletId = userWalletId, count = 20),
totalAccounts = 20,
).getOrNull()!!,
@ -246,8 +230,6 @@ class AccountListTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Minus {
private val userWallet = mockk<UserWallet>()
@ParameterizedTest
@MethodSource("provideTestModels")
fun invoke(model: MinusTestModel) {
@ -266,13 +248,13 @@ class AccountListTest {
MinusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount, secondaryAccount),
totalAccounts = 2,
).getOrNull()!!,
toRemove = secondaryAccount,
expected = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
),
@ -286,13 +268,13 @@ class AccountListTest {
MinusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
).getOrNull()!!,
toRemove = notInList,
expected = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
),
@ -305,7 +287,7 @@ class AccountListTest {
MinusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount),
totalAccounts = 1,
).getOrNull()!!,
@ -321,7 +303,7 @@ class AccountListTest {
MinusTestModel(
initial = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = setOf(mainAccount, secondaryAccount),
totalAccounts = 2,
).getOrNull()!!,

View file

@ -14,7 +14,6 @@ import com.tangem.domain.account.utils.createAccount
import com.tangem.domain.account.utils.createAccounts
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -35,20 +34,16 @@ class AddCryptoPortfolioUseCaseTest {
mainAccountTokensMigration = mainAccountTokensMigration,
)
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, singleAccountListFetcher, mainAccountTokensMigration, userWallet)
every { userWallet.walletId } returns userWalletId
clearMocks(crudRepository, singleAccountListFetcher, mainAccountTokensMigration)
}
@Test
fun `invoke should add new crypto portfolio account to existing list`() = runTest {
// Arrange
val newAccount = createNewAccount()
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = (accountList + newAccount).getOrNull()!!
coEvery {
@ -152,7 +147,7 @@ class AddCryptoPortfolioUseCaseTest {
fun `invoke should return error if account list requirements not met`() = runTest {
// Arrange
val accountList = AccountList(
userWallet = userWallet,
userWalletId = userWalletId,
accounts = createAccounts(userWalletId = userWalletId, count = 20),
totalAccounts = 20,
).getOrNull()!!
@ -228,7 +223,7 @@ class AddCryptoPortfolioUseCaseTest {
fun `invoke should return error if saveAccounts throws exception`() = runTest {
// Arrange
val newAccount = createNewAccount()
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = (accountList + newAccount).getOrNull()!!
val exception = IllegalStateException("Test error")
@ -266,7 +261,7 @@ class AddCryptoPortfolioUseCaseTest {
fun `invoke should return new account if migrate returns error`() = runTest {
// Arrange
val newAccount = createNewAccount()
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = (accountList + newAccount).getOrNull()!!
val exception = Exception("Migration error")

View file

@ -11,7 +11,6 @@ import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase.Error
import com.tangem.domain.account.utils.createAccount
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -24,19 +23,17 @@ class ArchiveCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = ArchiveCryptoPortfolioUseCase(crudRepository)
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, userWallet)
every { userWallet.walletId } returns userWalletId
clearMocks(crudRepository)
}
@Test
fun `invoke should archive existing crypto portfolio account`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = (AccountList.empty(userWallet) + account).getOrNull()!!
val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!!
val accountId = account.accountId
val updatedAccountList = (accountList - account).getOrNull()!!
@ -103,7 +100,7 @@ class ArchiveCryptoPortfolioUseCaseTest {
@Test
fun `invoke should return error if account not found`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex(1).getOrNull()!!,
@ -126,7 +123,7 @@ class ArchiveCryptoPortfolioUseCaseTest {
fun `invoke should return error if saveAccounts throws exception`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = (AccountList.empty(userWallet) + account).getOrNull()!!
val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!!
val accountId = account.accountId
val updatedAccountList = (accountList - account).getOrNull()!!

View file

@ -12,7 +12,6 @@ import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase.Error
import com.tangem.domain.account.utils.createAccount
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -28,19 +27,17 @@ class RecoverCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = RecoverCryptoPortfolioUseCase(crudRepository)
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, userWallet)
every { userWallet.walletId } returns userWalletId
clearMocks(crudRepository)
}
@Test
fun `invoke should recover archived crypto portfolio account`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val archivedAccount = ArchivedAccount(
accountId = account.accountId,
name = account.accountName,
@ -122,7 +119,7 @@ class RecoverCryptoPortfolioUseCaseTest {
fun `invoke should return error if getArchivedAccount throws exception`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val exception = IllegalStateException("Test error")
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
@ -146,7 +143,7 @@ class RecoverCryptoPortfolioUseCaseTest {
fun `invoke should return error if getArchivedAccount returns null`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns None
@ -169,7 +166,7 @@ class RecoverCryptoPortfolioUseCaseTest {
fun `invoke should return error if saveAccounts throws exception`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val archivedAccount = ArchivedAccount(
accountId = account.accountId,
name = account.accountName,

View file

@ -12,7 +12,6 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -29,19 +28,15 @@ class UpdateCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = UpdateCryptoPortfolioUseCase(crudRepository = crudRepository)
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, userWallet)
every { userWallet.walletId } returns userWalletId
clearMocks(crudRepository)
}
@Test
fun `invoke should update crypto portfolio account with new name`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountName = AccountName("New name").getOrNull()!!
@ -66,7 +61,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke should update crypto portfolio account with new icon`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountIcon = CryptoPortfolioIcon.ofCustomAccount(
@ -94,7 +89,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke should update crypto portfolio account with new name and icon`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountName = AccountName("New name").getOrNull()!!
@ -123,7 +118,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke if name and icon are null`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption()
@ -144,7 +139,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke if getAccounts throws exception`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountName = AccountName("New name").getOrNull()!!
@ -192,7 +187,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke if getAccounts does not contain accountId`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex(1).getOrNull()!!,
@ -217,7 +212,7 @@ class UpdateCryptoPortfolioUseCaseTest {
@Test
fun `invoke if saveAccounts throws exception`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet = userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
val accountId = accountList.mainAccount.accountId
val newAccountName = AccountName("New name").getOrNull()!!

View file

@ -25,12 +25,15 @@ dependencies {
api(projects.domain.staking)
api(projects.domain.tokens)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
implementation(tangemDeps.blockchain)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)

View file

@ -1,5 +1,6 @@
package com.tangem.domain.account.status.di
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.supplier.SingleAccountListSupplier
@ -31,7 +32,9 @@ internal object AccountStatusUseCaseModule {
@Provides
@Singleton
fun provideGetAccountCurrencyStatusUseCase(): GetAccountCurrencyStatusUseCase {
return GetAccountCurrencyStatusUseCase()
fun provideGetAccountCurrencyStatusUseCase(
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
): GetAccountCurrencyStatusUseCase {
return GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = singleAccountStatusListSupplier)
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokensGroupType
@ -39,6 +40,7 @@ import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultSingleAccountStatusListProducer @AssistedInject constructor(
@Assisted private val params: SingleAccountStatusListProducer.Params,
private val userWalletsListRepository: UserWalletsListRepository,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory,
private val dispatchers: CoroutineDispatcherProvider,
@ -58,8 +60,12 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
if (account.cryptoCurrencies.isEmpty()) {
createEmptyAccountStatusFlow(account)
} else {
val userWallet = userWalletsListRepository.userWalletsSync().first {
it.walletId == params.userWalletId
}
getAccountStatusFlow(
userWallet = accountList.userWallet,
userWallet = userWallet,
account = account,
groupType = accountList.groupType,
sortType = accountList.sortType,
@ -71,7 +77,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
val balances = accountStatuses.map { it.tokenList.totalFiatBalance }
AccountStatusList(
userWallet = accountList.userWallet,
userWalletId = accountList.userWalletId,
accountStatuses = accountStatuses.toSet(),
totalAccounts = accountList.totalAccounts,
totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances),

View file

@ -3,6 +3,8 @@ package com.tangem.domain.account.status.supplier
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.core.flow.FlowCachingSupplier
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Supplier that provides a single [AccountStatusList] for a specific user wallet.
@ -12,4 +14,10 @@ import com.tangem.domain.core.flow.FlowCachingSupplier
abstract class SingleAccountStatusListSupplier(
override val factory: SingleAccountStatusListProducer.Factory,
override val keyCreator: (SingleAccountStatusListProducer.Params) -> String,
) : FlowCachingSupplier<SingleAccountStatusListProducer, SingleAccountStatusListProducer.Params, AccountStatusList>()
) : FlowCachingSupplier<SingleAccountStatusListProducer, SingleAccountStatusListProducer.Params, AccountStatusList>() {
operator fun invoke(userWalletId: UserWalletId): Flow<AccountStatusList> {
val params = SingleAccountStatusListProducer.Params(userWalletId)
return this.invoke(params)
}
}

View file

@ -117,7 +117,7 @@ class GetAccountCurrencyByAddressUseCase(
.firstOrNull()
return ensureNotNull(result) {
"No account found for network: $networkId in walletId: ${accountList.userWallet.walletId}"
"No account found for network: $networkId in walletId: ${accountList.userWalletId}"
}
}

View file

@ -2,24 +2,110 @@ package com.tangem.domain.account.status.usecase
import arrow.core.Option
import arrow.core.none
import arrow.core.toOption
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
/**
* Use case to retrieve the status of a specific cryptocurrency associated with an account.
*
* @property singleAccountStatusListSupplier supplier to get the list of account statuses.
*
[REDACTED_AUTHOR]
*/
// TODO: Implement [REDACTED_JIRA]
class GetAccountCurrencyStatusUseCase {
class GetAccountCurrencyStatusUseCase(
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
) {
/**
* Invokes the use case to get the [AccountCryptoCurrencyStatus] for the given [currencyId].
* Invokes the use case to get the status of a specific cryptocurrency for a given user wallet.
*
* @param currencyId The ID of the cryptocurrency to look up.
*
* @return An [Option] containing the [AccountCryptoCurrencyStatus] if found,
* or [arrow.core.None] if not found or if any validation fails.
* @param userWalletId the ID of the user wallet.
* @param currency the cryptocurrency for which the status is to be retrieved.
* @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None.
*/
suspend operator fun invoke(currencyId: CryptoCurrency.ID): Option<AccountCryptoCurrencyStatus> = none()
suspend operator fun invoke(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): Option<AccountCryptoCurrencyStatus> {
return invoke(userWalletId = userWalletId, currencyId = currency.id, network = currency.network)
}
/**
* Invokes the use case to get the status of a specific cryptocurrency by its ID for a given user wallet and network.
* If the [network] is null, it searches across all accounts for the cryptocurrency.
*
* @param userWalletId the ID of the user wallet.
* @param currencyId the ID of the cryptocurrency.
* @param network the network associated with the cryptocurrency, can be null.
* @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None.
*/
suspend operator fun invoke(
userWalletId: UserWalletId,
currencyId: CryptoCurrency.ID,
network: Network?,
): Option<AccountCryptoCurrencyStatus> {
val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull(
params = SingleAccountStatusListProducer.Params(userWalletId),
) ?: return none()
return accountStatusList.getExpectedAccountStatuses(network)
.asSequence()
.filterIsInstance<AccountStatus.CryptoPortfolio>()
.mapNotNull { accountStatus ->
val status = accountStatus.flattenCurrencies().firstOrNull { it.currency.id == currencyId }
?: return@mapNotNull null
AccountCryptoCurrencyStatus(account = accountStatus.account, status = status)
}
.firstOrNull()
.toOption()
}
/**
* Retrieves the expected account statuses based on the provided [network].
* If the [network] is null, all account statuses are returned.
* If the network has a specific derivation index, it filters the accounts accordingly.
*
* @param network the network to filter accounts by, can be null.
* @return a set of [AccountStatus] that match the expected criteria.
*/
private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): Set<AccountStatus> {
val possibleAccountIndex = network?.getAccountIndexOrNull()
return when (possibleAccountIndex) {
// currency can be in any account
null -> accountStatuses
// currency only in the main account
DerivationIndex.Main.value -> setOf(mainAccount)
// currency only in the account with specific derivation index or in the main account
else -> {
val accountStatus = accountStatuses.firstOrNull {
val cryptoPortfolio = it.account as? Account.CryptoPortfolio ?: return@firstOrNull false
cryptoPortfolio.derivationIndex.value == possibleAccountIndex
}
setOfNotNull(accountStatus, mainAccount)
}
}
}
private fun Network.getAccountIndexOrNull(): Int? {
val blockchain = Blockchain.fromNetworkId(networkId = rawId) ?: return null
val recognizer = AccountNodeRecognizer(blockchain)
return recognizer.recognize(derivationPath)?.toInt()
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.models.StatusSource
@ -37,6 +38,7 @@ import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultSingleAccountStatusListProducerTest {
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory = mockk()
@ -47,6 +49,7 @@ class DefaultSingleAccountStatusListProducerTest {
private val producer = DefaultSingleAccountStatusListProducer(
params = SingleAccountStatusListProducer.Params(userWalletId),
userWalletsListRepository = userWalletsListRepository,
singleAccountListSupplier = singleAccountListSupplier,
cryptoCurrencyStatusesFlowFactory = cryptoCurrencyStatusesFlowFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
@ -60,7 +63,7 @@ class DefaultSingleAccountStatusListProducerTest {
@Test
fun `flow is mapped for user wallet id from params`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId = userWalletId)
every {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
@ -71,7 +74,7 @@ class DefaultSingleAccountStatusListProducerTest {
// Assert
val expected = AccountStatusList(
userWallet = userWallet,
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = accountList.mainAccount,
@ -92,8 +95,8 @@ class DefaultSingleAccountStatusListProducerTest {
@Test
fun `flow will updated if balances are updated`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet)
val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.BALANCE)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.BALANCE)
val accountListFlow = MutableStateFlow(value = accountList)
@ -106,7 +109,7 @@ class DefaultSingleAccountStatusListProducerTest {
// Assert (first emission)
val expected = AccountStatusList(
userWallet = userWallet,
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = accountList.mainAccount,
@ -125,7 +128,7 @@ class DefaultSingleAccountStatusListProducerTest {
// Assert (second emission)
val expected2 = AccountStatusList(
userWallet = userWallet,
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = updatedAccountList.mainAccount,
@ -147,7 +150,7 @@ class DefaultSingleAccountStatusListProducerTest {
@Test
fun `flow is filtered the same balance`() = runTest {
// Arrange
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val accountListFlow = MutableStateFlow(value = accountList)
every {
@ -155,7 +158,7 @@ class DefaultSingleAccountStatusListProducerTest {
} returns accountListFlow
val expected = AccountStatusList(
userWallet = userWallet,
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = accountList.mainAccount,
@ -191,10 +194,12 @@ class DefaultSingleAccountStatusListProducerTest {
// Arrange
val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
val accountList = AccountList.empty(
userWallet = userWallet,
userWalletId = userWalletId,
cryptoCurrencies = cryptoCurrencyFactory.ethereumAndStellar.toSet(),
)
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
every {
singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId))
} returns flowOf(accountList)
@ -220,7 +225,7 @@ class DefaultSingleAccountStatusListProducerTest {
// Assert
val expected = AccountStatusList(
userWallet = userWallet,
userWalletId = userWalletId,
accountStatuses = setOf(
AccountStatus.CryptoPortfolio(
account = accountList.mainAccount,

View file

@ -218,7 +218,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
},
value = NetworkStatus.Unreachable(address = validNetworkAddress),
)
val accountList = AccountList.empty(multiUserWallet)
val accountList = AccountList.empty(userWalletId)
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {
@ -253,7 +253,7 @@ class GetAccountCurrencyByAddressUseCaseTest {
network = currency.network,
value = NetworkStatus.Unreachable(address = validNetworkAddress),
)
val accountList = AccountList.empty(userWallet = multiUserWallet, cryptoCurrencies = setOf(currency))
val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = setOf(currency))
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {

View file

@ -0,0 +1,157 @@
package com.tangem.domain.account.status.usecase
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.assertNone
import com.tangem.common.test.utils.assertSome
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetAccountCurrencyStatusUseCaseTest {
private val supplier = mockk<SingleAccountStatusListSupplier>()
private val useCase = GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = supplier)
private val userWalletId = UserWalletId("011")
private val supplierParams = SingleAccountStatusListProducer.Params(userWalletId)
private val currency = MockCryptoCurrencyFactory().ethereum.let {
val derivationPath = Network.DerivationPath.Card("m/44'/60'/0'/0/1")
it.copy(
network = it.network.copy(
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
derivationPath = derivationPath,
),
)
}
@BeforeEach
fun setUp() {
clearMocks(supplier)
}
@Test
fun `invoke returns None when supplier returns null`() = runTest {
// Arrange
coEvery { supplier.getSyncOrNull(supplierParams) } returns null
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
// Assert
assertNone(actual)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
@Test
fun `invoke returns None when AccountList does not contain required currency id`() = runTest {
// Arrange
val accountStatus = AccountStatus.CryptoPortfolio(
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
tokenList = TokenList.Empty,
priceChangeLce = lceLoading(),
)
val accountStatusList = mockk<AccountStatusList>(relaxed = true) {
every { this@mockk.accountStatuses } returns setOf(accountStatus)
}
coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
// Assert
assertNone(actual)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
@Test
fun `invoke returns Some if network is not null`() = runTest {
// Arrange
val mainAccountStatus = AccountStatus.CryptoPortfolio(
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
tokenList = TokenList.Empty,
priceChangeLce = lceLoading(),
)
val account = mockk<Account.CryptoPortfolio>(relaxed = true) {
every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!!
every { this@mockk.cryptoCurrencies } returns setOf(currency)
}
val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)
val accountStatus = AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loading,
sortedBy = TokensSortType.NONE,
currencies = listOf(currencyStatus),
),
priceChangeLce = lceLoading(),
)
val accountStatusList = mockk<AccountStatusList>(relaxed = true) {
every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk())
}
coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = currency.network)
// Assert
val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus)
assertSome(actual, expected)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
@Test
fun `invoke returns Some if network is null`() = runTest {
// Arrange
val account = mockk<Account.CryptoPortfolio>(relaxed = true) {
every { this@mockk.cryptoCurrencies } returns setOf(currency)
}
val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading)
val accountStatus = AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenList.Ungrouped(
totalFiatBalance = TotalFiatBalance.Loading,
sortedBy = TokensSortType.NONE,
currencies = listOf(currencyStatus),
),
priceChangeLce = lceLoading(),
)
val accountStatusList = mockk<AccountStatusList>(relaxed = true) {
every { this@mockk.accountStatuses } returns setOf(accountStatus)
}
coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
// Assert
val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus)
assertSome(actual, expected)
coVerifyOrder { supplier.getSyncOrNull(supplierParams) }
}
}

View file

@ -212,6 +212,8 @@ data object Wallet2CardConfig : CardConfig {
Blockchain.HyperliquidTestnet -> EllipticCurve.Secp256k1
Blockchain.Quai -> EllipticCurve.Secp256k1
Blockchain.QuaiTestnet -> EllipticCurve.Secp256k1
Blockchain.Linea -> EllipticCurve.Secp256k1
Blockchain.LineaTestnet -> EllipticCurve.Secp256k1
}
}
}

View file

@ -168,6 +168,8 @@ class Wallet2CardConfigTest {
Blockchain.HyperliquidTestnet to EllipticCurve.Secp256k1,
Blockchain.Quai to EllipticCurve.Secp256k1,
Blockchain.QuaiTestnet to EllipticCurve.Secp256k1,
Blockchain.Linea to EllipticCurve.Secp256k1,
Blockchain.LineaTestnet to EllipticCurve.Secp256k1,
)
@Test

View file

@ -1,16 +0,0 @@
package com.tangem.domain.tokens
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import org.rekotlin.Action
sealed interface TokensAction : Action {
/** Single way to pass data to the screen */
sealed interface SetArgs : TokensAction {
object ManageAccess : SetArgs
object ReadAccess : SetArgs
}
}
data class TokenWithBlockchain(val token: Token, val blockchain: Blockchain)

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