Updated on 2026-08-14
This commit is contained in:
commit
24ef1d4ed9
120 changed files with 6250 additions and 675 deletions
|
|
@ -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]}<redacted>" }
|
||||
|
||||
/**
|
||||
* 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") ?: "<missing>"
|
||||
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", "<no error field>")
|
||||
val errorType = jsonObject.optString("errorType", "<no errorType field>")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<android.content.Context>()
|
||||
val intent = Intent(ACTION_VIEW, Uri.parse(deeplinkScheme + deepLinkUri)).apply {
|
||||
val intent = Intent(ACTION_VIEW, finalUri).apply {
|
||||
addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent(category, event, params) {
|
||||
|
||||
class Finished : Onboarding("Onboarding", "Onboarding Finished")
|
||||
}
|
||||
|
|
@ -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<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent("Sign In", event, params) {
|
||||
|
||||
class ButtonBiometricSignIn : SignIn(event = "Button - Biometric Sign In")
|
||||
class ButtonCardSignIn : SignIn(event = "Button - Card Sign In")
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
plugins {
|
||||
alias(deps.plugins.kotlin.jvm)
|
||||
alias(deps.plugins.kotlin.serialization)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(deps.kotlin.serialization)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,65 +2,41 @@ package com.tangem.core.analytics.models
|
|||
|
||||
sealed class Basic(
|
||||
event: String,
|
||||
params: Map<String, String> = mapOf(),
|
||||
) : AnalyticsEvent("Basic", event, params) {
|
||||
params: Map<String, String> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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<String, String> = 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<String, String> = 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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, String> = 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",
|
||||
|
|
|
|||
|
|
@ -66,5 +66,9 @@
|
|||
{
|
||||
"name": "ADDRESS_SYNC_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "SWAP_SWITCH_TO_TRANSFER_ENABLED",
|
||||
"version": "undefined"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -517,6 +517,7 @@
|
|||
<string name="dynamic_addresses_notification_funds_found_description">Foram encontrados fundos em endereços adicionais. Ative os Endereços Dinâmicos para acessá-los.</string>
|
||||
<string name="dynamic_addresses_notification_funds_found_title">Fundos encontrados em endereços adicionais</string>
|
||||
<string name="dynamic_addresses_receive_badge">Endereço dinâmico</string>
|
||||
<string name="dynamic_addresses_unavailability_reason_pending_transaction_send">O gerenciamento de endereços dinâmicos estará disponível assim que as transações pendentes estiverem na rede. %@ está completo</string>
|
||||
<string name="earn_best_opportunities">Melhores oportunidades</string>
|
||||
<string name="earn_clear_filter">Limpar filtro</string>
|
||||
<string name="earn_empty">A lista está temporariamente vazia, pois está sendo atualizada. Volte daqui a pouco.</string>
|
||||
|
|
@ -595,6 +596,7 @@
|
|||
<string name="express_provider_min_amount">Disponível em %s</string>
|
||||
<string name="express_provider_not_available">Indisponível para este par</string>
|
||||
<string name="express_provider_permission_needed">Permissão necessária</string>
|
||||
<string name="express_provider_permission_needed_v2">É necessária permissão.</string>
|
||||
<string name="express_provider_recommended">Recomendado</string>
|
||||
<string name="express_status_bought">Comprado %s</string>
|
||||
<string name="express_status_buying">Comprando %s</string>
|
||||
|
|
@ -641,6 +643,7 @@
|
|||
<string name="give_permission_staking_footer">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.</string>
|
||||
<string name="give_permission_staking_subtitle">Para continuar, você precisa permitir que o contrato inteligente da Polygon use seus dados. %s</string>
|
||||
<string name="give_permission_swap_subtitle" formatted="false">Para continuar, conceda %1s permissão de contratos inteligentes para usar seu %2s</string>
|
||||
<string name="give_permission_swap_subtitle_v2">As corretoras descentralizadas exigem permissão para interagir com sua carteira. %1s</string>
|
||||
<string name="give_permission_title">Conceder permissão</string>
|
||||
<string name="give_permission_unlimited">Ilimitado</string>
|
||||
<string name="hardware_wallet_backup_feature_description">Os endereços são gerados diretamente na sua carteira de hardware Tangem — prontos para usar e totalmente protegidos.</string>
|
||||
|
|
@ -1115,6 +1118,14 @@
|
|||
<string name="onramp_error_transaction_already_processed">Esta transação já foi processada. Nenhuma ação adicional é necessária.</string>
|
||||
<string name="onramp_fetching_best_rates">Obtendo as melhores taxas...</string>
|
||||
<string name="onramp_instant_status">Instantâneo</string>
|
||||
<string name="onramp_kyc_verification_bullet_free">A verificação é gratuita e geralmente leva de 1 a 2 minutos.</string>
|
||||
<string name="onramp_kyc_verification_bullet_privacy">A Tangem não terá acesso às suas informações de identidade; você compartilha os dados diretamente com o provedor regulamentado.</string>
|
||||
<string name="onramp_kyc_verification_bullet_unlocks">A verificação desbloqueia o acesso total a transações futuras com este fornecedor.</string>
|
||||
<string name="onramp_kyc_verification_choose_another">Escolha outro método</string>
|
||||
<string name="onramp_kyc_verification_subtitle">Para cumprir os requisitos regulamentares locais %@ Requer verificação de identidade.</string>
|
||||
<string name="onramp_kyc_verification_title">Verificação de identidade exigida pelo provedor de pagamento</string>
|
||||
<string name="onramp_kyc_verification_verify_button">Verificar</string>
|
||||
<string name="onramp_kyc_verification_whats_important">O que é importante</string>
|
||||
<string name="onramp_legal">Ao utilizar a funcionalidade de acesso prioritário, você concorda com os termos do provedor. %1$s e %2$s</string>
|
||||
<string name="onramp_legal_text">O serviço é fornecido por um provedor externo. A Tangem não se responsabiliza por ele.</string>
|
||||
<string name="onramp_max_amount_restriction">O valor da compra não deve ser superior a %s</string>
|
||||
|
|
@ -1181,6 +1192,8 @@
|
|||
<string name="quick_action_buy_description">Cartão de crédito ou conta bancária</string>
|
||||
<string name="quick_action_receive_description">Compartilhe seu endereço ou código QR.</string>
|
||||
<string name="quick_action_swap_description">Entre seus portfólios</string>
|
||||
<string name="quick_top_up_chip_other">Outro</string>
|
||||
<string name="quick_top_up_title">Recarga rápida</string>
|
||||
<string name="receive_bottom_sheet_no_memo_required_message">Não é necessário memorando</string>
|
||||
<string name="receive_bottom_sheet_warning_message">%1$s (%2$s) sobre %3$s rede</string>
|
||||
<string name="receive_bottom_sheet_warning_message_compact">%1$s sobre %2$s rede</string>
|
||||
|
|
|
|||
|
|
@ -293,11 +293,13 @@
|
|||
<string name="common_delete">Удалить</string>
|
||||
<string name="common_disable">Отключить</string>
|
||||
<string name="common_disabled">Отключено</string>
|
||||
<string name="common_disabling">Выключение</string>
|
||||
<string name="common_disconnect">Отключить</string>
|
||||
<string name="common_done">Готово</string>
|
||||
<string name="common_edit">Изменить</string>
|
||||
<string name="common_enable">Включить</string>
|
||||
<string name="common_enabled">Включено</string>
|
||||
<string name="common_enabling">Включение</string>
|
||||
<string name="common_error">Ошибка</string>
|
||||
<string name="common_estimated_fee">Комиссия сети</string>
|
||||
<string name="common_exchange">Обменять</string>
|
||||
|
|
@ -347,6 +349,7 @@
|
|||
<item quantity="other">%dмин назад</item>
|
||||
</plurals>
|
||||
<string name="common_month">месяц</string>
|
||||
<string name="common_more">Еще</string>
|
||||
<string name="common_network_fee_title">Комиссия сети</string>
|
||||
<string name="common_network_fee_warning_content">Сумма отправки будет уменьшена на %1$s (%2$s) для покрытия выбранного уровня комиссии</string>
|
||||
<plurals name="common_networks_count">
|
||||
|
|
|
|||
|
|
@ -596,6 +596,7 @@
|
|||
<string name="express_provider_min_amount">Available from %s</string>
|
||||
<string name="express_provider_not_available">Unavailable for this pair</string>
|
||||
<string name="express_provider_permission_needed">Permission Required</string>
|
||||
<string name="express_provider_permission_needed_v2">Permission needed</string>
|
||||
<string name="express_provider_recommended">Recommended</string>
|
||||
<string name="express_status_bought">Bought %s</string>
|
||||
<string name="express_status_buying">Buying %s</string>
|
||||
|
|
@ -643,6 +644,7 @@
|
|||
<string name="give_permission_staking_footer">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.</string>
|
||||
<string name="give_permission_staking_subtitle">To continue you need to allow Polygon smart contract to use your %s</string>
|
||||
<string name="give_permission_swap_subtitle" formatted="false">To continue, grant %1s smart contracts permission to use your %2s</string>
|
||||
<string name="give_permission_swap_subtitle_v2">Decentralized exchanges require permission to interact with your wallet. %1s</string>
|
||||
<string name="give_permission_title">Give Permission</string>
|
||||
<string name="give_permission_unlimited">Unlimited</string>
|
||||
<string name="hardware_wallet_backup_feature_description">Addresses are generated directly on your Tangem hardware wallet — ready to use and fully protected.</string>
|
||||
|
|
@ -670,6 +672,8 @@
|
|||
<string name="hw_backup_banner_description">Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault.</string>
|
||||
<string name="hw_backup_close_description">If you do, you\'ll need to start over.</string>
|
||||
<string name="hw_backup_google_drive_description">Recover existing wallet via Google Drive backup</string>
|
||||
<string name="hw_backup_google_drive_dialog_message">We\'re working on Google Drive backup to make wallet recovery even easier.</string>
|
||||
<string name="hw_backup_google_drive_dialog_title">Google Drive backup is coming soon</string>
|
||||
<string name="hw_backup_google_drive_title">Google Drive backup</string>
|
||||
<string name="hw_backup_hardware_create_description">Create a secure wallet and transfer your funds for extra protection.</string>
|
||||
<string name="hw_backup_hardware_create_title">Create new wallet</string>
|
||||
|
|
|
|||
|
|
@ -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<Test>().configureEach {
|
||||
|
|
@ -83,6 +104,7 @@ android {
|
|||
|
||||
val verifyDesignTokens = tasks.register<VerifyDesignTokensTask>("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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <reified T : TangemBottomSheetConfigContent> 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 <reified T : TangemBottomSheetConfigContent> 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 <reified T : TangemBottomSheetConfigContent> BsContent(
|
|||
.clip(TangemTheme.shapes.roundedCornersLarge)
|
||||
.background(containerColor)
|
||||
.heightIn(max = maxHeight.dp)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
title(model)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -472,6 +472,8 @@ val LocalMessageEffectAnimation = compositionLocalOf<MessageEffectAnimation> {
|
|||
error("No MessageEffectAnimation provided")
|
||||
}
|
||||
|
||||
val LocalCanScrollBackward = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* Determines whether the dark theme should be used based on the given [AppThemeMode].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
84387e888f54e5056380c38e077962bdfa4a32cfca194d822c13aa7e35661968
|
||||
b32332414db19b8a3e4a62ac2ce1dffcddb8d9e2394053dd2af55a0ce81464eb
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
4d82cc51cdc43627423b9cd186c61fda845cb0773f1d4e272249c777b8555aa1
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
340
core/ui/token-gen/build-icons.mjs
Normal file
340
core/ui/token-gen/build-icons.mjs
Normal file
|
|
@ -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(/<svg\b[^>]*>/);
|
||||
if (!svgOpen) throw new Error('No <svg> 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 (/<g\b[^>]*\btransform=/.test(src)) {
|
||||
throw new Error('<g transform="…"> is not supported by the current generator');
|
||||
}
|
||||
|
||||
// <path .../> elements
|
||||
const paths = [];
|
||||
const pathRe = /<path\b([^>]*?)\/?>/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 <path> elements found');
|
||||
for (const p of paths) {
|
||||
if (!p.d) throw new Error('A <path> 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();
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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<String, String> = 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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ internal class CreateWalletStartModelTest {
|
|||
verify {
|
||||
router.push(
|
||||
route = AppRoute.CreateMobileWallet(
|
||||
source = AnalyticsParam.ScreensSources.CreateWalletIntro.value,
|
||||
source = AnalyticsParam.ScreensSources.CreateWalletIntro,
|
||||
),
|
||||
onComplete = any(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,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
|
||||
|
|
@ -138,7 +139,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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PriceChangeType>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ExchangesBottomSheetContent>(
|
||||
listOf(
|
||||
ExchangesBottomSheetContent.Loading(exchangesCount = 13),
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Params, CreateMobileWalletComponent>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ android {
|
|||
namespace = "com.tangem.features.hotwallet.impl"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
|
|||
onRecoveryPhraseClick = {},
|
||||
onGoogleDriveClick = {},
|
||||
onHardwareWalletClick = {},
|
||||
backedUp = false,
|
||||
isBackedUp = false,
|
||||
),
|
||||
WalletBackupUM(
|
||||
hardwareWalletOption = LabelUM(
|
||||
|
|
@ -152,7 +152,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
|
|||
onRecoveryPhraseClick = {},
|
||||
onGoogleDriveClick = {},
|
||||
onHardwareWalletClick = {},
|
||||
backedUp = false,
|
||||
isBackedUp = false,
|
||||
),
|
||||
WalletBackupUM(
|
||||
hardwareWalletOption = LabelUM(
|
||||
|
|
@ -172,7 +172,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
|
|||
onRecoveryPhraseClick = {},
|
||||
onGoogleDriveClick = {},
|
||||
onHardwareWalletClick = {},
|
||||
backedUp = false,
|
||||
isBackedUp = false,
|
||||
),
|
||||
WalletBackupUM(
|
||||
hardwareWalletOption = null,
|
||||
|
|
@ -189,7 +189,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
|
|||
onRecoveryPhraseClick = {},
|
||||
onGoogleDriveClick = {},
|
||||
onHardwareWalletClick = {},
|
||||
backedUp = false,
|
||||
isBackedUp = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
package com.tangem.features.hotwallet.walletbackup.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.utils.TrackingContextProxy
|
||||
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.extensions.resourceReference
|
||||
import com.tangem.core.ui.message.DialogMessage
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
|
||||
import com.tangem.domain.wallets.models.GetUserWalletError
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase
|
||||
import com.tangem.features.hotwallet.WalletBackupComponent
|
||||
import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus
|
||||
import com.tangem.hot.sdk.model.HotWalletId
|
||||
import com.tangem.hot.sdk.model.UnlockHotWallet
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class WalletBackupModelTest {
|
||||
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
|
||||
private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase = mockk()
|
||||
private val router: Router = mockk(relaxUnitFun = true)
|
||||
private val trackingContextProxy: TrackingContextProxy = mockk(relaxUnitFun = true)
|
||||
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
|
||||
private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true)
|
||||
private val paramsContainer: ParamsContainer = mockk()
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val hotWalletId: HotWalletId = mockk()
|
||||
private val params = WalletBackupComponent.Params(
|
||||
userWalletId = walletId,
|
||||
isColdWalletOptionShown = true,
|
||||
)
|
||||
private val hotWalletNotBackedUp: UserWallet.Hot = mockk {
|
||||
every { walletId } returns this@WalletBackupModelTest.walletId
|
||||
every { hotWalletId } returns this@WalletBackupModelTest.hotWalletId
|
||||
every { backedUp } returns false
|
||||
}
|
||||
private val hotWalletBackedUp: UserWallet.Hot = mockk {
|
||||
every { walletId } returns this@WalletBackupModelTest.walletId
|
||||
every { hotWalletId } returns this@WalletBackupModelTest.hotWalletId
|
||||
every { backedUp } returns true
|
||||
}
|
||||
private val coldWallet: UserWallet.Cold = mockk {
|
||||
every { walletId } returns this@WalletBackupModelTest.walletId
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
every { paramsContainer.require<WalletBackupComponent.Params>() } 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<WalletSettingsAnalyticEvents.BackupScreenOpened> { 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<WalletSettingsAnalyticEvents.BackupScreenOpened> { 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<UnlockHotWallet>().right()
|
||||
|
||||
val model = createModel(this)
|
||||
advanceUntilIdle()
|
||||
|
||||
model.uiState.value.onRecoveryPhraseClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { 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<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { 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<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { 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<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { 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<WalletSettingsAnalyticEvents.ButtonHardwareUpdate> { 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<WalletSettingsAnalyticEvents.ButtonGoogleDriveBackup> { true }
|
||||
)
|
||||
}
|
||||
verify {
|
||||
uiMessageSender.send(
|
||||
match<DialogMessage> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> = mapOf(),
|
||||
) : AnalyticsEvent(category, event, params) {
|
||||
|
||||
class Started : OnboardingEvent("Onboarding", "Onboarding Started")
|
||||
class Finished : OnboardingEvent("Onboarding", "Onboarding Finished")
|
||||
|
||||
sealed class CreateWallet(
|
||||
event: String,
|
||||
params: Map<String, String> = 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<String, String> = mapOf(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Step>()
|
||||
|
||||
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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<OnboardingEvent.Started> { true }) }
|
||||
verify { analyticsHandler.send(match<OnboardingAnalyticsEvent.Onboarding.Started> { true }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
package com.tangem.features.swap
|
||||
|
||||
interface SwapFeatureToggles
|
||||
interface SwapFeatureToggles {
|
||||
val isSwapSwitchToTransferEnabled: Boolean
|
||||
}
|
||||
|
|
@ -9,6 +9,14 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "com.tangem.features.domain.swap"
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = false
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().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)
|
||||
}
|
||||
|
|
@ -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<Set<CryptoCurrency.RawID>>().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<TransactionExtras>(relaxed = true).right()
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
} returns mockk<TransactionFee.Single>(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<String>(), 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<String>(), any()) } returns ByteArray(931)
|
||||
io.mockk.mockkObject(SolanaTransactionHelper)
|
||||
every {
|
||||
SolanaTransactionHelper.removeSignaturesPlaceholders(any())
|
||||
} returns ByteArray(931)
|
||||
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val coldWallet = mockk<UserWallet.Cold>(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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CryptoCurrency.Coin>(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<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { network } returns mockk<Network>(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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<com.tangem.domain.swap.models.SwapPairModel>().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<com.tangem.domain.swap.models.SwapPairModel>().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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { decimals } returns 18
|
||||
}
|
||||
val value = mockk<CryptoCurrencyStatus.Loaded>(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<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { decimals } returns 8
|
||||
}
|
||||
val value = mockk<CryptoCurrencyStatus.Loading>(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<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { decimals } returns 6
|
||||
}
|
||||
val value = mockk<CryptoCurrencyStatus.Loaded>(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"))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue