diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt index 27b8c945ad..c8d2dfe099 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt @@ -6,14 +6,25 @@ import org.json.JSONObject import com.tangem.utils.logging.TangemLogger import java.util.concurrent.TimeUnit +// WC URIs embed a session symKey that lets anyone join/hijack the session — strip it before logging. +private val WC_SECRET_REGEX = Regex("(symKey(?:=|%3D))[^&\\s\"']+", RegexOption.IGNORE_CASE) + +private fun redactWcSecrets(text: String): String = + WC_SECRET_REGEX.replace(text) { "${it.groupValues[1]}" } + /** + * Requests a WalletConnect URI from the qa-tools service. * + * Response shape (see qa-tools `/wc_uri` swagger): + * - 200: { success: true, wcUri: "wc:...", network, wallet, tangemDeepLink, timestamp, processingTime } + * - 5xx: { error, network, timestamp, errorType } */ fun getWcUri( network: String = "ethereum", baseUrl: String = "[REDACTED_ENV_URL]" ): String? { - TangemLogger.i("Getting WC URI for network: $network") + val url = "$baseUrl/wc_uri?network=$network" + TangemLogger.i("getWcUri: requesting $url") val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) @@ -23,37 +34,94 @@ fun getWcUri( .build() val request = Request.Builder() - .url("$baseUrl/wc_uri?network=$network") + .url(url) + .header("Accept", "application/json") .get() .build() return try { client.newCall(request).execute().use { response -> - TangemLogger.i("Response code: ${response.code}") + val body = response.body?.string().orEmpty() + val contentType = response.header("Content-Type") ?: "" + TangemLogger.i( + "getWcUri: HTTP ${response.code} ${response.message}, " + + "Content-Type=$contentType, body.length=${body.length}" + ) + TangemLogger.i("getWcUri: raw body=${redactWcSecrets(body)}") - if (response.isSuccessful) { - val body = response.body?.string() ?: "" - TangemLogger.i("Response body: $body") - - val jsonObject = JSONObject(body) - - if (jsonObject.getBoolean("success")) { - val wcUri = jsonObject.getString("wcUri") - TangemLogger.i("Got WC URI successfully: $wcUri") - - wcUri - } else { - TangemLogger.e("API returned error: ${jsonObject.optString("error", "Unknown")}") - null - } - } else { - val errorBody = response.body?.string() ?: "No error body" - TangemLogger.e("Request failed: ${response.code}, body: $errorBody") - null + if (!response.isSuccessful) { + TangemLogger.e("getWcUri: non-2xx response (${response.code}), body=${redactWcSecrets(body)}") + return@use null } + + if (body.isBlank()) { + TangemLogger.e("getWcUri: response body is empty") + return@use null + } + + if (contentType.contains("text/html", ignoreCase = true) || + body.trimStart().startsWith("<") + ) { + val server = response.header("Server").orEmpty() + val wwwAuth = response.header("WWW-Authenticate").orEmpty() + val isCloudflareAccess = server.contains("cloudflare", ignoreCase = true) || + wwwAuth.contains("Cloudflare-Access", ignoreCase = true) || + body.contains("cloudflareaccess.com", ignoreCase = true) + if (isCloudflareAccess) { + TangemLogger.e( + "getWcUri: blocked by Cloudflare Access. The test runner is not " + + "authorized to reach $url — connect to the corporate VPN or " + + "configure a Cloudflare Access service token (CF-Access-Client-Id / " + + "CF-Access-Client-Secret headers) on the device." + ) + } else { + TangemLogger.e( + "getWcUri: server returned HTML instead of JSON for $url. " + + "Verify [REDACTED_ENV_URL] and the current API in /docs." + ) + } + return@use null + } + + val jsonObject = try { + JSONObject(body) + } catch (e: Exception) { + TangemLogger.e("getWcUri: failed to parse body as JSON: ${redactWcSecrets(body)}", e) + return@use null + } + + TangemLogger.i("getWcUri: response keys=${jsonObject.keys().asSequence().toList()}") + + val success = jsonObject.optBoolean("success", false) + if (!success) { + val error = jsonObject.optString("error", "") + val errorType = jsonObject.optString("errorType", "") + TangemLogger.e( + "getWcUri: API success=false, error=$error, errorType=$errorType, body=${redactWcSecrets(body)}" + ) + return@use null + } + + val wcUri = jsonObject.optString("wcUri", "") + val tangemDeepLink = jsonObject.optString("tangemDeepLink", "") + val processingTime = jsonObject.optString("processingTime", "") + TangemLogger.i( + "getWcUri: parsed wcUri=${redactWcSecrets(wcUri)}, " + + "tangemDeepLink=${redactWcSecrets(tangemDeepLink)}, processingTime=$processingTime" + ) + + if (wcUri.isBlank() || !wcUri.startsWith("wc:")) { + TangemLogger.e( + "getWcUri: wcUri is missing or has unexpected format. " + + "wcUri='${redactWcSecrets(wcUri)}', body=${redactWcSecrets(body)}" + ) + return@use null + } + + wcUri } } catch (e: Exception) { - TangemLogger.e("Error getting WC URI", e) + TangemLogger.e("getWcUri: exception while requesting $url", e) null } } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt index e141a7cc1c..18ca6bb741 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DeepLinksScenarios.kt @@ -7,9 +7,10 @@ import androidx.test.core.app.ApplicationProvider import io.github.kakaocup.kakao.intent.KIntent fun openAppByDeepLink(deepLinkUri: String?) { - val deeplinkScheme = "tangem://wc?uri=" + requireNotNull(deepLinkUri) { "openAppByDeepLink: deepLinkUri is null" } + val finalUri = Uri.parse(deepLinkUri) val context = ApplicationProvider.getApplicationContext() - val intent = Intent(ACTION_VIEW, Uri.parse(deeplinkScheme + deepLinkUri)).apply { + val intent = Intent(ACTION_VIEW, finalUri).apply { addFlags(FLAG_ACTIVITY_NEW_TASK) } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt index facffbfd2d..ddb18238d0 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt @@ -140,15 +140,6 @@ fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { step("Assert app URL is displayed") { onWalletConnectDetailsBottomSheet { appUrl.assertIsDisplayed() } } - step("Assert wallet icon is displayed") { - onWalletConnectDetailsBottomSheet { walletIcon.assertIsDisplayed() } - } - step("Assert wallet title is displayed") { - onWalletConnectDetailsBottomSheet { walletTitle.assertIsDisplayed() } - } - step("Assert wallet name is displayed") { - onWalletConnectDetailsBottomSheet { walletName.assertIsDisplayed() } - } step("Assert 'Connected networks' title is displayed") { onWalletConnectDetailsBottomSheet { connectedNetworksTitle.assertIsDisplayed() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/EthereumWalletConnectTest.kt similarity index 86% rename from app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt rename to app/src/androidTest/kotlin/com/tangem/tests/walletConnect/EthereumWalletConnectTest.kt index ae35d70e6b..fe0e4e40af 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/EthereumWalletConnectTest.kt @@ -1,32 +1,36 @@ -package com.tangem.tests +package com.tangem.tests.walletConnect import android.Manifest import com.tangem.common.BaseTestCase -import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT +import com.tangem.common.constants.TestConstants import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.getWcUri import com.tangem.common.utils.setClipboardText -import com.tangem.scenarios.* +import com.tangem.scenarios.checkWalletConnectBottomSheet +import com.tangem.scenarios.checkWalletConnectDetailsBottomSheet +import com.tangem.scenarios.checkWalletConnectScreen +import com.tangem.scenarios.openAppByDeepLink +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openWalletConnectScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onScanQrScreen import com.tangem.screens.onWalletConnectBottomSheet import com.tangem.screens.onWalletConnectDetailsBottomSheet -import com.tangem.screens.onScanQrScreen import com.tangem.screens.onWalletConnectScreen import com.tangem.wallet.BuildConfig import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName -import org.junit.Ignore import org.junit.Test @HiltAndroidTest -class WalletConnectTest : BaseTestCase() { +class EthereumWalletConnectTest : BaseTestCase() { @AllureId("3958") @DisplayName("WC (React App): open session from deeplink on main screen") - @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test fun openWalletConnectSessionOnMainScreenTest() { - val dAppName = "React App" + val dAppName = "Tangem QA Tools" val deepLinkUri = getWcUri() setupHooks().run { @@ -36,11 +40,11 @@ class WalletConnectTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Create WC session buy deeplink") { + step("Create WC session by deeplink") { openAppByDeepLink(deepLinkUri) } step("Check 'Wallet Connect' bottom sheet") { - flakySafely(WAIT_UNTIL_TIMEOUT) { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { checkWalletConnectBottomSheet() } } @@ -59,7 +63,7 @@ class WalletConnectTest : BaseTestCase() { openWalletConnectScreen() } step("Check 'Wallet Connect' screen with connections") { - flakySafely(WAIT_UNTIL_TIMEOUT) { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { checkWalletConnectScreen(withConnections = true) } } @@ -80,10 +84,9 @@ class WalletConnectTest : BaseTestCase() { @AllureId("3959") @DisplayName("WC (React App): open session from deeplink not on main screen") - @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test fun openWalletConnectSessionNotOnMainScreenTest() { - val dAppName = "React App" + val dAppName = "Tangem QA Tools" val deepLinkUri = getWcUri() setupHooks().run { @@ -97,11 +100,11 @@ class WalletConnectTest : BaseTestCase() { openWalletConnectScreen() checkWalletConnectScreen(false) } - step("Create WC session buy deeplink") { + step("Create WC session by deeplink") { openAppByDeepLink(deepLinkUri) } step("Check 'Wallet Connect' bottom sheet") { - flakySafely(WAIT_UNTIL_TIMEOUT) { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { checkWalletConnectBottomSheet() } } @@ -114,7 +117,7 @@ class WalletConnectTest : BaseTestCase() { onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } } step("Check 'Wallet Connect' screen with connections") { - flakySafely(WAIT_UNTIL_TIMEOUT) { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { checkWalletConnectScreen(withConnections = true) } } @@ -122,7 +125,7 @@ class WalletConnectTest : BaseTestCase() { onWalletConnectScreen { appIcon.performClick() } } step("Check 'Wallet Connect' details bottom sheet") { - flakySafely(WAIT_UNTIL_TIMEOUT) { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { checkWalletConnectDetailsBottomSheet(dAppName) } } @@ -136,11 +139,10 @@ class WalletConnectTest : BaseTestCase() { } @AllureId("3957") - @DisplayName("WC (React App): open session from deeplink ") - @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") + @DisplayName("WC (React App): open session from deeplink") @Test fun openWalletConnectSessionTest() { - val dAppName = "React App" + val dAppName = "Tangem QA Tools" val packageName = BuildConfig.APPLICATION_ID val deepLinkUri = getWcUri() @@ -154,11 +156,11 @@ class WalletConnectTest : BaseTestCase() { step("Kill app") { device.apps.kill(packageName) } - step("Create WC session buy deeplink") { + step("Create WC session by deeplink") { openAppByDeepLink(deepLinkUri) } step("Check 'Wallet Connect' bottom sheet") { - flakySafely(WAIT_UNTIL_TIMEOUT) { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { checkWalletConnectBottomSheet() } } @@ -191,10 +193,9 @@ class WalletConnectTest : BaseTestCase() { @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 dAppName = "React App" + val dAppName = "Tangem QA Tools" val context = device.context val deepLinkUri = getWcUri() val packageName = BuildConfig.APPLICATION_ID @@ -225,7 +226,7 @@ class WalletConnectTest : BaseTestCase() { } step("Check 'Wallet Connect' bottom sheet") { waitForIdle() - flakySafely(WAIT_UNTIL_TIMEOUT) { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { checkWalletConnectBottomSheet() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/SolanaWalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/SolanaWalletConnectTest.kt new file mode 100644 index 0000000000..facd7d0fad --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/SolanaWalletConnectTest.kt @@ -0,0 +1,301 @@ +package com.tangem.tests.walletConnect + +import android.Manifest +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.getWcUri +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setClipboardText +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.checkWalletConnectBottomSheet +import com.tangem.scenarios.checkWalletConnectDetailsBottomSheet +import com.tangem.scenarios.checkWalletConnectScreen +import com.tangem.scenarios.openAppByDeepLink +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openWalletConnectScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onScanQrScreen +import com.tangem.screens.onWalletConnectBottomSheet +import com.tangem.screens.onWalletConnectDetailsBottomSheet +import com.tangem.screens.onWalletConnectScreen +import com.tangem.wallet.BuildConfig +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SolanaWalletConnectTest : BaseTestCase() { + + @AllureId("4023") + @DisplayName("WC (Raydium): open session from deeplink on main screen") + @Test + fun openWalletConnectSessionOnMainScreenTest() { + val dAppName = "Tangem QA Tools" + val deepLinkUri = getWcUri("solana") + val scenarioState = "Solana" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") { + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState) + } + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Create WC session by deeplink") { + openAppByDeepLink(deepLinkUri) + } + step("Check 'Wallet Connect' bottom sheet") { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } + } + step("Assert 'Connect' button is enabled") { + onWalletConnectBottomSheet { connectButton.assertIsEnabled() } + } + step("Click on 'Connect' button") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Assert 'Connect' button is not displayed") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + } + step("Check 'Wallet Connect' screen with connections") { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { + 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) + } + } + } + + @AllureId("4024") + @DisplayName("WC (Raydium): open session from deeplink not on main screen") + @Test + fun openWalletConnectSessionNotOnMainScreenTest() { + val dAppName = "Tangem QA Tools" + val deepLinkUri = getWcUri("solana") + val scenarioState = "Solana" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") { + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState) + } + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + checkWalletConnectScreen(false) + } + step("Create WC session by deeplink") { + openAppByDeepLink(deepLinkUri) + } + step("Check 'Wallet Connect' bottom sheet") { + flakySafely(TestConstants.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") { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { + checkWalletConnectScreen(withConnections = true) + } + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { + checkWalletConnectDetailsBottomSheet(dAppName) + } + } + step("Click on 'Disconnect' button") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) + } + } + } + + @AllureId("4025") + @DisplayName("WC (Raydium): open session from deeplink") + @Test + fun openWalletConnectSessionTest() { + val dAppName = "Tangem QA Tools" + val packageName = BuildConfig.APPLICATION_ID + val deepLinkUri = getWcUri("solana") + val scenarioState = "Solana" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") { + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState) + } + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Kill app") { + device.apps.kill(packageName) + } + step("Create WC session by deeplink") { + openAppByDeepLink(deepLinkUri) + } + step("Check 'Wallet Connect' bottom sheet") { + flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } + } + step("Click on 'Connect' button") { + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Assert 'Connect' button is not displayed") { + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + } + 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) + } + } + } + + @AllureId("4026") + @DisplayName("WC: open session by 'Paste from clipboard' button") + @Test + fun openWalletConnectSessionByClipboardLinkTest() { + val dAppName = "Tangem QA Tools" + val context = device.context + val deepLinkUri = getWcUri("solana") + val scenarioState = "Solana" + val packageName = BuildConfig.APPLICATION_ID + val permissionName = Manifest.permission.CAMERA + + setupHooks( + additionalBeforeSection = { + device.uiDevice.executeShellCommand("pm grant $packageName $permissionName") + + }, + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") { + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState) + } + + step("Set URI to clipboard") { + setClipboardText(context, deepLinkUri) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + } + step("Click 'New connection' button") { + onWalletConnectScreen { newConnectionButton.performClick() } + } + step("CLick 'Paste from clipboard' button") { + onScanQrScreen { pasteFromClipboardButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' bottom sheet") { + waitForIdle() + flakySafely(TestConstants.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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/AnalyticsParam.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/AnalyticsParam.kt index 125b159021..642fb88cdc 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/AnalyticsParam.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/AnalyticsParam.kt @@ -66,12 +66,6 @@ sealed class AnalyticsParam { data object BlockchainSdk : Error("Blockchain Sdk Error") } - sealed class WalletCreationType(val value: String) { - data object PrivateKey : WalletCreationType(value = "Private Key") - data object NewSeed : WalletCreationType(value = "New Seed") - data object SeedImport : WalletCreationType(value = "Seed Import") - } - sealed class AppTheme(val value: String) { data object System : AppTheme("System") data object Dark : AppTheme("Dark") diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/Onboarding.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/Onboarding.kt deleted file mode 100644 index a8deb0d88d..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/Onboarding.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.tap.common.analytics.events - -import com.tangem.core.analytics.models.AnalyticsEvent - -/** -[REDACTED_AUTHOR] - */ -sealed class Onboarding( - category: String, - event: String, - params: Map = emptyMap(), -) : AnalyticsEvent(category, event, params) { - - class Finished : Onboarding("Onboarding", "Onboarding Finished") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/SignIn.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/SignIn.kt deleted file mode 100644 index 21f5f23180..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/SignIn.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.tap.common.analytics.events - -import com.tangem.core.analytics.models.AnalyticsEvent - -/** -[REDACTED_AUTHOR] - */ -sealed class SignIn( - event: String, - params: Map = emptyMap(), -) : AnalyticsEvent("Sign In", event, params) { - - class ButtonBiometricSignIn : SignIn(event = "Button - Biometric Sign In") - class ButtonCardSignIn : SignIn(event = "Button - Card Sign In") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 333d1170d2..411869a6cd 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -8,6 +8,7 @@ import arrow.core.right import com.tangem.common.* import com.tangem.common.core.TangemSdkError import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.datasource.local.preferences.AppPreferencesStore @@ -19,10 +20,7 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod import com.tangem.domain.common.wallets.error.* import com.tangem.domain.hotwallet.repository.HotWalletRepository -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.isImported -import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.models.wallet.* import com.tangem.domain.wallets.R import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.builder.UserWalletIdBuilder @@ -261,7 +259,7 @@ internal class DefaultUserWalletsListRepository( when (unlockMethod) { UserWalletsListRepository.UnlockMethod.Biometric -> { unlockAllWallets().bind() - trackSignInEvent(userWallet, Basic.SignedIn.SignInType.Biometric) + trackSignInEvent(userWallet, AnalyticsParam.SignInType.Biometric) select(userWalletId) } UserWalletsListRepository.UnlockMethod.AccessCode -> { @@ -292,7 +290,7 @@ internal class DefaultUserWalletsListRepository( sensitiveInformationRepository.getAll(listOf(encryptionKey)) .doOnSuccess { sensitiveInfo -> updateWallets { it?.updateWith(sensitiveInfo) } - trackSignInEvent(userWallet, Basic.SignedIn.SignInType.AccessCode) + trackSignInEvent(userWallet, AnalyticsParam.SignInType.AccessCode) } .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) } } @@ -333,7 +331,7 @@ internal class DefaultUserWalletsListRepository( walletIdToDerivedKeys = mapOf(userWallet.walletId to scanResponse.derivedKeys), ) } - trackSignInEvent(userWallet, Basic.SignedIn.SignInType.Card) + trackSignInEvent(userWallet, AnalyticsParam.SignInType.Card) } .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) } } @@ -376,7 +374,7 @@ internal class DefaultUserWalletsListRepository( .doOnSuccess { sensitiveInfo -> updateWallets { wallets -> wallets?.updateWith(sensitiveInfo) } selectedUserWallet.value?.let { - trackSignInEvent(it, Basic.SignedIn.SignInType.Biometric) + trackSignInEvent(it, AnalyticsParam.SignInType.Biometric) } } .doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) } @@ -605,18 +603,14 @@ internal class DefaultUserWalletsListRepository( return lastOrNull() } - private fun trackSignInEvent(userWallet: UserWallet, type: Basic.SignedIn.SignInType) { + private fun trackSignInEvent(userWallet: UserWallet, type: AnalyticsParam.SignInType) { trackingContextProxy.addContext(userWallet) - val isBackedUp = when (userWallet) { - is UserWallet.Cold -> userWallet.scanResponse.card.backupStatus?.isActive == true - is UserWallet.Hot -> userWallet.backedUp - } analyticsEventHandler.send( event = Basic.SignedIn( signInType = type, walletsCount = userWallets.value?.size ?: 0, isImported = userWallet.isImported(), - hasBackup = isBackedUp, + isBackedUp = userWallet.isBackedUpForAnalytics(), ), ) } diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index f90ef24ac4..91913162ff 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -14,8 +14,10 @@ import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.common.ui.notifications.NotificationId import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.ExceptionAnalyticsEvent +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child @@ -30,7 +32,7 @@ import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isBackedUpForAnalytics import com.tangem.domain.models.wallet.isImported import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.notifications.repository.NotificationsRepository @@ -47,7 +49,6 @@ import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.android.create import com.tangem.sdk.api.BackupServiceHolder import com.tangem.tap.common.SnackbarHandler -import com.tangem.tap.common.analytics.events.Onboarding import com.tangem.tap.features.hot.TangemHotSDKProxy import com.tangem.tap.features.root.RootDetectedWarningComponent import com.tangem.tap.features.scanfails.ScanFailsComponent @@ -352,7 +353,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor( val unfinishedBackup = onboardingRepository.getUnfinishedFinalizeOnboarding() ?: return@launch cardRepository.finishCardActivation(unfinishedBackup.card.cardId) onboardingRepository.clearUnfinishedFinalizeOnboarding() - analyticsEventHandler.send(Onboarding.Finished()) + analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished()) } } @@ -360,16 +361,12 @@ internal class DefaultRoutingComponent @AssistedInject constructor( val userWallets = userWalletsListRepository.userWalletsSync() val selectedWallet = userWalletsListRepository.selectedUserWalletSync() ?: return trackingContextProxy.addContext(selectedWallet) - val isBackedUp = when (selectedWallet) { - is UserWallet.Cold -> selectedWallet.scanResponse.card.backupStatus?.isActive == true - is UserWallet.Hot -> selectedWallet.backedUp - } analyticsEventHandler.send( event = Basic.SignedIn( - signInType = Basic.SignedIn.SignInType.NoSecurity, + signInType = AnalyticsParam.SignInType.NoSecurity, walletsCount = userWallets.size, isImported = selectedWallet.isImported(), - hasBackup = isBackedUp, + isBackedUp = selectedWallet.isBackedUpForAnalytics(), ), ) } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 7f32d68de6..b8dd701b27 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -649,10 +649,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.TangemPayDetails -> { createComponentChild( context = context, - params = TangemPayDetailsContainerComponent.Params( - userWalletId = route.userWalletId, - config = route.config, - ), + params = TangemPayDetailsContainerComponent.Params(initialStatus = route.status), componentFactory = tangemPayDetailsContainerComponentFactory, ) } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 42bedf6b3e..d4df954b65 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -7,6 +7,7 @@ import android.os.Bundle import com.tangem.common.routing.bundle.RouteBundleParams import com.tangem.common.routing.bundle.bundle import com.tangem.common.routing.entity.InitScreenLaunchMode +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.navigation.Route import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.feedback.models.WalletMetaInfo @@ -16,6 +17,7 @@ import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.PreselectedEarnType import com.tangem.domain.models.scan.ScanResponse @@ -23,7 +25,6 @@ import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.onramp.model.OnrampSource -import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.tokens.model.details.NavigationAction import kotlinx.serialization.Serializable @@ -377,7 +378,7 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class CreateMobileWallet( - val source: String, + val source: AnalyticsParam.ScreensSources, ) : AppRoute(path = "/create_mobile_wallet") @Serializable @@ -449,9 +450,8 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class TangemPayDetails( - val userWalletId: UserWalletId, - val config: TangemPayDetailsConfig, - ) : AppRoute(path = "/tangem_pay_details/${userWalletId.stringValue}") + val status: AccountStatus.Payment, + ) : AppRoute(path = "/tangem_pay_details/${status.account}") @Serializable data class TangemPayOnboarding( diff --git a/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt index 52d77fa3a1..9ad0a6c814 100644 --- a/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt @@ -73,7 +73,7 @@ object MockYieldDTOFactory { type = "type", rewardSchedule = YieldDTO.MetadataDTO.RewardScheduleDTO.DAY, cooldownPeriod = null, - warmupPeriod = YieldDTO.MetadataDTO.PeriodDTO(1), + warmupPeriod = YieldDTO.MetadataDTO.PeriodDTO(1, seconds = null), rewardClaiming = YieldDTO.MetadataDTO.RewardClaimingDTO.AUTO, defaultValidator = null, minimumStake = null, diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt index 62d8e486f5..0347e87447 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt @@ -2,7 +2,10 @@ package com.tangem.common.ui.markets.tokenselector import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -10,7 +13,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layoutId import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.marketprice.PriceChangeInPercent import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.ds.image.TangemIcon @@ -26,8 +28,8 @@ fun SingleUserAssetItem(shouldUsePriceBlock: Boolean, item: UserAssetItemUM.Sing TangemIcon( modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.HEAD) - .size(40.dp) - .padding(end = TangemTheme.dimens2.x1), + .padding(end = TangemTheme.dimens2.x3) + .size(TangemTheme.dimens2.x10), tangemIconUM = item.icon, ) diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt index e74695b572..9dd905225f 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -31,6 +32,7 @@ import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags import com.tangem.domain.models.account.AccountName @Composable @@ -48,7 +50,9 @@ fun PortfolioSelectRow( leftContent() val leftText = if (state.isAccountMode) R.string.account_details_title else R.string.wc_common_wallet Text( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag(WalletConnectBottomSheetTestTags.WALLET_NAME_TITLE), text = stringResourceSafe(leftText), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -66,7 +70,9 @@ fun PortfolioSelectRow( Text( maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 4.dp), + modifier = Modifier + .padding(horizontal = 4.dp) + .testTag(WalletConnectBottomSheetTestTags.WALLET_NAME), text = state.name.resolveReference(), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, diff --git a/core/analytics/models/build.gradle.kts b/core/analytics/models/build.gradle.kts index 7ff7fb7522..ed80a19c56 100644 --- a/core/analytics/models/build.gradle.kts +++ b/core/analytics/models/build.gradle.kts @@ -1,4 +1,9 @@ plugins { alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) id("configuration") +} + +dependencies { + api(deps.kotlin.serialization) } \ No newline at end of file diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index d6b77eb6ba..e408f3855d 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -2,6 +2,7 @@ package com.tangem.core.analytics.models import com.tangem.core.analytics.models.AnalyticsParam.Key.REFERRAL import com.tangem.core.analytics.models.AnalyticsParam.Key.REFERRAL_ID +import kotlinx.serialization.Serializable const val IS_NOT_HTTP_ERROR = "Is not http error" @@ -67,39 +68,40 @@ sealed class AnalyticsParam { data object BlockchainSdk : Error("Blockchain Sdk Error") } - sealed class ScreensSources(val value: String) { - data object Settings : ScreensSources("Settings") - data object Main : ScreensSources("Main") - data object SignIn : ScreensSources("Sign In") - data object Send : ScreensSources("Send") - data object Intro : ScreensSources("Introduction") - data object MyWallets : ScreensSources("My Wallets") - data object Token : ScreensSources("Token") - data object Stories : ScreensSources("Stories") - data object Buy : ScreensSources("Buy") - data object Swap : ScreensSources("Swap") - data object Sell : ScreensSources("Sell") - data object Backup : ScreensSources("Backup") - data object Onboarding : ScreensSources("Onboarding") - data object LongTap : ScreensSources("Long Tap") - data object Market : ScreensSources("Market") - data object Markets : ScreensSources("Markets") - data object MarketPulse : ScreensSources("Market Pulse") - data object TangemPay : ScreensSources("Tangem Pay") - data object WalletSettings : ScreensSources("Wallet Settings") - data object Upgrade : ScreensSources("Upgrade") - data object HardwareWallet : ScreensSources("Hardware Wallet") - data object ImportWallet : ScreensSources("Import Wallet") - data object CreateWalletIntro : ScreensSources("Create Wallet Intro") - data object AddNewWallet : ScreensSources("Add New Wallet") - data object AddNew : ScreensSources("Add New") - data object CreateWallet : ScreensSources("Create Wallet") - data object NewsList : ScreensSources("News List") - data object NewsLink : ScreensSources("News Link") - data object NewsPage : ScreensSources("News Page") - data object Portfolio : ScreensSources("Portfolio") - data object Staking : ScreensSources("Staking") - data object Earn : ScreensSources("Earn") + @Serializable + enum class ScreensSources(val value: String) { + Settings("Settings"), + Main("Main"), + SignIn("Sign In"), + Send("Send"), + Intro("Introduction"), + MyWallets("My Wallets"), + Token("Token"), + Stories("Stories"), + Buy("Buy"), + Swap("Swap"), + Sell("Sell"), + Backup("Backup"), + Onboarding("Onboarding"), + LongTap("Long Tap"), + Market("Market"), + Markets("Markets"), + MarketPulse("Market Pulse"), + TangemPay("Tangem Pay"), + WalletSettings("Wallet Settings"), + Upgrade("Upgrade"), + HardwareWallet("Hardware Wallet"), + ImportWallet("Import Wallet"), + CreateWalletIntro("Create Wallet Intro"), + AddNewWallet("Add New Wallet"), + AddNew("Add New"), + CreateWallet("Create Wallet"), + NewsList("News List"), + NewsLink("News Link"), + NewsPage("News Page"), + Portfolio("Portfolio"), + Staking("Staking"), + Earn("Earn"), } sealed class TxSentFrom(val value: String) { @@ -191,9 +193,9 @@ sealed class AnalyticsParam { } sealed class WalletCreationType(val value: String) { - data object PrivateKey : WalletCreationType("Private key") - data object NewSeed : WalletCreationType("New seed") - data object SeedImport : WalletCreationType("Seed import") + data object PrivateKey : WalletCreationType(value = "Private Key") + data object NewSeed : WalletCreationType(value = "New Seed") + data object SeedImport : WalletCreationType(value = "Seed Import") } sealed class WalletType(val value: String) { @@ -239,6 +241,13 @@ sealed class AnalyticsParam { MobileWallet("Mobile Wallet"), } + enum class SignInType(val value: String) { + Card("Card"), + Biometric("Biometric"), + NoSecurity("No Security"), + AccessCode("Access Code"), + } + companion object Key { const val BLOCKCHAIN = "Blockchain" const val TOKEN_PARAM = "Token" @@ -297,6 +306,11 @@ sealed class AnalyticsParam { const val REFERRAL_ID = "Referral_ID" const val SEARCHED = "Searched" const val RATE_TYPE = "Rate Type" + const val SIGN_IN_TYPE = "Sign in type" + const val WALLETS_COUNT = "Wallets Count" + const val WALLET_TYPE = "Wallet Type" + const val BACKUPED = "Backuped" + const val MEMO = "Memo" } } diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt index 0e916a0e2d..f370340d57 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt @@ -2,65 +2,41 @@ package com.tangem.core.analytics.models sealed class Basic( event: String, - params: Map = mapOf(), -) : AnalyticsEvent("Basic", event, params) { + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Basic", event = event, params = params) { + /** + * Tracks card scanning from specific entry points (Introduction, Main, My Wallets, Sign In). + * The originating screen is reported via the [AnalyticsParam.SOURCE] parameter. + */ class CardWasScanned( source: AnalyticsParam.ScreensSources, ) : Basic( event = "Card Was Scanned", params = mapOf( - AnalyticsParam.Key.SOURCE to source.value, + AnalyticsParam.SOURCE to source.value, ), - ) - - class SignedInLegacy( - currency: AnalyticsParam.WalletType, - batch: String, - signInType: SignInType, - walletsCount: String, - isImported: Boolean, - hasBackup: Boolean?, - ) : Basic( - event = "Signed in", - params = buildMap { - put(AnalyticsParam.Key.CURRENCY, currency.value) - put(AnalyticsParam.Key.BATCH, batch) - put("Wallet Type", if (isImported) "Seed Phrase" else "Seedless") - put("Sign in type", signInType.name) - put("Wallets Count", walletsCount) - if (hasBackup != null) { - put("Backuped", if (hasBackup) "Yes" else "No") - } - }, - ) { - enum class SignInType { - Card, Biometric - } - } + ), CriticalEvent + /** + * Tracks any sign-in into a wallet (card scan, FaceID, or wallet switch). + * Counted as a single sign-in per session — subsequent card scans within the same session are ignored. + */ class SignedIn( - signInType: SignInType, + signInType: AnalyticsParam.SignInType, walletsCount: Int, isImported: Boolean, - hasBackup: Boolean?, + isBackedUp: Boolean, ) : Basic( event = "Signed in", params = buildMap { - put("Sign in type", signInType.value) - put("Wallets Count", walletsCount.toString()) - put("Wallet Type", if (isImported) "Seed Phrase" else "Seedless") - if (hasBackup != null) { - put("Backuped", if (hasBackup) "Yes" else "No") - } + put(AnalyticsParam.SIGN_IN_TYPE, signInType.value) + put(AnalyticsParam.WALLETS_COUNT, walletsCount.toString()) + put(AnalyticsParam.WALLET_TYPE, if (isImported) "Seed Phrase" else "Seedless") + put(AnalyticsParam.BACKUPED, if (isBackedUp) "Yes" else "No") }, - ) { - enum class SignInType(val value: String) { - Card("Card"), - Biometric("Biometric"), - NoSecurity("No Security"), - AccessCode("Access Code"), - } + ), CriticalEvent, OneTimePerSessionEvent { + override val oneTimeEventId: String = id } class ButtonBuy( @@ -68,39 +44,46 @@ sealed class Basic( ) : Basic( event = "Button - Buy", params = buildMap { - put(AnalyticsParam.Key.SOURCE, source.value) + put(AnalyticsParam.SOURCE, source.value) }, ) - class ToppedUp(userWalletId: String, currency: AnalyticsParam.WalletType) : + /** + * Tracks the first time a user wallet is topped up. Sent once per wallet, when the balance + * transitions from zero to positive (Total Balance for multi-currency wallets, or Balance for Note). + * A wallet scanned with a non-zero balance does not count as a top-up — the event must be sent + * only after all tokens have finished loading. + */ + class ToppedUp(userWalletId: String, walletType: AnalyticsParam.WalletType) : Basic( event = "Topped up", - params = mapOf(AnalyticsParam.Key.CURRENCY to currency.value), + params = mapOf(AnalyticsParam.CURRENCY to walletType.value), ), - OneTimeAnalyticsEvent { + OneTimeAnalyticsEvent, AppsFlyerIncludedEvent, CriticalEvent { override val oneTimeEventId: String = id + userWalletId } + /** + * Tracks transaction submission from various screens (Send, Swap, WalletConnect, Sell, Approve, Staking). + */ class TransactionSent(sentFrom: AnalyticsParam.TxSentFrom, memoType: MemoType) : Basic( event = "Transaction sent", params = buildMap { - this[AnalyticsParam.Key.SOURCE] = sentFrom.value + put(AnalyticsParam.SOURCE, sentFrom.value) if (sentFrom is AnalyticsParam.TxData) { - this[AnalyticsParam.Key.BLOCKCHAIN] = sentFrom.blockchain - this[AnalyticsParam.Key.TOKEN_PARAM] = sentFrom.token - sentFrom.feeType?.value?.let { - this[AnalyticsParam.Key.FEE_TYPE] = it - } - this[AnalyticsParam.Key.FEE_TOKEN] = sentFrom.feeToken + put(AnalyticsParam.BLOCKCHAIN, sentFrom.blockchain) + put(AnalyticsParam.TOKEN_PARAM, sentFrom.token) + sentFrom.feeType?.value?.let { put(AnalyticsParam.FEE_TYPE, it) } + put(AnalyticsParam.FEE_TOKEN, sentFrom.feeToken) } if (sentFrom is AnalyticsParam.TxSentFrom.Approve) { - this[AnalyticsParam.Key.PERMISSION_TYPE] = sentFrom.permissionType + put(AnalyticsParam.PERMISSION_TYPE, sentFrom.permissionType) } - this["Memo"] = memoType.name + put(AnalyticsParam.MEMO, memoType.name) }, - ), AppsFlyerIncludedEvent { + ), AppsFlyerIncludedEvent, CriticalEvent { enum class MemoType { Empty, Full, Null } @@ -110,31 +93,33 @@ sealed class Basic( } } + /** + * Tracks the user invoking the "Request Support" email flow from various screens of the app. + */ class ButtonSupport(source: AnalyticsParam.ScreensSources) : Basic( event = "Request Support", params = mapOf( - AnalyticsParam.Key.SOURCE to source.value, + AnalyticsParam.SOURCE to source.value, + ), + ), CriticalEvent + + /** + * Tracks loading of the user's total balance after sign-in. Reports whether the balance is + * empty, has funds, failed to load, or could not be returned because of a custom token. + */ + class BalanceLoaded(balance: AnalyticsParam.CardBalanceState, tokensCount: Int?) : Basic( + event = "Balance Loaded", + params = buildMap { + put(AnalyticsParam.BALANCE, balance.value) + tokensCount?.let { put(AnalyticsParam.TOKENS_COUNT, it.toString()) } + }, + ), AppsFlyerIncludedEvent, CriticalEvent + + class TokenBalance(balance: AnalyticsParam.EmptyFull, token: String) : Basic( + event = "Token Balance", + params = mapOf( + AnalyticsParam.STATE to balance.value, + AnalyticsParam.TOKEN_PARAM to token, ), ) - - class BiometryFailed( - source: AnalyticsParam.ScreensSources, - reason: BiometricFailReason, - ) : Basic( - event = "Biometry Failed", - params = mapOf( - AnalyticsParam.Key.SOURCE to source.value, - "Reason" to reason.value, - ), - ) { - sealed class BiometricFailReason(val value: String) { - data object AuthenticationLockout : BiometricFailReason("BiometricsAuthenticationLockout") - data object AuthenticationLockoutPermanent : BiometricFailReason("BiometricsAuthenticationLockoutPermanent") - data object BiometricsAuthenticationDisabled : BiometricFailReason("BiometricsAuthenticationDisabled") - data object AllKeysInvalidated : BiometricFailReason("AllKeysInvalidated") - data object AuthenticationCancelled : BiometricFailReason("AuthenticationCancelled") - data object AuthenticationAlreadyInProgress : BiometricFailReason("AuthenticationAlreadyInProgress") - data class Other(val reason: String) : BiometricFailReason(reason) - } - } } \ No newline at end of file diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/CriticalEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/CriticalEvent.kt new file mode 100644 index 0000000000..ff4cb0907b --- /dev/null +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/CriticalEvent.kt @@ -0,0 +1,8 @@ +package com.tangem.core.analytics.models + +/** + * Marker interface for analytics events that require special attention. + * + * Implemented by events listed in the analytics specification (events.csv). + */ +interface CriticalEvent \ No newline at end of file diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/OnboardingAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/OnboardingAnalyticsEvent.kt index 6743eccfea..f85138e90a 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/OnboardingAnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/OnboardingAnalyticsEvent.kt @@ -1,9 +1,6 @@ package com.tangem.core.analytics.models.event -import com.tangem.core.analytics.models.AnalyticsEvent -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.AppsFlyerIncludedEvent -import com.tangem.core.analytics.models.getReferralParams +import com.tangem.core.analytics.models.* sealed class OnboardingAnalyticsEvent( category: String, @@ -16,23 +13,29 @@ sealed class OnboardingAnalyticsEvent( params: Map = mapOf(), ) : OnboardingAnalyticsEvent(category = "Onboarding", event = event, params = params) { + /** + * Tracks the start of the onboarding process. + */ class Started( - source: String, + source: AnalyticsParam.ScreensSources? = null, ) : Onboarding( event = "Onboarding Started", - params = mapOf( - AnalyticsParam.SOURCE to source, - ), - ) + params = buildMap { + source?.value?.let { put(AnalyticsParam.SOURCE, it) } + }, + ), CriticalEvent + /** + * Tracks the completion of the onboarding process. + */ class Finished( - source: String, + source: AnalyticsParam.ScreensSources? = null, ) : Onboarding( event = "Onboarding Finished", - params = mapOf( - AnalyticsParam.SOURCE to source, - ), - ) + params = buildMap { + source?.value?.let { put(AnalyticsParam.SOURCE, it) } + }, + ), CriticalEvent class ButtonMobileWallet( source: String, @@ -49,31 +52,42 @@ sealed class OnboardingAnalyticsEvent( params: Map = mapOf(), ) : OnboardingAnalyticsEvent(category = "Onboarding / Create Wallet", event = event, params = params) { - class ButtonCreateWallet : CreateWallet("Button - Create Wallet") + /** + * Tracks opening of the create wallet screen. + */ + class ScreenOpened : CreateWallet("Create Wallet Screen Opened"), CriticalEvent + /** + * Tracks the user clicking the "Create Wallet" button. + */ + class ButtonCreateWallet : CreateWallet("Button - Create Wallet"), CriticalEvent + + /** + * Tracks the user clicking the "Other Options" button on the create wallet screen. + */ + class ButtonOtherOptions : CreateWallet("Button - Other Options"), CriticalEvent + + /** + * Tracks successful wallet creation, either on a Tangem card or as a mobile wallet. + */ class WalletCreatedSuccessfully( - source: String, - creationType: WalletCreationType = WalletCreationType.NewSeed, + creationType: AnalyticsParam.WalletCreationType = AnalyticsParam.WalletCreationType.PrivateKey, seedPhraseLength: Int? = null, passPhraseState: AnalyticsParam.EmptyFull, referralId: String?, + source: AnalyticsParam.ScreensSources? = null, ) : CreateWallet( event = "Wallet Created Successfully", params = buildMap { - put(AnalyticsParam.SOURCE, source) put("Creation Type", creationType.value) put("Passphrase", passPhraseState.value) if (seedPhraseLength != null) { put("Seed Phrase Length", seedPhraseLength.toString()) } + source?.value?.let { put(AnalyticsParam.SOURCE, it) } putAll(getReferralParams(referralId)) }, - ), AppsFlyerIncludedEvent - - sealed class WalletCreationType(val value: String) { - data object NewSeed : WalletCreationType(value = "New Seed") - data object SeedImport : WalletCreationType(value = "Seed Import") - } + ), AppsFlyerIncludedEvent, CriticalEvent } sealed class SeedPhrase( @@ -82,16 +96,19 @@ sealed class OnboardingAnalyticsEvent( ) : OnboardingAnalyticsEvent(category = "Onboarding / Seed Phrase", event = event, params = params) { class CreateMobileScreenOpened( - source: String, + source: AnalyticsParam.ScreensSources, ) : SeedPhrase( event = "Create Mobile Screen Opened", params = mapOf( - AnalyticsParam.SOURCE to source, + AnalyticsParam.SOURCE to source.value, ), ), AppsFlyerIncludedEvent class ButtonImportWallet : SeedPhrase("Button - Import Wallet") - class ImportSeedPhraseScreenOpened : SeedPhrase("Import Seed Phrase Screen Opened") + /** + * Tracks opening of the seed phrase import screen. + */ + class ImportSeedPhraseScreenOpened : SeedPhrase("Import Seed Phrase Screen Opened"), CriticalEvent class ButtonImport : SeedPhrase("Button - Import") } diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SignIn.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SignIn.kt index f62e70074f..d28f389f08 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SignIn.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SignIn.kt @@ -2,20 +2,22 @@ package com.tangem.core.analytics.models.event import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.CriticalEvent sealed class SignIn( event: String, params: Map = emptyMap(), -) : AnalyticsEvent("Sign In", event, params) { +) : AnalyticsEvent(category = "Sign In", event = event, params = params) { - data class ScreenOpened( - val walletsCount: Int, - ) : SignIn( + /** + * Tracks the user landing on the app's sign-in screen when a saved card exists. + */ + class ScreenOpened(walletsCount: Int) : SignIn( event = "Sign In Screen Opened", params = mapOf( - "Wallets Count" to walletsCount.toString(), + AnalyticsParam.WALLETS_COUNT to walletsCount.toString(), ), - ) + ), CriticalEvent class ButtonBiometricSignIn : SignIn(event = "Button - Biometric Sign In") @@ -26,24 +28,17 @@ sealed class SignIn( ) : SignIn(event = "Error - Biometric Updated") class ButtonWallet( - signInType: SignInType, + signInType: AnalyticsParam.SignInType, walletsCount: Int, ) : SignIn( event = "Button - Wallet", params = buildMap { - put("Wallets Count", walletsCount.toString()) - put("Sign in type", signInType.value) + put(AnalyticsParam.WALLETS_COUNT, walletsCount.toString()) + put(AnalyticsParam.SIGN_IN_TYPE, signInType.value) }, - ) { - enum class SignInType(val value: String) { - Card("Card"), - Biometric("Biometric"), - NoSecurity("No Security"), - AccessCode("Access Code"), - } - } + ) - data class ButtonAddWallet( + class ButtonAddWallet( val sources: AnalyticsParam.ScreensSources, ) : SignIn( event = "Button - Add Wallet", diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index fa3065b6c9..ba8ee3c7b1 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -66,5 +66,9 @@ { "name": "ADDRESS_SYNC_ENABLED", "version": "undefined" + }, + { + "name": "SWAP_SWITCH_TO_TRANSFER_ENABLED", + "version": "undefined" } ] diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt index be0fb0cf91..67bae3eb18 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt @@ -150,6 +150,8 @@ data class YieldDTO( data class PeriodDTO( @Json(name = "days") val days: Int?, + @Json(name = "seconds") + val seconds: Int?, ) @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index d51eed7b2f..3c87c7cfd2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -58,6 +58,7 @@ sealed interface PaymentAccountStatusValueDM { data class DeactivatedAccount( @Json(name = "deactivated_account") val marker: Boolean = true, @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, + @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, ) : PaymentAccountStatusValueDM @JsonClass(generateAdapter = true) diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 45dbb43d03..4cd264c962 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -517,6 +517,7 @@ Foram encontrados fundos em endereços adicionais. Ative os Endereços Dinâmicos para acessá-los. Fundos encontrados em endereços adicionais Endereço dinâmico + O gerenciamento de endereços dinâmicos estará disponível assim que as transações pendentes estiverem na rede. %@ está completo Melhores oportunidades Limpar filtro A lista está temporariamente vazia, pois está sendo atualizada. Volte daqui a pouco. @@ -595,6 +596,7 @@ Disponível em %s Indisponível para este par Permissão necessária + É necessária permissão. Recomendado Comprado %s Comprando %s @@ -641,6 +643,7 @@ A função Aprovar é necessária para conceder permissão a outro endereço para usar uma quantidade específica de seus tokens. Por definição, os contratos inteligentes não podem acessar seus tokens a menos que você aprove. Ao \"desbloquear\" seus tokens, você autoriza o contrato inteligente StakeKit a usá-los. Os mineradores da rede recebem uma taxa de gás (paga por você) para registrar essa ação no blockchain. Você pode fazer staking de seus tokens após conceder a aprovação. Para continuar, você precisa permitir que o contrato inteligente da Polygon use seus dados. %s Para continuar, conceda %1s permissão de contratos inteligentes para usar seu %2s + As corretoras descentralizadas exigem permissão para interagir com sua carteira. %1s Conceder permissão Ilimitado Os endereços são gerados diretamente na sua carteira de hardware Tangem — prontos para usar e totalmente protegidos. @@ -1115,6 +1118,14 @@ Esta transação já foi processada. Nenhuma ação adicional é necessária. Obtendo as melhores taxas... Instantâneo + A verificação é gratuita e geralmente leva de 1 a 2 minutos. + A Tangem não terá acesso às suas informações de identidade; você compartilha os dados diretamente com o provedor regulamentado. + A verificação desbloqueia o acesso total a transações futuras com este fornecedor. + Escolha outro método + Para cumprir os requisitos regulamentares locais %@ Requer verificação de identidade. + Verificação de identidade exigida pelo provedor de pagamento + Verificar + O que é importante Ao utilizar a funcionalidade de acesso prioritário, você concorda com os termos do provedor. %1$s e %2$s O serviço é fornecido por um provedor externo. A Tangem não se responsabiliza por ele. O valor da compra não deve ser superior a %s @@ -1181,6 +1192,8 @@ Cartão de crédito ou conta bancária Compartilhe seu endereço ou código QR. Entre seus portfólios + Outro + Recarga rápida Não é necessário memorando %1$s (%2$s) sobre %3$s rede %1$s sobre %2$s rede diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 0a6290a4fb..5f7b583b7d 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -293,11 +293,13 @@ Удалить Отключить Отключено + Выключение Отключить Готово Изменить Включить Включено + Включение Ошибка Комиссия сети Обменять @@ -324,6 +326,12 @@ Скрыть Удерживайте, чтобы %s час + + %d час + %d часa + %d часов + %d часа + %dч назад %dч назад @@ -347,6 +355,7 @@ %dмин назад месяц + Еще Комиссия сети Сумма отправки будет уменьшена на %1$s (%2$s) для покрытия выбранного уровня комиссии diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index e20ccf50f8..0b2a2f4972 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -217,6 +217,7 @@ Access denied Account Accounts + %s failed Activate Add Add funds @@ -233,6 +234,8 @@ Apply Approval Approve + Approved + Approving Attention Available networks Backup @@ -315,6 +318,10 @@ Hide Hold to %s hour + + %d hour + %d hours + %dh ago %dh ago @@ -364,6 +371,8 @@ %1$s — %2$s Read more Receive + Received + Receiving Recommended Reject Reload @@ -382,6 +391,8 @@ Send Send: Failed to send transaction + Sending + Sent The server is not available, please try again later Share Share Link @@ -392,6 +403,7 @@ Skip Something went wrong Stake + Staked Staking Start Submit @@ -399,6 +411,8 @@ Support Supported networks Swap + Swapped + Swapping Tangem Tangem Wallet Tap and hold @@ -416,6 +430,7 @@ Transaction status Transactions Transfer + Transferred Unable to load data… I understand I understand, continue @@ -425,10 +440,12 @@ Unstake Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. Value copied + Voting Wallets Warning week with + Withdrawing Yes Yield Mode Contract address copied! @@ -590,12 +607,14 @@ Best rate FCA Warning List Fixed rate is unavailable + Provider for swap Competitive rate Provider in FCA warning list Available up to %s Available from %s Unavailable for this pair Permission Required + Permission needed Recommended Bought %s Buying %s @@ -643,6 +662,7 @@ The Approve function is needed to grant permission to another address to use a specific amount of your tokens. By design, smart contracts can\'t access your tokens unless you approve. By \"unlocking\" your tokens, you authorize the StakeKit smart contract to use them. The network\'s miners receive a gas fee (paid by you) to record this action on the blockchain. You can stake your token after giving approval. To continue you need to allow Polygon smart contract to use your %s To continue, grant %1s smart contracts permission to use your %2s + Decentralized exchanges require permission to interact with your wallet. %1s Give Permission Unlimited Addresses are generated directly on your Tangem hardware wallet — ready to use and fully protected. @@ -670,6 +690,8 @@ Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault. If you do, you\'ll need to start over. Recover existing wallet via Google Drive backup + We\'re working on Google Drive backup to make wallet recovery even easier. + Google Drive backup is coming soon Google Drive backup Create a secure wallet and transfer your funds for extra protection. Create new wallet @@ -1330,6 +1352,8 @@ Target account is not created. Please change the amount to send. The amount to send must be at least %s Leave %s + A trustline for %s is required first. + Can\'t receive token Reduce by %s Reduce to %s Kindly be aware that your transaction may experience delays under specific fee settings @@ -1817,12 +1841,21 @@ Unavailable to sell Unavailable for swap from %s Unavailable for swap + Claiming reward contract: %s + Disabling Yield mode + Earned from stake You don\'t have any transactions yet Failed to load transaction history.\nClick on reload button to update the information. + from: %%image%% %s Multiple addresses Transaction history is currently not supported for this blockchain. But don\'t worry, we\'re working on it! In the meantime you can check it in the explorer. Operation + Pending + Rewards restaked + Rewards restaking + Staking reward + to: %%image%% %s for: %s from: %s to: %s diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 10573a0d9d..3423aacdbf 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -16,6 +16,10 @@ abstract class VerifyDesignTokensTask : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val tokensDir: DirectoryProperty + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val iconsDir: DirectoryProperty + @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val hashFile: RegularFileProperty @@ -37,21 +41,21 @@ abstract class VerifyDesignTokensTask : DefaultTask() { "Run: git submodule update --init --recursive" } - val digest = MessageDigest.getInstance("SHA-256") - val jsonFiles = tokensDirValue.walkTopDown() - .filter { it.isFile && it.extension == "json" } - .sortedBy { it.relativeTo(tokensDirValue).path } - .toList() - - val nul = byteArrayOf(0) - for (file in jsonFiles) { - digest.update(file.relativeTo(tokensDirValue).invariantSeparatorsPath.toByteArray()) - digest.update(nul) - digest.update(file.readBytes()) - digest.update(nul) + val iconsDirValue = iconsDir.get().asFile + require(iconsDirValue.exists() && iconsDirValue.isDirectory) { + "ds-tokens icons folder not found: ${iconsDirValue.absolutePath}\n" + + "Run: git submodule update --init --recursive" } - val actual = digest.digest() + val tokensInputHash = hashTreeHex(tokensDirValue, "json") + val iconsHash = hashTreeHex(iconsDirValue, "svg") + + // Mirror build-tokens.mjs: sha256(tokensInputHash + 0x00 + iconsHash), all hex strings. + val outer = MessageDigest.getInstance("SHA-256") + outer.update(tokensInputHash.toByteArray()) + outer.update(0) + outer.update(iconsHash.toByteArray()) + val actual = outer.digest() .joinToString("") { b: Byte -> b.toInt().and(0xFF).toString(16).padStart(2, '0') } val expected = hashFileValue.readText().trim() @@ -64,6 +68,23 @@ abstract class VerifyDesignTokensTask : DefaultTask() { stampFile.get().asFile.writeText(actual) } + + private fun hashTreeHex(root: java.io.File, extension: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val files = root.walkTopDown() + .filter { it.isFile && it.extension == extension } + .sortedBy { it.relativeTo(root).invariantSeparatorsPath } + .toList() + val nul = byteArrayOf(0) + for (file in files) { + digest.update(file.relativeTo(root).invariantSeparatorsPath.toByteArray()) + digest.update(nul) + digest.update(file.readBytes()) + digest.update(nul) + } + return digest.digest() + .joinToString("") { b: Byte -> b.toInt().and(0xFF).toString(16).padStart(2, '0') } + } } tasks.withType().configureEach { @@ -83,6 +104,7 @@ android { val verifyDesignTokens = tasks.register("verifyDesignTokens") { tokensDir.set(file("ds-tokens/tokens")) + iconsDir.set(file("ds-tokens/icons")) hashFile.set(file("src/main/java/com/tangem/core/ui/res/generated/.tokens-hash")) stampFile.set(layout.buildDirectory.file("tokens-verified.stamp")) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt index 38674b5788..0d2a37f3fd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt @@ -46,7 +46,7 @@ enum class AccountIconSize { @Composable fun AccountResIcon(@DrawableRes resId: Int, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) { val boxSize by animateDpAsState( - targetValue = size.boxSizeInDp(), + targetValue = size.toBoxSize(), animationSpec = animation(), ) val boxShapeCornerSize by animateDpAsState( @@ -84,7 +84,7 @@ fun AccountResIcon(@DrawableRes resId: Int, color: Color, size: AccountIconSize, @Composable fun PaymentAccountIcon(size: AccountIconSize, modifier: Modifier = Modifier) { val boxSize by animateDpAsState( - targetValue = size.boxSizeInDp(), + targetValue = size.toBoxSize(), animationSpec = animation(), ) val boxShapeCornerSize by animateDpAsState( @@ -115,7 +115,7 @@ fun PaymentAccountIcon(size: AccountIconSize, modifier: Modifier = Modifier) { @Composable fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) { val boxSize by animateDpAsState( - size.boxSizeInDp(), + size.toBoxSize(), animationSpec = animation(), ) val boxShapeCornerSize by animateDpAsState( @@ -163,7 +163,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) { AccountIconSize.RedesignedDefault -> 20.dp } -private fun AccountIconSize.boxSizeInDp(): Dp = when (this) { +fun AccountIconSize.toBoxSize(): Dp = when (this) { AccountIconSize.Default -> 36.dp AccountIconSize.Large -> 88.dp AccountIconSize.Medium -> 28.dp diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index f6275d6cf5..d0b6888c00 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -1,7 +1,6 @@ package com.tangem.core.ui.components.bottomsheets.modal import android.content.res.Configuration -import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -13,13 +12,18 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.PrimaryButton @@ -31,6 +35,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.internal.ModalBottomSheetWithBackHandling import com.tangem.core.ui.components.bottomsheets.internal.collapse import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible +import com.tangem.core.ui.res.LocalCanScrollBackward import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.WindowInsetsZero @@ -111,17 +116,13 @@ inline fun DefaultModalBottomSheet( sheetState = sheetState, onBack = onBack, bsContent = { - CompositionLocalProvider( - LocalOverscrollFactory provides null, - ) { - BsContent( - config = config, - containerColor = containerColor, - scrollableContent = scrollableContent, - title = title, - content = content, - ) - } + BsContent( + config = config, + containerColor = containerColor, + scrollableContent = scrollableContent, + title = title, + content = content, + ) }, ) } @@ -178,6 +179,18 @@ inline fun BsContent( val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT + val canScrollBackward = LocalCanScrollBackward.current + + val nestedScrollConnection = remember(canScrollBackward) { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = + if (canScrollBackward) available else Offset.Zero + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = + if (canScrollBackward) available else Velocity.Zero + } + } + Column( modifier = Modifier .systemBarsPadding() @@ -185,7 +198,8 @@ inline fun BsContent( .clip(TangemTheme.shapes.roundedCornersLarge) .background(containerColor) .heightIn(max = maxHeight.dp) - .fillMaxWidth(), + .fillMaxWidth() + .nestedScroll(nestedScrollConnection), ) { Box(modifier = Modifier.fillMaxWidth()) { title(model) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt index c0e1f4704f..a303ff1acc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt @@ -19,10 +19,49 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.utils.getGreyScaleColorFilter +/** + * Cryptocurrency icon driven entirely by the supplied [modifier]: the icon and the network badge + * lay out within the size set by the modifier — !!!no fixed icon/badge size params!!!. + * Use the more configurable [CurrencyIcon] when custom sizing is required. + */ +@Composable +fun TangemCurrencyIcon(state: CurrencyIconState, modifier: Modifier = Modifier, shouldDisplayNetwork: Boolean = true) { + Box(modifier = modifier) { + val iconModifier = Modifier.matchParentSize() + + when (state) { + is CurrencyIconState.Loading -> LoadingIcon(modifier = iconModifier) + is CurrencyIconState.Locked -> LockedIcon(modifier = iconModifier) + is CurrencyIconState.Empty -> EmptyIcon(resId = state.resId, modifier = iconModifier) + is CurrencyIconState.CoinIcon, + is CurrencyIconState.FiatIcon, + is CurrencyIconState.CustomTokenIcon, + is CurrencyIconState.TokenIcon, + is CurrencyIconState.PaymentAccount, + is CurrencyIconState.CryptoPortfolio.Icon, + is CurrencyIconState.CryptoPortfolio.Letter, + -> { + ContentIconContainer( + icon = state, + modifier = iconModifier, + shouldShowTopBadge = shouldDisplayNetwork, + networkBadgeSize = 14.dp, + networkBadgeBackground = if (LocalRedesignEnabled.current) { + TangemTheme.colors2.surface.level1 + } else { + TangemTheme.colors.background.primary + }, + ) + } + } + } +} + /** * Cryptocurrency icon with network badge * diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt index 86bf70b0c4..2a6dc14bdc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/image/TangemIconUM.kt @@ -16,8 +16,8 @@ import androidx.compose.ui.res.vectorResource import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import com.tangem.core.ui.components.CircleShimmer -import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon import com.tangem.core.ui.components.icons.identicon.IdentIcon import com.tangem.core.ui.extensions.ColorReference2 import com.tangem.core.ui.res.TangemTheme @@ -69,7 +69,7 @@ sealed interface TangemIconUM { fun TangemIcon(tangemIconUM: TangemIconUM, modifier: Modifier = Modifier) { when (tangemIconUM) { is TangemIconUM.Currency -> { - CurrencyIcon( + TangemCurrencyIcon( state = tangemIconUM.currencyIconState, modifier = modifier, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index f61cdd1722..286eda1a58 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -472,6 +472,8 @@ val LocalMessageEffectAnimation = compositionLocalOf { error("No MessageEffectAnimation provided") } +val LocalCanScrollBackward = compositionLocalOf { false } + /** * Determines whether the dark theme should be used based on the given [AppThemeMode]. * diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash index ff5efb6203..41b11527e1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash @@ -1 +1 @@ -84387e888f54e5056380c38e077962bdfa4a32cfca194d822c13aa7e35661968 +b32332414db19b8a3e4a62ac2ce1dffcddb8d9e2394053dd2af55a0ce81464eb diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/.icons-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/.icons-hash new file mode 100644 index 0000000000..50d69be80c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/.icons-hash @@ -0,0 +1 @@ +4d82cc51cdc43627423b9cd186c61fda845cb0773f1d4e272249c777b8555aa1 diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown12.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown12.kt new file mode 100644 index 0000000000..db3695c070 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown12.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_down_12: ImageVector? = null + +val Icons.ic_arrow_down_12: ImageVector + get() { + if (_ic_arrow_down_12 != null) return _ic_arrow_down_12!! + _ic_arrow_down_12 = ImageVector.Builder( + name = "ic_arrow_down_12", + defaultWidth = 12.dp, + defaultHeight = 12.dp, + viewportWidth = 12f, + viewportHeight = 12f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.5 2L5.5 9.04297L2.35352 5.89648C2.15825 5.70122 1.84175 5.70122 1.64649 5.89648C1.45122 6.09175 1.45122 6.40825 1.64649 6.60352L5.64648 10.6035L5.72266 10.666C5.80419 10.7204 5.90056 10.75 6 10.75C6.13261 10.75 6.25975 10.6973 6.35352 10.6035L10.3535 6.60352C10.5488 6.40826 10.5488 6.09175 10.3535 5.89649C10.1583 5.70122 9.84175 5.70122 9.64649 5.89649L6.5 9.04297L6.5 2C6.5 1.72386 6.27614 1.5 6 1.5C5.72386 1.5 5.5 1.72386 5.5 2Z"), + ) + }.build() + return _ic_arrow_down_12!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowDown12Preview() { + Icon( + imageVector = Icons.ic_arrow_down_12, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown16.kt new file mode 100644 index 0000000000..a6780a26ec --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown16.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_down_16: ImageVector? = null + +val Icons.ic_arrow_down_16: ImageVector + get() { + if (_ic_arrow_down_16 != null) return _ic_arrow_down_16!! + _ic_arrow_down_16 = ImageVector.Builder( + name = "ic_arrow_down_16", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M7.4997 2.66669L7.4997 12.4597L3.02021 7.98016C2.82494 7.7849 2.50844 7.7849 2.31318 7.98016C2.11808 8.17544 2.11797 8.49199 2.31318 8.68719L7.64618 14.0202C7.73987 14.1139 7.86721 14.1666 7.9997 14.1667C8.13223 14.1667 8.25946 14.1139 8.35321 14.0202L13.6872 8.6872C13.8824 8.49204 13.8821 8.17545 13.6872 7.98017C13.4919 7.7849 13.1754 7.7849 12.9802 7.98017L8.4997 12.4606L8.4997 2.66669C8.4997 2.39055 8.27584 2.16669 7.9997 2.16669C7.72371 2.16686 7.4997 2.39065 7.4997 2.66669Z"), + ) + }.build() + return _ic_arrow_down_16!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowDown16Preview() { + Icon( + imageVector = Icons.ic_arrow_down_16, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown20.kt new file mode 100644 index 0000000000..f1c408a84a --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown20.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_down_20: ImageVector? = null + +val Icons.ic_arrow_down_20: ImageVector + get() { + if (_ic_arrow_down_20 != null) return _ic_arrow_down_20!! + _ic_arrow_down_20 = ImageVector.Builder( + name = "ic_arrow_down_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M9.25031 3.33331L9.2503 15.2728L3.86359 9.88605C3.57073 9.59337 3.09589 9.59337 2.80304 9.88605C2.5102 10.1789 2.51031 10.6537 2.80304 10.9466L9.47003 17.6136L9.58429 17.7073C9.70654 17.7888 9.85124 17.8333 10.0003 17.8333C10.1991 17.8332 10.39 17.7542 10.5306 17.6136L17.1966 10.9466C17.4895 10.6537 17.4895 10.1789 17.1966 9.88605C16.9037 9.59348 16.4288 9.59326 16.136 9.88605L10.7503 15.2728L10.7503 3.33331C10.7503 2.91921 10.4144 2.58349 10.0003 2.58331C9.58609 2.58331 9.25031 2.9191 9.25031 3.33331Z"), + ) + }.build() + return _ic_arrow_down_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowDown20Preview() { + Icon( + imageVector = Icons.ic_arrow_down_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown24.kt new file mode 100644 index 0000000000..1b96ee9eeb --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown24.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_down_24: ImageVector? = null + +val Icons.ic_arrow_down_24: ImageVector + get() { + if (_ic_arrow_down_24 != null) return _ic_arrow_down_24!! + _ic_arrow_down_24 = ImageVector.Builder( + name = "ic_arrow_down_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M11 4L11 18.0859L4.70703 11.793C4.31651 11.4024 3.6835 11.4024 3.29297 11.793C2.90245 12.1835 2.90245 12.8165 3.29297 13.207L11.293 21.207L11.3662 21.2734C11.5442 21.4193 11.7679 21.5 12 21.5C12.2652 21.5 12.5195 21.3946 12.707 21.207L20.707 13.207C21.0976 12.8165 21.0976 12.1835 20.707 11.793C20.3165 11.4024 19.6835 11.4024 19.293 11.793L13 18.0859L13 4C13 3.44772 12.5523 3 12 3C11.4477 3 11 3.44772 11 4Z"), + ) + }.build() + return _ic_arrow_down_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowDown24Preview() { + Icon( + imageVector = Icons.ic_arrow_down_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown28.kt new file mode 100644 index 0000000000..6443887f3a --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown28.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_down_28: ImageVector? = null + +val Icons.ic_arrow_down_28: ImageVector + get() { + if (_ic_arrow_down_28 != null) return _ic_arrow_down_28!! + _ic_arrow_down_28 = ImageVector.Builder( + name = "ic_arrow_down_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.7496 4.66669L12.7496 20.8991L5.55042 13.6999C5.06226 13.2117 4.27099 13.2117 3.78284 13.6999C3.29485 14.1881 3.29474 14.9794 3.78284 15.4675L13.1158 24.8005C13.3502 25.0348 13.6682 25.1666 13.9996 25.1667C14.3311 25.1667 14.649 25.0348 14.8834 24.8005L24.2174 15.4675C24.7055 14.9794 24.7052 14.1881 24.2174 13.6999C23.7293 13.2117 22.938 13.2117 22.4498 13.6999L15.2496 20.9001L15.2496 4.66669C15.2496 3.97633 14.69 3.41669 13.9996 3.41669C13.3094 3.41686 12.7496 3.97644 12.7496 4.66669Z"), + ) + }.build() + return _ic_arrow_down_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowDown28Preview() { + Icon( + imageVector = Icons.ic_arrow_down_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown32.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown32.kt new file mode 100644 index 0000000000..eb72495346 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowDown32.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_down_32: ImageVector? = null + +val Icons.ic_arrow_down_32: ImageVector + get() { + if (_ic_arrow_down_32 != null) return _ic_arrow_down_32!! + _ic_arrow_down_32 = ImageVector.Builder( + name = "ic_arrow_down_32", + defaultWidth = 32.dp, + defaultHeight = 32.dp, + viewportWidth = 32f, + viewportHeight = 32f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.5003 5.33331L14.5003 23.7122L6.39387 15.6058C5.80812 15.0202 4.85852 15.0202 4.27277 15.6058C3.68704 16.1915 3.68715 17.1411 4.27277 17.7269L14.9398 28.3939L15.0491 28.4935C15.3161 28.7123 15.6521 28.8333 16.0003 28.8333C16.398 28.8332 16.7796 28.6751 17.0609 28.3939L27.7269 17.7269C28.3127 17.1411 28.3127 16.1916 27.7269 15.6058C27.1411 15.0203 26.1915 15.0201 25.6058 15.6058L17.5003 23.7122L17.5003 5.33332C17.5003 4.505 16.8286 3.83349 16.0003 3.83332C15.1719 3.83331 14.5003 4.50489 14.5003 5.33331Z"), + ) + }.build() + return _ic_arrow_down_32!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowDown32Preview() { + Icon( + imageVector = Icons.ic_arrow_down_32, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp12.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp12.kt new file mode 100644 index 0000000000..7fc373d72e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp12.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_up_12: ImageVector? = null + +val Icons.ic_arrow_up_12: ImageVector + get() { + if (_ic_arrow_up_12 != null) return _ic_arrow_up_12!! + _ic_arrow_up_12 = ImageVector.Builder( + name = "ic_arrow_up_12", + defaultWidth = 12.dp, + defaultHeight = 12.dp, + viewportWidth = 12f, + viewportHeight = 12f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.5 10.25C5.5 10.5261 5.72386 10.75 6 10.75C6.27614 10.75 6.5 10.5261 6.5 10.25V3.20703L9.64648 6.35352C9.84175 6.54878 10.1583 6.54878 10.3535 6.35352C10.5488 6.15825 10.5488 5.84175 10.3535 5.64648L6.35352 1.64648C6.15825 1.45122 5.84175 1.45122 5.64648 1.64648L1.64648 5.64648C1.45122 5.84175 1.45122 6.15825 1.64648 6.35352C1.84175 6.54878 2.15825 6.54878 2.35352 6.35352L5.5 3.20703V10.25Z"), + ) + }.build() + return _ic_arrow_up_12!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowUp12Preview() { + Icon( + imageVector = Icons.ic_arrow_up_12, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp16.kt new file mode 100644 index 0000000000..f6368fe3a5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp16.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_up_16: ImageVector? = null + +val Icons.ic_arrow_up_16: ImageVector + get() { + if (_ic_arrow_up_16 != null) return _ic_arrow_up_16!! + _ic_arrow_up_16 = ImageVector.Builder( + name = "ic_arrow_up_16", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M7.49966 13.6667C7.49966 13.9427 7.72367 14.1665 7.99966 14.1667C8.27581 14.1667 8.49966 13.9428 8.49966 13.6667V3.87372L12.9801 8.35321C13.1754 8.54847 13.4919 8.54847 13.6872 8.35321C13.8821 8.15792 13.8823 7.84133 13.6872 7.64618L8.35318 2.31317C8.1579 2.11807 7.84136 2.11796 7.64615 2.31317L2.31314 7.64618C2.11793 7.84139 2.11804 8.15793 2.31314 8.35321C2.5084 8.54847 2.82491 8.54847 3.02017 8.35321L7.49966 3.87372V13.6667Z"), + ) + }.build() + return _ic_arrow_up_16!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowUp16Preview() { + Icon( + imageVector = Icons.ic_arrow_up_16, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp20.kt new file mode 100644 index 0000000000..64f34cde40 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp20.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_up_20: ImageVector? = null + +val Icons.ic_arrow_up_20: ImageVector + get() { + if (_ic_arrow_up_20 != null) return _ic_arrow_up_20!! + _ic_arrow_up_20 = ImageVector.Builder( + name = "ic_arrow_up_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M9.25034 17.0833C9.25034 17.4975 9.58612 17.8333 10.0003 17.8333C10.4144 17.8331 10.7503 17.4974 10.7503 17.0833V5.14484L16.1361 10.5306C16.4289 10.8234 16.9037 10.8231 17.1966 10.5306C17.4895 10.2377 17.4895 9.76293 17.1966 9.47003L10.5306 2.80304C10.2378 2.5102 9.76297 2.51031 9.47006 2.80304L2.80307 9.47003C2.51034 9.76294 2.51023 10.2377 2.80307 10.5306C3.09592 10.8233 3.57076 10.8233 3.86362 10.5306L9.25034 5.14386V17.0833Z"), + ) + }.build() + return _ic_arrow_up_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowUp20Preview() { + Icon( + imageVector = Icons.ic_arrow_up_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp24.kt new file mode 100644 index 0000000000..ed04e1df1d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp24.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_up_24: ImageVector? = null + +val Icons.ic_arrow_up_24: ImageVector + get() { + if (_ic_arrow_up_24 != null) return _ic_arrow_up_24!! + _ic_arrow_up_24 = ImageVector.Builder( + name = "ic_arrow_up_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M11 20.5C11 21.0523 11.4477 21.5 12 21.5C12.5523 21.5 13 21.0523 13 20.5V6.41406L19.293 12.707C19.6835 13.0976 20.3165 13.0976 20.707 12.707C21.0976 12.3165 21.0976 11.6835 20.707 11.293L12.707 3.29297C12.3165 2.90244 11.6835 2.90244 11.293 3.29297L3.29297 11.293C2.90245 11.6835 2.90245 12.3165 3.29297 12.707C3.68349 13.0976 4.31651 13.0976 4.70703 12.707L11 6.41406V20.5Z"), + ) + }.build() + return _ic_arrow_up_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowUp24Preview() { + Icon( + imageVector = Icons.ic_arrow_up_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp28.kt new file mode 100644 index 0000000000..827272110f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp28.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_up_28: ImageVector? = null + +val Icons.ic_arrow_up_28: ImageVector + get() { + if (_ic_arrow_up_28 != null) return _ic_arrow_up_28!! + _ic_arrow_up_28 = ImageVector.Builder( + name = "ic_arrow_up_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.7497 23.9167C12.7497 24.6069 13.3095 25.1665 13.9997 25.1667C14.69 25.1667 15.2497 24.607 15.2497 23.9167V7.68427L22.4499 14.8835C22.938 15.3716 23.7293 15.3716 24.2174 14.8835C24.7053 14.3953 24.7055 13.604 24.2174 13.1159L14.8835 3.7829C14.3953 3.29491 13.604 3.2948 13.1159 3.7829L3.78287 13.1159C3.29477 13.604 3.29487 14.3953 3.78287 14.8835C4.27102 15.3716 5.06229 15.3716 5.55045 14.8835L12.7497 7.68427V23.9167Z"), + ) + }.build() + return _ic_arrow_up_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowUp28Preview() { + Icon( + imageVector = Icons.ic_arrow_up_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp32.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp32.kt new file mode 100644 index 0000000000..0d73f8c9a2 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowUp32.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_up_32: ImageVector? = null + +val Icons.ic_arrow_up_32: ImageVector + get() { + if (_ic_arrow_up_32 != null) return _ic_arrow_up_32!! + _ic_arrow_up_32 = ImageVector.Builder( + name = "ic_arrow_up_32", + defaultWidth = 32.dp, + defaultHeight = 32.dp, + viewportWidth = 32f, + viewportHeight = 32f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.5003 27.3333C14.5003 28.1617 15.1719 28.8333 16.0003 28.8333C16.8286 28.8331 17.5003 28.1616 17.5003 27.3333V8.95538L25.6058 17.0609C26.1915 17.6465 27.1411 17.6463 27.7269 17.0609C28.3127 16.4751 28.3127 15.5255 27.7269 14.9398L17.0609 4.27277C16.4752 3.68703 15.5256 3.68714 14.9398 4.27277L4.2728 14.9398C3.68717 15.5256 3.68706 16.4751 4.2728 17.0609C4.85854 17.6464 5.80815 17.6464 6.39389 17.0609L14.5003 8.95441V27.3333Z"), + ) + }.build() + return _ic_arrow_up_32!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowUp32Preview() { + Icon( + imageVector = Icons.ic_arrow_up_32, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual12.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual12.kt new file mode 100644 index 0000000000..e0a6d9b4a6 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual12.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_sign_equal_12: ImageVector? = null + +val Icons.ic_sign_equal_12: ImageVector + get() { + if (_ic_sign_equal_12 != null) return _ic_sign_equal_12!! + _ic_sign_equal_12 = ImageVector.Builder( + name = "ic_sign_equal_12", + defaultWidth = 12.dp, + defaultHeight = 12.dp, + viewportWidth = 12f, + viewportHeight = 12f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M9.5 7.5C9.77614 7.5 10 7.72386 10 8C10 8.27614 9.77614 8.5 9.5 8.5H2.5C2.22386 8.5 2 8.27614 2 8C2 7.72386 2.22386 7.5 2.5 7.5H9.5ZM9.5 3.5C9.77614 3.5 10 3.72386 10 4C10 4.27614 9.77614 4.5 9.5 4.5H2.5C2.22386 4.5 2 4.27614 2 4C2 3.72386 2.22386 3.5 2.5 3.5H9.5Z"), + ) + }.build() + return _ic_sign_equal_12!! + } + +@Composable +@Preview(showBackground = true) +private fun IcSignEqual12Preview() { + Icon( + imageVector = Icons.ic_sign_equal_12, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual16.kt new file mode 100644 index 0000000000..8d36d86f45 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual16.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_sign_equal_16: ImageVector? = null + +val Icons.ic_sign_equal_16: ImageVector + get() { + if (_ic_sign_equal_16 != null) return _ic_sign_equal_16!! + _ic_sign_equal_16 = ImageVector.Builder( + name = "ic_sign_equal_16", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.5 10C12.7761 10 13 10.2239 13 10.5C13 10.7761 12.7761 11 12.5 11H3.5C3.22386 11 3 10.7761 3 10.5C3 10.2239 3.22386 10 3.5 10H12.5ZM12.5 5C12.7761 5 13 5.22386 13 5.5C13 5.77614 12.7761 6 12.5 6H3.5C3.22386 6 3 5.77614 3 5.5C3 5.22386 3.22386 5 3.5 5H12.5Z"), + ) + }.build() + return _ic_sign_equal_16!! + } + +@Composable +@Preview(showBackground = true) +private fun IcSignEqual16Preview() { + Icon( + imageVector = Icons.ic_sign_equal_16, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual20.kt new file mode 100644 index 0000000000..95b324e985 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual20.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_sign_equal_20: ImageVector? = null + +val Icons.ic_sign_equal_20: ImageVector + get() { + if (_ic_sign_equal_20 != null) return _ic_sign_equal_20!! + _ic_sign_equal_20 = ImageVector.Builder( + name = "ic_sign_equal_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M16 12.75C16.4142 12.75 16.75 13.0858 16.75 13.5C16.75 13.9142 16.4142 14.25 16 14.25H4C3.58579 14.25 3.25 13.9142 3.25 13.5C3.25 13.0858 3.58579 12.75 4 12.75H16ZM16 5.75C16.4142 5.75 16.75 6.08579 16.75 6.5C16.75 6.91421 16.4142 7.25 16 7.25H4C3.58579 7.25 3.25 6.91421 3.25 6.5C3.25 6.08579 3.58579 5.75 4 5.75H16Z"), + ) + }.build() + return _ic_sign_equal_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcSignEqual20Preview() { + Icon( + imageVector = Icons.ic_sign_equal_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual24.kt new file mode 100644 index 0000000000..b5558e2436 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual24.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_sign_equal_24: ImageVector? = null + +val Icons.ic_sign_equal_24: ImageVector + get() { + if (_ic_sign_equal_24 != null) return _ic_sign_equal_24!! + _ic_sign_equal_24 = ImageVector.Builder( + name = "ic_sign_equal_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M19 15C19.5523 15 20 15.4477 20 16C20 16.5523 19.5523 17 19 17H5C4.44772 17 4 16.5523 4 16C4 15.4477 4.44772 15 5 15H19ZM19 7C19.5523 7 20 7.44772 20 8C20 8.55228 19.5523 9 19 9H5C4.44772 9 4 8.55228 4 8C4 7.44772 4.44772 7 5 7H19Z"), + ) + }.build() + return _ic_sign_equal_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcSignEqual24Preview() { + Icon( + imageVector = Icons.ic_sign_equal_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual28.kt new file mode 100644 index 0000000000..403ff72bbb --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual28.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_sign_equal_28: ImageVector? = null + +val Icons.ic_sign_equal_28: ImageVector + get() { + if (_ic_sign_equal_28 != null) return _ic_sign_equal_28!! + _ic_sign_equal_28 = ImageVector.Builder( + name = "ic_sign_equal_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M22 17.25C22.6904 17.25 23.25 17.8096 23.25 18.5C23.25 19.1904 22.6904 19.75 22 19.75H6C5.30964 19.75 4.75 19.1904 4.75 18.5C4.75 17.8096 5.30964 17.25 6 17.25H22ZM22 8.25C22.6904 8.25 23.25 8.80964 23.25 9.5C23.25 10.1904 22.6904 10.75 22 10.75H6C5.30964 10.75 4.75 10.1904 4.75 9.5C4.75 8.80964 5.30964 8.25 6 8.25H22Z"), + ) + }.build() + return _ic_sign_equal_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcSignEqual28Preview() { + Icon( + imageVector = Icons.ic_sign_equal_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual32.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual32.kt new file mode 100644 index 0000000000..0a8134e413 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSignEqual32.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_sign_equal_32: ImageVector? = null + +val Icons.ic_sign_equal_32: ImageVector + get() { + if (_ic_sign_equal_32 != null) return _ic_sign_equal_32!! + _ic_sign_equal_32 = ImageVector.Builder( + name = "ic_sign_equal_32", + defaultWidth = 32.dp, + defaultHeight = 32.dp, + viewportWidth = 32f, + viewportHeight = 32f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M25.5 20C26.3284 20 27 20.6716 27 21.5C27 22.3284 26.3284 23 25.5 23H6.5C5.67157 23 5 22.3284 5 21.5C5 20.6716 5.67157 20 6.5 20H25.5ZM25.5 9C26.3284 9 27 9.67157 27 10.5C27 11.3284 26.3284 12 25.5 12H6.5C5.67157 12 5 11.3284 5 10.5C5 9.67157 5.67157 9 6.5 9H25.5Z"), + ) + }.build() + return _ic_sign_equal_32!! + } + +@Composable +@Preview(showBackground = true) +private fun IcSignEqual32Preview() { + Icon( + imageVector = Icons.ic_sign_equal_32, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/Icons.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/Icons.kt new file mode 100644 index 0000000000..1bd3c9b3bd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/Icons.kt @@ -0,0 +1,9 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +/** + * Auto-generated namespace for design-system icons. + * Each icon is provided as an extension property on this object. + */ +object Icons \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt index 6c5be9aecd..85378d4bc1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt @@ -8,6 +8,8 @@ import org.joda.time.DateTimeZone import org.joda.time.LocalDate import org.joda.time.format.DateTimeFormatter +const val SECONDS_IN_HOUR = 3600 + /** * If [this] timestamp is today or yesterday, returns relative date, * otherwise returns formatting date. diff --git a/core/ui/token-gen/build-icons.mjs b/core/ui/token-gen/build-icons.mjs new file mode 100644 index 0000000000..6313846665 --- /dev/null +++ b/core/ui/token-gen/build-icons.mjs @@ -0,0 +1,340 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// ── Paths ────────────────────────────────────────────────────────────────────── +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const iconsDir = path.join(__dirname, '..', 'ds-tokens', 'icons'); +const outputDir = path.join( + __dirname, '..', 'src', 'main', 'java', 'com', 'tangem', 'core', 'ui', + 'res', 'generated', 'icons', +); + +const PACKAGE = 'com.tangem.core.ui.res.generated.icons'; + +// Source SVGs use #0F0F0F as a "tint placeholder" — rewrite to Color.Black so +// Icon(tint = …) at the call site can re-color the icon. +const TINT_PLACEHOLDERS = new Set(['#0f0f0f', '#0F0F0F']); + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function* walkSvgs(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* walkSvgs(full); + else if (entry.name.endsWith('.svg')) yield full; + } +} + +/** Find an attribute value in a snippet of XML. */ +function attr(snippet, name) { + const m = snippet.match(new RegExp(`\\b${name}="([^"]*)"`)); + return m ? m[1] : null; +} + +function capitalize(s) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** Parse an SVG file into a normalized icon descriptor. */ +function parseSvg(filePath) { + const src = fs.readFileSync(filePath, 'utf8'); + + const svgOpen = src.match(/]*>/); + if (!svgOpen) throw new Error('No root element'); + const svgEl = svgOpen[0]; + + // Viewport / default size + const viewBox = attr(svgEl, 'viewBox'); + let viewportW, viewportH; + if (viewBox) { + const parts = viewBox.split(/\s+/).map(Number); + viewportW = parts[2]; + viewportH = parts[3]; + } + const defaultW = parseFloat(attr(svgEl, 'width')) || viewportW; + const defaultH = parseFloat(attr(svgEl, 'height')) || viewportH; + viewportW = viewportW ?? defaultW; + viewportH = viewportH ?? defaultH; + + if (!viewportW || !viewportH) { + throw new Error('Missing viewBox/width/height'); + } + + // Group transforms aren't supported (would need matrix decomposition). + if (/]*\btransform=/.test(src)) { + throw new Error(' is not supported by the current generator'); + } + + // elements + const paths = []; + const pathRe = /]*?)\/?>/g; + let m; + while ((m = pathRe.exec(src)) !== null) { + const a = m[1]; + paths.push({ + d: attr(a, 'd'), + fill: attr(a, 'fill'), + fillRule: attr(a, 'fill-rule'), + fillOpacity: attr(a, 'fill-opacity'), + stroke: attr(a, 'stroke'), + strokeWidth: attr(a, 'stroke-width'), + strokeLinecap: attr(a, 'stroke-linecap'), + strokeLinejoin: attr(a, 'stroke-linejoin'), + opacity: attr(a, 'opacity'), + }); + } + + if (paths.length === 0) throw new Error('No elements found'); + for (const p of paths) { + if (!p.d) throw new Error('A is missing the "d" attribute'); + } + + return { viewportW, viewportH, defaultW, defaultH, paths }; +} + +/** + * ic_arrow_down_24_regular.svg → + * { propName: 'ic_arrow_down_24', fileName: 'IcArrowDown24' } + */ +function deriveNames(svgFile) { + const base = path.basename(svgFile, '.svg').replace(/_regular$/, ''); + const fileName = base + .split('_') + .map(part => capitalize(part)) + .join(''); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(base)) { + throw new Error(`Icon name "${base}" is not a valid Kotlin identifier`); + } + return { propName: base, fileName }; +} + +/** Convert an SVG color string into a Compose Color expression, or null to skip. */ +function svgColorToKotlin(value) { + if (!value || value === 'none') return null; + if (TINT_PLACEHOLDERS.has(value.toLowerCase())) return 'Color.Black'; + + const hex6 = value.match(/^#([0-9a-fA-F]{6})$/); + if (hex6) return `Color(0xFF${hex6[1].toUpperCase()})`; + + const hex3 = value.match(/^#([0-9a-fA-F]{3})$/); + if (hex3) { + const [r, g, b] = hex3[1].toUpperCase().split(''); + return `Color(0xFF${r}${r}${g}${g}${b}${b})`; + } + + const hex8 = value.match(/^#([0-9a-fA-F]{8})$/); + if (hex8) return `Color(0x${hex8[1].toUpperCase()})`; + + const rgba = value.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/); + if (rgba) { + const r = (+rgba[1]).toString(16).padStart(2, '0').toUpperCase(); + const g = (+rgba[2]).toString(16).padStart(2, '0').toUpperCase(); + const b = (+rgba[3]).toString(16).padStart(2, '0').toUpperCase(); + const a = Math.round(parseFloat(rgba[4]) * 255).toString(16).padStart(2, '0').toUpperCase(); + return `Color(0x${a}${r}${g}${b})`; + } + + if (value === 'black') return 'Color.Black'; + if (value === 'white') return 'Color.White'; + if (value === 'transparent') return 'Color.Transparent'; + + throw new Error(`Unsupported SVG color: "${value}"`); +} + +function renderPath(p, indent) { + const pad = ' '.repeat(indent); + const pad1 = ' '.repeat(indent + 1); + const args = []; + + // If a path has no fill at all and has stroke, leave fill out. Otherwise default to tintable black. + const hasStroke = !!p.stroke && p.stroke !== 'none'; + const fillSpecified = p.fill != null; + let fillKt = svgColorToKotlin(p.fill); + if (!fillSpecified && !hasStroke) fillKt = 'Color.Black'; + if (fillKt) args.push(`fill = SolidColor(${fillKt})`); + + if (p.fillOpacity != null) { + args.push(`fillAlpha = ${parseFloat(p.fillOpacity)}f`); + } else if (p.opacity != null && fillKt) { + args.push(`fillAlpha = ${parseFloat(p.opacity)}f`); + } + + const strokeKt = svgColorToKotlin(p.stroke); + if (strokeKt) args.push(`stroke = SolidColor(${strokeKt})`); + if (p.strokeWidth != null) args.push(`strokeLineWidth = ${parseFloat(p.strokeWidth)}f`); + if (p.strokeLinecap) args.push(`strokeLineCap = StrokeCap.${capitalize(p.strokeLinecap)}`); + if (p.strokeLinejoin) args.push(`strokeLineJoin = StrokeJoin.${capitalize(p.strokeLinejoin)}`); + + args.push(`pathFillType = PathFillType.${p.fillRule === 'evenodd' ? 'EvenOdd' : 'NonZero'}`); + args.push(`pathData = addPathNodes(${JSON.stringify(p.d)})`); + + const lines = [`${pad}addPath(`]; + for (const arg of args) lines.push(`${pad1}${arg},`); + lines.push(`${pad})`); + return lines.join('\n'); +} + +function renderIconFile({ propName, fileName }, icon) { + const usesStroke = icon.paths.some(p => p.stroke && p.stroke !== 'none'); + + const imports = [ + 'androidx.compose.material3.Icon', + 'androidx.compose.runtime.Composable', + 'androidx.compose.ui.graphics.Color', + 'androidx.compose.ui.graphics.PathFillType', + 'androidx.compose.ui.graphics.SolidColor', + 'androidx.compose.ui.graphics.vector.ImageVector', + 'androidx.compose.ui.graphics.vector.addPathNodes', + 'androidx.compose.ui.tooling.preview.Preview', + 'androidx.compose.ui.unit.dp', + ]; + if (usesStroke) { + imports.push('androidx.compose.ui.graphics.StrokeCap'); + imports.push('androidx.compose.ui.graphics.StrokeJoin'); + } + imports.sort(); + + const pathBlocks = icon.paths.map(p => renderPath(p, 2)).join('\n'); + + return `@file:Suppress("all") + +package ${PACKAGE} + +${imports.map(i => `import ${i}`).join('\n')} + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _${propName}: ImageVector? = null + +val Icons.${propName}: ImageVector + get() { + if (_${propName} != null) return _${propName}!! + _${propName} = ImageVector.Builder( + name = ${JSON.stringify(propName)}, + defaultWidth = ${icon.defaultW}.dp, + defaultHeight = ${icon.defaultH}.dp, + viewportWidth = ${icon.viewportW}f, + viewportHeight = ${icon.viewportH}f, + ).apply { +${pathBlocks.replace(/^/gm, ' ')} + }.build() + return _${propName}!! + } + +@Composable +@Preview(showBackground = true) +private fun ${fileName}Preview() { + Icon( + imageVector = Icons.${propName}, + contentDescription = null, + ) +} +`; +} + +const ICONS_NAMESPACE = `@file:Suppress("all") + +package ${PACKAGE} + +/** + * Auto-generated namespace for design-system icons. + * Each icon is provided as an extension property on this object. + */ +object Icons +`; + +// ── Hash gate ────────────────────────────────────────────────────────────────── + +function computeIconsHash() { + const files = [...walkSvgs(iconsDir)]; + files.sort((a, b) => { + const ra = path.relative(iconsDir, a).split(path.sep).join('/'); + const rb = path.relative(iconsDir, b).split(path.sep).join('/'); + return ra.localeCompare(rb); + }); + const hash = crypto.createHash('sha256'); + for (const file of files) { + hash.update(path.relative(iconsDir, file).split(path.sep).join('/')); + hash.update('\0'); + hash.update(fs.readFileSync(file)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +// ── Main ─────────────────────────────────────────────────────────────────────── + +export async function buildIcons() { + console.log('\nBuilding icon vectors...'); + + const newHash = computeIconsHash(); + const hashFile = path.join(outputDir, '.icons-hash'); + if (fs.existsSync(hashFile)) { + const prev = fs.readFileSync(hashFile, 'utf8').trim(); + if (prev === newHash) { + console.log(` ✓ icons unchanged (${newHash.substring(0, 12)}…); skipping`); + return { hash: newHash }; + } + } + + fs.mkdirSync(outputDir, { recursive: true }); + + // Parse every SVG up-front so we fail fast on errors before writing anything. + const icons = []; + for (const svgFile of walkSvgs(iconsDir)) { + const names = deriveNames(svgFile); + let parsed; + try { + parsed = parseSvg(svgFile); + } catch (e) { + throw new Error(`${path.relative(iconsDir, svgFile)}: ${e.message}`); + } + icons.push({ names, parsed }); + } + + // Detect property-name collisions early. + const seen = new Map(); + for (const { names } of icons) { + if (seen.has(names.propName)) { + throw new Error( + `Duplicate icon property "${names.propName}" (file collision: ` + + `${seen.get(names.propName)}.kt vs ${names.fileName}.kt)`, + ); + } + seen.set(names.propName, names.fileName); + } + + // Write namespace + per-icon files. + const expectedFiles = new Set(['Icons.kt', '.icons-hash']); + fs.writeFileSync(path.join(outputDir, 'Icons.kt'), ICONS_NAMESPACE); + + for (const { names, parsed } of icons) { + const file = `${names.fileName}.kt`; + expectedFiles.add(file); + fs.writeFileSync(path.join(outputDir, file), renderIconFile(names, parsed)); + } + + // Cleanup stale generated files (icons that no longer have a source SVG). + let removed = 0; + for (const entry of fs.readdirSync(outputDir)) { + if (!expectedFiles.has(entry) && entry.endsWith('.kt')) { + fs.unlinkSync(path.join(outputDir, entry)); + removed++; + } + } + + fs.writeFileSync(hashFile, newHash + '\n'); + const removedNote = removed > 0 ? `, removed ${removed} stale` : ''; + console.log(` ✓ ${icons.length} icon(s) (${newHash.substring(0, 12)}…${removedNote})`); + return { hash: newHash }; +} + +// Run directly when executed as `node build-icons.mjs`. +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + await buildIcons(); +} diff --git a/core/ui/token-gen/build-tokens.mjs b/core/ui/token-gen/build-tokens.mjs index 9d30c49158..ad2ced4a4e 100644 --- a/core/ui/token-gen/build-tokens.mjs +++ b/core/ui/token-gen/build-tokens.mjs @@ -4,6 +4,7 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { buildIcons } from './build-icons.mjs'; // ── Paths ────────────────────────────────────────────────────────────────────── const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -908,8 +909,19 @@ function computeTokensHash() { return hash.digest('hex'); } -const tokensHash = computeTokensHash(); +// ── Build icons ─────────────────────────────────────────────────────────────── +// Run before writing .tokens-hash so the icons hash can be folded in — Gradle +// then has a single hash that invalidates on any ds-tokens change (tokens or icons). +const { hash: iconsHash } = await buildIcons(); + +const tokensInputHash = computeTokensHash(); +const tokensHash = crypto + .createHash('sha256') + .update(tokensInputHash) + .update('\0') + .update(iconsHash) + .digest('hex'); fs.writeFileSync(path.join(outputDir, '.tokens-hash'), tokensHash + '\n'); -console.log(` ✓ .tokens-hash (${tokensHash.substring(0, 12)}…)`); +console.log(`\n ✓ .tokens-hash (${tokensHash.substring(0, 12)}…)`); console.log(`\nDone! Output: ${outputDir}`); diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt index 9766779759..08217059f4 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt @@ -117,6 +117,7 @@ internal object YieldConverter : Converter { private fun convertPeriod(periodDTO: YieldDTO.MetadataDTO.PeriodDTO): Yield.Metadata.Period { return Yield.Metadata.Period( days = periodDTO.days.asMandatory("days"), + seconds = periodDTO.seconds, ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 23c8ce5ea3..6ea2c55ec8 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -59,6 +59,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty() is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount( fiatBalance = value.fiatBalance.toDM(), + cryptoBalance = value.cryptoBalance.toDM(), ) // Transient statuses are not persisted is PaymentAccountStatusValue.Loading, @@ -70,6 +71,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( } fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { + val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId) return when (value) { is PaymentAccountStatusValueDM.Empty -> PaymentAccountStatusValue.Empty is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated @@ -86,7 +88,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( depositAddress = value.depositAddress, fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), - cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + cryptoCurrency = cryptoCurrency, cards = value.cards.map { card -> TangemPayCard( id = card.id, @@ -113,6 +115,8 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated( source = StatusSource.CACHE, fiatBalance = value.fiatBalance.toDomain(), + cryptoBalance = value.cryptoBalance.toDomain(), + cryptoCurrency = cryptoCurrency, ) null -> PaymentAccountStatusValue.Error.Unavailable } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 2f13c10d8d..a337a6acb8 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -250,6 +250,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED val isFormer = state == CustomerInfo.State.FORMER val fiatBalance = fiatBalance + val cryptoBalance = cryptoBalance return when { kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty() -> { @@ -259,10 +260,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( customerId = requireNotNull(customerId) { "CustomerId must not be null" }, ) } - fiatBalance != null && (isDeactivated || isFormer) -> { + fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> { PaymentAccountStatusValue.Deactivated( source = StatusSource.ACTUAL, fiatBalance = fiatBalance, + cryptoBalance = cryptoBalance, + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), ) } cardInfo != null && productInstance != null && !customerId.isNullOrEmpty() -> convertToContentState( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index a05f3e7b14..1ecf466eb9 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -2,38 +2,32 @@ package com.tangem.data.pay.repository import arrow.core.Either import arrow.core.flatMap -import arrow.core.getOrElse +import arrow.core.left import arrow.core.right import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.pay.store.PaymentAccountStatusesStore +import com.tangem.data.pay.util.CustomerInfoConverter import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest import com.tangem.datasource.api.pay.models.request.OrderRequest import com.tangem.datasource.api.pay.models.request.SetTangemPayEnabledRequest import com.tangem.datasource.api.pay.models.response.CustomerMeResponse -import com.tangem.datasource.api.pay.models.response.FiatBalance import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus -import com.tangem.domain.models.pay.TangemPayCardLimit -import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.model.CustomerInfo -import com.tangem.domain.pay.model.CustomerInfo.CardInfo -import com.tangem.domain.pay.model.CustomerInfo.ProductInstance import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.error.VisaApiError -import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap @@ -99,10 +93,10 @@ internal class DefaultOnboardingRepository @Inject constructor( override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either { return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getCustomerMe(authHeader) } .flatMap { response -> - val result = response.result - val status = result?.productInstance?.status + val result = response.result ?: return@flatMap VisaApiError.UnknownWithoutCode.left() + val status = result.productInstance?.status val isDeactivated = status == CustomerMeResponse.ProductInstance.Status.DEACTIVATED - val isFormer = result?.state?.let { CustomerInfo.State.fromString(it) } == CustomerInfo.State.FORMER + val isFormer = result.state.let { CustomerInfo.State.fromString(it) } == CustomerInfo.State.FORMER if (isDeactivated || isFormer) { tangemPayStorage.storeIsTangemPayDeactivated(userWalletId) } @@ -166,70 +160,16 @@ internal class DefaultOnboardingRepository @Inject constructor( @Suppress("ComplexCondition") private suspend fun getCustomerInfo( userWalletId: UserWalletId, - response: CustomerMeResponse.Result?, + response: CustomerMeResponse.Result, ): CustomerInfo { - val kycStatus = KycStatus.fromString(status = response?.kyc?.status) - sendKycAnalytics(kycStatus) + val customerInfo = CustomerInfoConverter.convert(response) + sendKycAnalytics(customerInfo.kycStatus) - val card = response?.card - val fiatBalance = response?.balance?.fiat - val cryptoBalance = response?.balance?.crypto - val paymentAccount = response?.paymentAccount - val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null && cryptoBalance != null) { - CardInfo( - lastFourDigits = card.cardNumberEnd, - balance = fiatBalance.availableBalance, - currencyCode = fiatBalance.currency, - depositAddress = response.depositAddress, - isPinSet = response.card?.isPinSet == true, - fiatBalance = fiatBalance.toDomain(), - cryptoBalance = PaymentAccountStatusValue.CryptoBalance( - id = cryptoBalance.id, - chainId = cryptoBalance.chainId.toLong(), - depositAddress = cryptoBalance.depositAddress.orEmpty(), - tokenContractAddress = cryptoBalance.tokenContractAddress, - balance = cryptoBalance.balance, - ), - ) - } else { - null + customerInfo.productInstance?.let { instance -> + cardFrozenStateStore.store(key = instance.cardId, value = instance.frozenState) } - val productInstance = response?.productInstance?.let { instance -> - val cardFrozenState = when (instance.status) { - CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen - else -> TangemPayCardFrozenState.Frozen - } - cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState) - val displayName = instance.displayName?.ifEmpty { null } - - ProductInstance( - id = instance.id, - cardId = instance.cardId, - frozenState = cardFrozenState, - status = instance.status.toDomain(), - displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null, - actualCardLimit = instance.actualCardLimit?.parseCardLimit(), - adminCardLimit = instance.adminCardLimit?.parseCardLimit(), - ) - } - return CustomerInfo( - customerId = response?.id, - productInstance = productInstance, - kycStatus = kycStatus, - cardInfo = cardInfo, - state = response?.state?.let { CustomerInfo.State.fromString(it) } ?: CustomerInfo.State.UNDEFINED, - fiatBalance = fiatBalance?.toDomain(), - ).also { - lastFetchedCustomerInfoMap[userWalletId] = it - } - } - - private fun CustomerMeResponse.CardLimit.parseCardLimit(): TangemPayCardLimit { - return TangemPayCardLimit( - amount = amount, - period = TangemPayCardLimitPeriod.fromString(periodType), - ) + return customerInfo.also { lastFetchedCustomerInfoMap[userWalletId] = it } } private fun sendKycAnalytics(kycStatus: KycStatus) { @@ -308,24 +248,4 @@ internal class DefaultOnboardingRepository @Inject constructor( setHideMainOnboardingBanner(userWalletId) } } -} - -private fun FiatBalance.toDomain() = PaymentAccountStatusValue.FiatBalance( - availableBalance = availableBalance, - currency = currency, -) - -private fun CustomerMeResponse.ProductInstance.Status.toDomain() = when (this) { - CustomerMeResponse.ProductInstance.Status.NEW -> ProductInstance.Status.NEW - CustomerMeResponse.ProductInstance.Status.READY_FOR_MANUFACTURING -> ProductInstance.Status.READY_FOR_MANUFACTURING - CustomerMeResponse.ProductInstance.Status.MANUFACTURING -> ProductInstance.Status.MANUFACTURING - CustomerMeResponse.ProductInstance.Status.SENT_TO_DELIVERY -> ProductInstance.Status.SENT_TO_DELIVERY - CustomerMeResponse.ProductInstance.Status.DELIVERED -> ProductInstance.Status.DELIVERED - CustomerMeResponse.ProductInstance.Status.ACTIVATING -> ProductInstance.Status.ACTIVATING - CustomerMeResponse.ProductInstance.Status.ACTIVE -> ProductInstance.Status.ACTIVE - CustomerMeResponse.ProductInstance.Status.BLOCKED -> ProductInstance.Status.BLOCKED - CustomerMeResponse.ProductInstance.Status.DEACTIVATING -> ProductInstance.Status.DEACTIVATING - CustomerMeResponse.ProductInstance.Status.DEACTIVATED -> ProductInstance.Status.DEACTIVATED - CustomerMeResponse.ProductInstance.Status.CANCELED -> ProductInstance.Status.CANCELED - CustomerMeResponse.ProductInstance.Status.UNKNOWN -> ProductInstance.Status.UNKNOWN } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt index 74512256e4..ffcc761c68 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayWithdrawRepository.kt @@ -11,6 +11,7 @@ import com.tangem.datasource.api.pay.models.response.WithdrawResponse import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.pay.TangemPayWithdrawState import com.tangem.domain.pay.WithdrawalResult @@ -222,13 +223,13 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor( } } - override suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean { - val orderId = tangemPayStorage.getActiveWithdrawOrderId(userWallet.walletId) + override suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean { + val orderId = tangemPayStorage.getActiveWithdrawOrderId(userWalletId) if (orderId.isNullOrEmpty()) return false - val orderData = orderRepository.getOrderData(userWalletId = userWallet.walletId, orderId = orderId).getOrNull() + val orderData = orderRepository.getOrderData(userWalletId = userWalletId, orderId = orderId).getOrNull() val isActive = orderData?.status == OrderStatus.NEW || orderData?.status == OrderStatus.PROCESSING if (!isActive) { - tangemPayStorage.deleteActiveWithdrawOrder(userWalletId = userWallet.walletId) + tangemPayStorage.deleteActiveWithdrawOrder(userWalletId = userWalletId) } return isActive } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt new file mode 100644 index 0000000000..cc54b78c63 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -0,0 +1,103 @@ +package com.tangem.data.pay.util + +import arrow.core.getOrElse +import com.tangem.datasource.api.pay.models.response.CryptoBalance +import com.tangem.datasource.api.pay.models.response.CustomerMeResponse +import com.tangem.datasource.api.pay.models.response.FiatBalance +import com.tangem.domain.models.account.CardDisplayName +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.pay.TangemPayCardLimit +import com.tangem.domain.models.pay.TangemPayCardLimitPeriod +import com.tangem.domain.pay.model.CustomerInfo +import com.tangem.domain.pay.model.CustomerInfo.CardInfo +import com.tangem.domain.pay.model.CustomerInfo.ProductInstance +import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.utils.converter.Converter + +internal object CustomerInfoConverter : Converter { + @Suppress("ComplexCondition") + override fun convert(value: CustomerMeResponse.Result): CustomerInfo { + val kycStatus = KycStatus.fromString(status = value.kyc?.status) + val card = value.card + val fiatBalance = value.balance?.fiat + val cryptoBalance = value.balance?.crypto + val paymentAccount = value.paymentAccount + val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null && cryptoBalance != null) { + CardInfo( + lastFourDigits = card.cardNumberEnd, + balance = fiatBalance.availableBalance, + currencyCode = fiatBalance.currency, + depositAddress = value.depositAddress, + isPinSet = value.card?.isPinSet == true, + fiatBalance = fiatBalance.toDomain(), + cryptoBalance = cryptoBalance.toDomain(), + ) + } else { + null + } + val productInstance = value.productInstance?.let { instance -> + val status = instance.status.toDomain() + val cardFrozenState = when (status) { + Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen + else -> TangemPayCardFrozenState.Frozen + } + val displayName = instance.displayName?.ifEmpty { null } + + ProductInstance( + id = instance.id, + cardId = instance.cardId, + frozenState = cardFrozenState, + status = status, + displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null, + actualCardLimit = instance.actualCardLimit?.parseCardLimit(), + adminCardLimit = instance.adminCardLimit?.parseCardLimit(), + ) + } + return CustomerInfo( + customerId = value.id, + productInstance = productInstance, + kycStatus = kycStatus, + cardInfo = cardInfo, + state = CustomerInfo.State.fromString(value.state), + fiatBalance = fiatBalance?.toDomain(), + cryptoBalance = cryptoBalance?.toDomain(), + ) + } + + private fun CustomerMeResponse.CardLimit.parseCardLimit(): TangemPayCardLimit { + return TangemPayCardLimit( + amount = amount, + period = TangemPayCardLimitPeriod.fromString(periodType), + ) + } + + private fun FiatBalance.toDomain() = PaymentAccountStatusValue.FiatBalance( + availableBalance = availableBalance, + currency = currency, + ) + + private fun CryptoBalance.toDomain() = PaymentAccountStatusValue.CryptoBalance( + id = id, + chainId = chainId.toLong(), + depositAddress = depositAddress.orEmpty(), + tokenContractAddress = tokenContractAddress, + balance = balance, + ) + + private fun CustomerMeResponse.ProductInstance.Status.toDomain(): Status = when (this) { + CustomerMeResponse.ProductInstance.Status.NEW -> Status.NEW + CustomerMeResponse.ProductInstance.Status.READY_FOR_MANUFACTURING -> Status.READY_FOR_MANUFACTURING + CustomerMeResponse.ProductInstance.Status.MANUFACTURING -> Status.MANUFACTURING + CustomerMeResponse.ProductInstance.Status.SENT_TO_DELIVERY -> Status.SENT_TO_DELIVERY + CustomerMeResponse.ProductInstance.Status.DELIVERED -> Status.DELIVERED + CustomerMeResponse.ProductInstance.Status.ACTIVATING -> Status.ACTIVATING + CustomerMeResponse.ProductInstance.Status.ACTIVE -> Status.ACTIVE + CustomerMeResponse.ProductInstance.Status.BLOCKED -> Status.BLOCKED + CustomerMeResponse.ProductInstance.Status.DEACTIVATING -> Status.DEACTIVATING + CustomerMeResponse.ProductInstance.Status.DEACTIVATED -> Status.DEACTIVATED + CustomerMeResponse.ProductInstance.Status.CANCELED -> Status.CANCELED + CustomerMeResponse.ProductInstance.Status.UNKNOWN -> Status.UNKNOWN + } +} \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverterTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverterTest.kt index 4608c351b3..689f793d48 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverterTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverterTest.kt @@ -5,7 +5,9 @@ import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -17,9 +19,30 @@ internal class PaymentAccountStatusValueDMConverterTest { private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk() private val userWalletId = UserWalletId("1234567890ABCDEF") + private val cryptoCurrency: CryptoCurrency.Token = mockk() + + init { + every { tangemPayCurrencyFactory.create(userWalletId) } returns cryptoCurrency + } private val converter = PaymentAccountStatusValueDMConverter(tangemPayCurrencyFactory) + private fun cryptoBalance() = PaymentAccountStatusValue.CryptoBalance( + id = "usd-coin", + chainId = 137, + depositAddress = "0xDEPOSIT", + tokenContractAddress = "0xCONTRACT", + balance = BigDecimal("10"), + ) + + private fun cryptoBalanceDM() = PaymentAccountStatusValueDM.CryptoBalanceDM( + id = "usd-coin", + chainId = 137, + depositAddress = "0xDEPOSIT", + tokenContractAddress = "0xCONTRACT", + balance = BigDecimal("10"), + ) + @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Convert { @@ -44,7 +67,9 @@ internal class PaymentAccountStatusValueDMConverterTest { fiatBalance = PaymentAccountStatusValue.FiatBalance( availableBalance = BigDecimal("100"), currency = "USD", - ) + ), + cryptoBalance = cryptoBalance(), + cryptoCurrency = cryptoCurrency, ) // WHEN @@ -117,7 +142,8 @@ internal class PaymentAccountStatusValueDMConverterTest { fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM( availableBalance = BigDecimal("200"), currency = "EUR", - ) + ), + cryptoBalance = cryptoBalanceDM(), ) // WHEN diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt index 98114357c7..2b8b3299ae 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt @@ -2,6 +2,7 @@ package com.tangem.domain.card.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.CriticalEvent import com.tangem.core.analytics.models.getReferralParams sealed class IntroductionProcess( @@ -9,11 +10,17 @@ sealed class IntroductionProcess( params: Map = emptyMap(), ) : AnalyticsEvent("Introduction Process", event, params) { - class ScreenOpened : IntroductionProcess("Introduction Process Screen Opened") + /** + * Tracks the user opening the Introduction Process screen. + */ + class ScreenOpened : IntroductionProcess("Introduction Process Screen Opened"), CriticalEvent class ButtonTokensList : IntroductionProcess("Button - Tokens List") class ButtonBuyCards : IntroductionProcess("Button - Buy Cards") class ButtonScanCardLegacy : IntroductionProcess("Button - Scan Card") + /** + * Tracks opening the Create Wallet introduction screen. + */ class CreateWalletIntroScreenOpened( referralId: String?, ) : IntroductionProcess( @@ -21,7 +28,7 @@ sealed class IntroductionProcess( params = buildMap { putAll(getReferralParams(referralId)) }, - ) + ), CriticalEvent class ButtonScanCard( val source: AnalyticsParam.ScreensSources, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index 97af48e470..62c8c4481b 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -104,7 +104,30 @@ sealed class PaymentAccountStatusValue { data class Deactivated( override val source: StatusSource, val fiatBalance: FiatBalance, - ) : PaymentAccountStatusValue() + val cryptoBalance: CryptoBalance, + val cryptoCurrency: CryptoCurrency.Token, + ) : PaymentAccountStatusValue() { + val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = cryptoCurrency, + value = CryptoCurrencyStatus.Loaded( + amount = cryptoBalance.balance, + fiatAmount = fiatBalance.availableBalance, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + type = NetworkAddress.Address.Type.Primary, + value = cryptoBalance.depositAddress, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + pendingTransactions = emptySet(), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + ), + ) + } /** * Represents a state where the payment account is successfully loaded with complete information. diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt index 976b735b4f..acc0333fe9 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt @@ -93,6 +93,13 @@ fun UserWallet.isImported(): Boolean { } } +fun UserWallet.isBackedUpForAnalytics(): Boolean { + return when (this) { + is UserWallet.Cold -> scanResponse.card.backupStatus?.isActive == true + is UserWallet.Hot -> backedUp + } +} + fun UserWallet.copy(name: String = this.name, walletId: UserWalletId = this.walletId): UserWallet = when (this) { is UserWallet.Cold -> this.copy(name = name, walletId = walletId) is UserWallet.Hot -> this.copy(name = name, walletId = walletId) diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt index e1cf32955b..fc9daf4a8e 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt @@ -114,6 +114,7 @@ data class Yield( @Serializable data class Period( val days: Int, + val seconds: Int?, ) @Serializable diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt index bc66de0687..ea4ef686c0 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/CooldownPeriod.kt @@ -2,7 +2,7 @@ package com.tangem.domain.staking.model sealed class CooldownPeriod { - data class Fixed(val days: Int) : CooldownPeriod() + data class Fixed(val period: Period) : CooldownPeriod() data class Range(val minDays: Int, val maxDays: Int) : CooldownPeriod() } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt index 7f779afa4e..cf61efdcff 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -62,7 +62,7 @@ class P2PEthPoolIntegration( // Metadata - override val warmupPeriodDays: Int = 0 + override val warmupPeriod: Period = Period.Days(0) override val cooldownPeriod: CooldownPeriod = CooldownPeriod.Range( minDays = MIN_COOLDOWN_DAYS, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/Period.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/Period.kt new file mode 100644 index 0000000000..e54d402e00 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/Period.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.staking.model + +sealed class Period { + + abstract val value: Int + + data class Days( + override val value: Int, + ) : Period() + + data class Seconds( + override val value: Int, + ) : Period() +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt index 185d52ea8b..8f1017dddb 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakeKitIntegration.kt @@ -49,10 +49,22 @@ class StakeKitIntegration( // Metadata - override val warmupPeriodDays: Int = yield.metadata.warmupPeriod.days + override val warmupPeriod: Period = yield.metadata.warmupPeriod.let { period -> + period.seconds?.let { + Period.Seconds(it) + } ?: period.days.let { + Period.Days(it) + } + } - override val cooldownPeriod: CooldownPeriod? = yield.metadata.cooldownPeriod?.days?.let { - CooldownPeriod.Fixed(it) + override val cooldownPeriod: CooldownPeriod? = yield.metadata.cooldownPeriod?.let { period -> + CooldownPeriod.Fixed( + period.seconds?.let { + Period.Seconds(it) + } ?: period.days.let { + Period.Days(it) + }, + ) } override val rewardSchedule: RewardSchedule = yield.metadata.rewardSchedule.toRewardSchedule() diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt index 12895e0629..8acc7d79ca 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegration.kt @@ -43,7 +43,7 @@ sealed interface StakingIntegration { // Metadata - val warmupPeriodDays: Int + val warmupPeriod: Period val cooldownPeriod: CooldownPeriod? diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/StakeKitIntegrationTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/StakeKitIntegrationTest.kt new file mode 100644 index 0000000000..26f80a5941 --- /dev/null +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/StakeKitIntegrationTest.kt @@ -0,0 +1,206 @@ +package com.tangem.domain.staking.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.staking.NetworkType +import com.tangem.domain.models.staking.YieldToken +import com.tangem.domain.staking.model.stakekit.AddressArgument +import com.tangem.domain.staking.model.stakekit.Yield +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** + * Tests for [StakeKitIntegration] — specifically the Period/CooldownPeriod mapping from [Yield.Metadata]. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class StakeKitIntegrationTest { + + // region helpers + + private val dummyToken = YieldToken( + name = "Test Token", + network = NetworkType.SOLANA, + symbol = "SOL", + decimals = 9, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = false, + ) + + private val dummyEnter = Yield.Args.Enter( + addresses = Yield.Args.Enter.Addresses( + address = AddressArgument(required = false), + ), + args = emptyMap(), + ) + + private val dummyArgs = Yield.Args(enter = dummyEnter, exit = null) + + private val dummyStatus = Yield.Status(enter = true, exit = null) + + private val dummyEnabled = Yield.Metadata.Enabled(enabled = true) + + private fun buildYield( + warmupPeriod: Yield.Metadata.Period, + cooldownPeriod: Yield.Metadata.Period?, + ): Yield { + return Yield( + id = "test-integration", + token = dummyToken, + tokens = emptyList(), + args = dummyArgs, + status = dummyStatus, + apy = BigDecimal("5.0"), + rewardRate = 5.0, + rewardType = com.tangem.domain.staking.model.common.RewardType.APY, + metadata = Yield.Metadata( + name = "Test Staking", + logoUri = "https://example.com/logo.png", + description = "Test staking integration", + documentation = null, + gasFeeToken = dummyToken, + token = dummyToken, + tokens = emptyList(), + type = "liquid", + rewardSchedule = Yield.Metadata.RewardSchedule.DAY, + cooldownPeriod = cooldownPeriod, + warmupPeriod = warmupPeriod, + rewardClaiming = Yield.Metadata.RewardClaiming.AUTO, + defaultValidator = null, + minimumStake = null, + supportsMultipleValidators = false, + revshare = dummyEnabled, + fee = dummyEnabled, + ), + validators = emptyList(), + isAvailable = true, + ) + } + + private fun buildIntegration( + warmupPeriod: Yield.Metadata.Period, + cooldownPeriod: Yield.Metadata.Period?, + ): StakeKitIntegration { + return StakeKitIntegration( + integrationId = StakingIntegrationID.StakeKit.Coin.Solana, + yield = buildYield(warmupPeriod = warmupPeriod, cooldownPeriod = cooldownPeriod), + ) + } + + // endregion + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class `warmupPeriod mapping` { + + @Test + fun `should produce Period Seconds when seconds is non-null`() { + // given + val warmup = Yield.Metadata.Period(days = 3, seconds = 7200) + + // when + val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null) + + // then + assertThat(integration.warmupPeriod).isEqualTo(Period.Seconds(7200)) + } + + @Test + fun `should produce Period Days when seconds is null`() { + // given + val warmup = Yield.Metadata.Period(days = 5, seconds = null) + + // when + val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null) + + // then + assertThat(integration.warmupPeriod).isEqualTo(Period.Days(5)) + } + + @Test + fun `should prefer seconds over days when both are present`() { + // given — days is non-zero but seconds takes priority + val warmup = Yield.Metadata.Period(days = 10, seconds = 3600) + + // when + val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null) + + // then + assertThat(integration.warmupPeriod).isInstanceOf(Period.Seconds::class.java) + assertThat((integration.warmupPeriod as Period.Seconds).value).isEqualTo(3600) + } + + @Test + fun `should produce Period Days with zero value when days is zero and seconds is null`() { + // given + val warmup = Yield.Metadata.Period(days = 0, seconds = null) + + // when + val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null) + + // then + assertThat(integration.warmupPeriod).isEqualTo(Period.Days(0)) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class `cooldownPeriod mapping` { + + @Test + fun `should be null when yield cooldownPeriod is null`() { + // given + val warmup = Yield.Metadata.Period(days = 1, seconds = null) + + // when + val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null) + + // then + assertThat(integration.cooldownPeriod).isNull() + } + + @Test + fun `should produce Fixed Period Seconds when cooldown seconds is non-null`() { + // given + val warmup = Yield.Metadata.Period(days = 1, seconds = null) + val cooldown = Yield.Metadata.Period(days = 2, seconds = 86400) + + // when + val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = cooldown) + + // then + assertThat(integration.cooldownPeriod).isEqualTo(CooldownPeriod.Fixed(Period.Seconds(86400))) + } + + @Test + fun `should produce Fixed Period Days when cooldown seconds is null`() { + // given + val warmup = Yield.Metadata.Period(days = 1, seconds = null) + val cooldown = Yield.Metadata.Period(days = 3, seconds = null) + + // when + val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = cooldown) + + // then + assertThat(integration.cooldownPeriod).isEqualTo(CooldownPeriod.Fixed(Period.Days(3))) + } + + @Test + fun `should prefer seconds over days in cooldown when both are present`() { + // given + val warmup = Yield.Metadata.Period(days = 1, seconds = null) + val cooldown = Yield.Metadata.Period(days = 7, seconds = 604800) + + // when + val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = cooldown) + + // then + val period = integration.cooldownPeriod + assertThat(period).isInstanceOf(CooldownPeriod.Fixed::class.java) + assertThat((period as CooldownPeriod.Fixed).period).isInstanceOf(Period.Seconds::class.java) + assertThat((period.period as Period.Seconds).value).isEqualTo(604800) + } + } +} \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt index c2c7e8aa7c..32709d5676 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt @@ -22,14 +22,18 @@ sealed class TokenScreenAnalyticsEvent( blockchain: String, token: String, tokenBalance: TokenBalance, + isDynamicAddress: Boolean? = null, ) : AnalyticsEvent( category = "Details Screen", event = "Details Screen Opened", - params = mapOf( - BLOCKCHAIN to blockchain, - TOKEN_PARAM to token, - BALANCE to tokenBalance.name, - ), + params = buildMap { + put(BLOCKCHAIN, blockchain) + put(TOKEN_PARAM, token) + put(BALANCE, tokenBalance.name) + isDynamicAddress?.let { + put("Dynamic Address", if (it) "True" else "False") + } + }, ) { sealed class TokenBalance(val name: String) { data object Full : TokenBalance("Full") diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt deleted file mode 100644 index f94eb30195..0000000000 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.domain.pay - -import com.tangem.domain.models.account.CardDisplayName -import com.tangem.domain.visa.model.TangemPayCardFrozenState -import kotlinx.serialization.Serializable - -@Serializable -data class TangemPayDetailsConfig( - val customerId: String, - val cardId: String, - val isPinSet: Boolean, - val cardFrozenState: TangemPayCardFrozenState, - val cardNumberEnd: String, - val chainId: Int, - val isTangemPayDeactivated: Boolean, - val displayName: CardDisplayName?, -) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 67942f98f2..a2bd0d3656 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -1,9 +1,9 @@ package com.tangem.domain.pay.model -import com.tangem.domain.models.pay.TangemPayCardLimit import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.pay.TangemPayCardLimit import com.tangem.domain.visa.model.TangemPayCardFrozenState import java.math.BigDecimal import java.util.Locale @@ -27,6 +27,7 @@ data class CustomerInfo( val cardInfo: CardInfo?, val state: State, val fiatBalance: PaymentAccountStatusValue.FiatBalance?, + val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?, ) { enum class State { NEW, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt index 6079a4e874..e2b28cdb0c 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayWithdrawRepository.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.pay.WithdrawalResult import java.math.BigDecimal @@ -18,7 +19,7 @@ interface TangemPayWithdrawRepository { exchangeData: TangemPayWithdrawExchangeState, ): Either - suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean + suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean suspend fun pollWithdrawOrdersIfNeeds(userWallet: UserWallet) } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt index 850782d36a..b8c7e1c1f4 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt @@ -64,14 +64,18 @@ sealed class WalletSettingsAnalyticEvents( event = "Button - Recovery phrase", ) + class ButtonGoogleDriveBackup : WalletSettingsAnalyticEvents( + event = "Button - Cloud Backup", + ) + data class NoticeBackupFirst( val source: String, val action: Action, ) : WalletSettingsAnalyticEvents( event = "Notice - Backup First", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action.value, + AnalyticsParam.SOURCE to source, + ACTION to action.value, ), ) { enum class Action(val value: String) { @@ -111,8 +115,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Recovery Phrase Screen Info", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -122,8 +126,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Recovery Phrase Screen", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -133,8 +137,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Recovery Phrase Check", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -144,8 +148,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Backup Complete Screen", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -153,14 +157,14 @@ sealed class WalletSettingsAnalyticEvents( val source: String, ) : WalletSettingsAnalyticEvents( event = "Access Code Screen Opened", - params = mapOf(AnalyticsParam.Key.SOURCE to source), + params = mapOf(AnalyticsParam.SOURCE to source), ) data class ReEnterAccessCodeScreen( val source: String, ) : WalletSettingsAnalyticEvents( event = "Re-enter Access Code Screen", - params = mapOf(AnalyticsParam.Key.SOURCE to source), + params = mapOf(AnalyticsParam.SOURCE to source), ) class ButtonStartUpgrade : WalletSettingsAnalyticEvents( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt index aacbdb1b48..85639c2e6b 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt @@ -187,8 +187,8 @@ internal class ChooseTokenListItemConverter( is PaymentAccountStatusValue.UnderReview, PaymentAccountStatusValue.Loading, PaymentAccountStatusValue.Empty, - is PaymentAccountStatusValue.Deactivated, -> return null + is PaymentAccountStatusValue.Deactivated -> status.cryptoCurrencyStatus is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus } val account = this.account diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt index d739e4d5a9..47789d9ebd 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt @@ -11,9 +11,11 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip @@ -30,6 +32,7 @@ import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalCanScrollBackward import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.commonfeatures.impl.R @@ -46,51 +49,59 @@ internal fun PortfolioSelectorContent( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), ) { - LazyColumn( - modifier = modifier, - contentPadding = contentPadding, + val lazyListState = rememberLazyListState() + + CompositionLocalProvider( + LocalCanScrollBackward provides + lazyListState.canScrollBackward, ) { - val items = state.items - itemsIndexed( - items = items, - key = { _, item -> item.id }, - ) { index, item -> - val previewItem = items.getOrNull(index.dec()) - val offsetModifier = when { - previewItem == null -> Modifier - item is PortfolioSelectorItemUM.GroupTitle -> Modifier.padding( - top = TangemTheme.dimens.spacing16, - ) - else -> Modifier.padding( - top = TangemTheme.dimens.spacing8, - ) - } + LazyColumn( + state = lazyListState, + modifier = modifier, + contentPadding = contentPadding, + ) { + val items = state.items + itemsIndexed( + items = items, + key = { _, item -> item.id }, + ) { index, item -> + val previewItem = items.getOrNull(index.dec()) + val offsetModifier = when { + previewItem == null -> Modifier + item is PortfolioSelectorItemUM.GroupTitle -> Modifier.padding( + top = TangemTheme.dimens.spacing16, + ) + else -> Modifier.padding( + top = TangemTheme.dimens.spacing8, + ) + } - val portfolioShape = RoundedCornerShape(TangemTheme.dimens.radius14) - val border = BorderStroke( - width = 1.dp, - color = TangemTheme.colors.text.accent, - ) + val portfolioShape = RoundedCornerShape(TangemTheme.dimens.radius14) + val border = BorderStroke( + width = 1.dp, + color = TangemTheme.colors.text.accent, + ) - when (item) { - is PortfolioSelectorItemUM.Portfolio -> UserWalletItemRow( - state = item.item, - modifier = offsetModifier - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size68) - .clip(portfolioShape) - .background(TangemTheme.colors.background.action) - .conditional(item.isSelected) { border(border, portfolioShape) } - .clickable(enabled = item.item.isEnabled, onClick = item.item.onClick) - .padding(all = TangemTheme.dimens.spacing12) - .conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) }, - ) - is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow( - model = item, - modifier = offsetModifier - .fillMaxWidth() - .padding(horizontal = TangemTheme.dimens.spacing16), - ) + when (item) { + is PortfolioSelectorItemUM.Portfolio -> UserWalletItemRow( + state = item.item, + modifier = offsetModifier + .fillMaxWidth() + .heightIn(min = TangemTheme.dimens.size68) + .clip(portfolioShape) + .background(TangemTheme.colors.background.action) + .conditional(item.isSelected) { border(border, portfolioShape) } + .clickable(enabled = item.item.isEnabled, onClick = item.item.onClick) + .padding(all = TangemTheme.dimens.spacing12) + .conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) }, + ) + is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow( + model = item, + modifier = offsetModifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + } } } } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt index 1d7b42931f..e7eb3ef990 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -33,6 +34,7 @@ import com.tangem.core.ui.ds.row.TangemRowContainer import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.LocalCanScrollBackward import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -48,20 +50,41 @@ internal fun PortfolioSelectorContentV2( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), ) { - LazyColumn( - modifier = modifier, - contentPadding = contentPadding, + val lazyListState = rememberLazyListState() + + CompositionLocalProvider( + LocalCanScrollBackward provides + lazyListState.canScrollBackward, ) { - val items = state.items - itemsIndexed( - items = items, - key = { _, item -> item.id }, - ) { index, item -> - when (item) { - is PortfolioSelectorItemUM.Portfolio -> - PortfolioSelectorItem( - state = item.item, + LazyColumn( + state = lazyListState, + modifier = modifier, + contentPadding = contentPadding, + ) { + val items = state.items + itemsIndexed( + items = items, + key = { _, item -> item.id }, + ) { index, item -> + when (item) { + is PortfolioSelectorItemUM.Portfolio -> + PortfolioSelectorItem( + state = item.item, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + backgroundColor = TangemTheme.colors2.surface.level3, + radius = TangemTheme.dimens2.x5, + addDefaultPadding = false, + ) + .clickable(enabled = item.item.isEnabled, onClick = item.item.onClick) + .conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) }, + ) + is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow( + model = item, modifier = Modifier + .fillMaxWidth() .roundedShapeItemDecoration( currentIndex = index, lastIndex = state.items.lastIndex, @@ -69,22 +92,9 @@ internal fun PortfolioSelectorContentV2( radius = TangemTheme.dimens2.x5, addDefaultPadding = false, ) - .clickable(enabled = item.item.isEnabled, onClick = item.item.onClick) - .conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) }, + .padding(horizontal = TangemTheme.dimens.spacing16), ) - is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow( - model = item, - modifier = Modifier - .fillMaxWidth() - .roundedShapeItemDecoration( - currentIndex = index, - lastIndex = state.items.lastIndex, - backgroundColor = TangemTheme.colors2.surface.level3, - radius = TangemTheme.dimens2.x5, - addDefaultPadding = false, - ) - .padding(horizontal = TangemTheme.dimens.spacing16), - ) + } } } } diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt index 14265dcd96..5cd91beff4 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -96,7 +96,7 @@ internal class CreateWalletSelectionModel @Inject constructor( return } - router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.AddNewWallet.value)) + router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.AddNewWallet)) } private fun onHardwareWalletClick() { diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 71b570076c..125cf4eb0c 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -164,7 +164,7 @@ internal class CreateWalletStartModel @Inject constructor( return } - router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.CreateWalletIntro.value)) + router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.CreateWalletIntro)) } private fun onBuyClick() { diff --git a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt index b1fcae0b1e..0673e8d9f2 100644 --- a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt +++ b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt @@ -194,7 +194,7 @@ internal class CreateWalletStartModelTest { verify { router.push( route = AppRoute.CreateMobileWallet( - source = AnalyticsParam.ScreensSources.CreateWalletIntro.value, + source = AnalyticsParam.ScreensSources.CreateWalletIntro, ), onComplete = any(), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index 2c3c85e529..a9d942b51e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -87,7 +87,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, getUserCountryUseCase: GetUserCountryUseCase, paramsContainer: ParamsContainer, - private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, + addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val designFeatureToggles: DesignFeatureToggles, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, @@ -247,6 +247,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( val state = MutableStateFlow( MarketsTokenDetailsUM( tokenName = params.token.name, + symbol = params.token.symbol, priceText = params.token.tokenQuotes.currentPrice.format { fiat( fiatCurrencyCode = currentAppCurrency.value.code, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt index 93ab10f9bf..bd372df7e7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -23,6 +23,7 @@ import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.features.feed.components.FeedEntryChildFactory import com.tangem.features.feed.ui.utils.contentFeedEntryStackAnimation @@ -152,7 +153,9 @@ private fun EntryContentV2( }, ) .hazeSourceTangem(zIndex = 0f, state = hazeState), - contentPadding = PaddingValues(top = topBarHeight), + contentPadding = PaddingValues( + top = if (isOpenedInBottomSheet) topBarHeight else TangemTheme.dimens2.x2_5, + ), bottomSheetState = bottomSheetState, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt index ffdf82486b..fe24ef79ea 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt @@ -266,13 +266,13 @@ private fun LazyListScope.bestOpportunitiesItemsV2(state: EarnBestOpportunitiesU is EarnBestOpportunitiesUM.Empty -> { item(key = "best_opportunities_empty") { SpacerH(12.dp) - BestOpportunitiesEmpty() // TODO in [REDACTED_TASK_KEY] + BestOpportunitiesEmpty() } } is EarnBestOpportunitiesUM.EmptyFiltered -> { item(key = "best_opportunities_empty_filtered") { SpacerH(12.dp) - BestOpportunitiesEmptyFiltered(onClearFilterClick = state.onClearFilterClick) // TODO in [REDACTED_TASK_KEY] + BestOpportunitiesEmptyFiltered(onClearFilterClick = state.onClearFilterClick) } } is EarnBestOpportunitiesUM.Content -> { @@ -305,7 +305,7 @@ private fun LazyListScope.bestOpportunitiesItemsV2(state: EarnBestOpportunitiesU color = TangemTheme.colors2.surface.level3, shape = RoundedCornerShape(TangemTheme.dimens2.x5), ) - .padding(vertical = 142.dp, horizontal = 114.dp), + .padding(vertical = 142.dp, horizontal = 16.dp), contentAlignment = Alignment.Center, ) { UnableToLoadData(onRetryClick = state.onRetryClicked) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/BestOpportunitiesEmpty.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/BestOpportunitiesEmpty.kt index eaee4b4be9..2eac5ad631 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/BestOpportunitiesEmpty.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/BestOpportunitiesEmpty.kt @@ -85,7 +85,7 @@ private fun BestOpportunitiesEmptyV2(modifier: Modifier = Modifier) { Text( modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x8), text = stringResourceSafe(R.string.earn_empty), - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, color = TangemTheme.colors2.text.neutral.secondary, textAlign = TextAlign.Center, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/BestOpportunitiesEmptyFiltered.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/BestOpportunitiesEmptyFiltered.kt index 87314ee2f0..755b7df0a2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/BestOpportunitiesEmptyFiltered.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/BestOpportunitiesEmptyFiltered.kt @@ -44,7 +44,7 @@ private fun BestOpportunitiesEmptyFilteredV2(onClearFilterClick: () -> Unit, mod ) { Text( text = stringResourceSafe(R.string.earn_no_results), - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, color = TangemTheme.colors2.text.neutral.secondary, ) SpacerH(TangemTheme.dimens2.x2) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt index 50ffe3073a..bb5e5b99d4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt @@ -175,13 +175,15 @@ private fun NetworksTypesBlock( overflow = TextOverflow.Ellipsis, ) - TangemCheckbox( - modifier = Modifier - .padding(start = 8.dp) - .layoutId(layoutId = TangemRowLayoutId.TAIL), - isChecked = item.isSelected, - onCheckedChange = { onOptionClick(item) }, - ) + if (item.isSelected) { + TangemCheckbox( + modifier = Modifier + .padding(start = 8.dp) + .layoutId(layoutId = TangemRowLayoutId.TAIL), + isChecked = true, + onCheckedChange = { onOptionClick(item) }, + ) + } } } } @@ -233,13 +235,15 @@ private fun SpecificNetworksBlock( overflow = TextOverflow.Ellipsis, ) - TangemCheckbox( - modifier = Modifier - .padding(start = 8.dp) - .layoutId(layoutId = TangemRowLayoutId.TAIL), - isChecked = item.isSelected, - onCheckedChange = { onOptionClick(item) }, - ) + if (item.isSelected) { + TangemCheckbox( + modifier = Modifier + .padding(start = 8.dp) + .layoutId(layoutId = TangemRowLayoutId.TAIL), + isChecked = true, + onCheckedChange = { onOptionClick(item) }, + ) + } } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListItem.kt index 66ead3805f..b4b2932dca 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListItem.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnListItem.kt @@ -115,7 +115,8 @@ private fun EarnListItemV2(item: EarnListItemUM, modifier: Modifier = Modifier) tangemIconUM = TangemIconUM.Currency(item.currencyIconState), modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.HEAD) - .padding(end = TangemTheme.dimens2.x2), + .padding(end = TangemTheme.dimens2.x2) + .size(TangemTheme.dimens2.x10), ) TokenTitle( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt index e1e90b4328..9d3c18477f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt @@ -15,10 +15,10 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerW -import com.tangem.core.ui.ds.opportunities.OpportunitiesBG import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.opportunities.OpportunitiesBG import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.* @@ -53,10 +53,10 @@ private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier ) { Column(modifier = Modifier.padding(12.dp)) { CurrencyIcon( - modifier = Modifier.size(32.dp), state = item.currencyIconState, shouldDisplayNetwork = true, - networkBadgeSize = 12.dp, + networkBadgeSize = TangemTheme.dimens2.x4, + iconSize = TangemTheme.dimens2.x10, networkBadgeBackground = TangemTheme.colors.background.action, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index b84d7cde77..d9dd9222f6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -118,12 +118,7 @@ private fun Content( contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()), ) { item("header") { - Header( - state = state, - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), - ) + Header(state = state) } item { if (isRedesignEnabled) SpacerH(TangemTheme.dimens2.x3) else SpacerH16() @@ -202,15 +197,33 @@ internal fun MarketsTokenDetailsTopBar( } @Composable -private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) { +private fun Header(state: MarketsTokenDetailsUM) { + if (LocalRedesignEnabled.current) { + HeaderV2( + modifier = Modifier + .padding(TangemTheme.dimens2.x4) + .fillMaxWidth(), + state = state, + ) + } else { + HeaderV1( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + state = state, + ) + } +} + +@Composable +private fun HeaderV1(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) { Row( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, ) { Column(modifier = Modifier.weight(1f)) { - TokenPriceText( + TokenPriceTextV1( price = state.priceText, - priceAnnotated = state.priceAnnotated, triggerPriceChange = state.triggerPriceChange, ) Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) { @@ -240,23 +253,55 @@ private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) } @Composable -private fun TokenPriceText( - price: String, - triggerPriceChange: StateEvent, - priceAnnotated: TextReference, - modifier: Modifier = Modifier, -) { - if (LocalRedesignEnabled.current) { - TokenPriceTextV2( - priceAnnotated = priceAnnotated, - triggerPriceChange = triggerPriceChange, - modifier = modifier, - ) - } else { - TokenPriceTextV1( - price = price, - triggerPriceChange = triggerPriceChange, - modifier = modifier, +private fun HeaderV2(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Text( + text = state.tokenName, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + ) + Text( + text = state.symbol, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + } + SpacerH(TangemTheme.dimens2.x1) + TokenPriceTextV2( + priceAnnotated = state.priceAnnotated, + triggerPriceChange = state.triggerPriceChange, + ) + SpacerH(TangemTheme.dimens2.x4) + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) { + Text( + text = state.dateTimeText.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.primary, + ) + if (state.priceChangePercentText != null) { + PriceChangeInPercent( + valueInPercent = state.priceChangePercentText, + type = state.priceChangeType, + textStyle = TangemTheme.typography2.captionMedium12, + ) + } + } + } + SpacerW4() + CoinIcon( + modifier = Modifier.requiredSize(70.dp), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, ) } } @@ -323,9 +368,9 @@ private fun TokenPriceTextV2( text = priceAnnotated.resolveAnnotatedReference(), modifier = modifier, color = color.value, - autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography2.headingBold34.fontSize), + autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography2.titleRegular44.fontSize), maxLines = 1, - style = TangemTheme.typography2.headingBold34, + style = TangemTheme.typography2.titleRegular44, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt index 7d88242f03..d613f5e228 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ExchangesBottomSheet.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -49,6 +50,7 @@ import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.market.detailed.state.ExchangeItemUM import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent @@ -273,7 +275,7 @@ private fun ErrorV2(content: ExchangesBottomSheetContent.Error, modifier: Modifi text = stringResourceSafe(id = content.message), color = TangemTheme.colors2.text.neutral.tertiary, textAlign = TextAlign.Center, - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, ) SpacerH(8.dp) @@ -369,6 +371,7 @@ private fun ExchangeItemRowPlaceholder(modifier: Modifier = Modifier) { .layoutId(TangemRowLayoutId.HEAD), ) RectangleShimmer( + radius = TangemTheme.dimens2.x25, modifier = Modifier .width(108.dp) .height(20.dp) @@ -376,6 +379,7 @@ private fun ExchangeItemRowPlaceholder(modifier: Modifier = Modifier) { .layoutId(TangemRowLayoutId.START_TOP), ) RectangleShimmer( + radius = TangemTheme.dimens2.x25, modifier = Modifier .width(52.dp) .height(16.dp) @@ -383,6 +387,7 @@ private fun ExchangeItemRowPlaceholder(modifier: Modifier = Modifier) { .layoutId(TangemRowLayoutId.START_BOTTOM), ) RectangleShimmer( + radius = TangemTheme.dimens2.x25, modifier = Modifier .width(106.dp) .height(20.dp) @@ -390,6 +395,7 @@ private fun ExchangeItemRowPlaceholder(modifier: Modifier = Modifier) { .layoutId(TangemRowLayoutId.END_TOP), ) RectangleShimmer( + radius = TangemTheme.dimens2.x25, modifier = Modifier .width(52.dp) .height(16.dp) @@ -428,6 +434,25 @@ private fun Preview_ExchangesBottomSheet( } } +@Preview +@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ExchangesBottomSheetV2( + @PreviewParameter(ExchangesBottomSheetContentProvider::class) content: ExchangesBottomSheetContent, +) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + ExchangesBottomSheet( + config = TangemBottomSheetConfig( + onDismissRequest = {}, + content = content, + isShown = true, + ), + ) + } + } +} + private class ExchangesBottomSheetContentProvider : CollectionPreviewParameterProvider( listOf( ExchangesBottomSheetContent.Loading(exchangesCount = 13), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt index 63b06d0833..c73cfa497c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt @@ -20,6 +20,7 @@ internal object MarketsTokenDetailsPreview { val loadingState = MarketsTokenDetailsUM( tokenName = "Token Name", + symbol = "USDT", priceText = "$0.00000000324", dateTimeText = stringReference("Today"), priceChangePercentText = "52.00%", @@ -55,6 +56,7 @@ internal object MarketsTokenDetailsPreview { val contentState = MarketsTokenDetailsUM( tokenName = "Token Name", + symbol = "USDT", priceText = "$0.00000000324", dateTimeText = stringReference("Today"), priceChangePercentText = "52.00%", diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt index cb53a7dd7e..a9e4a602a4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt @@ -14,6 +14,7 @@ import java.math.BigDecimal internal data class MarketsTokenDetailsUM( val tokenName: String, + val symbol: String, val priceText: String, val priceAnnotated: TextReference, val iconUrl: String?, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/SortByMenu.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/SortByMenu.kt index 559240a0ca..34588157c5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/SortByMenu.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/SortByMenu.kt @@ -1,11 +1,25 @@ package com.tangem.features.feed.ui.market.list.components +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.ui.R import com.tangem.core.ui.ds.contextmenu.TangemContextMenu -import com.tangem.core.ui.ds.contextmenu.TangemContextMenuCheckboxItem +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.model.market.list.state.SortByMenuUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM @@ -22,15 +36,61 @@ internal fun SortByMenu( offset = DpOffset.Zero, modifier = modifier, ) { - SortByTypeUM.entries.fastForEach { sortType -> - TangemContextMenuCheckboxItem( - title = sortType.text, - isChecked = sortMenuUM.selectedOption == sortType, - onClick = { - sortMenuUM.onOptionClicked(sortType) - onDropdownDismiss() - }, - ) + SortByTypeUM.entries.fastForEachIndexed { index, sortType -> + Column { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .width(238.dp) + .clickableSingle( + onClick = { + sortMenuUM.onOptionClicked(sortType) + onDropdownDismiss() + }, + ) + .padding( + vertical = TangemTheme.dimens2.x5, + horizontal = TangemTheme.dimens2.x4, + ), + ) { + Text( + text = sortType.text.resolveReference(), + style = TangemTheme.typography2.headingSemibold17, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + if (sortMenuUM.selectedOption == sortType) { + Box( + modifier = Modifier + .padding(TangemTheme.dimens2.x0_5) + .size(TangemTheme.dimens2.x5) + .background( + color = TangemTheme.colors2.graphic.neutral.primary, + shape = CircleShape, + ), + ) { + Icon( + painter = rememberVectorPainter( + ImageVector.vectorResource(R.drawable.ic_check_default_24), + ), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primaryInverted, + modifier = Modifier + .align(Alignment.Center) + .padding(TangemTheme.dimens2.x0_5) + .size(TangemTheme.dimens2.x4), + ) + } + } + } + if (index < SortByTypeUM.entries.size - 1) { + HorizontalDivider( + thickness = 0.5.dp, + color = TangemTheme.colors2.border.neutral.quaternary, + ) + } + } } } } \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 3e871cd61e..4460856afc 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -8,8 +8,8 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.Basic.SignedInLegacy -import com.tangem.core.analytics.models.Basic.SignedInLegacy.SignInType +import com.tangem.core.analytics.models.AnalyticsParam.SignInType +import com.tangem.core.analytics.models.Basic.SignedIn import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -20,9 +20,7 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.message.dialog.Dialogs import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.analytics.IntroductionProcess -import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.analytics.Shop -import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.SaveWalletError @@ -34,18 +32,18 @@ import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase import com.tangem.features.home.api.HomeComponent import com.tangem.features.home.impl.ui.state.HomeUM import com.tangem.features.home.impl.ui.state.Stories import com.tangem.features.home.impl.ui.state.getRestrictedStories -import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.Debouncer +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import java.util.Locale import javax.inject.Inject @@ -218,19 +216,14 @@ internal class HomeModel @Inject constructor( } private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) { - val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) - if (currency != null) { - analyticsEventHandler.send( - SignedInLegacy( - currency = currency, - batch = scanResponse.card.batchId, - signInType = SignInType.Card, - walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), - isImported = isImported, - hasBackup = scanResponse.card.backupStatus?.isActive, - ), - ) - } + analyticsEventHandler.send( + SignedIn( + signInType = SignInType.Card, + walletsCount = userWalletsListRepository.userWalletsSync().size, + isImported = isImported, + isBackedUp = scanResponse.card.backupStatus?.isActive == true, + ), + ) } private fun setLoading(isLoading: Boolean) { diff --git a/features/hot-wallet/api/build.gradle.kts b/features/hot-wallet/api/build.gradle.kts index 7e7bd837fd..4ab40a7a26 100644 --- a/features/hot-wallet/api/build.gradle.kts +++ b/features/hot-wallet/api/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(projects.domain.wallets.models) /* Project - Core */ + api(projects.core.analytics.models) implementation(projects.core.decompose) implementation(projects.core.ui) diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateMobileWalletComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateMobileWalletComponent.kt index 72db10452d..232082c2ca 100644 --- a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateMobileWalletComponent.kt +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/CreateMobileWalletComponent.kt @@ -1,11 +1,12 @@ package com.tangem.features.hotwallet +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent interface CreateMobileWalletComponent : ComposableContentComponent { data class Params( - val source: String, + val source: AnalyticsParam.ScreensSources, ) interface Factory : ComponentFactory diff --git a/features/hot-wallet/impl/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts index 9d2eadef6e..5fb36dc68f 100644 --- a/features/hot-wallet/impl/build.gradle.kts +++ b/features/hot-wallet/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.hotwallet.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.hotWallet.api) @@ -78,4 +82,11 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt index 6c6efa8ece..1d47e02224 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt @@ -129,13 +129,13 @@ internal class AddExistingWalletImportModel @Inject constructor( analyticsEventHandler.send( event = OnboardingAnalyticsEvent.Onboarding.Finished( - source = AnalyticsParam.ScreensSources.ImportWallet.value, + source = AnalyticsParam.ScreensSources.ImportWallet, ), ) analyticsEventHandler.send( event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully( - source = AnalyticsParam.ScreensSources.ImportWallet.value, - creationType = OnboardingAnalyticsEvent.CreateWallet.WalletCreationType.SeedImport, + source = AnalyticsParam.ScreensSources.ImportWallet, + creationType = AnalyticsParam.WalletCreationType.SeedImport, seedPhraseLength = mnemonic.mnemonicComponents.size, passPhraseState = if (passphrase.isNullOrBlank()) { AnalyticsParam.EmptyFull.Empty diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt index a5c4e17316..fb2d8cc290 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createmobilewallet/CreateMobileWalletModel.kt @@ -62,7 +62,9 @@ internal class CreateMobileWalletModel @Inject constructor( init { trackingContextProxy.addHotWalletContext() - analyticsEventHandler.send(event = OnboardingAnalyticsEvent.Onboarding.Started(source = params.source)) + analyticsEventHandler.send( + event = OnboardingAnalyticsEvent.Onboarding.Started(source = params.source), + ) analyticsEventHandler.send( event = OnboardingAnalyticsEvent.SeedPhrase.CreateMobileScreenOpened(source = params.source), ) @@ -95,11 +97,13 @@ internal class CreateMobileWalletModel @Inject constructor( saveUserWalletUseCase(userWallet) - analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished(source = params.source)) + analyticsEventHandler.send( + OnboardingAnalyticsEvent.Onboarding.Finished(source = params.source), + ) analyticsEventHandler.send( event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully( source = params.source, - creationType = OnboardingAnalyticsEvent.CreateWallet.WalletCreationType.NewSeed, + creationType = AnalyticsParam.WalletCreationType.NewSeed, seedPhraseLength = SEED_PHRASE_LENGTH, passPhraseState = AnalyticsParam.EmptyFull.Empty, referralId = appsFlyerStore.get()?.refcode, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt similarity index 95% rename from features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt rename to features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt index a1219ed46a..a7a4115f71 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt @@ -11,7 +11,7 @@ internal data class WalletBackupUM( val onRecoveryPhraseClick: () -> Unit, val onGoogleDriveClick: () -> Unit, val onHardwareWalletClick: () -> Unit, - val backedUp: Boolean, + val isBackedUp: Boolean, ) internal sealed class BackupStatus { diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt index 32986a6549..6eb1b82ac3 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt @@ -7,10 +7,13 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.R import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -19,9 +22,9 @@ import com.tangem.features.hotwallet.WalletBackupComponent import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -35,6 +38,7 @@ internal class WalletBackupModel @Inject constructor( private val router: Router, private val trackingContextProxy: TrackingContextProxy, private val analyticsEventHandler: AnalyticsEventHandler, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params: WalletBackupComponent.Params = paramsContainer.require() @@ -59,9 +63,9 @@ internal class WalletBackupModel @Inject constructor( ), googleDriveStatus = BackupStatus.ComingSoon, onRecoveryPhraseClick = ::onRecoveryPhraseClick, - onGoogleDriveClick = { }, + onGoogleDriveClick = ::onGoogleDriveBackupClick, onHardwareWalletClick = ::onHardwareWalletClick, - backedUp = false, + isBackedUp = false, ), ) @@ -116,15 +120,16 @@ internal class WalletBackupModel @Inject constructor( ) }, googleDriveOption = LabelUM( - text = resourceReference(R.string.common_coming_soon), - style = LabelStyle.REGULAR, + text = resourceReference(R.string.hw_backup_no_backup), + style = LabelStyle.WARNING, ), - backedUp = userWallet.backedUp, + isBackedUp = userWallet.backedUp, + googleDriveStatus = BackupStatus.NoBackup, ) private fun onRecoveryPhraseClick() { analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonRecoveryPhrase()) - if (uiState.value.backedUp) { + if (uiState.value.isBackedUp) { getUserWalletUseCase.invoke(params.userWalletId) .fold( ifLeft = { @@ -150,6 +155,20 @@ internal class WalletBackupModel @Inject constructor( } } + private fun onGoogleDriveBackupClick() { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonGoogleDriveBackup()) + uiMessageSender.send( + DialogMessage( + title = resourceReference(id = R.string.hw_backup_google_drive_dialog_title), + message = resourceReference(id = R.string.hw_backup_google_drive_dialog_message), + firstAction = EventMessageAction( + title = resourceReference(id = R.string.common_ok), + onClick = {}, + ), + ), + ) + } + private fun showSeedPhrase(hotWallet: UserWallet.Hot) { modelScope.launch { unlockHotWalletContextualUseCase.invoke(hotWallet.hotWalletId) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt index dc2787f9c6..59fb6abcdd 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt @@ -132,7 +132,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider() } returns params + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletNotBackedUp.right()) + } + + @Test + fun `GIVEN hot wallet WHEN model is created THEN context added AND BackupScreenOpened sent AND state updated`() = + runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletNotBackedUp.right()) + + val model = createModel(this) + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify { + analyticsEventHandler.send(WalletSettingsAnalyticEvents.BackupScreenOpened(isBackedUp = false)) + } + val state = model.uiState.value + Assertions.assertEquals(false, state.isBackedUp) + Assertions.assertEquals(BackupStatus.NoBackup, state.googleDriveStatus) + } + + @Test + fun `GIVEN cold wallet WHEN model is created THEN context added AND BackupScreenOpened not sent AND state untouched`() = + runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(coldWallet.right()) + + val model = createModel(this) + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + val state = model.uiState.value + Assertions.assertEquals(false, state.isBackedUp) + Assertions.assertEquals(BackupStatus.ComingSoon, state.googleDriveStatus) + } + + @Test + fun `GIVEN error WHEN model is created THEN context added AND BackupScreenOpened not sent`() = runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(GetUserWalletError.UserWalletNotFound.left()) + + createModel(this) + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + } + + @Test + fun `WHEN onDestroy THEN trackingContextProxy removeContext is called`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.onDestroy() + + verify { trackingContextProxy.removeContext() } + } + + @Test + fun `GIVEN backed up hot wallet AND unlock success WHEN onRecoveryPhraseClick THEN ViewPhrase pushed`() = runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right()) + every { getUserWalletUseCase.invoke(walletId) } returns hotWalletBackedUp.right() + coEvery { unlockHotWalletContextualUseCase.invoke(hotWalletId) } returns mockk().right() + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onRecoveryPhraseClick() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { true }) } + coVerify { unlockHotWalletContextualUseCase.invoke(hotWalletId) } + verify { router.push(route = AppRoute.ViewPhrase(userWalletId = walletId), onComplete = any()) } + } + + @Test + fun `GIVEN backed up hot wallet AND unlock failure WHEN onRecoveryPhraseClick THEN ViewPhrase not pushed`() = + runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right()) + every { getUserWalletUseCase.invoke(walletId) } returns hotWalletBackedUp.right() + coEvery { unlockHotWalletContextualUseCase.invoke(hotWalletId) } returns Throwable("error").left() + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onRecoveryPhraseClick() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { true }) } + coVerify { unlockHotWalletContextualUseCase.invoke(hotWalletId) } + verify(exactly = 0) { + router.push(route = AppRoute.ViewPhrase(userWalletId = walletId), onComplete = any()) + } + } + + @Test + fun `GIVEN backed up cold wallet WHEN onRecoveryPhraseClick THEN no navigation AND no unlock`() = runTest { + every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right()) + every { getUserWalletUseCase.invoke(walletId) } returns coldWallet.right() + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onRecoveryPhraseClick() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { true }) } + coVerify(exactly = 0) { unlockHotWalletContextualUseCase.invoke(any()) } + verify(exactly = 0) { router.push(route = any(), onComplete = any()) } + } + + @Test + fun `GIVEN not backed up wallet WHEN onRecoveryPhraseClick THEN WalletActivation pushed`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onRecoveryPhraseClick() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { true }) } + verify(exactly = 0) { getUserWalletUseCase.invoke(walletId) } + coVerify(exactly = 0) { unlockHotWalletContextualUseCase.invoke(any()) } + verify { + router.push( + route = AppRoute.WalletActivation(userWalletId = walletId, isBackupExists = false), + onComplete = any(), + ) + } + } + + @Test + fun `WHEN onHardwareWalletClick THEN ButtonHardwareUpdate sent AND WalletHardwareBackup pushed`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onHardwareWalletClick() + + verify { analyticsEventHandler.send(match { true }) } + verify { + router.push( + route = AppRoute.WalletHardwareBackup(userWalletId = walletId), + onComplete = any(), + ) + } + } + + @Test + fun `WHEN onGoogleDriveClick THEN DialogMessage sent AND analytics sent`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onGoogleDriveClick() + + verify { + analyticsEventHandler.send( + event = match { true } + ) + } + verify { + uiMessageSender.send( + match { + val isTitleCorrect = it.title == resourceReference( + id = R.string.hw_backup_google_drive_dialog_title + ) + val isMessageCorrect = it.message == resourceReference( + id = R.string.hw_backup_google_drive_dialog_message + ) + isTitleCorrect && isMessageCorrect + } + ) + } + } + + @Test + fun `WHEN onBackClick THEN router pop is called`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onBackClick() + + verify { router.pop(onComplete = any()) } + } + + private fun createModel(testScope: TestScope): WalletBackupModel { + return WalletBackupModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + getUserWalletUseCase = getUserWalletUseCase, + unlockHotWalletContextualUseCase = unlockHotWalletContextualUseCase, + router = router, + trackingContextProxy = trackingContextProxy, + analyticsEventHandler = analyticsEventHandler, + uiMessageSender = uiMessageSender, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/analytics/OnboardingEvent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/analytics/OnboardingEvent.kt index 07da257630..b9248bb256 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/analytics/OnboardingEvent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/analytics/OnboardingEvent.kt @@ -2,9 +2,6 @@ package com.tangem.features.onboarding.v2.common.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.AppsFlyerIncludedEvent -import com.tangem.core.analytics.models.getReferralParams -import kotlin.collections.putAll sealed class OnboardingEvent( category: String, @@ -12,41 +9,6 @@ sealed class OnboardingEvent( params: Map = mapOf(), ) : AnalyticsEvent(category, event, params) { - class Started : OnboardingEvent("Onboarding", "Onboarding Started") - class Finished : OnboardingEvent("Onboarding", "Onboarding Finished") - - sealed class CreateWallet( - event: String, - params: Map = mapOf(), - ) : OnboardingEvent("Onboarding / Create Wallet", event, params) { - - class ScreenOpened : CreateWallet("Create Wallet Screen Opened") - class ButtonCreateWallet : CreateWallet("Button - Create Wallet") - class ButtonOtherOptions : CreateWallet("Button - Other Options") - class WalletCreatedSuccessfully( - creationType: WalletCreationType = WalletCreationType.PrivateKey, - seedPhraseLength: Int? = null, - passPhraseState: AnalyticsParam.EmptyFull, - referralId: String?, - ) : CreateWallet( - event = "Wallet Created Successfully", - params = buildMap { - put("Creation Type", creationType.value) - put("Passphrase", passPhraseState.value) - if (seedPhraseLength != null) { - put("Seed Phrase Length", seedPhraseLength.toString()) - } - putAll(getReferralParams(referralId)) - }, - ), AppsFlyerIncludedEvent - - sealed class WalletCreationType(val value: String) { - data object PrivateKey : WalletCreationType(value = "Private Key") - data object NewSeed : WalletCreationType(value = "New Seed") - data object SeedImport : WalletCreationType(value = "Seed Import") - } - } - sealed class Backup( event: String, params: Map = mapOf(), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index c1c8142bb6..7bb3e6821b 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -19,6 +19,7 @@ import com.arkivanov.decompose.router.stack.* import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.instancekeeper.getOrCreateSimple import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel @@ -28,7 +29,6 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent import com.tangem.features.onboarding.v2.addresssync.DefaultAddressSyncComponent -import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.MultiWalletAccessCodeComponent @@ -229,7 +229,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor } Done -> { // final step - navigate to parent - analyticsHandler.send(OnboardingEvent.Finished()) + analyticsHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished()) val userWallet = childParams.multiWalletState.value.resultUserWallet ?: return params.onDone(userWallet) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt index 205405cad4..365e636946 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt @@ -6,6 +6,7 @@ import com.tangem.common.core.TangemSdkError import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -19,7 +20,6 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.SaveWalletUseCase -import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.state.MultiWalletCreateWalletUM @@ -67,12 +67,12 @@ internal class MultiWalletCreateWalletModel @Inject constructor( resourceReference(R.string.onboarding_create_wallet_body) }, onCreateWalletClick = { - analyticsHandler.send(OnboardingEvent.CreateWallet.ButtonCreateWallet()) + analyticsHandler.send(OnboardingAnalyticsEvent.CreateWallet.ButtonCreateWallet()) createWallet(false) }, showOtherOptionsButton = params.parentParams.withSeedPhraseFlow, onOtherOptionsClick = { - analyticsHandler.send(OnboardingEvent.CreateWallet.ButtonOtherOptions()) + analyticsHandler.send(OnboardingAnalyticsEvent.CreateWallet.ButtonOtherOptions()) modelScope.launch { onDone.emit(Step.SeedPhrase) } @@ -85,7 +85,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor( val onDone = MutableSharedFlow() init { - analyticsHandler.send(OnboardingEvent.CreateWallet.ScreenOpened()) + analyticsHandler.send(OnboardingAnalyticsEvent.CreateWallet.ScreenOpened()) } private fun createWallet(shouldReset: Boolean) { @@ -110,7 +110,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor( cardRepository.startCardActivation(cardId = result.data.card.cardId) analyticsHandler.send( - event = OnboardingEvent.CreateWallet.WalletCreatedSuccessfully( + event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully( passPhraseState = AnalyticsParam.EmptyFull.Empty, referralId = appsFlyerStore.get()?.refcode, ), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt index c960e1022a..646ff7ad23 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt @@ -29,7 +29,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.IsWalletAlreadySavedUseCase import com.tangem.features.hotwallet.MnemonicRepository -import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.GenerateSeedPhraseUiStateBuilder @@ -239,11 +238,11 @@ internal class MultiWalletSeedPhraseModel @Inject constructor( if (!isWalletAlreadySaved) { analyticsHandler.send( - OnboardingEvent.CreateWallet.WalletCreatedSuccessfully( + OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully( creationType = if (generatedSeedPhrase) { - OnboardingEvent.CreateWallet.WalletCreationType.NewSeed + AnalyticsParam.WalletCreationType.NewSeed } else { - OnboardingEvent.CreateWallet.WalletCreationType.SeedImport + AnalyticsParam.WalletCreationType.SeedImport }, seedPhraseLength = mnemonic.mnemonicComponents.size, passPhraseState = if (passphrase.isNullOrBlank()) { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt index fe78a9bb43..b77bf11401 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.model import com.tangem.common.routing.AppRoute import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -12,7 +13,6 @@ import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ProductType import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.wallets.usecase.GetCardImageUseCase -import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.common.ui.interruptBackupDialog import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams @@ -60,7 +60,7 @@ internal class OnboardingMultiWalletModel @Inject constructor( val uiState = _uiState.asStateFlow() init { - analyticsHandler.send(OnboardingEvent.Started()) + analyticsHandler.send(OnboardingAnalyticsEvent.Onboarding.Started()) initScreenTitle() loadCardArtwork() subscribeToBackups() diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt index f083fdec7e..79fa723c64 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.note.impl.child.create.model import com.tangem.common.CompletionResult import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -10,10 +11,9 @@ import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.SaveWalletUseCase -import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.note.impl.child.create.OnboardingNoteCreateWalletComponent import com.tangem.features.onboarding.v2.note.impl.child.create.ui.state.OnboardingNoteCreateWalletUM import com.tangem.sdk.api.TangemSdkManager @@ -46,11 +46,11 @@ internal class OnboardingNoteCreateWalletModel @Inject constructor( ) init { - analyticsEventHandler.send(OnboardingEvent.CreateWallet.ScreenOpened()) + analyticsEventHandler.send(OnboardingAnalyticsEvent.CreateWallet.ScreenOpened()) modelScope.launch { val scanResponse = params.childParams.commonState.value.scanResponse ?: return@launch if (!cardRepository.isActivationStarted(scanResponse.card.cardId)) { - analyticsEventHandler.send(OnboardingEvent.Started()) + analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Started()) } } observeArtwork() @@ -64,11 +64,10 @@ internal class OnboardingNoteCreateWalletModel @Inject constructor( it.copy(createWalletInProgress = true) } val scanResponse = params.childParams.commonState.value.scanResponse ?: return@launch - val result = tangemSdkManager.createProductWallet(scanResponse) - when (result) { + when (val result = tangemSdkManager.createProductWallet(scanResponse)) { is CompletionResult.Success -> { analyticsEventHandler.send( - event = OnboardingEvent.CreateWallet.WalletCreatedSuccessfully( + event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully( passPhraseState = AnalyticsParam.EmptyFull.Empty, referralId = appsFlyerStore.get()?.refcode, ), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt index 04edacf8ef..234e608373 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.note.impl.model import com.arkivanov.decompose.router.stack.StackNavigation import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -10,7 +11,6 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetCardImageUseCase -import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.common.ui.exitOnboardingDialog import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent import com.tangem.features.onboarding.v2.note.impl.OnboardingNoteInnerNavigationState @@ -76,7 +76,7 @@ internal class OnboardingNoteModel @Inject constructor( } fun onWalletCreated(userWallet: UserWallet) { - analyticsEventHandler.send(OnboardingEvent.Finished()) + analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished()) commonUiState.update { it.copy(userWallet = userWallet) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/OnboardingTwinModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/OnboardingTwinModel.kt index 924382dc55..ad6c629a9f 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/OnboardingTwinModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/OnboardingTwinModel.kt @@ -10,6 +10,7 @@ import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toHexString import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.analytics.models.Basic import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -175,7 +176,7 @@ internal class OnboardingTwinModel @Inject constructor( } analyticsEventHandler.send( - event = OnboardingEvent.CreateWallet.WalletCreatedSuccessfully( + event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully( passPhraseState = AnalyticsParam.EmptyFull.Empty, referralId = appsFlyerStore.get()?.refcode, ), diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt index 3b10fcbbbe..3748355dea 100644 --- a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt @@ -19,7 +19,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.features.onboarding.v2.TitleProvider -import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams @@ -114,7 +114,7 @@ internal class OnboardingMultiWalletModelTest { createModel(this) advanceUntilIdle() - verify { analyticsHandler.send(match { true }) } + verify { analyticsHandler.send(match { true }) } } @Test diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt index 2cee0bc82d..0417ba1bf3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt @@ -1,23 +1,16 @@ package com.tangem.features.staking.impl.presentation.state.converters -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.pluralReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.extensions.* 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.utils.SECONDS_IN_HOUR import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.staking.BalanceType -import com.tangem.domain.models.staking.PendingAction -import com.tangem.domain.models.staking.StakingBalance -import com.tangem.domain.models.staking.StakingBalanceEntry -import com.tangem.domain.models.staking.StakingEntryActions -import com.tangem.domain.models.staking.StakingEntryType +import com.tangem.domain.models.staking.* import com.tangem.domain.models.staking.action.StakingActionType +import com.tangem.domain.staking.model.Period import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.StakingTarget import com.tangem.features.staking.impl.R @@ -125,12 +118,26 @@ internal class StakingBalanceEntryConverter( } } StakingEntryType.PREPARING -> { - val warmupPeriod = integration.warmupPeriodDays + val warmupPeriod = integration.warmupPeriod TextReference.Combined( wrappedList( resourceReference(R.string.staking_details_warmup_period), stringReference(" "), - pluralReference(R.plurals.common_days, warmupPeriod, wrappedList(warmupPeriod)), + when (warmupPeriod) { + is Period.Days -> pluralReference( + id = R.plurals.common_days, + count = warmupPeriod.value, + formatArgs = wrappedList(warmupPeriod.value), + ) + is Period.Seconds -> { + val hours = warmupPeriod.value / SECONDS_IN_HOUR + pluralReference( + id = R.plurals.common_hours, + count = hours, + formatArgs = wrappedList(hours), + ) + } + }, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index 97e0137e20..bb57a75f24 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -7,18 +7,20 @@ import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.utils.SECONDS_IN_HOUR import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.StakingBalanceEntry import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.Period import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.model.common.RewardClaiming @@ -199,17 +201,27 @@ internal class SetInitialDataStateTransformer( } private fun createWarmupPeriodItem(): RoundedListWithDividersItemData? { - val warmupPeriodDays = integration.warmupPeriodDays - if (warmupPeriodDays == 0) return null + val warmupPeriod = integration.warmupPeriod + if (warmupPeriod.value == 0) return null return RoundedListWithDividersItemData( id = R.string.staking_details_warmup_period, startText = TextReference.Res(R.string.staking_details_warmup_period), - endText = pluralReference( - id = R.plurals.common_days, - count = warmupPeriodDays, - formatArgs = wrappedList(warmupPeriodDays), - ), + endText = when (warmupPeriod) { + is Period.Days -> pluralReference( + id = R.plurals.common_days, + count = warmupPeriod.value, + formatArgs = wrappedList(warmupPeriod.value), + ) + is Period.Seconds -> { + val hours = warmupPeriod.value / SECONDS_IN_HOUR + pluralReference( + id = R.plurals.common_hours, + count = hours, + formatArgs = wrappedList(hours), + ) + } + }, iconClick = { clickIntents.onInfoClick(InfoType.WARMUP_PERIOD) }, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt index f31d5d3b6e..7237a3b641 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtils.kt @@ -1,22 +1,30 @@ package com.tangem.features.staking.impl.presentation.state.utils -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.combinedReference -import com.tangem.core.ui.extensions.pluralReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.utils.SECONDS_IN_HOUR import com.tangem.domain.staking.model.CooldownPeriod +import com.tangem.domain.staking.model.Period import com.tangem.features.staking.impl.R import com.tangem.utils.StringsSigns.MINUS import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE internal fun CooldownPeriod.toTextReference(): TextReference { return when (this) { - is CooldownPeriod.Fixed -> pluralReference( - id = R.plurals.common_days, - count = days, - formatArgs = wrappedList(days), - ) + is CooldownPeriod.Fixed -> when (period) { + is Period.Days -> pluralReference( + id = R.plurals.common_days, + count = period.value, + formatArgs = wrappedList(period.value), + ) + is Period.Seconds -> { + val hours = period.value / SECONDS_IN_HOUR + pluralReference( + id = R.plurals.common_hours, + count = hours, + formatArgs = wrappedList(hours), + ) + } + } is CooldownPeriod.Range -> combinedReference( stringReference("$minDays$MINUS$maxDays$NON_BREAKING_SPACE"), pluralReference( diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtilsTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtilsTest.kt new file mode 100644 index 0000000000..2c60ad2b9a --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/utils/CooldownPeriodUtilsTest.kt @@ -0,0 +1,221 @@ +package com.tangem.features.staking.impl.presentation.state.utils + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.SECONDS_IN_HOUR +import com.tangem.domain.staking.model.CooldownPeriod +import com.tangem.domain.staking.model.Period +import com.tangem.features.staking.impl.R +import com.tangem.utils.StringsSigns.MINUS +import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [CooldownPeriod.toTextReference] extension function. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CooldownPeriodUtilsTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class `Fixed with Days` { + + @Test + fun `should return plural days reference for Fixed Period Days`() { + // given + val cooldown = CooldownPeriod.Fixed(Period.Days(7)) + + // when + val result = cooldown.toTextReference() + + // then + val expected = pluralReference( + id = R.plurals.common_days, + count = 7, + formatArgs = wrappedList(7), + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `should return plural days with zero for Fixed Period Days zero`() { + // given + val cooldown = CooldownPeriod.Fixed(Period.Days(0)) + + // when + val result = cooldown.toTextReference() + + // then + val expected = pluralReference( + id = R.plurals.common_days, + count = 0, + formatArgs = wrappedList(0), + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `should pass day count as both count and format arg`() { + // given + val days = 14 + val cooldown = CooldownPeriod.Fixed(Period.Days(days)) + + // when + val result = cooldown.toTextReference() + + // then + assertThat(result).isInstanceOf(TextReference.PluralRes::class.java) + val plural = result as TextReference.PluralRes + assertThat(plural.count).isEqualTo(days) + assertThat(plural.formatArgs.first()).isEqualTo(days) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class `Fixed with Seconds` { + + @Test + fun `should return plural hours reference for Fixed Period Seconds`() { + // given — 2 hours worth of seconds + val cooldown = CooldownPeriod.Fixed(Period.Seconds(2 * SECONDS_IN_HOUR)) + + // when + val result = cooldown.toTextReference() + + // then + val expectedHours = 2 + val expected = pluralReference( + id = R.plurals.common_hours, + count = expectedHours, + formatArgs = wrappedList(expectedHours), + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `should convert exactly one hour worth of seconds to 1 hour`() { + // given + val cooldown = CooldownPeriod.Fixed(Period.Seconds(SECONDS_IN_HOUR)) + + // when + val result = cooldown.toTextReference() + + // then + assertThat(result).isInstanceOf(TextReference.PluralRes::class.java) + val plural = result as TextReference.PluralRes + assertThat(plural.count).isEqualTo(1) + assertThat(plural.formatArgs.first()).isEqualTo(1) + } + + @Test + fun `should floor to 1 hour when seconds are not divisible evenly`() { + // given — 5000 seconds = 1.388... hours → integer division → 1 + val cooldown = CooldownPeriod.Fixed(Period.Seconds(5000)) + + // when + val result = cooldown.toTextReference() + + // then + assertThat(result).isInstanceOf(TextReference.PluralRes::class.java) + val plural = result as TextReference.PluralRes + assertThat(plural.count).isEqualTo(1) + } + + @Test + fun `should return 0 hours for zero seconds`() { + // given + val cooldown = CooldownPeriod.Fixed(Period.Seconds(0)) + + // when + val result = cooldown.toTextReference() + + // then + assertThat(result).isInstanceOf(TextReference.PluralRes::class.java) + val plural = result as TextReference.PluralRes + assertThat(plural.count).isEqualTo(0) + assertThat(plural.formatArgs.first()).isEqualTo(0) + } + + @Test + fun `should use R plurals common_hours resource id`() { + // given + val cooldown = CooldownPeriod.Fixed(Period.Seconds(SECONDS_IN_HOUR)) + + // when + val result = cooldown.toTextReference() + + // then + assertThat(result).isInstanceOf(TextReference.PluralRes::class.java) + assertThat((result as TextReference.PluralRes).id).isEqualTo(R.plurals.common_hours) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Range { + + @Test + fun `should return combined reference for Range`() { + // given + val minDays = 2 + val maxDays = 5 + val cooldown = CooldownPeriod.Range(minDays = minDays, maxDays = maxDays) + + // when + val result = cooldown.toTextReference() + + // then + val expected = combinedReference( + stringReference("$minDays$MINUS$maxDays$NON_BREAKING_SPACE"), + pluralReference( + id = R.plurals.common_days_no_param, + count = maxDays, + ), + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `should use maxDays as count for plural in Range`() { + // given + val maxDays = 10 + val cooldown = CooldownPeriod.Range(minDays = 3, maxDays = maxDays) + + // when + val result = cooldown.toTextReference() + + // then + assertThat(result).isInstanceOf(TextReference.Combined::class.java) + val combined = result as TextReference.Combined + val pluralPart = combined.refs[1] + assertThat(pluralPart).isInstanceOf(TextReference.PluralRes::class.java) + assertThat((pluralPart as TextReference.PluralRes).count).isEqualTo(maxDays) + } + + @Test + fun `should include minDays and maxDays with minus and non-breaking-space in string part`() { + // given + val minDays = 1 + val maxDays = 7 + val cooldown = CooldownPeriod.Range(minDays = minDays, maxDays = maxDays) + + // when + val result = cooldown.toTextReference() + + // then + assertThat(result).isInstanceOf(TextReference.Combined::class.java) + val combined = result as TextReference.Combined + val stringPart = combined.refs[0] + assertThat(stringPart).isInstanceOf(TextReference.Str::class.java) + assertThat((stringPart as TextReference.Str).value) + .isEqualTo("$minDays$MINUS$maxDays$NON_BREAKING_SPACE") + } + } +} \ No newline at end of file diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/analytics/StoriesEvents.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/analytics/StoriesEvents.kt index 8a2c630fa9..9a979f5103 100644 --- a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/analytics/StoriesEvents.kt +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/analytics/StoriesEvents.kt @@ -13,7 +13,7 @@ internal sealed class StoriesEvents( val source: String, val watchCount: String, ) : StoriesEvents( - event = "Swap Stories", + event = "Swap Story", params = mapOf( AnalyticsParam.SOURCE to source, WATCHED to watchCount, diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index e0fe076fab..45b24ac9b2 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -1,3 +1,5 @@ package com.tangem.features.swap -interface SwapFeatureToggles \ No newline at end of file +interface SwapFeatureToggles { + val isSwapSwitchToTransferEnabled: Boolean +} \ No newline at end of file diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 5a567049e0..d0853de0bc 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -9,6 +9,14 @@ plugins { android { namespace = "com.tangem.features.domain.swap" + + testOptions { + unitTests.isIncludeAndroidResources = false + } +} + +tasks.withType().configureEach { + useJUnitPlatform() } dependencies { @@ -62,4 +70,8 @@ dependencies { implementation(tangemDeps.card.core) implementation(deps.moshi) ksp(deps.moshi.kotlin.codegen) + + /** Test */ + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt new file mode 100644 index 0000000000..b0241f7f9e --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -0,0 +1,913 @@ +package com.tangem.feature.swap.domain + +import android.util.Base64 +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionExtras +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.model.FeePaidCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.feature.swap.domain.models.ExpressDataError +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.ui.SwapState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Tests for [SwapInteractorImpl.findBestQuote] — the core quote-dispatch method. + * + * Covers: + * - Empty / unparseable amount handling + * - DEX provider path on EVM networks (balance enough, allowance enough) + * - DEX provider repository error handling (returns SwapError) + * - DEX_BRIDGE provider sharing the DEX dispatch branch + * - Solana DEX path routing via the Solana-specific branch + * - CEX provider dispatch including null txFee edge case + * - yieldSupplyStatus.isActive returning [ExpressDataError.DexActiveSupplyError] + * - Mixed (DEX + CEX) provider list — each routed to its own path + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val solanaNetwork = Blockchain.Solana.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + @BeforeEach + fun setup() { + // Common stubs that most tests rely on. Individual tests can override. + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") + coEvery { + getCurrencyCheckUseCase.invoke( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns buildCryptoCurrencyCheck() + coEvery { + validateTransactionUseCase.invoke( + amount = any(), + fee = any(), + memo = any(), + destination = any(), + userWalletId = any(), + network = any(), + ) + } returns Unit.right() + coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } answers { + firstArg>().map { rawId -> + QuoteStatus( + rawCurrencyId = rawId, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + fiatRateUSD = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + ), + ) + }.toSet() + } + coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null + coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() + every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false + coEvery { + getAllowanceInfoUseCase.invoke( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right() + every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns + mockk(relaxed = true).right() + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns mockk(relaxed = true).right() + } + + @Nested + inner class EmptyAmountHandling { + + @Test + fun `should return EmptyAmountState for all providers when amount is zero`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider, cexProvider), + amountToSwap = "0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(2) + assertThat(result[dexProvider]).isInstanceOf(SwapState.EmptyAmountState::class.java) + assertThat(result[cexProvider]).isInstanceOf(SwapState.EmptyAmountState::class.java) + } + + @Test + fun `should return EmptyAmountState for all providers when amount is unparseable`() = runTest { + // Given + val provider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(provider), + amountToSwap = "not-a-number", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result[provider]).isInstanceOf(SwapState.EmptyAmountState::class.java) + } + + @Test + fun `should return empty map when providers list is empty`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = emptyList(), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).isEmpty() + } + } + + @Nested + inner class DexProviderPath { + + @Test + fun `should return SwapState for DEX provider when repository findBestQuote succeeds and balance enough`() = + runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0", + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5")) + val swapData = buildSwapDataModelDex() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — has a result entry for the DEX provider; type of state is decided by internal logic + assertThat(result).hasSize(1) + assertThat(result.containsKey(dexProvider)).isTrue() + assertThat(result[dexProvider]).isNotNull() + } + + @Test + fun `should return SwapError with DexActiveSupplyError when yieldSupply is active`() = runTest { + // Given — yieldSupplyActive=true short-circuits to DexActiveSupplyError + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + yieldSupplyActive = true, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.SwapError::class.java) + val swapError = (state ?: error("state must not be null")) as SwapState.SwapError + assertThat(swapError.error).isEqualTo(ExpressDataError.DexActiveSupplyError) + } + + @Test + fun `should set isBalanceEnough to false when from-token balance is less than swap amount`() = runTest { + // Given — balance is 0.01, swap amount is 1.0 → insufficient + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0", + isCoin = true, + amount = BigDecimal("0.01"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5")) + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.isBalanceEnough).isFalse() + } + + @Test + fun `should return non-null state for DEX provider when repository findBestQuote returns error`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0", + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns ExpressDataError.UnknownError.left() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — a SwapState is emitted for the provider (not an EmptyAmountState) + assertThat(result).hasSize(1) + val state = result[dexProvider] + assertThat(state).isNotNull() + assertThat(state).isNotInstanceOf(SwapState.EmptyAmountState::class.java) + } + } + + @Nested + inner class DexBridgeProviderPath { + + @Test + fun `should return entry keyed by the DEX_BRIDGE provider type`() = runTest { + // Given — DEX_BRIDGE shares the same DEX branch as DEX + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexBridgeProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexBridgeProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexBridgeProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result.keys.first().type).isEqualTo(ExchangeProviderType.DEX_BRIDGE) + } + } + + @Nested + inner class SolanaDexPath { + + @Test + fun `should produce result entry when network is Solana and quote is successful`() = runTest { + // Given + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(0) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = solanaNetwork, + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + val solanaSwapData = buildSwapDataModelDex() + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns solanaSwapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result.containsKey(dexProvider)).isTrue() + } + + @Test + fun `should return SwapError TooLargeSolanaTransactionError when tx bytes exceed threshold on Cold wallet`() = + runTest { + // Given — decode returns an oversized array; mock the Solana helper to preserve its size + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(931) + io.mockk.mockkObject(SolanaTransactionHelper) + every { + SolanaTransactionHelper.removeSignaturesPlaceholders(any()) + } returns ByteArray(931) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val coldWallet = mockk(relaxed = true) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("10"), + ).let { status -> + // replace the relaxed UserWallet mock with a real Cold mock so `is UserWallet.Cold` is true + SwapCurrencyStatus( + userWallet = coldWallet, + status = status.status, + account = status.account, + ) + } + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + val solanaSwapData = buildSwapDataModelDex(txData = "oversized==") + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = solanaNetwork, + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns solanaSwapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — oversized Solana tx on Cold wallet produces SwapError with TooLargeSolanaTransactionError + assertThat(result).hasSize(1) + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.SwapError::class.java) + val swapError = state as SwapState.SwapError + assertThat(swapError.error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError) + } + + @Test + fun `should produce non-empty state via Solana path when balance insufficient`() = runTest { + // Given — Solana path with Right quote but insufficient balance → getQuotesState branch + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("0.000001"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = solanaNetwork, + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1000.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result[dexProvider]).isNotNull() + } + } + + @Nested + inner class CexProviderPath { + + @Test + fun `should produce result entry for CEX provider`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result.containsKey(cexProvider)).isTrue() + assertThat(result[cexProvider]).isNotNull() + } + + @Test + fun `should produce result entry for CEX provider with minimal fee state`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("5"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result[cexProvider]).isNotNull() + } + } + + @Nested + inner class MixedProviderDispatch { + + @Test + fun `should dispatch each provider to its branch and return one entry per provider`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-1") + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, "cex-1") + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = "dex-1", + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = "cex-1", + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = "dex-1", + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider, cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — both providers have an entry + assertThat(result).hasSize(2) + assertThat(result.containsKey(dexProvider)).isTrue() + assertThat(result.containsKey(cexProvider)).isTrue() + } + + @Test + fun `should return one entry per provider for DEX plus CEX plus DEX_BRIDGE on non-Solana network`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-mix") + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, "cex-mix") + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, "dex-bridge-mix") + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex() + + listOf("dex-mix", "cex-mix", "dex-bridge-mix").forEach { pid -> + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = pid, + rateType = any(), + ) + } returns quoteModel.right() + } + + listOf("dex-mix", "dex-bridge-mix").forEach { pid -> + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = pid, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + } + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider, cexProvider, dexBridgeProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — all three providers are dispatched and each has an entry + assertThat(result).hasSize(3) + assertThat(result.containsKey(dexProvider)).isTrue() + assertThat(result.containsKey(cexProvider)).isTrue() + assertThat(result.containsKey(dexBridgeProvider)).isTrue() + assertThat(result[dexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) + assertThat(result[dexBridgeProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) + assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) + } + } +} + +// region — test-local helpers + +private fun buildCryptoCurrencyCheck(): CryptoCurrencyCheck = CryptoCurrencyCheck( + dustValue = null, + reserveAmount = null, + minimumSendAmount = null, + existentialDeposit = null, + utxoAmountLimit = null, + isAccountFunded = true, + rentWarning = null, + isMemoRequired = false, +) + +private fun buildSwapDataModelDex( + txData: String = "dGVzdA==", + txValue: String? = "0", + toAmount: BigDecimal = BigDecimal("0.5"), +): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = txValue, + txId = "tx-id-123", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = txData, + otherNativeFeeWei = null, + gas = BigInteger.valueOf(21_000L), + ), +) + +// endregion \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt new file mode 100644 index 0000000000..1f7e5c8d85 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt @@ -0,0 +1,160 @@ +package com.tangem.feature.swap.domain + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import io.mockk.coEvery +import io.mockk.every +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [SwapInteractorImpl.findProvidersForPair] and [SwapInteractorImpl.findProvidersForPairWithCheck]. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplFindProvidersForPairTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + @Nested + inner class FindProvidersForPair { + + @Test + fun `should return providers of the first pair whose to-contractAddress equals destination contract`() { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true) + val expectedProvider = buildSwapProvider(ExchangeProviderType.DEX, "expected") + val matchingPair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = listOf(expectedProvider), + ) + + // When + val result = sut.findProvidersForPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = listOf(matchingPair), + ) + + // Then + assertThat(result).containsExactly(expectedProvider) + } + + @Test + fun `should return empty list when pairs list is empty`() { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findProvidersForPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = emptyList(), + ) + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `should return empty list when no pair's to-contractAddress equals destination contract`() { + // Given — destination is a token with contractAddress "0xAbc", but pair's to-contract is "0" + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus( + networkRawId = btcNetwork, + contractAddress = "0xAbc", + isCoin = false, + ) + val unrelatedPair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = listOf(buildSwapProvider(ExchangeProviderType.CEX, "unrelated")), + ) + + // When + val result = sut.findProvidersForPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = listOf(unrelatedPair), + ) + + // Then + assertThat(result).isEmpty() + } + } + + @Nested + inner class FindProvidersForPairWithCheck { + + @Test + fun `should return empty list when rampStateManager checkAssetRequirements returns false`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + ) + + coEvery { + getAssetRequirementsUseCase.invoke(any(), any()) + } returns null.right() + every { rampStateManager.checkAssetRequirements(any()) } returns false + + // When + val result = sut.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = listOf(pair), + ) + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `should return providers from matching pair when checkAssetRequirements returns true`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true) + val providerA = buildSwapProvider(ExchangeProviderType.DEX, "A") + val providerB = buildSwapProvider(ExchangeProviderType.CEX, "B") + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = listOf(providerA, providerB), + ) + + coEvery { + getAssetRequirementsUseCase.invoke(any(), any()) + } returns null.right() + every { rampStateManager.checkAssetRequirements(any()) } returns true + + // When + val result = sut.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = listOf(pair), + ) + + // Then + assertThat(result).containsExactly(providerA, providerB) + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetNativeTokenTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetNativeTokenTest.kt new file mode 100644 index 0000000000..b624a079ab --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetNativeTokenTest.kt @@ -0,0 +1,86 @@ +package com.tangem.feature.swap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [SwapInteractorImpl.getNativeToken]. + * + * Behavior: + * - Look up cached portfolio coins for the user wallet via [MultiWalletCryptoCurrenciesSupplier]. + * - Return the coin matching the target network (by `id` and `derivationPath`). + * - If supplier returns null or no match → fall back to [CurrenciesRepository.createCoinCurrency]. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplGetNativeTokenTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + + @Test + fun `should return a Coin from the supplier whose network matches the target`() = runTest { + // Given — a single matching coin in the supplier + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val targetNetwork = fromStatus.currency.network + + val matchingCoin = mockk(relaxed = true) { + every { network } returns targetNetwork + } + + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(matchingCoin) + + // When + val result = sut.getNativeToken(fromStatus) + + // Then + assertThat(result).isSameInstanceAs(matchingCoin) + } + + @Test + fun `should fall back to createCoinCurrency when supplier returns null`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val createdCoin = buildCoinCurrency(networkRawId = ethNetwork) + + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null + coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin + + // When + val result = sut.getNativeToken(fromStatus) + + // Then + assertThat(result).isSameInstanceAs(createdCoin) + coVerify(exactly = 1) { currenciesRepository.createCoinCurrency(any()) } + } + + @Test + fun `should fall back to createCoinCurrency when no matching coin is in the supplier's list`() = runTest { + // Given — all returned coins are for a different network + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val unrelatedCoin = mockk(relaxed = true) { + every { network } returns mockk(relaxed = true) { + every { id } returns mockk(relaxed = true) + every { derivationPath } returns Network.DerivationPath.None + } + } + val createdCoin = buildCoinCurrency(networkRawId = ethNetwork) + + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(unrelatedCoin) + coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin + + // When + val result = sut.getNativeToken(fromStatus) + + // Then + assertThat(result).isSameInstanceAs(createdCoin) + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetPairTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetPairTest.kt new file mode 100644 index 0000000000..fc6ad354f3 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetPairTest.kt @@ -0,0 +1,201 @@ +package com.tangem.feature.swap.domain + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.swap.models.SwapTxType +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import io.mockk.coEvery +import io.mockk.coVerify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplGetPairTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + private val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0", + isCoin = true, + ) + private val toStatus = buildSwapCurrencyStatus( + networkRawId = btcNetwork, + contractAddress = "0", + isCoin = true, + ) + + @Nested + inner class `getPair happy path` { + + @Test + fun `should return Right with mapped SwapPairLeast list when use case succeeds`() = runTest { + // Given + val expressProvider = buildExpressProvider(providerId = "p1", type = ExpressProviderType.DEX) + val pairModel = buildSwapPairModel( + fromNetworkRawId = ethNetwork, + fromContractAddress = "0", + toNetworkRawId = btcNetwork, + toContractAddress = "0", + providers = listOf(expressProvider), + ) + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = fromStatus, + secondarySwapCurrencyStatus = toStatus, + filterProviderTypes = any(), + swapTxType = SwapTxType.Swap, + ) + } returns listOf(pairModel).right() + + // When + val result = sut.getPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + filterProviderTypes = listOf(ExchangeProviderType.DEX), + ) + + // Then + assertThat(result.isRight()).isTrue() + result.onRight { pairs -> + assertThat(pairs).hasSize(1) + val pair = pairs.first() + assertThat(pair.from.network).isEqualTo(ethNetwork) + assertThat(pair.from.contractAddress).isEqualTo("0") + assertThat(pair.to.network).isEqualTo(btcNetwork) + assertThat(pair.providers).hasSize(1) + assertThat(pair.providers.first().providerId).isEqualTo("p1") + } + } + + @Test + fun `should map coin contractAddress to 0 in LeastTokenInfo`() = runTest { + // Given — coin currency (contractAddress = "0" by convention) + val pairModel = buildSwapPairModel( + fromNetworkRawId = ethNetwork, + fromContractAddress = "0", + toNetworkRawId = btcNetwork, + toContractAddress = "0", + ) + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = any(), + swapTxType = any(), + ) + } returns listOf(pairModel).right() + + // When + val result = sut.getPair(fromStatus, toStatus, emptyList()) + + // Then + assertThat(result.isRight()).isTrue() + result.onRight { pairs -> + assertThat(pairs.first().from.contractAddress).isEqualTo("0") + assertThat(pairs.first().to.contractAddress).isEqualTo("0") + } + } + + @Test + fun `should map all ExchangeProviderType variants to ExpressProviderType correctly`() = runTest { + // Given + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = listOf( + ExpressProviderType.DEX, + ExpressProviderType.CEX, + ExpressProviderType.DEX_BRIDGE, + ), + swapTxType = SwapTxType.Swap, + ) + } returns emptyList().right() + + // When + val result = sut.getPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + filterProviderTypes = listOf( + ExchangeProviderType.DEX, + ExchangeProviderType.CEX, + ExchangeProviderType.DEX_BRIDGE, + ), + ) + + // Then + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = listOf( + ExpressProviderType.DEX, + ExpressProviderType.CEX, + ExpressProviderType.DEX_BRIDGE, + ), + swapTxType = SwapTxType.Swap, + ) + } + } + + @Test + fun `should return empty list when use case returns empty pairs`() = runTest { + // Given + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = any(), + swapTxType = any(), + ) + } returns emptyList().right() + + // When + val result = sut.getPair(fromStatus, toStatus, emptyList()) + + // Then + assertThat(result.isRight()).isTrue() + result.onRight { pairs -> + assertThat(pairs).isEmpty() + } + } + } + + @Nested + inner class `getPair error path` { + + @Test + fun `should return Left with ExpressError when use case returns Left`() = runTest { + // Given + val expectedError = ExpressError.DataError(code = 400, description = "bad request") + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = any(), + swapTxType = any(), + ) + } returns expectedError.left() + + // When + val result = sut.getPair(fromStatus, toStatus, emptyList()) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(ExpressError.DataError::class.java) + assertThat((error as ExpressError.DataError).code).isEqualTo(400) + } + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetTokenBalanceTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetTokenBalanceTest.kt new file mode 100644 index 0000000000..fde79fbcf5 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetTokenBalanceTest.kt @@ -0,0 +1,76 @@ +package com.tangem.feature.swap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** + * Tests for [SwapInteractorImpl.getTokenBalance]. + * + * Trivial conversion: `SwapAmount(value.amount ?: ZERO, currency.decimals)`. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplGetTokenBalanceTest : SwapInteractorImplTestBase() { + + @Test + fun `should return SwapAmount with the reported balance and decimals when value amount is non-null`() { + // Given + val currency = mockk(relaxed = true) { + every { decimals } returns 18 + } + val value = mockk(relaxed = true) { + every { amount } returns BigDecimal("5.75") + } + val status = CryptoCurrencyStatus(currency = currency, value = value) + + // When + val result = sut.getTokenBalance(status) + + // Then + assertThat(result.value).isEqualTo(BigDecimal("5.75")) + assertThat(result.decimals).isEqualTo(18) + } + + @Test + fun `should return SwapAmount with ZERO when value amount is null`() { + // Given — a non-Loaded value with null amount (e.g. Loading state) + val currency = mockk(relaxed = true) { + every { decimals } returns 8 + } + val value = mockk(relaxed = true) { + every { amount } returns null + } + val status = CryptoCurrencyStatus(currency = currency, value = value) + + // When + val result = sut.getTokenBalance(status) + + // Then + assertThat(result.value).isEqualTo(BigDecimal.ZERO) + assertThat(result.decimals).isEqualTo(8) + } + + @Test + fun `should preserve decimals from the underlying currency`() { + // Given — Token with custom decimals + val currency = mockk(relaxed = true) { + every { decimals } returns 6 + } + val value = mockk(relaxed = true) { + every { amount } returns BigDecimal("100") + } + val status = CryptoCurrencyStatus(currency = currency, value = value) + + // When + val result = sut.getTokenBalance(status) + + // Then + assertThat(result.decimals).isEqualTo(6) + assertThat(result.value).isEqualTo(BigDecimal("100")) + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeTest.kt new file mode 100644 index 0000000000..d1f5800196 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeTest.kt @@ -0,0 +1,441 @@ +package com.tangem.feature.swap.domain + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.feature.swap.domain.models.ExpressDataError +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** + * Tests for [SwapInteractorImpl.loadFeeForSwapTransaction] (both overloads). + * + * Overload 1 (returns [Either]): + * - DEX / DEX_BRIDGE → always GaslessError.NetworkIsNotSupported + * - CEX + zero or unparseable amount → UnknownError + * - CEX + selectedFeeToken != null → delegates to [estimateFeeForTokenUseCase] + * - CEX + selectedFeeToken == null → delegates to [estimateFeeForGaslessTxUseCase] + * + * Overload 2 (returns [Either]): + * - DEX / DEX_BRIDGE + zero amount → UnknownError + * - DEX / DEX_BRIDGE + getExchangeData error → UnknownError + * - CEX + zero amount → UnknownError + * - CEX + non-zero amount → delegates to [estimateFeeUseCase] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplLoadFeeTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + // ------------------------------------------------------------------------- + // Overload 1 + // ------------------------------------------------------------------------- + + @Nested + inner class `overload 1 — CEX and token fee paths` { + + @Test + fun `should return Left GaslessError for DEX provider`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java) + } + } + + @Test + fun `should return Left GaslessError for DEX_BRIDGE provider`() = runTest { + // Given + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexBridgeProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java) + } + } + + @Test + fun `should return Left UnknownError for CEX provider when amount is zero`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) + } + } + + @Test + fun `should return Left UnknownError for CEX provider when amount is invalid string`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "not-a-decimal", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) + } + } + + @Test + fun `should delegate to estimateFeeForTokenUseCase when CEX provider has non-null selectedFeeToken`() = + runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val feeTokenStatus = mockk(relaxed = true) + val expectedFeeExtended = mockk(relaxed = true) + + coEvery { + estimateFeeForTokenUseCase.invoke( + userWallet = any(), + feeTokenCurrencyStatus = feeTokenStatus, + sendingTokenCurrencyStatus = any(), + amount = any(), + ) + } returns expectedFeeExtended.right() + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "1.5", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = feeTokenStatus, + ) + + // Then + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + estimateFeeForTokenUseCase.invoke( + userWallet = any(), + feeTokenCurrencyStatus = feeTokenStatus, + sendingTokenCurrencyStatus = any(), + amount = BigDecimal("1.5"), + ) + } + } + + @Test + fun `should pass positive non-NaN amount to estimateFeeForGaslessTxUseCase for CEX with tiny nonzero amount and null selectedFeeToken`() = + runTest { + // Given — tiny but nonzero amount; null selectedFeeToken routes to estimateFeeForGaslessTxUseCase + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val feeExtended = mockk(relaxed = true) + val capturedAmount = slot() + + coEvery { + estimateFeeForGaslessTxUseCase.invoke( + amount = capture(capturedAmount), + userWallet = any(), + sendingTokenCurrencyStatus = any(), + ) + } returns feeExtended.right() + + // When + sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "0.000001", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = null, + ) + + // Then — captured amount is positive, finite, non-NaN + assertThat(capturedAmount.captured).isNotNull() + assertThat(capturedAmount.captured.signum()).isGreaterThan(0) + assertThat(capturedAmount.captured.toDouble().isNaN()).isFalse() + assertThat(capturedAmount.captured.toDouble().isInfinite()).isFalse() + // verify estimateFeeForGaslessTxUseCase was called with the exact parsed amount + coVerify(exactly = 1) { + estimateFeeForGaslessTxUseCase.invoke( + amount = BigDecimal("0.000001"), + userWallet = any(), + sendingTokenCurrencyStatus = any(), + ) + } + } + + @Test + fun `should delegate to estimateFeeForGaslessTxUseCase when CEX provider has null selectedFeeToken`() = + runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val expectedFeeExtended = mockk(relaxed = true) + + coEvery { + estimateFeeForGaslessTxUseCase.invoke( + amount = any(), + userWallet = any(), + sendingTokenCurrencyStatus = any(), + ) + } returns expectedFeeExtended.right() + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "2.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + estimateFeeForGaslessTxUseCase.invoke( + amount = BigDecimal("2.0"), + userWallet = any(), + sendingTokenCurrencyStatus = any(), + ) + } + } + } + + // ------------------------------------------------------------------------- + // Overload 2 + // ------------------------------------------------------------------------- + + @Nested + inner class `overload 2 — DEX and CEX TransactionFee paths` { + + @Test + fun `should return Left UnknownError for DEX when amount is zero`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) + } + } + + @Test + fun `should return Left UnknownError for DEX when getExchangeData returns error`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns ExpressDataError.UnknownError.left() + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) + } + } + + @Test + fun `should not call getExchangeData and return UnknownError for DEX when amount is zero`() = runTest { + // Given — zero amount must short-circuit before hitting repository + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) } + coVerify(exactly = 0) { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } + } + + @Test + fun `should return Left UnknownError for DEX_BRIDGE when amount is zero`() = runTest { + // Given + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexBridgeProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + } + + @Test + fun `should return Left UnknownError for CEX when amount is zero`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + } + + @Test + fun `should delegate to estimateFeeUseCase for CEX provider with non-zero amount`() = runTest { + // Given — return Left to avoid the patchTransactionFeeForSwap branch which requires concrete Fee types + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + coEvery { + estimateFeeUseCase.invoke( + amount = any(), + userWallet = any(), + cryptoCurrencyStatus = any(), + ) + } returns GetFeeError.UnknownError.left() + + // When + sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + ) + + // Then + coVerify(exactly = 1) { + estimateFeeUseCase.invoke( + amount = BigDecimal("1.0"), + userWallet = any(), + cryptoCurrencyStatus = any(), + ) + } + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt new file mode 100644 index 0000000000..131da6aa29 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt @@ -0,0 +1,1034 @@ +package com.tangem.feature.swap.domain + +import android.util.Base64 +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.feature.swap.domain.models.ExpressDataError +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.ui.* +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import java.math.BigDecimal +import java.math.BigInteger + +@TestInstance(PER_CLASS) +internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val solanaNetwork = Blockchain.Solana.toNetworkId() + + @BeforeEach + fun setupOnSwap() { + // Clear recorded calls so that coVerify(exactly = 1) counts only the current test's call. + clearMocks( + sendTransactionUseCase, + createTransactionUseCase, + createTransferTransactionUseCase, + createAndSendGaslessTransactionUseCase, + repository, + swapTransactionRepository, + answers = false, + ) + // isDemoCardUseCase should return false by default so the non-demo path is exercised. + // Individual tests that need demo mode override this. + every { isDemoCardUseCase(any()) } returns false + } + + // region — shared helpers + + /** + * Builds a SwapCurrencyStatus backed by an explicit UserWallet.Hot mock so that + * `userWallet is UserWallet.Cold` evaluates to false reliably. + */ + private fun buildHotSwapCurrencyStatus( + networkRawId: String = ethNetwork, + isCoin: Boolean = true, + ): SwapCurrencyStatus { + val hotWallet = mockk(relaxed = true) + return buildSwapCurrencyStatus(networkRawId = networkRawId, isCoin = isCoin).let { + SwapCurrencyStatus(userWallet = hotWallet, status = it.status, account = it.account) + } + } + + private fun buildCexSwapDataModel( + txTo: String = "0xCexAddress", + txId: String = "cex-tx-id", + txExtraId: String? = null, + externalTxUrl: String = "https://explorer.com/tx/123", + externalTxId: String = "ext-id-123", + toAmount: BigDecimal = BigDecimal("0.9"), + ): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.CEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = null, + txId = txId, + txTo = txTo, + txExtraId = txExtraId, + externalTxId = externalTxId, + externalTxUrl = externalTxUrl, + txExtraIdName = null, + ), + ) + + // endregion + + // ------------------------------------------------------------------------- + // Dispatcher Branches + // ------------------------------------------------------------------------- + + @Nested + inner class DispatcherBranches { + + @Test + fun `should return DemoMode for Cold card when isDemoCardUseCase returns true`() = runTest { + // Given + val coldWallet = mockk(relaxed = true) + every { isDemoCardUseCase(any()) } returns true + + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork).let { + SwapCurrencyStatus(userWallet = coldWallet, status = it.status, account = it.account) + } + val toStatus = buildHotSwapCurrencyStatus() + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val swapData = buildSwapDataModelDex() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = buildTxFee(), + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.DemoMode::class.java) + coVerify(exactly = 0) { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + coVerify(exactly = 0) { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + coVerify(exactly = 0) { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } + } + + @Test + fun `should route to onSwapCex and call getExchangeData for CEX provider`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, providerId = "cex-route-id") + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val cexSwapData = buildCexSwapDataModel() + + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = cexProvider.providerId, rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns cexSwapData.right() + + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + coEvery { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } returns txDataMock.right() + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xhash".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = buildTxFee(), + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = cexProvider.providerId, rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } + } + + @Test + fun `should return UnknownError for DEX non-Solana when fee is null`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = null, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + coVerify(exactly = 0) { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + } + + @Test + fun `should route to onSwapDex for DEX_BRIDGE non-Solana with valid fee`() = runTest { + // Given + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex(txValue = "1000000000000000") + val fee = buildTxFee() + + every { + createTransactionExtrasUseCase.invoke( + data = any(), network = any(), gasLimit = any(), + ) + } returns mockk(relaxed = true).right() + + val txDataMock = mockk(relaxed = true) + coEvery { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } returns txDataMock.right() + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xhash-bridge".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexBridgeProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } + } + + @Test + fun `should route to onSwapSolanaDex for DEX Solana without calling createTransactionUseCase`() = runTest { + // Given + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(100) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val toStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val swapData = buildSwapDataModelDex(txData = "dGVzdA==") + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xsolana-hash".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = buildTxFee(), + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 0) { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + + unmockkStatic(Base64::class) + } + } + + // ------------------------------------------------------------------------- + // OnSwapDex + // ------------------------------------------------------------------------- + + @Nested + inner class OnSwapDex { + + @Test + fun `should return TxSent and call exchangeSent and storeTransaction on happy path`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex(txValue = "1000000000000000") + val fee = buildTxFee() + + every { + createTransactionExtrasUseCase.invoke(data = any(), network = any(), gasLimit = any()) + } returns mockk(relaxed = true).right() + + val txDataMock = mockk(relaxed = true) + coEvery { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } returns txDataMock.right() + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xdex-hash".right() + + every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 ETH" + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) + val txSent = result as SwapTransactionState.TxSent + assertThat(txSent.txHash).isEqualTo("0xdex-hash") + + coVerify(exactly = 1) { + repository.exchangeSent( + userWallet = any(), txId = any(), fromNetwork = any(), + fromAddress = any(), payInAddress = any(), + txHash = "0xdex-hash", payInExtraId = any(), + ) + } + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), toUserWalletId = any(), + fromCryptoCurrency = any(), toCryptoCurrency = any(), + fromAccount = any(), toAccount = any(), transaction = any(), + ) + } + } + + @Test + fun `should return UnknownError and not send when createTransactionUseCase fails`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex(txValue = "1000000000000000") + val fee = buildTxFee() + + every { + createTransactionExtrasUseCase.invoke(data = any(), network = any(), gasLimit = any()) + } returns mockk(relaxed = true).right() + + coEvery { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } returns RuntimeException("create tx failed").left() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + coVerify(exactly = 0) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + } + + @Test + fun `should return TransactionError and not call exchangeSent when sendTransactionUseCase fails`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex(txValue = "1000000000000000") + val fee = buildTxFee() + val sendError = SendTransactionError.NetworkError(message = "timeout", code = "503") + + every { + createTransactionExtrasUseCase.invoke(data = any(), network = any(), gasLimit = any()) + } returns mockk(relaxed = true).right() + + val txDataMock = mockk(relaxed = true) + coEvery { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } returns txDataMock.right() + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns sendError.left() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java) + val txError = result as SwapTransactionState.Error.TransactionError + assertThat(txError.error).isEqualTo(sendError) + + coVerify(exactly = 0) { + repository.exchangeSent(any(), any(), any(), any(), any(), any(), any()) + } + coVerify(exactly = 0) { + swapTransactionRepository.storeTransaction(any(), any(), any(), any(), any(), any(), any()) + } + } + } + + // ------------------------------------------------------------------------- + // OnSwapSolanaDex + // ------------------------------------------------------------------------- + + @Nested + inner class OnSwapSolanaDex { + + @AfterEach + fun tearDown() { + unmockkStatic(Base64::class) + } + + @Test + fun `should return TxSent and call exchangeSent and storeTransaction on happy path`() = runTest { + // Given + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(100) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val toStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val swapData = buildSwapDataModelDex(txData = "dGVzdA==") + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xsolana-hash".right() + + every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 SOL" + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = null, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) + val txSent = result as SwapTransactionState.TxSent + assertThat(txSent.txHash).isEqualTo("0xsolana-hash") + + coVerify(exactly = 1) { + repository.exchangeSent( + userWallet = any(), txId = any(), fromNetwork = any(), + fromAddress = any(), payInAddress = any(), + txHash = "0xsolana-hash", payInExtraId = any(), + ) + } + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), toUserWalletId = any(), + fromCryptoCurrency = any(), toCryptoCurrency = any(), + fromAccount = any(), toAccount = any(), transaction = any(), + ) + } + } + + @Test + fun `should return TransactionError when sendTransactionUseCase fails on Solana path`() = runTest { + // Given + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(100) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val toStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val swapData = buildSwapDataModelDex(txData = "dGVzdA==") + val sendError = SendTransactionError.UserCancelledError + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns sendError.left() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = null, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java) + val txError = result as SwapTransactionState.Error.TransactionError + assertThat(txError.error).isEqualTo(sendError) + } + } + + // ------------------------------------------------------------------------- + // OnSwapCex + // ------------------------------------------------------------------------- + + @Nested + inner class OnSwapCex { + + private val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, providerId = "cex-id") + + // Both from and to use Hot wallets to avoid spurious is-Cold checks + private val fromStatus = buildHotSwapCurrencyStatus() + private val toStatus = buildHotSwapCurrencyStatus() + + private fun stubGetExchangeData(result: SwapDataModel) { + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns result.right() + } + + private fun stubCreateTransferTx(txDataMock: TransactionData.Uncompiled = mockk(relaxed = true)) { + coEvery { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } returns txDataMock.right() + } + + private suspend fun callOnSwap( + fee: TxFee? = buildTxFee(), + isTangemPayWithdrawal: Boolean = false, + ) = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = isTangemPayWithdrawal, + ) + + @Test + fun `should return ExpressError when getExchangeData fails`() = runTest { + // Given + val expressError = ExpressDataError.UnknownError + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns expressError.left() + + // When + val result = callOnSwap() + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.ExpressError::class.java) + val error = result as SwapTransactionState.Error.ExpressError + assertThat(error.error).isEqualTo(expressError) + + coVerify(exactly = 0) { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + } + + @Test + fun `should return UnknownError when getExchangeData returns DEX transaction type`() = runTest { + // Given — DEX-typed SwapDataModel where CEX path expects CEX type + val dexSwapData = buildSwapDataModelDex() + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns dexSwapData.right() + + // When + val result = callOnSwap() + + // Then — cast to CEX returns null → UnknownError + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + } + + @Test + fun `should return TangemPayWithdrawalData without sending when isTangemPayWithdrawal is true`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel(txTo = "0xCexDepositAddress") + stubGetExchangeData(cexSwapData) + every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 ETH" + + // When + val result = callOnSwap(isTangemPayWithdrawal = true) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.TangemPayWithdrawalData::class.java) + val withdrawalData = result as SwapTransactionState.TangemPayWithdrawalData + assertThat(withdrawalData.cexAddress).isEqualTo("0xCexDepositAddress") + assertThat(withdrawalData.storeData).isNotNull() + assertThat(withdrawalData.exchangeData).isNotNull() + + coVerify(exactly = 0) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + coVerify(exactly = 0) { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = any(), userWallet = any(), fee = any(), + ) + } + } + + @Test + fun `should return UnknownError for Cold demo card checked inside onSwapCex after getExchangeData`() = runTest { + // Given + // This demo check is at line ~818 of SwapInteractorImpl, AFTER getExchangeData succeeds. + // The dispatcher-level check is bypassed by returning false on the first call. + val coldWallet = mockk(relaxed = true) + + // First call → false (dispatcher check passes), second call → true (onSwapCex internal check) + every { isDemoCardUseCase(any()) } returnsMany listOf(false, true) + + val fromStatusCold = buildSwapCurrencyStatus(networkRawId = ethNetwork).let { + SwapCurrencyStatus(userWallet = coldWallet, status = it.status, account = it.account) + } + val cexSwapData = buildCexSwapDataModel() + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns cexSwapData.right() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatusCold, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = buildTxFee(), + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + } + + @Test + fun `should return UnknownError when createTransferTransactionUseCase fails`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + coEvery { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } returns RuntimeException("create transfer failed").left() + + // When + val result = callOnSwap() + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + } + + @Test + fun `should return UnknownError when txData extras is null but txExtraId is present`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel(txExtraId = "extra-id-required") + stubGetExchangeData(cexSwapData) + + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + // When + val result = callOnSwap() + + // Then — extras == null AND txExtraId != null → UnknownError + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + } + + @Test + fun `should invoke createAndSendGaslessTransactionUseCase when FeeComponent with Token and LoadedExtended`() = + runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + val tokenCurrencyStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false) + val extendedFee = mockk(relaxed = true) + val gaslessFee = TxFee.FeeComponent( + fee = mockk(relaxed = true), + transactionFeeResult = TransactionFeeResult.LoadedExtended(extendedFee), + selectedToken = tokenCurrencyStatus.status, + ) + + coEvery { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = any(), userWallet = any(), fee = any(), + ) + } returns "0xgasless-hash".right() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = gaslessFee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = any(), userWallet = any(), fee = extendedFee, + ) + } + coVerify(exactly = 0) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) + } + + @Test + fun `should invoke sendTransactionUseCase when FeeComponent but selectedToken is null`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + val feeNoToken = TxFee.FeeComponent( + fee = mockk(relaxed = true), + transactionFeeResult = TransactionFeeResult.Loaded(mockk(relaxed = true)), + selectedToken = null, + ) + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xhash-notgasless".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = feeNoToken, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + coVerify(exactly = 0) { + createAndSendGaslessTransactionUseCase.invoke(any(), any(), any()) + } + } + + @Test + fun `should invoke sendTransactionUseCase for Legacy fee`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + val legacyFee = buildTxFeeLegacy() + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xlegacy-hash".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = legacyFee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + coVerify(exactly = 0) { + createAndSendGaslessTransactionUseCase.invoke(any(), any(), any()) + } + } + + @Test + fun `should return TxSent and call all three side effects on CEX send success`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xcex-hash".right() + + every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 ETH" + + // When + val result = callOnSwap() + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) + val txSent = result as SwapTransactionState.TxSent + assertThat(txSent.txHash).isEqualTo("0xcex-hash") + + coVerify(exactly = 1) { + repository.exchangeSent( + userWallet = any(), txId = any(), fromNetwork = any(), + fromAddress = any(), payInAddress = any(), + txHash = "0xcex-hash", payInExtraId = any(), + ) + } + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), toUserWalletId = any(), + fromCryptoCurrency = any(), toCryptoCurrency = any(), + fromAccount = any(), toAccount = any(), transaction = any(), + ) + } + coVerify(exactly = 1) { + swapTransactionRepository.storeLastSwappedCryptoCurrencyId( + userWalletId = any(), cryptoCurrencyId = any(), + ) + } + } + + @Test + fun `should return TransactionError when CEX send fails`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + val sendError = SendTransactionError.DataError("connection reset") + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns sendError.left() + + // When + val result = callOnSwap() + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java) + val txError = result as SwapTransactionState.Error.TransactionError + assertThat(txError.error).isEqualTo(sendError) + } + } +} + +// region — file-private builders + +private fun buildSwapDataModelDex( + txData: String = "dGVzdA==", + txValue: String? = "0", + toAmount: BigDecimal = BigDecimal("0.5"), +): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = txValue, + txId = "tx-id-123", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = txData, + otherNativeFeeWei = null, + gas = BigInteger.valueOf(21_000L), + ), +) + +private fun buildTxFeeLegacy( + feeValue: BigDecimal = BigDecimal("0.001"), +): TxFee.Legacy = TxFee.Legacy( + feeValue = feeValue, + feeFiatFormatted = "$0.01", + feeCryptoFormatted = "0.001 ETH", + feeIncludeOtherNativeFee = feeValue, + feeFiatFormattedWithNative = "$0.01", + feeCryptoFormattedWithNative = "0.001 ETH", + cryptoSymbol = "ETH", + feeType = FeeType.NORMAL, + fee = mockk(relaxed = true), +) + +// endregion \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt new file mode 100644 index 0000000000..b560b6cfa8 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt @@ -0,0 +1,164 @@ +package com.tangem.feature.swap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import io.mockk.clearMocks +import io.mockk.coVerify +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Tests for [SwapInteractorImpl.storeSwapTransaction]. + * + * Behavior: + * - Delegates to [SwapTransactionRepository.storeTransaction] with fields derived from + * the from/to currency statuses, the amount, the provider, and the [SwapDataModel]. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplStoreSwapTransactionTest : SwapInteractorImplTestBase() { + + @BeforeEach + fun resetSwapTransactionRepository() { + clearMocks(swapTransactionRepository) + } + + @Test + fun `should delegate to swapTransactionRepository storeTransaction with correct fields`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus( + networkRawId = Blockchain.Ethereum.toNetworkId(), + isCoin = true, + ) + val toStatus = buildSwapCurrencyStatus( + networkRawId = Blockchain.Bitcoin.toNetworkId(), + isCoin = true, + ) + val amount = SwapAmount(value = BigDecimal("1.25"), decimals = 18) + val provider = buildSwapProvider(type = ExchangeProviderType.DEX, providerId = "dex-store") + val swapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(BigDecimal("0.42"), 8), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal("1.25"), 18), + toAmount = SwapAmount(BigDecimal("0.42"), 8), + txValue = "0", + txId = "persisted-tx-id", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = "dGVzdA==", + otherNativeFeeWei = null, + gas = BigInteger.valueOf(21_000L), + ), + ) + val timestamp = 1_700_000_000L + + val transactionSlot = slot() + + // When + sut.storeSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = amount, + swapProvider = provider, + swapDataModel = swapDataModel, + timestamp = timestamp, + txExternalUrl = "https://explorer/tx", + txExternalId = "ext-id-1", + averageDuration = 120, + ) + + // Then + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), + toUserWalletId = any(), + fromCryptoCurrency = any(), + toCryptoCurrency = any(), + fromAccount = any(), + toAccount = any(), + transaction = capture(transactionSlot), + ) + } + + val captured = transactionSlot.captured + assertThat(captured.txId).isEqualTo("persisted-tx-id") + assertThat(captured.provider).isEqualTo(provider) + assertThat(captured.timestamp).isEqualTo(timestamp) + assertThat(captured.fromCryptoAmount).isEqualTo(BigDecimal("1.25")) + assertThat(captured.toCryptoAmount).isEqualTo(BigDecimal("0.42")) + val status = requireNotNull(captured.status) { "status should not be null" } + assertThat(status.providerId).isEqualTo("dex-store") + assertThat(status.status).isEqualTo(ExchangeStatus.New) + assertThat(status.txExternalUrl).isEqualTo("https://explorer/tx") + assertThat(status.txExternalId).isEqualTo("ext-id-1") + assertThat(status.averageDuration).isEqualTo(120) + } + + @Test + fun `should accept null txExternalUrl and txExternalId and averageDuration`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus() + val toStatus = buildSwapCurrencyStatus() + val amount = SwapAmount(BigDecimal("0.5"), 18) + val provider = buildSwapProvider() + val swapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(BigDecimal("0.1"), 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal("0.5"), 18), + toAmount = SwapAmount(BigDecimal("0.1"), 18), + txValue = "0", + txId = "tx-id-2", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = "dGVzdA==", + otherNativeFeeWei = null, + gas = BigInteger.valueOf(21_000L), + ), + ) + + val transactionSlot = slot() + + // When + sut.storeSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = amount, + swapProvider = provider, + swapDataModel = swapDataModel, + timestamp = 1L, + txExternalUrl = null, + txExternalId = null, + averageDuration = null, + ) + + // Then + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), + toUserWalletId = any(), + fromCryptoCurrency = any(), + toCryptoCurrency = any(), + fromAccount = any(), + toAccount = any(), + transaction = capture(transactionSlot), + ) + } + val status = requireNotNull(transactionSlot.captured.status) + assertThat(status.txExternalUrl).isNull() + assertThat(status.txExternalId).isNull() + assertThat(status.averageDuration).isNull() + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt new file mode 100644 index 0000000000..ba41e6a388 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt @@ -0,0 +1,403 @@ +package com.tangem.feature.swap.domain + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.appcurrency.repository.AppCurrencyRepository +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.quotes.QuotesRepository +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapPairModel +import com.tangem.domain.swap.usecase.GetSwapPairUseCase +import com.tangem.domain.tokens.GetAssetRequirementsUseCase +import com.tangem.domain.tokens.GetCurrencyCheckUseCase +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.feature.swap.domain.api.SwapRepository +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.ui.AmountFormatter +import com.tangem.feature.swap.domain.models.ui.TxFee +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import java.math.BigDecimal + +/** + * Base class that wires all ~30 dependencies of [SwapInteractorImpl] as relaxed MockK mocks. + * Extend this in every test class and override individual stubs in `@BeforeEach` or within tests. + */ +internal open class SwapInteractorImplTestBase { + + // region — mocked dependencies + + protected val repository: SwapRepository = mockk(relaxed = true) + protected val allowPermissionsHandler: AllowPermissionsHandler = mockk(relaxed = true) + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk(relaxed = true) + protected val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true) + protected val createTransactionUseCase: CreateTransactionUseCase = mockk(relaxed = true) + protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk(relaxed = true) + protected val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true) + protected val isDemoCardUseCase: IsDemoCardUseCase = mockk(relaxed = true) + protected val quotesRepository: QuotesRepository = mockk(relaxed = true) + protected val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk(relaxed = true) + protected val swapTransactionRepository: SwapTransactionRepository = mockk(relaxed = true) + private val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true) + private val appCurrencyRepository: AppCurrencyRepository = mockk(relaxed = true) + protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true) + protected val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true) + protected val validateTransactionUseCase: ValidateTransactionUseCase = mockk(relaxed = true) + protected val estimateFeeUseCase: EstimateFeeUseCase = mockk(relaxed = true) + protected val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase = mockk(relaxed = true) + protected val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase = mockk(relaxed = true) + private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true) + protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = + mockk(relaxed = true) + protected val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true) + private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = mockk(relaxed = true) + protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk(relaxed = true) + protected val getAssetRequirementsUseCase: GetAssetRequirementsUseCase = mockk(relaxed = true) + protected val amountFormatter: AmountFormatter = mockk(relaxed = true) + protected val rampStateManager: RampStateManager = mockk(relaxed = true) + protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = + mockk(relaxed = true) + protected val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + protected val getAllowanceInfoUseCase: GetAllowanceInfoUseCase = mockk(relaxed = true) + protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true) + + // endregion + + protected val sut: SwapInteractorImpl by lazy { + SwapInteractorImpl( + repository = repository, + allowPermissionsHandler = allowPermissionsHandler, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, + sendTransactionUseCase = sendTransactionUseCase, + createTransactionUseCase = createTransactionUseCase, + createTransferTransactionUseCase = createTransferTransactionUseCase, + createTransactionExtrasUseCase = createTransactionExtrasUseCase, + isDemoCardUseCase = isDemoCardUseCase, + quotesRepository = quotesRepository, + multiQuoteStatusFetcher = multiQuoteStatusFetcher, + swapTransactionRepository = swapTransactionRepository, + currencyChecksRepository = currencyChecksRepository, + appCurrencyRepository = appCurrencyRepository, + currenciesRepository = currenciesRepository, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + validateTransactionUseCase = validateTransactionUseCase, + estimateFeeUseCase = estimateFeeUseCase, + estimateFeeForTokenUseCase = estimateFeeForTokenUseCase, + estimateFeeForGaslessTxUseCase = estimateFeeForGaslessTxUseCase, + getFeeForTokenUseCase = getFeeForTokenUseCase, + createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, + getFeeUseCase = getFeeUseCase, + getEthSpecificFeeUseCase = getEthSpecificFeeUseCase, + getCurrencyCheckUseCase = getCurrencyCheckUseCase, + getAssetRequirementsUseCase = getAssetRequirementsUseCase, + amountFormatter = amountFormatter, + rampStateManager = rampStateManager, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + walletManagersFacade = walletManagersFacade, + getAllowanceInfoUseCase = getAllowanceInfoUseCase, + getSwapPairUseCase = getSwapPairUseCase, + ) + } + + /** + * Clears recorded calls and stubbed answers on all MockK mocks AND releases any + * `mockkStatic` / `mockkObject` declarations between tests. + * + * - `clearAllMocks()` wipes recorded calls and stubbed answers; relaxed mocks remain relaxed + * (creation-time property). Each test must (re)stub any required behavior in its own + * `@BeforeEach` or test body. + * - `unmockkAll()` releases static/object mocks set up inline by some tests + * (e.g. `mockkStatic(Base64::class)`, `mockkObject(SolanaTransactionHelper)`) so leaks + * do not propagate across tests within the same class. + */ + @AfterEach + open fun clearMocksAfterEachTest() { + clearAllMocks() + unmockkAll() + } + + /** + * Defensive shutdown hook — releases any remaining `mockkStatic` / `mockkObject` declarations + * after the entire test class finishes, in case `@AfterEach` was bypassed (e.g. JVM shutdown + * during a hard crash). + * + * Requires `@TestInstance(Lifecycle.PER_CLASS)` on every subclass — already the case across + * all `SwapInteractorImpl*Test` classes. + */ + @AfterAll + open fun releaseStaticMocksAfterAllTests() { + unmockkAll() + } +} + +// region — Test Builders + +/** + * Builds a [SwapCurrencyStatus] backed entirely by relaxed mocks. + * + * The [Network] mock is fully relaxed — [Network.rawId] is stubbed to return [networkRawId]. + * The extension function [com.tangem.blockchainsdk.utils.toBlockchain] is not stubbed here; + * call-sites that need a specific Blockchain should use [io.mockk.mockkStatic] around the test. + * + * @param networkRawId raw network id — use `Blockchain.Ethereum.toNetworkId()` for EVM + * @param contractAddress "0" for native coins, a real contract address for tokens + * @param isCoin true to make the currency a [CryptoCurrency.Coin], false for [CryptoCurrency.Token] + * @param amount token balance to expose via [CryptoCurrencyStatus.Value.amount] + */ +internal fun buildSwapCurrencyStatus( + networkRawId: String = Blockchain.Ethereum.toNetworkId(), + contractAddress: String = "0", + isCoin: Boolean = true, + amount: BigDecimal = BigDecimal("1"), + decimals: Int = 18, + userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"), + yieldSupplyActive: Boolean = false, +): SwapCurrencyStatus { + val networkId = mockk(relaxed = true) { + every { rawId } returns Network.RawID(networkRawId) + } + val network = mockk(relaxed = true) { + every { rawId } returns networkRawId + every { id } returns networkId + every { derivationPath } returns Network.DerivationPath.None + } + + val currencyId = mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID(contractAddress) + } + val currency: CryptoCurrency = if (isCoin) { + mockk(relaxed = true) { + every { this@mockk.network } returns network + every { this@mockk.decimals } returns decimals + every { this@mockk.id } returns currencyId + } + } else { + mockk(relaxed = true) { + every { this@mockk.network } returns network + every { this@mockk.decimals } returns decimals + every { this@mockk.contractAddress } returns contractAddress + every { this@mockk.id } returns currencyId + } + } + + val networkAddress = mockk(relaxed = true) { + every { defaultAddress } returns NetworkAddress.Address( + value = "0xTestAddress", + type = NetworkAddress.Address.Type.Primary, + ) + } + + val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) { + mockk(relaxed = true) { + every { isActive } returns true + } + } else { + null + } + + val statusValue = mockk(relaxed = true) { + every { this@mockk.amount } returns amount + every { this@mockk.networkAddress } returns networkAddress + every { this@mockk.pendingTransactions } returns emptySet() + every { this@mockk.yieldSupplyStatus } returns maybeYield + } + + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = currency, + value = statusValue, + ) + + val userWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + val account = mockk(relaxed = true) { + every { accountId } returns mockk(relaxed = true) + } + + return SwapCurrencyStatus( + userWallet = userWallet, + status = cryptoCurrencyStatus, + account = account, + ) +} + +/** + * Builds a mocked [CryptoCurrency.Coin] with a stubbed network. Used where APIs require the concrete Coin subtype. + */ +internal fun buildCoinCurrency( + networkRawId: String = Blockchain.Ethereum.toNetworkId(), + decimals: Int = 18, +): CryptoCurrency.Coin { + val networkId = mockk(relaxed = true) { + every { rawId } returns Network.RawID(networkRawId) + } + val network = mockk(relaxed = true) { + every { rawId } returns networkRawId + every { id } returns networkId + every { derivationPath } returns Network.DerivationPath.None + } + val currencyId = mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID("0") + } + return mockk(relaxed = true) { + every { this@mockk.network } returns network + every { this@mockk.decimals } returns decimals + every { this@mockk.id } returns currencyId + } +} + +/** + * Builds a [SwapProvider] for a given [ExchangeProviderType]. + */ +internal fun buildSwapProvider( + type: ExchangeProviderType = ExchangeProviderType.DEX, + providerId: String = "test-provider-${type.name}", +): SwapProvider = SwapProvider( + providerId = providerId, + rateTypes = listOf(RateType.FLOAT), + name = "TestProvider-${type.name}", + type = type, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, +) + +/** + * Builds a [TxFee.FeeComponent] wrapping a [Fee.Common] with the given fiat-equivalent amount. + */ +internal fun buildTxFee( + feeValue: BigDecimal = BigDecimal("0.001"), + selectedToken: CryptoCurrencyStatus? = null, +): TxFee.FeeComponent { + val amount = mockk(relaxed = true) { + every { value } returns feeValue + } + val fee = mockk(relaxed = true) { + every { this@mockk.amount } returns amount + } + return TxFee.FeeComponent( + fee = fee, + transactionFeeResult = TransactionFeeResult.Loaded( + fee = mockk(relaxed = true), + ), + selectedToken = selectedToken, + ) +} + +/** + * Builds a [TxFeeSealedState.Component] wrapping a [TxFee.FeeComponent]. + */ +internal fun buildTxFeeSealedState( + feeValue: BigDecimal = BigDecimal("0.001"), + selectedToken: CryptoCurrencyStatus? = null, +): TxFeeSealedState = TxFeeSealedState.Component( + txFee = buildTxFee(feeValue = feeValue, selectedToken = selectedToken), +) + +/** + * Builds a [SwapPairLeast] with matching from/to network+contract pairs. + */ +internal fun buildSwapPairLeast( + fromNetwork: String = Blockchain.Ethereum.toNetworkId(), + fromContract: String = "0", + toNetwork: String = Blockchain.Bitcoin.toNetworkId(), + toContract: String = "0", + providers: List = listOf(buildSwapProvider()), +): SwapPairLeast = SwapPairLeast( + from = LeastTokenInfo(contractAddress = fromContract, network = fromNetwork), + to = LeastTokenInfo(contractAddress = toContract, network = toNetwork), + providers = providers, +) + +/** + * Builds a [QuoteModel] with optional allowance contract. + */ +internal fun buildQuoteModel( + toAmount: BigDecimal = BigDecimal("0.5"), + decimals: Int = 18, + allowanceContract: String? = null, +): QuoteModel = QuoteModel( + toTokenAmount = SwapAmount(toAmount, decimals), + allowanceContract = allowanceContract, +) + +/** + * Builds an [ExpressProvider] — used by [GetSwapPairUseCase] results. + */ +internal fun buildExpressProvider( + providerId: String = "express-provider", + type: ExpressProviderType = ExpressProviderType.DEX, +): ExpressProvider = ExpressProvider( + providerId = providerId, + rateTypes = listOf(ExpressRateType.Float), + name = "ExpressProvider-${type.name}", + type = type, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, +) + +/** + * Builds a [SwapPairModel] — used as the result type of [GetSwapPairUseCase]. + */ +internal fun buildSwapPairModel( + fromNetworkRawId: String = Blockchain.Ethereum.toNetworkId(), + fromContractAddress: String = "0", + toNetworkRawId: String = Blockchain.Bitcoin.toNetworkId(), + toContractAddress: String = "0", + providers: List = listOf(buildExpressProvider()), +): SwapPairModel { + val fromCurrencyStatus = buildSwapCurrencyStatus( + networkRawId = fromNetworkRawId, + contractAddress = fromContractAddress, + ) + val toCurrencyStatus = buildSwapCurrencyStatus( + networkRawId = toNetworkRawId, + contractAddress = toContractAddress, + ) + return SwapPairModel( + from = fromCurrencyStatus.status, + to = toCurrencyStatus.status, + providers = providers, + ) +} + +// endregion \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index c202111fb6..f6ac98d33d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -1,5 +1,15 @@ package com.tangem.feature.swap +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.swap.SwapFeatureToggles +import javax.inject.Inject -internal class DefaultSwapFeatureToggles : SwapFeatureToggles \ No newline at end of file +internal class DefaultSwapFeatureToggles @Inject constructor( + featureTogglesManager: FeatureTogglesManager, +) : SwapFeatureToggles { + + override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.SWAP_SWITCH_TO_TRANSFER_ENABLED, + ) +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt index 990f8c8ec9..5cf4ea502f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.di +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.feature.swap.DefaultSwapComponent import com.tangem.feature.swap.DefaultSwapFeatureToggles import com.tangem.features.swap.SwapComponent @@ -17,8 +18,8 @@ internal object SwapFeatureModule { @Provides @Singleton - fun provideSwapFeatureToggles(): SwapFeatureToggles { - return DefaultSwapFeatureToggles() + fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles { + return DefaultSwapFeatureToggles(featureTogglesManager) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt index a245cc7a9f..df9ff0e757 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt @@ -130,6 +130,7 @@ internal class InitialCurrenciesResolver @Inject constructor( private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { val paymentCryptoCurrencyStatus = when (val statusValue = accountStatus.value) { is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + is PaymentAccountStatusValue.Deactivated -> statusValue.cryptoCurrencyStatus else -> null } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 6755e4209a..5bad26508b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -98,6 +98,7 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.swap.SwapComponent +import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import com.tangem.utils.isNullOrZero @@ -144,13 +145,14 @@ internal class SwapModel @Inject constructor( private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, - private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, private val appsFlyerStore: AppsFlyerStore, private val messageSender: UiMessageSender, private val initialCurrenciesResolver: InitialCurrenciesResolver, private val allowPermissionsHandler: AllowPermissionsHandler, + private val swapFeatureToggles: SwapFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -184,7 +186,7 @@ internal class SwapModel @Inject constructor( isBalanceHiddenProvider = Provider { isBalanceHidden }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), isAccountsModeProvider = Provider { isAccountsMode }, - iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, ) private val inputNumberFormatter = InputNumberFormatter( @@ -1548,6 +1550,7 @@ internal class SwapModel @Inject constructor( } private fun filterTokensFromSelector() { + if (swapFeatureToggles.isSwapSwitchToTransferEnabled) return val tokenFilter = { accountStatus: AccountStatus, currencyStatus: CryptoCurrencyStatus -> if (currencyStatus.currency.isCustom) { false diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 2bff6bb705..ddc42b9e81 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -35,7 +35,7 @@ import java.math.BigDecimal @Suppress("LargeClass") internal class SwapNotificationsFactory( private val actions: UiActions, - private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { fun getGeneralErrorStateNotifications( @@ -314,7 +314,7 @@ internal class SwapNotificationsFactory( val isNotEnoughFee = quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough - val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) && + val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && quoteModel.swapProvider.type == ExchangeProviderType.CEX if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) { add( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index deb2acdde2..814623edd8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -57,12 +57,12 @@ internal class StateBuilder( private val isBalanceHiddenProvider: Provider, private val appCurrencyProvider: Provider, private val isAccountsModeProvider: Provider, - private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) { - SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork) + SwapNotificationsFactory(actions, isGaslessFeeSupportedForNetwork) } fun createInitialLoadingState(): SwapStateHolder { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt index e8724ad601..74d5f2bf6b 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt @@ -30,7 +30,7 @@ internal class StateBuilderInitialStateTest { private val isBalanceHiddenProvider: Provider = mockk() private val appCurrencyProvider: Provider = mockk() private val isAccountsModeProvider: Provider = mockk() - private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() private lateinit var sut: StateBuilder @@ -47,7 +47,7 @@ internal class StateBuilderInitialStateTest { isBalanceHiddenProvider = isBalanceHiddenProvider, appCurrencyProvider = appCurrencyProvider, isAccountsModeProvider = isAccountsModeProvider, - iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, ) } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt index c1f52f0e5d..ef0ac3d90e 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt @@ -25,7 +25,7 @@ internal class StateBuilderPairsTest { private val isBalanceHiddenProvider: Provider = mockk() private val appCurrencyProvider: Provider = mockk() private val isAccountsModeProvider: Provider = mockk() - private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() private lateinit var sut: StateBuilder @@ -52,7 +52,7 @@ internal class StateBuilderPairsTest { isBalanceHiddenProvider = isBalanceHiddenProvider, appCurrencyProvider = appCurrencyProvider, isAccountsModeProvider = isAccountsModeProvider, - iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, ) } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt index e335298820..0302eebdfc 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt @@ -2,7 +2,6 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.swap.models.SwapCurrencyStatus @@ -14,12 +13,10 @@ import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.models.* import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState -import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.ui.StateBuilder import com.tangem.utils.Provider import io.mockk.every import io.mockk.mockk -import kotlinx.collections.immutable.persistentListOf import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -31,7 +28,7 @@ internal class StateBuilderQuotesTest { private val isBalanceHiddenProvider: Provider = mockk() private val appCurrencyProvider: Provider = mockk() private val isAccountsModeProvider: Provider = mockk() - private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() private lateinit var sut: StateBuilder @@ -52,14 +49,14 @@ internal class StateBuilderQuotesTest { every { isBalanceHiddenProvider() } returns false every { appCurrencyProvider() } returns AppCurrency.Default every { isAccountsModeProvider() } returns false - every { iGaslessFeeSupportedForNetwork(any()) } returns false + every { isGaslessFeeSupportedForNetwork(any()) } returns false sut = StateBuilder( actions = actions, isBalanceHiddenProvider = isBalanceHiddenProvider, appCurrencyProvider = appCurrencyProvider, isAccountsModeProvider = isAccountsModeProvider, - iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, ) } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt index ed55977ec8..a37096e697 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt @@ -4,13 +4,11 @@ import com.google.common.truth.Truth.assertThat import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.model.SwapProcessDataState import com.tangem.feature.swap.models.* -import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.ui.StateBuilder @@ -30,7 +28,7 @@ internal class StateBuilderSwapDataTest { private val isBalanceHiddenProvider: Provider = mockk() private val appCurrencyProvider: Provider = mockk() private val isAccountsModeProvider: Provider = mockk() - private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() private lateinit var sut: StateBuilder @@ -51,14 +49,14 @@ internal class StateBuilderSwapDataTest { every { isBalanceHiddenProvider() } returns false every { appCurrencyProvider() } returns AppCurrency.Default every { isAccountsModeProvider() } returns false - every { iGaslessFeeSupportedForNetwork(any()) } returns false + every { isGaslessFeeSupportedForNetwork(any()) } returns false sut = StateBuilder( actions = actions, isBalanceHiddenProvider = isBalanceHiddenProvider, appCurrencyProvider = appCurrencyProvider, isAccountsModeProvider = isAccountsModeProvider, - iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, ) } diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt index 137a7efff8..acbd67b6f7 100644 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt @@ -2,10 +2,9 @@ package com.tangem.features.tangempay.components import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayDetailsConfig +import com.tangem.domain.models.account.AccountStatus interface TangemPayDetailsContainerComponent : ComposableContentComponent { - data class Params(val userWalletId: UserWalletId, val config: TangemPayDetailsConfig) + data class Params(val initialStatus: AccountStatus.Payment) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 94de672f75..e5383007e1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -67,7 +67,7 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru ) TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create( context = childByContext(componentContext = componentContext, router = innerRouter), - params = TangemPayCardPageComponent.Params(userWalletId = params.userWalletId, config = params.config), + params = TangemPayCardPageComponent.Params(initialStatus = params.initialStatus), ) TangemPayAccountDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt index 7058acc0a1..77fda36811 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.features.tangempay.model.TangemPayAddFundsModel @@ -32,7 +33,7 @@ internal class TangemPayAddFundsComponent( val cryptoBalance: BigDecimal, val fiatBalance: BigDecimal, val depositAddress: String, - val chainId: Int, + val cryptoCurrency: CryptoCurrency, ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt index f04ea0b8de..58d8d1acc6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt @@ -13,6 +13,8 @@ import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCard import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.model.TangemPayAddToWalletModel import com.tangem.features.tangempay.ui.TangemPayAddToWalletScreen +import com.tangem.features.tangempay.utils.firstCard +import com.tangem.features.tangempay.utils.userWalletId internal class TangemPayAddToWalletComponent( private val appComponentContext: AppComponentContext, @@ -24,7 +26,8 @@ internal class TangemPayAddToWalletComponent( private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( appComponentContext = child("cardDetailsBlockComponent"), params = TangemPayCardDetailsBlockComponent.Params( - params = params, + card = params.initialStatus.firstCard(), + userWalletId = params.initialStatus.userWalletId, isEditingNameEnabled = false, ), ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt index 6d2e0dc50b..44ded62254 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -16,8 +16,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayDetailsConfig +import com.tangem.domain.models.account.AccountStatus import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupComponent import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupSuccessComponent import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute @@ -70,34 +69,22 @@ internal class TangemPayCardPageComponent @AssistedInject constructor( ) TangemPayCardDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = TangemPayDetailsContainerComponent.Params( - userWalletId = params.userWalletId, - config = params.config, - ), + params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus), ) TangemPayCardDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), ) TangemPayCardDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = TangemPayDetailsContainerComponent.Params( - userWalletId = params.userWalletId, - config = params.config, - ), + params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus), ) TangemPayCardDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = TangemPayDetailsContainerComponent.Params( - userWalletId = params.userWalletId, - config = params.config, - ), + params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus), ) TangemPayCardDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = TangemPayDetailsContainerComponent.Params( - userWalletId = params.userWalletId, - config = params.config, - ), + params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus), ) TangemPayCardDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), @@ -112,7 +99,7 @@ internal class TangemPayCardPageComponent @AssistedInject constructor( } } - data class Params(val userWalletId: UserWalletId, val config: TangemPayDetailsConfig) + data class Params(val initialStatus: AccountStatus.Payment) @AssistedFactory interface Factory : ComponentFactory { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt index 87a347f661..2804ef46d5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -20,6 +20,8 @@ import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetails import com.tangem.features.tangempay.entity.TangemPayCardNavigation import com.tangem.features.tangempay.model.TangemPayCardPageModel import com.tangem.features.tangempay.ui.TangemPayCardPageScreen +import com.tangem.features.tangempay.utils.firstCard +import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokenreceive.TokenReceiveComponent internal class TangemPayCardPageScreenComponent( @@ -30,15 +32,11 @@ internal class TangemPayCardPageScreenComponent( private val model: TangemPayCardPageModel = getOrCreateModel(params = params) - private val containerParams = TangemPayDetailsContainerComponent.Params( - userWalletId = params.userWalletId, - config = params.config, - ) - private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( appComponentContext = child("cardDetailsBlockComponent"), params = TangemPayCardDetailsBlockComponent.Params( - params = containerParams, + card = params.initialStatus.firstCard(), + userWalletId = params.initialStatus.userWalletId, isEditingNameEnabled = true, ), ) @@ -86,8 +84,8 @@ internal class TangemPayCardPageScreenComponent( appComponentContext = context, params = TangemPayReissueCardComponent.Params( listener = model, - userWalletId = params.userWalletId, - cardId = params.config.cardId, + userWalletId = params.initialStatus.userWalletId, + cardId = params.initialStatus.firstCard().id, ), ) is TangemPayCardNavigation.AddFunds -> TangemPayAddFundsComponent( @@ -98,7 +96,7 @@ internal class TangemPayCardPageScreenComponent( cryptoBalance = navigation.cryptoBalance, fiatBalance = navigation.fiatBalance, depositAddress = navigation.depositAddress, - chainId = navigation.chainId, + cryptoCurrency = navigation.cryptoCurrency, ), ) is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 806f7c1980..faa746e4b8 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -22,6 +22,8 @@ import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDeta import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.model.TangemPayDetailsModel import com.tangem.features.tangempay.ui.TangemPayDetailsScreen +import com.tangem.features.tangempay.utils.requireLoaded +import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokenreceive.TokenReceiveComponent internal class TangemPayDetailsComponent( @@ -42,7 +44,7 @@ internal class TangemPayDetailsComponent( private val txHistoryComponent = DefaultTangemPayTxHistoryComponent( appComponentContext = child("txHistoryComponent"), params = DefaultTangemPayTxHistoryComponent.Params( - userWalletId = params.userWalletId, + userWalletId = params.initialStatus.userWalletId, uiActions = model, ), ) @@ -50,7 +52,7 @@ internal class TangemPayDetailsComponent( private val expressTransactionsComponent by lazy { expressTransactionsComponentProvider.create( appComponentContext = child("expressTransactionsComponent"), - userWalletId = params.userWalletId, + userWalletId = params.initialStatus.userWalletId, cryptoCurrency = model.cryptoCurrency, ) } @@ -95,8 +97,8 @@ internal class TangemPayDetailsComponent( params = TangemPayTxHistoryDetailsComponent.Params( transaction = navigation.transaction, isBalanceHidden = navigation.isBalanceHidden, - userWalletId = params.userWalletId, - customerId = params.config.customerId, + userWalletId = params.initialStatus.userWalletId, + customerId = params.initialStatus.requireLoaded().customerId, onDismiss = model.bottomSheetNavigation::dismiss, ), ) @@ -107,7 +109,7 @@ internal class TangemPayDetailsComponent( cryptoBalance = navigation.cryptoBalance, fiatBalance = navigation.fiatBalance, depositAddress = navigation.depositAddress, - chainId = navigation.chainId, + cryptoCurrency = navigation.cryptoCurrency, listener = model, ), ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt index 571c294824..6b67faa04f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt @@ -14,6 +14,8 @@ import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetails import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel import com.tangem.features.tangempay.ui.TangemPayEditDisplayNameScreen +import com.tangem.features.tangempay.utils.firstCard +import com.tangem.features.tangempay.utils.userWalletId internal class TangemPayEditDisplayNameComponent( private val appComponentContext: AppComponentContext, @@ -24,7 +26,11 @@ internal class TangemPayEditDisplayNameComponent( private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( appComponentContext = child("editDisplayNameCardDetails"), - params = TangemPayCardDetailsBlockComponent.Params(params = params, isEditingNameEnabled = false), + params = TangemPayCardDetailsBlockComponent.Params( + card = params.initialStatus.firstCard(), + userWalletId = params.initialStatus.userWalletId, + isEditingNameEnabled = false, + ), ) @Composable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt index c877c6cddb..750250f2c1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt @@ -3,7 +3,8 @@ package com.tangem.features.tangempay.components.cardDetails import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier -import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import kotlinx.coroutines.flow.StateFlow @@ -15,7 +16,8 @@ internal interface TangemPayCardDetailsBlockComponent { fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) data class Params( - val params: TangemPayDetailsContainerComponent.Params, + val card: TangemPayCard, + val userWalletId: UserWalletId, val isEditingNameEnabled: Boolean, ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt index 7e527748ee..f972f46a39 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.entity import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable @@ -22,7 +23,7 @@ internal sealed class TangemPayCardNavigation { val cryptoBalance: SerializedBigDecimal, val fiatBalance: SerializedBigDecimal, val depositAddress: String, - val chainId: Int, + val cryptoCurrency: CryptoCurrency, ) : TangemPayCardNavigation() @Serializable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt index 86009f09f0..5f3ba814f9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.entity import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.visa.model.TangemPayTxHistoryItem @@ -18,7 +19,7 @@ internal sealed class TangemPayDetailsNavigation { val cryptoBalance: SerializedBigDecimal, val fiatBalance: SerializedBigDecimal, val depositAddress: String, - val chainId: Int, + val cryptoCurrency: CryptoCurrency, ) : TangemPayDetailsNavigation() @Serializable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 2f630968ac..0ac34d8ff2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -55,7 +55,7 @@ internal class TangemPayDetailsStateFactory( ), ), onAddCardClick = intents::onAddCardClick, - ), + ).takeIf { !isTangemPayDeactivated }, ), isBalanceHidden = false, addFundsEnabled = true, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 50fb7a2683..2575939acf 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -63,23 +63,23 @@ internal sealed interface DisplayNameState { internal sealed class TangemPayDetailsBalanceBlockState { abstract val actionButtons: ImmutableList - abstract val cardsBlockState: CardsBlockState + abstract val cardsBlockState: CardsBlockState? data class Loading( override val actionButtons: ImmutableList, - override val cardsBlockState: CardsBlockState, + override val cardsBlockState: CardsBlockState?, ) : TangemPayDetailsBalanceBlockState() data class Content( override val actionButtons: ImmutableList, - override val cardsBlockState: CardsBlockState, + override val cardsBlockState: CardsBlockState?, val fiatBalance: String, val isBalanceFlickering: Boolean, ) : TangemPayDetailsBalanceBlockState() data class Error( override val actionButtons: ImmutableList, - override val cardsBlockState: CardsBlockState, + override val cardsBlockState: CardsBlockState?, ) : TangemPayDetailsBalanceBlockState() data class CardsBlockState(val cards: ImmutableList, val onAddCardClick: () -> Unit) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index 487e568445..3c5b2b8b2a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -23,6 +23,8 @@ import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute +import com.tangem.features.tangempay.utils.firstCard +import com.tangem.features.tangempay.utils.userWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -44,6 +46,8 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( ) : Model() { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + private val cardId: String = params.initialStatus.firstCard().id + private val userWalletId = params.initialStatus.userWalletId private var currentAdminLimit: BigDecimal? = null val uiState: StateFlow @@ -70,15 +74,15 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( } private fun observeCardState() { - paymentAccountStatusSupplier.invoke(params.userWalletId) + paymentAccountStatusSupplier.invoke(userWalletId) .map { it.value } .filterIsInstance() .filter { status -> - status.source == StatusSource.ACTUAL && status.findCardWithId(params.config.cardId) != null + status.source == StatusSource.ACTUAL && status.findCardWithId(cardId) != null } .withIndex() .onEach { (index, status) -> - val card = status.requireCardWithId(params.config.cardId) + val card = status.requireCardWithId(cardId) val currentLimit = card.limit?.actualCardLimit ?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } @@ -131,8 +135,8 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( modelScope.launch { uiState.update { it.copy(isSubmitButtonLoading = true) } setTangemPayCardLimitUseCase( - cardId = params.config.cardId, - userWalletId = params.userWalletId, + cardId = cardId, + userWalletId = userWalletId, amount = amount, ).fold( ifLeft = { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt index 40b49906f5..49a497f963 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt @@ -6,9 +6,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.ReceiveAddressModel.DisplayType -import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.pay.model.TangemPayTopUpData -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.tangempay.components.TangemPayAddFundsComponent import com.tangem.features.tangempay.entity.TangemPayAddFundsUM import com.tangem.features.tangempay.model.transformers.TangemPayAddFundsUMConverter @@ -20,8 +18,6 @@ import javax.inject.Inject internal class TangemPayAddFundsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory, - private val getUserWalletUseCase: GetUserWalletUseCase, ) : Model() { private val params = paramsContainer.require() @@ -29,25 +25,19 @@ internal class TangemPayAddFundsModel @Inject constructor( val uiState: TangemPayAddFundsUM = getInitialState() private fun getInitialState(): TangemPayAddFundsUM { - val userWallet = getUserWalletUseCase(params.walletId).getOrNull() - val currency = userWallet?.let { - tangemPayCryptoCurrencyFactory.create(userWallet = userWallet, chainId = params.chainId).getOrNull() - } - val data = currency?.let { - TangemPayTopUpData( - currency = currency, - walletId = params.walletId, - cryptoBalance = params.cryptoBalance, - fiatBalance = params.fiatBalance, - depositAddress = params.depositAddress, - receiveAddress = listOf( - ReceiveAddressModel( - displayType = DisplayType.Default, - value = params.depositAddress, - ), + val data = TangemPayTopUpData( + currency = params.cryptoCurrency, + walletId = params.walletId, + cryptoBalance = params.cryptoBalance, + fiatBalance = params.fiatBalance, + depositAddress = params.depositAddress, + receiveAddress = listOf( + ReceiveAddressModel( + displayType = DisplayType.Default, + value = params.depositAddress, ), - ) - } + ), + ) return TangemPayAddFundsUMConverter(listener = params.listener).convert(data) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index 4e97b58c7d..6b57692fdc 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -57,10 +57,11 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( ) : Model() { private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require() + private val card = params.card private val stateFactory = TangemPayCardDetailsBlockStateFactory( - cardNumberEnd = params.params.config.cardNumberEnd, - displayName = params.params.config.displayName, + cardNumberEnd = card.lastDigits, + displayName = card.displayName, isEditingNameEnabled = params.isEditingNameEnabled, onEditNameClick = ::startEditingDisplayName, onReveal = ::revealCardDetails, @@ -74,7 +75,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val showCardDetailsTimerJobHolder = JobHolder() init { - subscribeToCardNameChanges(cardId = params.params.config.cardId, userWalletId = params.params.userWalletId) + subscribeToCardNameChanges(cardId = card.id, userWalletId = params.userWalletId) subscribeToCardFrozenState() modelScope.launch { cardDetailsEventListener.event.collectLatest { event -> @@ -105,7 +106,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private fun subscribeToCardFrozenState() { cardDetailsRepository - .cardFrozenState(params.params.config.cardId) + .cardFrozenState(card.id) .onEach { uiState.update { state -> state.copy(cardFrozenState = it) } } .launchIn(modelScope) } @@ -116,7 +117,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( uiState.transformerUpdate( transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails), ) - cardDetailsRepository.revealCardDetails(params.params.userWalletId) + cardDetailsRepository.revealCardDetails(params.userWalletId) .onRight { cardDetails -> uiState.transformerUpdate( transformer = DetailsRevealedStateTransformer( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 735d0b9398..5c5b40b9ae 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -41,6 +41,9 @@ import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.* import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.features.tangempay.utils.TangemPayMessagesFactory +import com.tangem.features.tangempay.utils.cryptoCurrency +import com.tangem.features.tangempay.utils.firstCard +import com.tangem.features.tangempay.utils.userWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -65,6 +68,9 @@ internal class TangemPayCardPageModel @Inject constructor( ) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener { private val params: TangemPayCardPageComponent.Params = paramsContainer.require() + private val cardId: String = params.initialStatus.firstCard().id + private val userWalletId = params.initialStatus.userWalletId + private val cryptoCurrency = params.initialStatus.cryptoCurrency private val addToWalletBannerJobHolder = JobHolder() private val addFundsJobHolder = JobHolder() @@ -85,14 +91,14 @@ internal class TangemPayCardPageModel @Inject constructor( analytics.send(TangemPayAnalyticsEvents.CardManagementScreenOpened()) fetchAddToWalletBanner() - paymentAccountStatusSupplier.invoke(params.userWalletId) + paymentAccountStatusSupplier.invoke(userWalletId) .onEach { state -> val status = state.value if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL && - status.hasCardWithId(params.config.cardId) + status.hasCardWithId(cardId) ) { - val card = status.requireCardWithId(params.config.cardId) + val card = status.requireCardWithId(cardId) val limit = card.limit?.actualCardLimit?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } val dailyLimitState = if (limit != null) { TangemPayDailyLimitBlockState.Content( @@ -138,8 +144,8 @@ internal class TangemPayCardPageModel @Inject constructor( } else { bottomSheetNavigation.activate( TangemPayCardNavigation.ViewPinCode( - userWalletId = params.userWalletId, - cardId = params.config.cardId, + userWalletId = userWalletId, + cardId = cardId, ), ) } @@ -164,7 +170,7 @@ internal class TangemPayCardPageModel @Inject constructor( onReissueOrderStatusReceived(order.orderStatus) if (order.orderStatus != OrderStatus.CANCELED) { modelScope.launch { - reissueCardRepository.storeReissueOrderId(params.config.cardId, order.orderId) + reissueCardRepository.storeReissueOrderId(cardId, order.orderId) } } else { uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong))) @@ -178,7 +184,7 @@ internal class TangemPayCardPageModel @Inject constructor( override fun onClickAddFunds() { bottomSheetNavigation.dismiss() modelScope.launch { - val balance = cardDetailsRepository.getCardBalance(params.userWalletId).getOrNull() + val balance = cardDetailsRepository.getCardBalance(userWalletId).getOrNull() val depositAddress = balance?.depositAddress if (balance == null || depositAddress == null) { uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_error))) @@ -186,11 +192,11 @@ internal class TangemPayCardPageModel @Inject constructor( } bottomSheetNavigation.activate( TangemPayCardNavigation.AddFunds( - walletId = params.userWalletId, + walletId = userWalletId, fiatBalance = balance.fiatBalance, cryptoBalance = balance.cryptoBalance, depositAddress = depositAddress, - chainId = params.config.chainId, + cryptoCurrency = cryptoCurrency, ), ) }.saveIn(addFundsJobHolder) @@ -233,8 +239,8 @@ internal class TangemPayCardPageModel @Inject constructor( private fun freezeCard() { modelScope.launch { cardDetailsRepository.freezeCard( - userWalletId = params.userWalletId, - cardId = params.config.cardId, + userWalletId = userWalletId, + cardId = cardId, ).onLeft { val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) uiMessageSender.send(message) @@ -252,8 +258,8 @@ internal class TangemPayCardPageModel @Inject constructor( private fun unfreezeCard() { modelScope.launch { cardDetailsRepository.unfreezeCard( - userWalletId = params.userWalletId, - cardId = params.config.cardId, + userWalletId = userWalletId, + cardId = cardId, ).onLeft { val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) uiMessageSender.send(message) @@ -270,7 +276,7 @@ internal class TangemPayCardPageModel @Inject constructor( private fun fetchAddToWalletBanner() { modelScope.launch { - val isDone = cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true + val isDone = cardDetailsRepository.isAddToWalletDone(userWalletId).getOrNull() == true if (!isDone) { uiState.update { state -> state.copy( @@ -290,7 +296,7 @@ internal class TangemPayCardPageModel @Inject constructor( private fun onClickCloseBanner() { modelScope.launch { - cardDetailsRepository.setAddToWalletAsDone(params.userWalletId) + cardDetailsRepository.setAddToWalletAsDone(userWalletId) uiState.update { it.copy(addToWalletBlockState = null) } }.saveIn(addToWalletBannerJobHolder) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt index 01ff0e469d..b1d77aa35f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -17,6 +17,7 @@ import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayChangePinUM import com.tangem.features.tangempay.model.transformers.PinCodeChangeTransformer import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute +import com.tangem.features.tangempay.utils.userWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update @@ -55,7 +56,7 @@ internal class TangemPayChangePinModel @Inject constructor( uiState.update { it.copy(submitButtonLoading = true) } val result = try { cardDetailsRepository.setPin( - userWalletId = params.userWalletId, + userWalletId = params.initialStatus.userWalletId, pin = uiState.value.pinCode, ).getOrNull() } catch (e: Exception) { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index dded4437b7..8382f135fc 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -20,16 +20,15 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayWithdrawRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.domain.visa.model.TangemPayTxHistoryItem -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.tangempay.TangemPayConstants import com.tangem.features.tangempay.components.AddFundsListener import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent @@ -41,10 +40,7 @@ import com.tangem.features.tangempay.model.listener.CardDetailsEvent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.model.transformers.* import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute -import com.tangem.features.tangempay.utils.TangemPayDetailIntents -import com.tangem.features.tangempay.utils.TangemPayMessagesFactory -import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions -import com.tangem.features.tangempay.utils.TangemPayTxHistoryUpdateListener +import com.tangem.features.tangempay.utils.* import com.tangem.features.tokendetails.ExpressTransactionsEvent import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -74,27 +70,38 @@ internal class TangemPayDetailsModel @Inject constructor( private val uiMessageSender: UiMessageSender, private val cardDetailsEventListener: CardDetailsEventListener, private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener, - private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory, private val tangemPayWithdrawRepository: TangemPayWithdrawRepository, - private val getUserWalletUseCase: GetUserWalletUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val expressTransactionsEventListener: ExpressTransactionsEventListener, ) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + private val userWalletId = params.initialStatus.userWalletId + private val isTangemPayDeactivated = params.initialStatus.isDeactivated + private val loaded: PaymentAccountStatusValue.Loaded? = + params.initialStatus.value as? PaymentAccountStatusValue.Loaded + private val firstCard = loaded?.cards?.firstOrNull() + val cryptoCurrency: CryptoCurrency = params.initialStatus.cryptoCurrency + + private val initialCardFrozenState: TangemPayCardFrozenState = when { + firstCard == null -> TangemPayCardFrozenState.Unfrozen + firstCard.isFrozen -> TangemPayCardFrozenState.Frozen + else -> TangemPayCardFrozenState.Unfrozen + } + private val stateFactory = TangemPayDetailsStateFactory( onBack = router::pop, onOpenMenu = ::onOpenMenu, intents = this, - cardFrozenState = params.config.cardFrozenState, + cardFrozenState = initialCardFrozenState, ) val uiState: StateFlow field = MutableStateFlow( stateFactory.getInitialState( - isTangemPayDeactivated = params.config.isTangemPayDeactivated, - cardNumberEnd = params.config.cardNumberEnd, + isTangemPayDeactivated = isTangemPayDeactivated, + cardNumberEnd = firstCard?.lastDigits.orEmpty(), ), ) @@ -104,19 +111,14 @@ internal class TangemPayDetailsModel @Inject constructor( private var balance: TangemPayCardBalance? = null - private val userWallet: UserWallet? = getUserWalletUseCase(params.userWalletId).getOrNull() - val cryptoCurrency: CryptoCurrency? = userWallet?.let { wallet -> - tangemPayCryptoCurrencyFactory.create(userWallet = wallet, chainId = params.config.chainId).getOrNull() - } - val bottomSheetNavigation: SlotNavigation = SlotNavigation() init { analytics.send(TangemPayAnalyticsEvents.MainScreenOpened()) handleBalanceHiding() fetchBalance() - if (!params.config.isTangemPayDeactivated) { - subscribeToCardFrozenState() + if (!isTangemPayDeactivated && firstCard != null) { + subscribeToCardFrozenState(firstCard.id) fetchAddToWalletBanner() } } @@ -133,9 +135,9 @@ internal class TangemPayDetailsModel @Inject constructor( } } - private fun subscribeToCardFrozenState() { + private fun subscribeToCardFrozenState(cardId: String) { cardDetailsRepository - .cardFrozenState(params.config.cardId) + .cardFrozenState(cardId) .onEach { uiState.update(TangemPayFreezeUnfreezeStateTransformer(cardFrozenState = it)) } .launchIn(modelScope) } @@ -149,11 +151,11 @@ internal class TangemPayDetailsModel @Inject constructor( } else { bottomSheetNavigation.activate( TangemPayDetailsNavigation.AddFunds( - walletId = params.userWalletId, + walletId = userWalletId, fiatBalance = currentBalance.availableForWithdrawal, cryptoBalance = currentBalance.availableForWithdrawal, depositAddress = depositAddress, - chainId = params.config.chainId, + cryptoCurrency = cryptoCurrency, ), ) } @@ -165,31 +167,18 @@ internal class TangemPayDetailsModel @Inject constructor( val depositAddress = currentBalance?.depositAddress if (currentBalance == null || depositAddress == null) { showBottomSheetError(TangemPayDetailsErrorType.Withdraw) - } else { - val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull() - if (userWallet == null) { - showBottomSheetError(TangemPayDetailsErrorType.Withdraw) + return + } + modelScope.launch { + val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWalletId) + if (hasActiveWithdrawal) { + showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress) } else { - modelScope.launch { - val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWallet = userWallet) - if (hasActiveWithdrawal) { - showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress) - } else { - val currency = cryptoCurrency ?: tangemPayCryptoCurrencyFactory.create( - userWallet = userWallet, - chainId = params.config.chainId, - ).getOrNull() - if (currency != null) { - uiMessageSender.send( - message = TangemPayMessagesFactory.createWithdrawWarning( - onGotItClick = { onConfirmWithdrawal(currency, currentBalance, depositAddress) }, - ), - ) - } else { - showBottomSheetError(TangemPayDetailsErrorType.Withdraw) - } - } - } + uiMessageSender.send( + message = TangemPayMessagesFactory.createWithdrawWarning( + onGotItClick = { onConfirmWithdrawal(cryptoCurrency, currentBalance, depositAddress) }, + ), + ) } } } @@ -202,7 +191,7 @@ internal class TangemPayDetailsModel @Inject constructor( router.push( AppRoute.Swap( cryptoCurrency = currency, - userWalletId = params.userWalletId, + userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.TangemPay.value, currencyPosition = AppRoute.Swap.CurrencyPosition.FROM, tangemPayInput = AppRoute.Swap.TangemPayInput( @@ -218,25 +207,19 @@ internal class TangemPayDetailsModel @Inject constructor( private fun fetchBalance(): Job { return modelScope.launch { val result = try { - cardDetailsRepository.getCardBalance(params.userWalletId).onRight { balance = it } + cardDetailsRepository.getCardBalance(userWalletId).onRight { balance = it } } catch (e: Exception) { TangemLogger.e("Error", e) return@launch } - uiState.update( - transformer = DetailsBalanceTransformer( - balance = result, - userWallet = getUserWalletUseCase(params.userWalletId).getOrNull(), - cryptoCurrencyFactory = tangemPayCryptoCurrencyFactory, - ), - ) + uiState.update(transformer = DetailsBalanceTransformer(balance = result)) }.saveIn(fetchBalanceJobHolder) } private fun fetchAddToWalletBanner() { modelScope.launch { val isDone = try { - cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true + cardDetailsRepository.isAddToWalletDone(userWalletId).getOrNull() == true } catch (e: Exception) { TangemLogger.e("Error", e) return@launch @@ -259,11 +242,12 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onContactSupportClicked() { analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay)) + val customerId = loaded?.customerId ?: return modelScope.launch { sendFeedbackEmailUseCase.invoke( type = FeedbackEmailType.Visa.FeatureIsBeta( - walletMetaInfo = WalletMetaInfo(userWalletId = params.userWalletId), - customerId = params.config.customerId, + walletMetaInfo = WalletMetaInfo(userWalletId = userWalletId), + customerId = customerId, ), ) } @@ -288,7 +272,7 @@ internal class TangemPayDetailsModel @Inject constructor( private fun onClickCloseAddToWalletBlock() { modelScope.launch { try { - cardDetailsRepository.setAddToWalletAsDone(params.userWalletId) + cardDetailsRepository.setAddToWalletAsDone(userWalletId) } catch (e: Exception) { TangemLogger.e("Error", e) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt index 352d5d1373..ce52805326 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -21,6 +21,8 @@ import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM +import com.tangem.features.tangempay.utils.firstCard +import com.tangem.features.tangempay.utils.userWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -39,7 +41,8 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() - private val originalDisplayName = params.config.displayName?.value.orEmpty() + private val card = params.initialStatus.firstCard() + private val originalDisplayName = card.displayName?.value.orEmpty() val uiState: StateFlow field = MutableStateFlow( @@ -56,7 +59,7 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( ) init { - subscribeToCardNameChanges(params.config.cardId, params.userWalletId) + subscribeToCardNameChanges(card.id, params.initialStatus.userWalletId) } private fun subscribeToCardNameChanges(cardId: String, userWalletId: UserWalletId) { @@ -100,8 +103,8 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( modelScope.launch { uiState.update { it.copy(isLoading = true) } updateCardNameUseCase( - cardId = params.config.cardId, - userWalletId = params.userWalletId, + cardId = card.id, + userWalletId = params.initialStatus.userWalletId, displayName = cardDisplayName, ).onRight { router.pop() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index af0cec0bd5..d2a9482577 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -4,8 +4,6 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState import com.tangem.features.tangempay.entity.TangemPayDetailsUM @@ -15,8 +13,6 @@ import java.util.Currency internal class DetailsBalanceTransformer( private val balance: Either, - private val cryptoCurrencyFactory: TangemPayCryptoCurrencyFactory, - private val userWallet: UserWallet?, ) : Transformer { override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { @@ -28,22 +24,12 @@ internal class DetailsBalanceTransformer( ) } is Either.Right -> { - val cryptoCurrency = userWallet?.let { - cryptoCurrencyFactory.create(userWallet, balance.value.chainId).getOrNull() - } - if (cryptoCurrency == null) { - TangemPayDetailsBalanceBlockState.Error( - actionButtons = persistentListOf(), - cardsBlockState = prevState.balanceBlockState.cardsBlockState, - ) - } else { - TangemPayDetailsBalanceBlockState.Content( - isBalanceFlickering = false, - fiatBalance = getFiatBalanceText(balance.value), - actionButtons = prevState.balanceBlockState.actionButtons, - cardsBlockState = prevState.balanceBlockState.cardsBlockState, - ) - } + TangemPayDetailsBalanceBlockState.Content( + isBalanceFlickering = false, + fiatBalance = getFiatBalanceText(balance.value), + actionButtons = prevState.balanceBlockState.actionButtons, + cardsBlockState = prevState.balanceBlockState.cardsBlockState, + ) } } return prevState.copy(balanceBlockState = balance) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index c6f426ec27..26ae43630d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -237,12 +237,14 @@ private fun TangemPayDetailsBalanceBlock( state = state, isBalanceHidden = isBalanceHidden, ) - CardsBlockRow( - modifier = Modifier - .wrapContentSize() - .padding(horizontal = 12.dp, vertical = 8.dp), - cardsBlockState = state.cardsBlockState, - ) + state.cardsBlockState?.let { cardsBlockState -> + CardsBlockRow( + modifier = Modifier + .wrapContentSize() + .padding(horizontal = 12.dp, vertical = 8.dp), + cardsBlockState = cardsBlockState, + ) + } if (state.actionButtons.isNotEmpty()) { HorizontalActionChips( modifier = Modifier.padding(top = 12.dp), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt new file mode 100644 index 0000000000..025bf5a7b8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -0,0 +1,26 @@ +package com.tangem.features.tangempay.utils + +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.wallet.UserWalletId + +internal val AccountStatus.Payment.userWalletId: UserWalletId + get() = account.userWalletId + +internal val AccountStatus.Payment.cryptoCurrency: CryptoCurrency.Token + get() = when (val v = value) { + is PaymentAccountStatusValue.Loaded -> v.cryptoCurrency + is PaymentAccountStatusValue.Deactivated -> v.cryptoCurrency + else -> error("TangemPayDetails opened with unsupported status: $v") + } + +internal val AccountStatus.Payment.isDeactivated: Boolean + get() = value is PaymentAccountStatusValue.Deactivated + +internal fun AccountStatus.Payment.requireLoaded(): PaymentAccountStatusValue.Loaded = + value as? PaymentAccountStatusValue.Loaded + ?: error("Card-detail subflow requires Loaded status, got ${value::class.simpleName}") + +internal fun AccountStatus.Payment.firstCard(): TangemPayCard = requireLoaded().cards.first() \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index f9a1defa96..41c46b2d00 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.pay.TangemPayCard @@ -12,10 +13,8 @@ import com.tangem.domain.models.pay.TangemPayCardLimit import com.tangem.domain.models.pay.TangemPayCardLimitData import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase -import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every @@ -40,20 +39,24 @@ internal class TangemPayCardLimitSetupModelTest { private val setLimitUseCase: SetTangemPayCardLimitUseCase = mockk(relaxed = true) private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() - private val params = TangemPayDetailsContainerComponent.Params( - userWalletId = userWalletId, - config = TangemPayDetailsConfig( - customerId = "customer1", - cardId = cardId, - isPinSet = false, - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - cardNumberEnd = "1234", - chainId = 1, - isTangemPayDeactivated = false, - displayName = null, - ), + private val initialCard = TangemPayCard( + id = cardId, + hasPinCode = false, + displayName = null, + isFrozen = false, + lastDigits = "1234", + limit = null, ) + private val initialStatus: AccountStatus.Payment = AccountStatus.Payment( + account = Account.Payment(userWalletId = userWalletId), + value = mockk(relaxed = true) { + every { cards } returns listOf(initialCard) + }, + ) + + private val params = TangemPayDetailsContainerComponent.Params(initialStatus = initialStatus) + private fun createModel( adminLimit: BigDecimal? = BigDecimal("1000"), ): TangemPayCardLimitSetupModel { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsAnalyticsEvent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsAnalyticsEvent.kt index dcbe0119f9..56ed840731 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsAnalyticsEvent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsAnalyticsEvent.kt @@ -9,6 +9,31 @@ internal open class TokenDetailsAnalyticsEvent( params: Map = mapOf(), ) : AnalyticsEvent(category = "Token", event, params) { + class DynamicAddressesScreenOpened(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Dynamic Addresses Screen Opened", + params = currency.toAnalyticsParams(), + ) + + class ButtonEnableDynamicAddresses(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Button - Enable Dynamic Addresses", + params = currency.toAnalyticsParams(), + ) + + class DynamicAddressesEnabled(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Dynamic Addresses Enabled", + params = currency.toAnalyticsParams(), + ) + + class ButtonDisableDynamicAddresses(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Button - Disable Dynamic Addresses", + params = currency.toAnalyticsParams(), + ) + + class DynamicAddressesDisabled(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent( + event = "Dynamic Addresses Disabled", + params = currency.toAnalyticsParams(), + ) + open class Notice( event: String, params: Map = mapOf(), @@ -19,14 +44,40 @@ internal open class TokenDetailsAnalyticsEvent( params = currency.toAnalyticsParams(), ) - class NotEnoughFee(currency: CryptoCurrency) : Notice( + class NotEnoughFee(currency: CryptoCurrency, source: Source) : Notice( event = "Not Enough Fee", - params = currency.toAnalyticsParams(), - ) + params = currency.toAnalyticsParams() + ("Source" to source.value), + ) { + enum class Source(val value: String) { + DetailedScreen("Detailed Screen"), + DynamicAddresses("Dynamic Addresses"), + } + } class Reveal(currency: CryptoCurrency) : Notice( event = "Reveal Transaction", params = currency.toAnalyticsParams(), ) + + class DynamicAddressesUnavailable(currency: CryptoCurrency) : Notice( + event = "Dynamic Addresses Unavailable", + params = currency.toAnalyticsParams(), + ) + + class AdditionalAddressesFound(currency: CryptoCurrency) : Notice( + event = "Additional Addresses Found", + params = currency.toAnalyticsParams(), + ) + } + + open class Error( + event: String, + params: Map = emptyMap(), + ) : TokenDetailsAnalyticsEvent(event = "Error - $event", params) { + + class DynamicAddressesUnavailable(currency: CryptoCurrency) : Error( + event = "Dynamic Addresses Unavailable", + params = currency.toAnalyticsParams(), + ) } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt index ba37caff40..7e71e96ddf 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt @@ -37,6 +37,7 @@ internal class TokenDetailsNotificationsAnalyticsSender( is TokenDetailsNotification.NetworkFeeWithBuyButton, -> TokenDetailsAnalyticsEvent.Notice.NotEnoughFee( currency = cryptoCurrency, + source = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee.Source.DetailedScreen, ) is TokenDetailsNotification.SwapPromo -> PromoAnalyticsEvent.NoticePromotionBanner( program = PromoAnalyticsEvent.Program.Empty, // Use it on new promo action @@ -49,6 +50,10 @@ internal class TokenDetailsNotificationsAnalyticsSender( token = cryptoCurrency.symbol, blockchain = cryptoCurrency.network.name, ) + is TokenDetailsNotification.DynamicAddressesFundsFound -> + TokenDetailsAnalyticsEvent.Notice.AdditionalAddressesFound( + currency = cryptoCurrency, + ) is TokenDetailsNotification.NetworksUnreachable, is TokenDetailsNotification.ExistentialDeposit, is TokenDetailsNotification.NetworksNoAccount, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt index 63f7200f14..b2c42270c5 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt @@ -1,6 +1,8 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import com.tangem.common.core.TangemSdkError +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.api.ResettableOneTimeEventSender import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.res.R @@ -23,6 +25,7 @@ import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsAnalyticsEvent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching @@ -37,7 +40,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class DynamicAddressesDelegate @AssistedInject constructor( private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase, private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase, @@ -47,6 +50,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( private val getDerivedXpubUseCase: GetDerivedXpubUseCase, private val dynamicAddressesRepository: DynamicAddressesRepository, private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val dispatchers: CoroutineDispatcherProvider, @Assisted private val userWallet: UserWallet, @@ -68,17 +72,20 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( ) val bottomSheetConfig: StateFlow = _bottomSheetConfig.asStateFlow() + private val resettableOneTimeEventSender = ResettableOneTimeEventSender(analyticsEventHandler) + // region Entry point fun onDynamicAddressesClick() { - val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + val currency = cryptoCurrencyStatusProvider()?.currency ?: return + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened(currency)) coroutineScope.launch(dispatchers.main) { - val status = dynamicAddressesRepository.getStatus(userWalletId, network).first() + val status = dynamicAddressesRepository.getStatus(userWalletId, currency.network).first() when (status) { DynamicAddressesStatus.ENABLED, DynamicAddressesStatus.ENABLED_REQUIRES_SETUP, - -> onDisableFlow(network) - DynamicAddressesStatus.DISABLED -> onEnableFlow(network) + -> onDisableFlow(currency.network) + DynamicAddressesStatus.DISABLED -> onEnableFlow(currency.network) } } } @@ -90,6 +97,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( private suspend fun onEnableFlow(network: Network) { val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) if (hasConflicts) { + cryptoCurrencyStatusProvider()?.currency?.let { + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable(it)) + } _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( onDismissClick = dismissBottomSheet, ) @@ -106,7 +116,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } private fun onEnableClick() { - val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + val currency = cryptoCurrencyStatusProvider()?.currency ?: return + val network = currency.network + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonEnableDynamicAddresses(currency)) coroutineScope.launch(dispatchers.main) { _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( isCardScanRequired = false, @@ -120,6 +132,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( dismissBottomSheet() } else { TangemLogger.e("Failed to get XPUB: ${error.message}") + analyticsEventHandler.send( + TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(currency), + ) _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( onDismissClick = dismissBottomSheet, ) @@ -131,21 +146,24 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( enableDynamicAddressesUseCase(userWalletId, network, xpub).fold( ifLeft = { error -> - when (error) { - is EnableDynamicAddressesError.ConflictingCustomTokens -> { - _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( + analyticsEventHandler.send( + TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(currency), + ) + _bottomSheetConfig.value = when (error) { + is EnableDynamicAddressesError.ConflictingCustomTokens -> + DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( onDismissClick = dismissBottomSheet, ) - } is EnableDynamicAddressesError.ServiceError -> { TangemLogger.e("Failed to enable dynamic addresses: ${error.cause.message}") - _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + DynamicAddressesBottomSheetConfig.ServiceUnavailable( onDismissClick = dismissBottomSheet, ) } } }, ifRight = { + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesEnabled(currency)) dismissBottomSheet() onDynamicAddressesStateChanged() uiMessageSender.send( @@ -190,10 +208,13 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } private fun onSimpleDisableClick() { - val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + val currency = cryptoCurrencyStatusProvider()?.currency ?: return + val network = currency.network + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses(currency)) coroutineScope.launch(dispatchers.main) { runSuspendCatching { dynamicAddressesRepository.disable(userWalletId, network) } .onSuccess { + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesDisabled(currency)) dismissBottomSheet() onDynamicAddressesStateChanged() uiMessageSender.send( @@ -210,6 +231,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } private fun showDisableSheetAndLoadFee() { + resettableOneTimeEventSender.reset(NOT_ENOUGH_FEE_EVENT_KEY) _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading, onDisableClick = ::onDisableClick, @@ -245,6 +267,13 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( cryptoCurrency = currency, ).fold( ifLeft = { + resettableOneTimeEventSender.sendEventOnce( + key = NOT_ENOUGH_FEE_EVENT_KEY, + event = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee( + currency = currency, + source = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee.Source.DynamicAddresses, + ), + ) _bottomSheetConfig.value = disableWithConsolidationConfig().copy( feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error, ) @@ -282,7 +311,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } private fun onDisableClick() { - val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + val currency = cryptoCurrencyStatusProvider()?.currency ?: return + val network = currency.network + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses(currency)) coroutineScope.launch(dispatchers.main) { _bottomSheetConfig.value = disableWithConsolidationConfig().copy( isSending = true, @@ -320,6 +351,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } catch (e: Exception) { TangemLogger.e("Failed to disable dynamic addresses after consolidation: ${e.message}") } + analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesDisabled(currency)) dismissBottomSheet() onDynamicAddressesStateChanged() uiMessageSender.send( @@ -359,4 +391,8 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( @Assisted("onDynamicAddressesStateChanged") onDynamicAddressesStateChanged: () -> Unit, ): DynamicAddressesDelegate } + + private companion object { + const val NOT_ENOUGH_FEE_EVENT_KEY = "DynamicAddressesNotEnoughFee" + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 0c00b2b411..f6ea084bff 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -13,6 +13,7 @@ import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter @@ -176,6 +177,7 @@ internal class TokenDetailsModel @Inject constructor( private val signCloreMessageUseCase: SignCloreMessageUseCase, private val isXpubSupportedUseCase: IsXpubSupportedUseCase, private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val dynamicAddressesRepository: DynamicAddressesRepository, private val dynamicAddressesDelegateFactory: DynamicAddressesDelegate.Factory, private val dialogFactory: TokenDetailsDialogFactory, private val userWalletsListRepository: UserWalletsListRepository, @@ -1258,7 +1260,7 @@ internal class TokenDetailsModel @Inject constructor( return TokenDetailsBottomSheetConfig.Receive(receiveConfig) } - private fun sendOneTimeBalanceLoadedAnalyticsEvent(cryptoCurrencyStatus: CryptoCurrencyStatus?) { + private suspend fun sendOneTimeBalanceLoadedAnalyticsEvent(cryptoCurrencyStatus: CryptoCurrencyStatus?) { if (isBalanceLoadedEventSent || cryptoCurrencyStatus == null) return val tokenBalance = when (val value = cryptoCurrencyStatus.value) { @@ -1290,11 +1292,19 @@ internal class TokenDetailsModel @Inject constructor( blockchain = cryptoCurrency.network.name, token = cryptoCurrency.symbol, tokenBalance = tokenBalance, + isDynamicAddress = getIsDynamicAddressParam(), ), ) isBalanceLoadedEventSent = true } + private suspend fun getIsDynamicAddressParam(): Boolean? { + if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(cryptoCurrency.network.rawId)) return null + return dynamicAddressesRepository + .isDynamicAddressesEnabledForNetwork(userWalletId, cryptoCurrency.network.id) + .first() + } + private suspend fun needShowYieldSupplyWarning(): Boolean { return needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus) } diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSenderTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSenderTest.kt new file mode 100644 index 0000000000..89f9583fd0 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSenderTest.kt @@ -0,0 +1,137 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.analytics + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.collections.immutable.toPersistentList +import org.junit.jupiter.api.Test + +internal class TokenDetailsNotificationsAnalyticsSenderTest { + + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + + private val network: Network = mockk(relaxed = true) { + every { name } returns "Ethereum" + } + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { symbol } returns "ETH" + every { this@mockk.network } returns this@TokenDetailsNotificationsAnalyticsSenderTest.network + } + + private val sender = TokenDetailsNotificationsAnalyticsSender( + cryptoCurrency = cryptoCurrency, + analyticsEventHandler = analyticsEventHandler, + ) + + @Test + fun `GIVEN NetworkFee notification WHEN send THEN NotEnoughFee event with DetailedScreen source is sent`() { + // GIVEN + val notification = mockk() + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.NotEnoughFee + assertThat(event.category).isEqualTo("Token") + assertThat(event.event).isEqualTo("Notice - Not Enough Fee") + assertThat(event.params).containsEntry("Token", "ETH") + assertThat(event.params).containsEntry("Blockchain", "Ethereum") + assertThat(event.params).containsEntry("Source", "Detailed Screen") + } + + @Test + fun `GIVEN NetworkFeeWithBuyButton notification WHEN send THEN NotEnoughFee event with DetailedScreen source is sent`() { + // GIVEN + val notification = mockk() + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.NotEnoughFee + assertThat(event.params).containsEntry("Source", "Detailed Screen") + } + + @Test + fun `GIVEN DynamicAddressesFundsFound notification WHEN send THEN AdditionalAddressesFound event is sent`() { + // GIVEN + val notification = TokenDetailsNotification.DynamicAddressesFundsFound(onLearnMoreClick = {}) + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + verify(exactly = 1) { analyticsEventHandler.send(any()) } + val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.AdditionalAddressesFound + assertThat(event.category).isEqualTo("Token") + assertThat(event.event).isEqualTo("Notice - Additional Addresses Found") + assertThat(event.params).containsEntry("Token", "ETH") + assertThat(event.params).containsEntry("Blockchain", "Ethereum") + } + + @Test + fun `GIVEN empty new notifications WHEN send THEN no event is sent`() { + // GIVEN + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = emptyList()) + + // THEN + verify(exactly = 0) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN pullToRefresh is refreshing WHEN send THEN no event is sent`() { + // GIVEN + val notification = TokenDetailsNotification.DynamicAddressesFundsFound(onLearnMoreClick = {}) + val displayedState = createState(notifications = emptyList(), isRefreshing = true) + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + verify(exactly = 0) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN notification without matching event WHEN send THEN no event is sent`() { + // GIVEN: NetworksUnreachable is an unmapped notification (returns null in getEvent) + val notification = TokenDetailsNotification.NetworksUnreachable + val displayedState = createState(notifications = emptyList(), isRefreshing = false) + + // WHEN + sender.send(displayedUiState = displayedState, newNotifications = listOf(notification)) + + // THEN + verify(exactly = 0) { analyticsEventHandler.send(any()) } + } + + private fun createState( + notifications: List, + isRefreshing: Boolean, + ): TokenDetailsState { + return mockk(relaxed = true) { + every { this@mockk.notifications } returns notifications.toPersistentList() + every { pullToRefreshConfig.isRefreshing } returns isRefreshing + } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt new file mode 100644 index 0000000000..5c47799232 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt @@ -0,0 +1,441 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.model + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.TransactionData +import com.tangem.common.core.TangemSdkError +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase +import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase +import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsAnalyticsEvent +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +private const val TEST_XPUB = "xpub-test-value" +private const val TOKEN_SYMBOL = "ETH" +private const val BLOCKCHAIN_NAME = "Ethereum" +private const val TEST_ADDRESS = "0xTestAddress" + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DynamicAddressesDelegateTest { + + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + + private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase = mockk() + private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase = mockk() + private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase = mockk() + private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true) + private val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true) + private val getDerivedXpubUseCase: GetDerivedXpubUseCase = mockk() + private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true) + private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + + private val network: Network = mockk(relaxed = true) { + every { name } returns BLOCKCHAIN_NAME + } + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { symbol } returns TOKEN_SYMBOL + every { this@mockk.network } returns this@DynamicAddressesDelegateTest.network + } + private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) { + every { currency } returns cryptoCurrency + } + + private val userWalletId = UserWalletId("1234567890ABCDEF") + private val userWallet: UserWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val showBottomSheet: () -> Unit = mockk(relaxed = true) + private val dismissBottomSheet: () -> Unit = mockk(relaxed = true) + private val onDynamicAddressesStateChanged: () -> Unit = mockk(relaxed = true) + + @Test + fun `GIVEN currency is available WHEN onDynamicAddressesClick THEN DynamicAddressesScreenOpened event is sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + val eventSlot = slot() + every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit + + // WHEN + delegate.onDynamicAddressesClick() + + // THEN + val event = eventSlot.captured as TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened + assertThat(event.category).isEqualTo("Token") + assertThat(event.event).isEqualTo("Dynamic Addresses Screen Opened") + assertThat(event.params).containsEntry("Token", TOKEN_SYMBOL) + assertThat(event.params).containsEntry("Blockchain", BLOCKCHAIN_NAME) + } + + @Test + fun `GIVEN no currency WHEN onDynamicAddressesClick THEN no event is sent`() = runTest { + // GIVEN + val delegate = createDelegate(cryptoCurrencyStatus = null) + + // WHEN + delegate.onDynamicAddressesClick() + + // THEN + verify(exactly = 0) { analyticsEventHandler.send(any()) } + } + + @Test + fun `GIVEN DISABLED status AND conflicts WHEN onDynamicAddressesClick THEN Notice DynamicAddressesUnavailable is sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns true + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + + // WHEN + delegate.onDynamicAddressesClick() + + // THEN + verify { + analyticsEventHandler.send( + match { + it.event == "Notice - Dynamic Addresses Unavailable" && + it.params["Token"] == TOKEN_SYMBOL && + it.params["Blockchain"] == BLOCKCHAIN_NAME + }, + ) + } + } + + @Test + fun `GIVEN DISABLED status WHEN enable button clicked AND enable succeeds THEN ButtonEnable and DynamicAddressesEnabled are sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns TEST_XPUB.right() + coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns Unit.right() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() + + // THEN + verify { + analyticsEventHandler.send( + match { + it.event == "Button - Enable Dynamic Addresses" && + it.params["Token"] == TOKEN_SYMBOL + }, + ) + analyticsEventHandler.send( + match { + it.event == "Dynamic Addresses Enabled" && + it.params["Token"] == TOKEN_SYMBOL + }, + ) + } + coVerify { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } + } + + @Test + fun `GIVEN DISABLED status WHEN enable button clicked AND xpub retrieval fails THEN Error DynamicAddressesUnavailable is sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns + IllegalStateException("xpub fail").left() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() + + // THEN + verify { + analyticsEventHandler.send( + match { + it.event == "Error - Dynamic Addresses Unavailable" && + it.params["Token"] == TOKEN_SYMBOL + }, + ) + } + } + + @Test + fun `GIVEN DISABLED status WHEN enable button clicked AND user cancels xpub derivation THEN Error event is NOT sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns + TangemSdkError.UserCancelled().left() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() + + // THEN + verify(exactly = 0) { + analyticsEventHandler.send(ofType()) + analyticsEventHandler.send(ofType()) + } + } + + @Test + fun `GIVEN DISABLED status WHEN enable useCase fails THEN Error DynamicAddressesUnavailable is sent`() = runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.DISABLED) + coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB + coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns TEST_XPUB.right() + coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns + EnableDynamicAddressesError.ServiceError(RuntimeException("boom")).left() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() + + // THEN + verify { + analyticsEventHandler.send(ofType()) + } + verify(exactly = 0) { + analyticsEventHandler.send(ofType()) + } + } + + @Test + fun `GIVEN ENABLED status AND no consolidation WHEN simple disable clicked THEN ButtonDisable and DynamicAddressesDisabled are sent`() = + runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.ENABLED) + coEvery { disableDynamicAddressesUseCase(userWalletId, network) } returns false.right() + coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation) + .onDisableClick() + + // THEN + verify { + analyticsEventHandler.send( + match { + it.event == "Button - Disable Dynamic Addresses" + }, + ) + analyticsEventHandler.send( + match { + it.event == "Dynamic Addresses Disabled" + }, + ) + } + } + + @Test + fun `GIVEN consolidation required AND fee fails WHEN load fee THEN NotEnoughFee with DynamicAddresses source is sent`() = + runTest { + // GIVEN: consolidation required, status provides balance and address, fee load fails + setupConsolidationFlow() + coEvery { + getFeeUseCase(any(), any(), userWallet, cryptoCurrency) + } returns mockk(relaxed = true).left() + val events = mutableListOf() + every { analyticsEventHandler.send(capture(events)) } returns Unit + + // WHEN + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // THEN + val notEnoughFee = events + .filterIsInstance() + .single() + assertThat(notEnoughFee.event).isEqualTo("Notice - Not Enough Fee") + assertThat(notEnoughFee.params).containsEntry("Source", "Dynamic Addresses") + assertThat(notEnoughFee.params).containsEntry("Token", TOKEN_SYMBOL) + assertThat(notEnoughFee.params).containsEntry("Blockchain", BLOCKCHAIN_NAME) + } + + @Test + fun `GIVEN consolidation required AND fee fails WHEN load fee retried THEN NotEnoughFee is sent only once`() = + runTest { + // GIVEN + setupConsolidationFlow() + coEvery { + getFeeUseCase(any(), any(), userWallet, cryptoCurrency) + } returns mockk(relaxed = true).left() + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + + // WHEN: initial load + refresh + delegate.onDynamicAddressesClick() + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation) + .onRefreshFee() + + // THEN: one-time event sender collapses repeated errors + verify(exactly = 1) { + analyticsEventHandler.send(ofType()) + } + } + + @Test + fun `GIVEN consolidation flow WHEN disable clicked AND tx succeeds THEN ButtonDisable and DynamicAddressesDisabled are sent`() = + runTest { + // GIVEN + setupConsolidationFlow() + coEvery { + getFeeUseCase(any(), any(), userWallet, cryptoCurrency) + } returns mockk(relaxed = true).left() + val txData = mockk(relaxed = true) + coEvery { createConsolidationTransactionUseCase(userWalletId, network) } returns txData.right() + coEvery { sendTransactionUseCase(txData, userWallet, network) } returns "tx-hash".right() + coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation) + .onDisableClick() + + // THEN + verify { + analyticsEventHandler.send(ofType()) + analyticsEventHandler.send(ofType()) + } + coVerify { sendTransactionUseCase(txData, userWallet, network) } + } + + @Test + fun `GIVEN consolidation flow WHEN disable clicked AND tx cancelled by user THEN DynamicAddressesDisabled is NOT sent`() = + runTest { + // GIVEN + setupConsolidationFlow() + coEvery { + getFeeUseCase(any(), any(), userWallet, cryptoCurrency) + } returns mockk(relaxed = true).left() + val txData = mockk(relaxed = true) + coEvery { createConsolidationTransactionUseCase(userWalletId, network) } returns txData.right() + coEvery { sendTransactionUseCase(txData, userWallet, network) } returns + SendTransactionError.UserCancelledError.left() + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.onDynamicAddressesClick() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation) + .onDisableClick() + + // THEN: ButtonDisable is sent on click, but success event is not + verify { analyticsEventHandler.send(ofType()) } + verify(exactly = 0) { + analyticsEventHandler.send(ofType()) + } + } + + @Test + fun `GIVEN Notice category event WHEN sent THEN id starts with Token category`() { + // GIVEN + val notice = TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable(cryptoCurrency) + val error = TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(cryptoCurrency) + + // THEN + assertThat(notice.category).isEqualTo("Token") + assertThat(notice.event).isEqualTo("Notice - Dynamic Addresses Unavailable") + assertThat(error.category).isEqualTo("Token") + assertThat(error.event).isEqualTo("Error - Dynamic Addresses Unavailable") + } + + private fun setupConsolidationFlow() { + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.ENABLED) + coEvery { disableDynamicAddressesUseCase(userWalletId, network) } returns true.right() + val address = NetworkAddress.Address(value = TEST_ADDRESS, type = NetworkAddress.Address.Type.Primary) + every { cryptoCurrencyStatus.value } returns mockk(relaxed = true) { + every { amount } returns BigDecimal.ONE + every { fiatRate } returns BigDecimal.ONE + every { networkAddress } returns NetworkAddress.Single(defaultAddress = address) + } + } + + private fun createDelegate(cryptoCurrencyStatus: CryptoCurrencyStatus?): DynamicAddressesDelegate { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + return DynamicAddressesDelegate( + enableDynamicAddressesUseCase = enableDynamicAddressesUseCase, + disableDynamicAddressesUseCase = disableDynamicAddressesUseCase, + createConsolidationTransactionUseCase = createConsolidationTransactionUseCase, + getFeeUseCase = getFeeUseCase, + sendTransactionUseCase = sendTransactionUseCase, + getDerivedXpubUseCase = getDerivedXpubUseCase, + dynamicAddressesRepository = dynamicAddressesRepository, + getExtendedPublicKeyUseCase = getExtendedPublicKeyUseCase, + analyticsEventHandler = analyticsEventHandler, + uiMessageSender = uiMessageSender, + dispatchers = TestingCoroutineDispatcherProvider(), + userWallet = userWallet, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + appCurrencyProvider = Provider { mockk(relaxed = true) }, + coroutineScope = scope, + showBottomSheet = showBottomSheet, + dismissBottomSheet = dismissBottomSheet, + onDynamicAddressesStateChanged = onDynamicAddressesStateChanged, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 6bc6f97a27..7ee1dd5b1d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -243,10 +243,6 @@ internal class WalletModel @Inject constructor( } else { null } - val isBackedUp = when (selectedWallet) { - is UserWallet.Cold -> selectedWallet.scanResponse.card.backupStatus?.isActive == true - is UserWallet.Hot -> selectedWallet.backedUp - } val result = getAppThemeModeUseCase().firstOrNull() val theme = result?.getOrElse { AppThemeMode.FOLLOW_SYSTEM } ?: AppThemeMode.FOLLOW_SYSTEM val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }.code @@ -254,7 +250,7 @@ internal class WalletModel @Inject constructor( WalletScreenAnalyticsEvent.MainScreen.ScreenOpened( hasMobileWallet = hasMobileWallet, accountsCount = accountsCount, - isBackedUp = isBackedUp, + isBackedUp = selectedWallet.isBackedUpForAnalytics(), theme = theme.value, isImported = selectedWallet.isImported(), referralId = appsFlyerStore.get()?.refcode, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt index 5f01821a79..83770da949 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt @@ -18,9 +18,9 @@ import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.TangemPayEntryPoint @@ -40,7 +40,7 @@ internal interface TangemPayIntents { fun onRefreshPayToken(userWallet: UserWallet) - fun openDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig) + fun openDetails(status: AccountStatus.Payment) fun onKycProgressClicked(userWalletId: UserWalletId) @@ -110,11 +110,8 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( } } - override fun openDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig) { - router.openTangemPayDetails( - userWalletId = userWalletId, - config = config, - ) + override fun openDetails(status: AccountStatus.Payment) { + router.openTangemPayDetails(status = status) } override fun onKycProgressClicked(userWalletId: UserWalletId) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 962bd6549c..13ebe72dd2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -12,13 +12,13 @@ import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.qrscanning.models.QrSendTarget import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayDetailsConfig +import com.tangem.domain.qrscanning.models.QrSendTarget import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.tokens.model.details.TokenAction import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent @@ -142,8 +142,8 @@ internal class DefaultWalletRouter @Inject constructor( router.push(route = AppRoute.TangemPayOnboarding(mode = mode)) } - override fun openTangemPayDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig) { - router.push(AppRoute.TangemPayDetails(userWalletId = userWalletId, config = config)) + override fun openTangemPayDetails(status: AccountStatus.Payment) { + router.push(AppRoute.TangemPayDetails(status = status)) } override fun openYieldSupplyBottomSheet( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 597093c071..60dc1d47ef 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -7,13 +7,13 @@ import com.tangem.common.routing.AppRoute import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.qrscanning.models.QrSendTarget import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayDetailsConfig +import com.tangem.domain.qrscanning.models.QrSendTarget import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.tokens.model.details.TokenAction import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent @@ -82,7 +82,7 @@ internal interface InnerWalletRouter { fun openTangemPayOnboarding(mode: AppRoute.TangemPayOnboarding.Mode) - fun openTangemPayDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig) + fun openTangemPayDetails(status: AccountStatus.Payment) /** Open BS abput yield supply active and all money deposited in AAVE */ fun openYieldSupplyBottomSheet( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt index 4462ad2539..f7e59102b3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt @@ -1,56 +1,14 @@ package com.tangem.feature.wallet.presentation.wallet.analytics import com.tangem.core.analytics.models.* -import com.tangem.domain.models.wallet.UserWalletId sealed class WalletScreenAnalyticsEvent { - sealed class Basic( - event: String, - params: Map = mapOf(), - ) : AnalyticsEvent(category = "Basic", event = event, params = params) { - - class WalletToppedUp(userWalletId: UserWalletId, walletType: AnalyticsParam.WalletType) : - Basic( - event = "Topped up", - params = mapOf(AnalyticsParam.CURRENCY to walletType.value), - ), - OneTimeAnalyticsEvent, AppsFlyerIncludedEvent { - - override val oneTimeEventId: String = id + userWalletId.stringValue - } - - class CardWasScanned(source: AnalyticsParam.ScreensSources) : Basic( - event = "Card Was Scanned", - params = mapOf( - AnalyticsParam.SOURCE to source.value, - ), - ) - - class BalanceLoaded(balance: AnalyticsParam.CardBalanceState, tokensCount: Int?) : Basic( - event = "Balance Loaded", - params = buildMap { - put(AnalyticsParam.BALANCE, balance.value) - tokensCount?.let { put(AnalyticsParam.TOKENS_COUNT, it.toString()) } - }, - ), AppsFlyerIncludedEvent - - class TokenBalance(balance: AnalyticsParam.EmptyFull, token: String) : Basic( - event = "Token Balance", - params = mapOf( - AnalyticsParam.STATE to balance.value, - AnalyticsParam.TOKEN_PARAM to token, - ), - ) - } - sealed class MainScreen( event: String, params: Map = mapOf(), ) : AnalyticsEvent(category = "Main Screen", event = event, params = params) { - class ScreenOpenedLegacy : MainScreen(event = "Screen opened") - data class ScreenOpened( private val hasMobileWallet: Boolean, private val accountsCount: Int?, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt index 0a6847ed30..ea8648688a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt @@ -8,6 +8,7 @@ import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.common.extensions.isZero import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.domain.analytics.CheckIsWalletToppedUpUseCase @@ -18,7 +19,6 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider @@ -206,7 +206,12 @@ internal class TokenListAnalyticsSender @Inject constructor( AnalyticsParam.WalletType.SingleCurrency(currency.currency.name) } - analyticsEventHandler.send(Basic.WalletToppedUp(userWallet.walletId, walletType)) + analyticsEventHandler.send( + Basic.ToppedUp( + userWalletId = userWallet.walletId.stringValue, + walletType = walletType, + ), + ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index 9a7844cdeb..d7f37ae607 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -12,16 +12,12 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus -import com.tangem.domain.pay.TangemPayDetailsConfig -import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.feature.wallet.child.wallet.model.intents.TangemPayIntents import com.tangem.features.tangempay.entity.TangemPayMainUM import com.tangem.utils.converter.Converter import java.math.BigDecimal import java.util.Currency -private const val POLYGON_CHAIN_ID = 137 - internal class TangemPayMainBlockConverter( private val tangemPayClickIntents: TangemPayIntents, private val isRedesignEnabled: Boolean, @@ -63,24 +59,9 @@ internal class TangemPayMainBlockConverter( currencyCode = statusValue.fiatBalance.currency, balance = statusValue.fiatBalance.availableBalance, ), - balanceSubtitle = stringReference("USDC"), // TODO hardcode for now + balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol), shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, - onClick = { - // Dummy config for deactivated account just to open details screen - tangemPayClickIntents.openDetails( - userWalletId = value.account.userWalletId, - config = TangemPayDetailsConfig( - customerId = "", - cardId = "", - isPinSet = false, - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - cardNumberEnd = "", - chainId = POLYGON_CHAIN_ID, - displayName = null, - isTangemPayDeactivated = true, - ), - ) - }, + onClick = { tangemPayClickIntents.openDetails(value) }, ) is PaymentAccountStatusValue.Loaded -> { val card = statusValue.cards.firstOrNull() ?: return TangemPayMainUM.TemporaryUnavailable @@ -91,27 +72,9 @@ internal class TangemPayMainBlockConverter( currencyCode = statusValue.currencyCode, balance = statusValue.fiatBalance.availableBalance, ), - balanceSubtitle = stringReference("USDC"), // TODO hardcode for now + balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol), shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, - onClick = { - tangemPayClickIntents.openDetails( - value.account.userWalletId, - TangemPayDetailsConfig( - customerId = statusValue.customerId, - cardId = card.id, - isPinSet = card.hasPinCode, - cardFrozenState = if (card.isFrozen) { - TangemPayCardFrozenState.Frozen - } else { - TangemPayCardFrozenState.Unfrozen - }, - cardNumberEnd = card.lastDigits, - chainId = POLYGON_CHAIN_ID, - displayName = card.displayName, - isTangemPayDeactivated = false, - ), - ) - }, + onClick = { tangemPayClickIntents.openDetails(value) }, ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/PrimaryCurrencySubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/PrimaryCurrencySubscriber.kt index 9e389948bd..063a5d7309 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/PrimaryCurrencySubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/PrimaryCurrencySubscriber.kt @@ -3,12 +3,12 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers import com.tangem.common.extensions.isZero import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetPrimaryCurrencyTransformer import dagger.assisted.Assisted @@ -70,7 +70,7 @@ internal class PrimaryCurrencySubscriber @AssistedInject constructor( cardBalanceState?.let { balanceState -> // do not send tokens count for single currency wallet analyticsEventHandler.send( - event = WalletScreenAnalyticsEvent.Basic.BalanceLoaded( + event = Basic.BalanceLoaded( balance = balanceState, tokensCount = null, ), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index e9a58bd659..13b03bfcc4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -209,16 +209,27 @@ private fun WalletContent2( .fillMaxSize() .hazeSourceTangem(zIndex = -2f), ) { - NorthernLightsBackground( - containerColor = if (LocalIsInDarkTheme.current) { - TangemTheme.colors2.surface.level1 - } else { - TangemTheme.colors2.surface.level2 - }, + val backgroundColor = if (LocalIsInDarkTheme.current) { + TangemTheme.colors2.surface.level1 + } else { + TangemTheme.colors2.surface.level2 + } + Box( modifier = Modifier - .graphicsLayer { alpha = 1 - behavior.state.collapsedFraction * 2 } - .matchParentSize(), + .matchParentSize() + .background(backgroundColor), ) + val isSheetExpanded by remember { + derivedStateOf { bottomSheetState.targetValue == TangemSheetValue.Expanded } + } + if (!isSheetExpanded) { + NorthernLightsBackground( + containerColor = backgroundColor, + modifier = Modifier + .graphicsLayer { alpha = 1 - behavior.state.collapsedFraction * 2 } + .matchParentSize(), + ) + } WalletPagerIndicator( pagerState = walletsPagerState, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt index 1c4838b7b9..87350a5742 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContent.kt @@ -34,6 +34,7 @@ import com.tangem.common.ui.tokens.NonContentItemContent import com.tangem.common.ui.tokens.SlideInItemVisibility import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.account.toBoxSize import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.tokenlist.NON_CONTENT_TOKENS_LIST_KEY import com.tangem.core.ui.components.tokenlist.TokenListItem @@ -382,12 +383,14 @@ internal fun PortfolioRowItem( headIcon } + val iconBoxSize = when (headIcon) { + is TangemIconUM.Empty -> TangemTheme.dimens2.x9 + else -> size.toBoxSize() + } TangemIcon( tangemIconUM = sizedHeadIcon, modifier = modifier - .conditionalCompose(headIcon is TangemIconUM.Empty) { - size(TangemTheme.dimens2.x9) - } + .size(iconBoxSize) .sharedBounds( sharedContentState = iconSharedContentState, animatedVisibilityScope = animatedContentScope, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt index 16074b661c..fb612dee25 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.walletconnect.transaction.model import androidx.compose.runtime.Stable +import arrow.core.Either import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pushNew @@ -12,9 +13,12 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.domain.transaction.error.SendTransactionError.UserCancelledError +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.WcRequestUseCaseFactory import com.tangem.domain.walletconnect.model.WcEthMethod +import com.tangem.domain.walletconnect.model.WcRequestError import com.tangem.domain.walletconnect.model.WcSolanaMethod import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase import com.tangem.domain.walletconnect.usecase.method.WcSignState @@ -30,13 +34,12 @@ import com.tangem.features.walletconnect.transaction.entity.common.WcTransaction import com.tangem.features.walletconnect.transaction.entity.sign.WcSignTransactionUM import com.tangem.features.walletconnect.transaction.routes.WcTransactionRoutes import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger -import com.tangem.domain.walletconnect.WC_TAG import javax.inject.Inject import kotlin.properties.Delegates @@ -167,21 +170,34 @@ internal class WcSignTransactionModel @Inject constructor( } private fun signingIsDone(signState: WcSignState<*>): Boolean { - (signState.domainStep as? WcSignStep.Result)?.result?.let { result -> - if (result.isRight()) { - val event = WcAnalyticEvents.SignatureRequestHandled( - rawRequest = useCase.rawSdkRequest, - network = useCase.network, - securityStatus = CheckDAppResult.FAILED_TO_VERIFY, - accountDerivation = useCase.session.account.derivationIndex.value, - ) - analytics.send(event) - showSuccessSignMessage() + return when (val step = signState.domainStep) { + WcSignStep.PreSign, + WcSignStep.Signing, + -> false + is WcSignStep.Result -> when (val result = step.result) { + is Either.Right -> { + val event = WcAnalyticEvents.SignatureRequestHandled( + rawRequest = useCase.rawSdkRequest, + network = useCase.network, + securityStatus = CheckDAppResult.FAILED_TO_VERIFY, + accountDerivation = useCase.session.account.derivationIndex.value, + ) + analytics.send(event) + showSuccessSignMessage() + router.pop() + true + } + is Either.Left -> { + val error = result.value + if (error is WcRequestError.WrappedSendError && error.sendTransactionError is UserCancelledError) { + false + } else { + cancel(useCase) + true + } + } } - router.pop() - return true } - return false } private fun cancel(useCase: WcSignUseCase<*>) { diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt index 806de55c70..87cbad4063 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt @@ -19,10 +19,7 @@ import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.SaveWalletError import com.tangem.domain.common.wallets.error.UnlockWalletError -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.isImported -import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.models.wallet.* import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.domain.wallets.builder.ColdUserWalletBuilder @@ -76,9 +73,9 @@ internal class WelcomeModel @Inject constructor( val userWallet = userWallets.first { it.walletId == walletId } trackingContextProxy.addContext(userWallet) val signInType = when { - !userWallet.isLocked -> SignIn.ButtonWallet.SignInType.NoSecurity - userWallet is UserWallet.Cold -> SignIn.ButtonWallet.SignInType.Card - else -> SignIn.ButtonWallet.SignInType.AccessCode + !userWallet.isLocked -> AnalyticsParam.SignInType.NoSecurity + userWallet is UserWallet.Cold -> AnalyticsParam.SignInType.Card + else -> AnalyticsParam.SignInType.AccessCode } analyticsEventHandler.send( event = SignIn.ButtonWallet( @@ -259,7 +256,7 @@ internal class WelcomeModel @Inject constructor( if (userWallet.isLocked.not()) { // If the wallet is not locked, we can proceed to the wallet screen directly userWalletsListRepository.select(userWallet.walletId) - trackSignInEvent(userWallet, Basic.SignedIn.SignInType.NoSecurity) + trackSignInEvent(userWallet, AnalyticsParam.SignInType.NoSecurity) router.replaceAll(AppRoute.Wallet) return@launch } @@ -320,19 +317,15 @@ internal class WelcomeModel @Inject constructor( } } - private suspend fun trackSignInEvent(userWallet: UserWallet, type: Basic.SignedIn.SignInType) { + private suspend fun trackSignInEvent(userWallet: UserWallet, type: AnalyticsParam.SignInType) { val walletsCount = userWalletsListRepository.userWalletsSync().size trackingContextProxy.addContext(userWallet) - val isBackedUp = when (userWallet) { - is UserWallet.Cold -> userWallet.scanResponse.card.backupStatus?.isActive == true - is UserWallet.Hot -> userWallet.backedUp - } analyticsEventHandler.send( event = Basic.SignedIn( signInType = type, walletsCount = walletsCount, isImported = userWallet.isImported(), - hasBackup = isBackedUp, + isBackedUp = userWallet.isBackedUpForAnalytics(), ), ) }