Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-06 12:18:13 +03:00
commit 531eb86ac5
1243 changed files with 49302 additions and 12422 deletions

View file

@ -75,6 +75,10 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

41
Marathonfile Normal file
View file

@ -0,0 +1,41 @@
name: "Tangem Android UI Tests"
outputDir: "build/reports/marathon"
testOutputTimeoutMillis: 180000 # 3 minutes
testBatchTimeoutMillis: 600000 # 10 minutes
vendorConfiguration:
type: "Android"
applicationApk: "app/build/outputs/apk/google/mocked/app-google-mocked.apk"
testApplicationApk: "app/build/outputs/apk/androidTest/google/mocked/app-google-mocked-androidTest.apk"
autoGrantPermission: true
applicationPmClear: true
testApplicationPmClear: true
# wiremockBaseUrl redirects API calls from wiremock.tests-d.com to local WireMock
# This is used on CI where each emulator has its own WireMock instance via adb reverse
instrumentationArgs:
wiremockBaseUrl: "http://localhost:8080"
allureConfiguration:
enabled: false # we collect allure via adb pull, not through marathon
poolingStrategy:
type: "omni"
batchingStrategy:
type: "isolate"
sortingStrategy:
type: "no-sorting"
retryStrategy:
type: "fixed-quota"
totalAllowedRetryQuota: 30
retryPerTestQuota: 1
flakinessStrategy:
type: "ignore"
analyticsConfiguration:
type: "disabled"
debug: false

View file

@ -6,6 +6,7 @@ import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.printToLog
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.kaspersky.components.alluresupport.interceptors.step.ScreenshotStepInterceptor
import com.kaspersky.components.alluresupport.withForcedAllureSupport
@ -19,6 +20,7 @@ import com.tangem.common.rules.ApiEnvironmentRule
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.PromoId
import com.tangem.tap.MainActivity
@ -86,6 +88,9 @@ abstract class BaseTestCase : TestCase(
additionalAfterSection: () -> Unit = {},
) = before {
Allure.label(ALLURE_LABEL_NAME, ALLURE_LABEL_VALUE)
// Setup WireMock redirect for CI with local WireMock instances
val wiremockUrl = InstrumentationRegistry.getArguments().getString(WIREMOCK_BASE_URL_ARG)
WireMockRedirectInterceptor.overriddenBaseUrl = wiremockUrl
hiltRule.inject()
runBlocking {
appPreferencesStore.editData { mutablePreferences ->
@ -138,12 +143,16 @@ abstract class BaseTestCase : TestCase(
private fun applicationInjectionRule(): ApplicationInjectionExecutionRule {
return ApplicationInjectionExecutionRule(
toggleStates = mapOf(
"NEW_TOKEN_RECEIVE_ENABLED" to true,
"WALLET_BALANCE_FETCHER_ENABLED" to true,
"SWAP_REDESIGN_ENABLED" to true,
"SWAP_REDESIGN_ENABLED" to false,
"NEW_ONRAMP_MAIN_ENABLED" to true,
"HOT_WALLET_ENABLED" to true
"HOT_WALLET_ENABLED" to true,
"YIELD_SUPPLY_FEATURE_ENABLED" to true,
"ACCOUNTS_FEATURE_ENABLED" to true
)
)
}
private companion object {
const val WIREMOCK_BASE_URL_ARG = "wiremockBaseUrl"
}
}

View file

@ -8,6 +8,7 @@ object TestConstants {
const val ETHEREUM_RECIPIENT_ADDRESS = "0x5aa711F440Eb6d4361148bBD89d03464628ace84"
const val ETHEREUM_RECIPIENT_SHORTENED_ADDRESS = "0x5aa711F440Eb6d43...89d03464628ace84"
const val BITCOIN_ADDRESS = "bc1qtg9aa6jcpqtvun0pe0uct7sxm8nq2nsxfmfxm3"
const val BITCOIN_RECIPIENT_ADDRESS = "bc1qt90qc0na7z05nh63kyd78tujfc8vqv6sl7e4a9"
const val CARDANO_ADDRESS =
"addr1q8f9499e58k4hhfd9vhawprxt3xd94x7rmlyp33ee4xkatakcl2zgkrg0p6ceqkndtkw4cumfe9enhdph8yhuswn785srksm9p"
const val SOLANA_RECIPIENT_ADDRESS = "5fcy9woa8Di1QHcce65CsV3XKrxdB2pD4HJx5xx82ipM"
@ -30,6 +31,7 @@ object TestConstants {
const val XRP_ACTIVATED_RECIPIENT_ADDRESS = "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH"
const val DOGECOIN_RECIPIENT_ADDRESS = "DJQR3bdhBKcFGMHX2BkMCkrMFApNWNzr6V"
const val DOGECOIN_ADDRESS = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaqz"
const val TERRA_RECIPIENT_ADDRESS = "terra148dmp5ccazcwdmrcpvqz5rprnn886kemqen3tj"
const val WAIT_UNTIL_TIMEOUT = 20_000L
const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L

View file

@ -1,22 +1,31 @@
package com.tangem.common.utils
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import timber.log.Timber
import java.io.IOException
private const val DEFAULT_WIREMOCK_URL = "[REDACTED_ENV_URL]"
/**
* Returns the WireMock base URL to use.
*/
private fun getWireMockBaseUrl(): String =
WireMockRedirectInterceptor.overriddenBaseUrl ?: DEFAULT_WIREMOCK_URL
/**
* Method uses to set WireMock scenario state
* @param scenarioName Name of the scenario to modify
* @param state The target state to set (must be one of the scenario's possibleStates)
* @param baseUrl WireMock base URL
* @param baseUrl WireMock base URL (defaults to local override if set, otherwise remote)
* @return true if state was set successfully, false otherwise
*/
fun setWireMockScenarioState(
scenarioName: String,
state: String,
baseUrl: String = "[REDACTED_ENV_URL]"
baseUrl: String = getWireMockBaseUrl()
): Boolean {
Timber.i("=== WireMock Scenario Set ===")
Timber.i("Setting scenario '$scenarioName' to state: $state")
@ -46,8 +55,9 @@ fun setWireMockScenarioState(
/**
* Method checks accessibility of WireMock
* @param baseUrl WireMock base URL (defaults to local override if set, otherwise remote)
*/
fun checkWireMockStatus(baseUrl: String = "[REDACTED_ENV_URL]"): Boolean {
fun checkWireMockStatus(baseUrl: String = getWireMockBaseUrl()): Boolean {
val client = OkHttpClient()
val request = Request.Builder()
.url("$baseUrl/__admin/scenarios")
@ -69,8 +79,9 @@ fun checkWireMockStatus(baseUrl: String = "[REDACTED_ENV_URL]"): Boolean {
/**
* Method to reset all WireMock scenarios
* @param baseUrl WireMock base URL (defaults to local override if set, otherwise remote)
*/
fun resetWireMockScenarios(baseUrl: String = "[REDACTED_ENV_URL]"): Boolean {
fun resetWireMockScenarios(baseUrl: String = getWireMockBaseUrl()): Boolean {
Timber.i("=== WireMock Scenarios Reset ===")
Timber.i("Base URL: $baseUrl")
@ -105,13 +116,13 @@ fun resetWireMockScenarios(baseUrl: String = "[REDACTED_ENV_URL]"): Boolean {
* Method to reset a specific WireMock scenario to its initial state
* @param scenarioName Name of the scenario to reset
* @param initialState The target state to reset the scenario to (must be one of the scenario's possibleStates)
* @param baseUrl WireMock base URL
* @param baseUrl WireMock base URL (defaults to local override if set, otherwise remote)
* @return true if reset was successful, false otherwise
*/
fun resetWireMockScenarioState(
scenarioName: String,
initialState: String = "Started",
baseUrl: String = "[REDACTED_ENV_URL]"
baseUrl: String = getWireMockBaseUrl()
): Boolean {
Timber.i("=== WireMock Scenario Reset ===")
Timber.i("Resetting scenario '$scenarioName' to initial state: $initialState")

View file

@ -9,6 +9,7 @@ import com.tangem.screens.AlreadyUsedWalletDialogPageObject.thisIsMyWalletButton
import com.tangem.screens.AlreadyUsedWalletDialogPageObject.title
import com.tangem.screens.ScanWarningDialogPageObject
import com.tangem.screens.onActionIsUnavailableDialog
import com.tangem.screens.onDataNotLoadedDialog
import com.tangem.screens.onFailedTransactionDialog
import io.qameta.allure.kotlin.Allure.step
@ -76,4 +77,16 @@ fun BaseTestCase.checkActionIsUnavailableDialog() {
step("Assert 'Action is unavailable' dialog 'Ok' button is displayed") {
onActionIsUnavailableDialog { okButton.assertIsDisplayed() }
}
}
fun BaseTestCase.checkDataNotLoadedDialog() {
step("Assert 'The data has not loaded yet' dialog title is displayed") {
onDataNotLoadedDialog { title.assertIsDisplayed() }
}
step("Assert 'The data has not loaded yet' dialog text is displayed") {
onDataNotLoadedDialog { text.assertIsDisplayed() }
}
step("Assert 'The data has not loaded yet' dialog 'Ok' button is displayed") {
onDataNotLoadedDialog { okButton.assertIsDisplayed() }
}
}

View file

@ -54,6 +54,10 @@ fun BaseTestCase.checkNetworkFeeBlock(currentFeeAmount: String, withFeeSelector:
step("Assert select fee icon is displayed") {
onSendConfirmScreen { selectFeeIcon.assertIsDisplayed() }
}
} else {
step("Assert select fee icon is not displayed") {
onSendConfirmScreen { selectFeeIcon.assertIsNotDisplayed() }
}
}
}
@ -138,4 +142,29 @@ fun BaseTestCase.checkRecentAddressItem(address: String, description: String?) {
recentAddressItem(recipientAddress = address, description = description).assertIsDisplayed()
}
}
}
fun BaseTestCase.checkCustomFeeTooltip(title: String, tooltip: String) {
step("Click on tooltip icon for '$title'") {
waitForIdle()
onSendSelectNetworkFeeBottomSheet { tooltipIcon(title).performClick() }
}
step("Check '$title' tooltip text") {
onSendSelectNetworkFeeBottomSheet { tooltipText(tooltip).assertIsDisplayed() }
}
step("Click on tooltip icon again to close tooltip") {
onSendSelectNetworkFeeBottomSheet { tooltipIcon(title).performClick() }
}
}
fun BaseTestCase.checkChangesInInputTextField(title: String, newValue: String, addition: String = "") {
step("Click on '$title' input text field") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(title).performClick() }
}
step("Type '$newValue' in '$title' input text field") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(title).performTextReplacement(newValue) }
}
step("Assert '$title' value: '$newValue + $addition'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(title).assertTextContains(newValue + addition) }
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.BaseDialogTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import com.tangem.common.ui.R as CommonUIR
class DataNotLoadedDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DataNotLoadedDialogPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasTestTag(BaseDialogTestTags.TITLE)
hasText(getResourceString(CommonUIR.string.action_buttons_service_loading_alert_title))
useUnmergedTree = true
}
val text: KNode = child {
hasTestTag(BaseDialogTestTags.TEXT)
hasText(getResourceString(CommonUIR.string.action_buttons_service_loading_alert_message))
useUnmergedTree = true
}
val okButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_ok))
}
}
internal fun BaseTestCase.onDataNotLoadedDialog(function: DataNotLoadedDialogPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -1,42 +0,0 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class SelectNetworkFeePageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SelectNetworkFeePageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.common_fee_selector_title))
useUnmergedTree = true
}
val marketSelectorItem: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM)
hasAnyChild(withText(getResourceString(R.string.common_fee_selector_option_market)))
useUnmergedTree = true
}
val fastSelectorItem: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM)
hasAnyChild(withText(getResourceString(R.string.common_fee_selector_option_fast)))
useUnmergedTree = true
}
val readMoreTextBlock: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.READ_MORE_TEXT)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSelectNetworkFeeBottomSheet(function: SelectNetworkFeePageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -40,8 +40,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
}
val addressTextFieldHint: KNode = child {
hasParent(withTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD))
useUnmergedTree = true
hasTestTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD)
}
fun recipientNetworkCaution(network: String): KNode = child {
@ -95,10 +94,8 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
hasAnyDescendant(withText(description, substring = true))
}
if (isMyWallet) {
hasAnySibling(withText(getResourceString(CoreUiR.string.send_recipient_wallets_title)))
hasAnyDescendant(withText(getResourceString(CoreUiR.string.manage_tokens_network_selector_wallet)))
} else {
hasAnySibling(withText(getResourceString(CoreUiR.string.send_recent_transactions)))
hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TRANSACTION_ICON))
}
}
@ -119,8 +116,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
}
val destinationTagTextFieldHint: KNode = child {
hasParent(withTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD))
useUnmergedTree = true
hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD)
}
val destinationTagBlockText: KNode = child {

View file

@ -0,0 +1,102 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
class SendSelectNetworkFeeBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SendSelectNetworkFeeBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val chooseSpeedTitle: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.fee_selector_choose_speed_title))
useUnmergedTree = true
}
fun regularFeeSelectorItem(title: String): KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM)
hasAnyDescendant(withText(title))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_ICON))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.TOKEN_AMOUNT))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.FIAT_AMOUNT))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.DOT_SIGN))
useUnmergedTree = true
}
val customSelectorItem: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_FEE_ITEM)
hasAnyDescendant(withText(getResourceString(R.string.common_custom)))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_ICON))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_TITLE))
useUnmergedTree = true
}
fun customInputItem(title: String, hasFiatAmount: Boolean = false): KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM)
hasAnyDescendant(withText(title))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TITLE))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TOOLTIP_ICON))
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD))
useUnmergedTree = true
if (hasFiatAmount) {
hasAnyDescendant(withTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_FIAT_AMOUNT))
}
}
val nonceInputItem: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_ITEM)
hasAnyDescendant(withText(getResourceString(R.string.send_nonce)))
hasAnyDescendant(withText(getResourceString(R.string.send_nonce_hint)))
useUnmergedTree = true
}
fun tooltipIcon(title: String): KNode = child {
hasAnySibling(withText(title))
useUnmergedTree = true
}
fun tooltipText(text: String): KNode = child {
hasText(text)
useUnmergedTree = true
}
private fun inputTextField(title: String): KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD)
hasAnySibling(withText(title))
useUnmergedTree = true
}
fun inputTextFieldValue(title: String): KNode = inputTextField(title).child {
hasParent(withTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD))
useUnmergedTree = true
}
val nonceInputTextField: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_TEXT_FIELD)
useUnmergedTree = true
}
val customInputItemFiatAmount: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_FIAT_AMOUNT)
useUnmergedTree = true
}
val doneButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_done))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSendSelectNetworkFeeBottomSheet(function: SendSelectNetworkFeeBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,39 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class SwapSelectNetworkFeeBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SwapSelectNetworkFeeBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasText(getResourceString(R.string.fee_selector_choose_speed_title))
useUnmergedTree = true
}
val marketSelectorItem: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE)
hasText(getResourceString(R.string.common_fee_selector_option_market))
useUnmergedTree = true
}
val fastSelectorItem: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE)
hasText(getResourceString(R.string.common_fee_selector_option_fast))
useUnmergedTree = true
}
val readMoreTextBlock: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.LEARN_MORE_TEXT)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSwapSelectNetworkFeeBottomSheet(function: SwapSelectNetworkFeeBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -29,7 +29,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
val networkFeeBlock: KNode = child {
hasTestTag(BaseBlockTestTags.BLOCK)
hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK)
useUnmergedTree = true
}
val selectFeeIcon: KNode = child {
hasTestTag(FeeSelectorBlockTestTags.SELECT_FEE_ICON)
useUnmergedTree = true
}

View file

@ -113,8 +113,8 @@ class OrganizeTokensTest : BaseTestCase() {
}
step("Check positions of tokens on 'Organize tokens' screen") {
onOrganizeTokensScreen {
tokenWithTitleAndPosition(bitcoinTitle, 1).assertIsDisplayed()
tokenWithTitleAndPosition(ethereumTitle, 2).assertIsDisplayed()
tokenWithTitleAndPosition(bitcoinTitle, 0).assertIsDisplayed()
tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed()
}
}
}
@ -200,10 +200,10 @@ class OrganizeTokensTest : BaseTestCase() {
}
step("Check positions of tokens on 'Organize tokens' screen") {
onOrganizeTokensScreen {
tokenWithTitleAndPosition(bitcoinTitle, 1).assertIsDisplayed()
tokenWithTitleAndPosition(ethereumTitle, 2).assertIsDisplayed()
tokenWithTitleAndPosition(polygonTitle, 3).assertIsDisplayed()
tokenWithTitleAndPosition(polExMaticTitle, 4).assertIsDisplayed()
tokenWithTitleAndPosition(bitcoinTitle, 0).assertIsDisplayed()
tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed()
tokenWithTitleAndPosition(polygonTitle, 2).assertIsDisplayed()
tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed()
}
}
step("Click 'By Balance' button") {
@ -213,10 +213,10 @@ class OrganizeTokensTest : BaseTestCase() {
}
step("Check positions of tokens by balance on 'Organize tokens' screen") {
onOrganizeTokensScreen {
tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed()
tokenWithTitleAndPosition(polExMaticTitle, 2).assertIsDisplayed()
tokenWithTitleAndPosition(polygonTitle, 3).assertIsDisplayed()
tokenWithTitleAndPosition(bitcoinTitle, 4).assertIsDisplayed()
tokenWithTitleAndPosition(ethereumTitle, 0).assertIsDisplayed()
tokenWithTitleAndPosition(polExMaticTitle, 1).assertIsDisplayed()
tokenWithTitleAndPosition(polygonTitle, 2).assertIsDisplayed()
tokenWithTitleAndPosition(bitcoinTitle, 3).assertIsDisplayed()
}
}
step("Click 'Apply' button") {

View file

@ -84,7 +84,7 @@ class SwapTokenTest : BaseTestCase() {
}
step("Assert 'Providers' block is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
providersBlock.assertIsDisplayed()
}
}
@ -210,24 +210,25 @@ class SwapTokenTest : BaseTestCase() {
step("Click on 'Network fee' block") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
networkFeeBlock.clickWithAssertion()
selectFeeIcon.clickWithAssertion()
}
}
}
step("Assert 'Select fee' bottom sheet title is displayed") {
onSelectNetworkFeeBottomSheet { title.assertIsDisplayed() }
onSwapSelectNetworkFeeBottomSheet { title.assertIsDisplayed() }
}
step("Assert 'Market' item is displayed") {
onSelectNetworkFeeBottomSheet { marketSelectorItem.assertIsDisplayed() }
printSemanticTree(rootIndex = 1, useUnmergedTree = true)
onSwapSelectNetworkFeeBottomSheet { marketSelectorItem.assertIsDisplayed() }
}
step("Assert 'Fast' item is displayed") {
onSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() }
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() }
}
step("Assert 'Read more' text block is displayed") {
onSelectNetworkFeeBottomSheet { readMoreTextBlock.assertIsDisplayed() }
onSwapSelectNetworkFeeBottomSheet { readMoreTextBlock.assertIsDisplayed() }
}
step("Click on 'Fast' item") {
onSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() }
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() }
}
step("Assert 'Network fee' block is displayed") {
onSwapTokenScreen {

View file

@ -6,9 +6,11 @@ import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig
import com.tangem.common.constants.TestConstants.BITCOIN_ADDRESS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.*
import com.tangem.common.utils.*
import com.tangem.common.utils.assertClipboardTextEquals
import com.tangem.common.utils.clearClipboard
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.scenarios.*

View file

@ -12,10 +12,6 @@ import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.*
import com.tangem.screens.onMainScreen
import com.tangem.screens.onSwapStoriesScreen
import com.tangem.screens.onSwapTokenScreen
import com.tangem.screens.onTokenDetailsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
@ -84,8 +80,8 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
step("Assert 'Buy' button is not dimmed") {
onTokenDetailsScreen { buyButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) }
}
step("Assert 'Send' button is dimmed") {
onTokenDetailsScreen { sendButton().assertContentDescriptionEquals(actionButtonIsDimmed) }
step("Assert 'Send' button is not dimmed") {
onTokenDetailsScreen { sendButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) }
@ -239,7 +235,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Assert 'Receive' button is displayed") {
step("Click on 'Receive' button") {
onTokenDetailsScreen { receiveButton().performClick() }
}
step("Go to QR code bottom sheet") {

View file

@ -0,0 +1,441 @@
package com.tangem.tests.send.feeScreen
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.TERRA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.*
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class SendFeeScreenTest : BaseTestCase() {
@AllureId("4906")
@DisplayName("Send (Fee screen): check fee block for fee in token")
@Test
fun checkFeeBlockForFeeInTokenTest() {
val tokenName = "TerraClassicUSD"
val scenarioName = "Terra"
val tokenAmount = "1"
val feeAmount = "<$0.01"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen") {
openSendScreen(tokenName, scenarioName)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(tokenAmount)
}
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(TERRA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed without fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = false)
}
}
}
@AllureId("4868")
@DisplayName("Send (Fee screen): check fee block for fixed fee")
@Test
fun checkFeeBlockForFixedFeeTest() {
val tokenName = "Polkadot"
val tokenAmount = "1"
val feeAmount = "$0.05"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen") {
openSendScreen(tokenName)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(tokenAmount)
}
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed without fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = false)
}
step("Click on 'Fee selector' block") {
onSendConfirmScreen { feeSelectorBlock.performClick() }
}
step("Assert 'Fee selector' bottom sheet is not displayed") {
onSwapSelectNetworkFeeBottomSheet { title.assertIsNotDisplayed() }
}
}
}
@AllureId("4869")
@DisplayName("Send (Fee screen): check network fee bottom sheet for EVM networks")
@Test
fun checkNetworkFeeBottomSheetForEvmTest() {
val tokenName = "Ethereum"
val tokenAmount = "0.1"
val feeAmount = "~$1.06"
val fiatFeeAmount = "$1.09"
val newFeeAmount = "~$1.09"
val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market)
val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast)
val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow)
val feeUpTo = getResourceString(R.string.send_max_fee)
val feeUpToTooltip = getResourceString(R.string.send_custom_amount_fee_footer)
val feeUpToValue = "0.00042 ETH"
val newFeeUpToValue = "0.00043"
val maxFee = getResourceString(R.string.send_custom_evm_max_fee)
val maxFeeTooltip = getResourceString(R.string.send_custom_evm_max_fee_footer)
val maxFeeValue = "20 GWEI"
val newMaxFeeValue = "21"
val priorityFee = getResourceString(R.string.send_custom_evm_priority_fee)
val priorityFeeTooltip = getResourceString(R.string.send_custom_evm_priority_fee_footer)
val priorityFeeValue = "2 GWEI"
val newPriorityFeeValue = "3"
val gasLimit = getResourceString(R.string.send_gas_limit)
val gasLimitTooltip = getResourceString(R.string.send_gas_limit_footer)
val gasLimitValue = "21,000 "
val newGasLimitValue = "22"
val nonce = getResourceString(R.string.send_nonce)
val nonceTooltip = getResourceString(R.string.send_nonce_footer)
val nonceValue = "1"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen") {
openSendScreen(tokenName)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(tokenAmount)
}
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed with fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true)
}
step("Click on fee selector icon") {
onSendConfirmScreen { feeSelectorIcon.performClick() }
}
step("Assert 'Fee selector' bottom sheet title is displayed") {
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsDisplayed() }
}
step("Assert '$marketSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() }
}
step("Assert '$fastSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() }
}
step("Assert '$slowSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(slowSelectorItem).assertIsDisplayed() }
}
step("Assert '$marketSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() }
}
step("Assert 'Custom' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() }
}
step("Click on 'Custom' selector item") {
onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() }
}
step("Assert '$feeUpTo' input item is displayed") {
onSendSelectNetworkFeeBottomSheet {
customInputItem(title = feeUpTo, hasFiatAmount = true).assertIsDisplayed()
}
}
step("Assert '$maxFee' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { customInputItem(maxFee).assertIsDisplayed() }
}
step("Assert '$priorityFee' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { customInputItem(priorityFee).assertIsDisplayed() }
}
step("Assert '$gasLimit' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { customInputItem(gasLimit).assertIsDisplayed() }
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP)
}
step("Assert '$nonce' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { nonceInputItem.assertIsDisplayed() }
}
step("Check '$feeUpTo' tooltip") {
checkCustomFeeTooltip(title = feeUpTo, tooltip = feeUpToTooltip)
}
step("Check '$maxFee' tooltip") {
checkCustomFeeTooltip(title = maxFee, tooltip = maxFeeTooltip)
}
step("Check '$priorityFee' tooltip") {
checkCustomFeeTooltip(title = priorityFee, tooltip = priorityFeeTooltip)
}
step("Check '$gasLimit' tooltip") {
checkCustomFeeTooltip(title = gasLimit, tooltip = gasLimitTooltip)
}
step("Check '$nonce' tooltip") {
checkCustomFeeTooltip(title = nonce, tooltip = nonceTooltip)
}
step("Assert '$feeUpTo' value: '$feeUpToValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(feeUpTo).assertTextContains(feeUpToValue) }
}
step("Assert '$maxFee' value: '$maxFeeValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(maxFee).assertTextContains(maxFeeValue) }
}
step("Assert '$priorityFee' value: '$priorityFeeValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(priorityFee).assertTextContains(priorityFeeValue) }
}
step("Assert '$gasLimit' value: '$gasLimitValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(gasLimit).assertTextContains(gasLimitValue) }
}
step("Check changes in '$maxFee' input text field") {
checkChangesInInputTextField(title = maxFee, newValue = newMaxFeeValue, addition = " GWEI")
}
step("Check changes in '$priorityFee' input text field") {
checkChangesInInputTextField(title = priorityFee, newValue = newPriorityFeeValue, addition = " GWEI")
}
step("Check changes in '$gasLimit' input text field") {
checkChangesInInputTextField(title = gasLimit, newValue = newGasLimitValue, addition = " ")
}
step("Type '$nonceValue' in '$nonce' text field") {
onSendSelectNetworkFeeBottomSheet {
nonceInputTextField.performClick()
nonceInputTextField.performTextReplacement(nonceValue)
}
}
step("Assert '$nonce' value: '$nonceValue'") {
onSendSelectNetworkFeeBottomSheet { nonceInputTextField.assertTextContains(nonceValue) }
}
step("Check changes in '$feeUpTo' input text field") {
checkChangesInInputTextField(title = feeUpTo, newValue = newFeeUpToValue, addition = " ETH")
}
step("Assert new fiat fee amount: '$fiatFeeAmount'") {
onSendSelectNetworkFeeBottomSheet { customInputItemFiatAmount.assertTextContains(fiatFeeAmount) }
}
step("Click on 'Done' button") {
onSendSelectNetworkFeeBottomSheet { doneButton.performClick() }
}
step("Assert fee block is displayed with new fee amount: '$newFeeAmount'") {
checkNetworkFeeBlock(currentFeeAmount = newFeeAmount, withFeeSelector = true)
}
}
}
@AllureId("4870")
@DisplayName("Send (Fee screen): check network fee bottom sheet for Bitcoin")
@Test
fun checkNetworkFeeBottomSheetForBitcoinTest() {
val tokenName = "Bitcoin"
val tokenAmount = "0.00000001"
val feeAmount = "$2.86"
val fiatFeeAmount = "$0.24"
val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market)
val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast)
val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow)
val feeUpTo = getResourceString(R.string.send_max_fee)
val feeUpToValue = "0.0000264 BTC"
val newFeeUpToValue = "0.0000022 BTC"
val satoshi = getResourceString(R.string.send_satoshi_per_byte_title)
val satoshiValue = "2"
val decimalNumber = "2.11"
val newSatoshiValue = "1"
val bitcoinUtxoScenarioName = "bitcoin_utxo"
val bitcoinUtxoScenarioState = "Balance"
val feeScenarioName = "bitcoin_estimate_smart_fee"
val feeScenarioState = "Started"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(bitcoinUtxoScenarioName)
}
).run {
step("Set WireMock scenario: '$bitcoinUtxoScenarioName' to state: '$bitcoinUtxoScenarioState'") {
setWireMockScenarioState(bitcoinUtxoScenarioName, bitcoinUtxoScenarioState)
}
step("Set WireMock scenario: '$feeScenarioName' to state: '$feeScenarioState'") {
setWireMockScenarioState(feeScenarioName, feeScenarioState)
}
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Send address' screen") {
openSendAddressScreen(tokenName, tokenAmount)
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(BITCOIN_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed with fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true)
}
step("Click on fee selector icon") {
onSendConfirmScreen { feeSelectorIcon.performClick() }
}
step("Assert 'Choose speed' bottom sheet title is displayed") {
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsDisplayed() }
}
step("Assert '$fastSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() }
}
step("Assert '$slowSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(slowSelectorItem).assertIsDisplayed() }
}
step("Assert '$marketSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() }
}
step("Assert 'Custom' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() }
}
step("Click on 'Custom' selector item") {
onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() }
}
step("Assert '$feeUpTo' input item is displayed") {
onSendSelectNetworkFeeBottomSheet {
customInputItem(title = feeUpTo, hasFiatAmount = true).assertIsDisplayed()
}
}
step("Assert '$feeUpTo' value: '$feeUpToValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(feeUpTo).assertTextContains(feeUpToValue) }
}
step("Assert '$satoshi' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { customInputItem(satoshi).assertIsDisplayed() }
}
step("Click on '$satoshi' input text field") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(satoshi).performClick() }
}
step("Type '$decimalNumber' in '$satoshi' input text field") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(satoshi).performTextReplacement(decimalNumber) }
}
step("Assert '$satoshi' value: '$satoshiValue +  '") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(satoshi).assertTextContains("$satoshiValue ") }
}
step("Check changes in '$satoshi' input text field") {
checkChangesInInputTextField(title = satoshi, newValue = newSatoshiValue, addition = " ")
}
step("Assert new fiat fee amount: '$fiatFeeAmount'") {
onSendSelectNetworkFeeBottomSheet { customInputItemFiatAmount.assertTextContains(fiatFeeAmount) }
}
step("Assert '$feeUpTo' value: '$newFeeUpToValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(feeUpTo).assertTextContains(newFeeUpToValue) }
}
step("Click on 'Done' button") {
onSendSelectNetworkFeeBottomSheet { doneButton.performClick() }
}
step("Assert fee block is displayed with new fee amount: '$fiatFeeAmount'") {
checkNetworkFeeBlock(currentFeeAmount = fiatFeeAmount, withFeeSelector = true)
}
}
}
@AllureId("4871")
@DisplayName("Send (Fee screen): check network fee bottom sheet networks with fee in token")
@Test
fun checkNetworkFeeBottomSheetForVeThorTest() {
val tokenName = "VeThor"
val mockState = "Vechain"
val tokenAmount = "0.1"
val feeAmount = "<$0.01"
val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market)
val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast)
val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen") {
openSendScreen(tokenName = tokenName, mockState = mockState)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(tokenAmount)
}
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed with fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true)
}
step("Click on fee selector icon") {
onSendConfirmScreen { feeSelectorIcon.performClick() }
}
step("Assert 'Choose speed' bottom sheet title is displayed") {
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsDisplayed() }
}
step("Assert '$fastSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() }
}
step("Assert '$slowSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(slowSelectorItem).assertIsDisplayed() }
}
step("Assert '$marketSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() }
}
}
}
}

View file

@ -307,6 +307,17 @@
android:host="tangem.onelink.me"
android:scheme="https" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="tangem.com"
android:pathPrefix="/news" />
</intent-filter>
</activity>
<!-- Disable android.startup completely. Used for Worker according doc -->

View file

@ -42,6 +42,7 @@ import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
@ -334,15 +335,23 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
ExceptionHandler.append(blockchainExceptionHandler)
if (LogConfig.network.isBlockchainSdkNetworkLogEnabled) {
BlockchainSdkRetrofitBuilder.interceptors = listOf(
createNetworkLoggingInterceptor(),
ChuckerInterceptor(this),
)
BlockchainSdkRetrofitBuilder.interceptors = buildList {
if (BuildConfig.MOCK_DATA_SOURCE) {
add(WireMockRedirectInterceptor())
}
add(createNetworkLoggingInterceptor())
add(ChuckerInterceptor(this@TangemApplication))
}
TangemApiServiceSettings.addInterceptors(
createNetworkLoggingInterceptor(),
ChuckerInterceptor(this),
NetworkLogsSaveInterceptor(appLogsStore),
*buildList {
if (BuildConfig.MOCK_DATA_SOURCE) {
add(WireMockRedirectInterceptor())
}
add(createNetworkLoggingInterceptor())
add(ChuckerInterceptor(this@TangemApplication))
add(NetworkLogsSaveInterceptor(appLogsStore))
}.toTypedArray(),
)
}
@ -360,7 +369,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private fun createReduxStore(): Store<AppState> {
return Store(
reducer = { action, state -> appReducer(action, state) },
reducer = { action, state -> appReducer(action, requireNotNull(state)) },
middleware = AppState.getMiddleware(),
state = AppState(
daggerGraphState = DaggerGraphState(

View file

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

View file

@ -6,9 +6,7 @@ import com.tangem.tap.features.welcome.redux.WelcomeReducer
import com.tangem.tap.proxy.redux.DaggerGraphReducer
import org.rekotlin.Action
@Suppress("CanBeNonNullable")
fun appReducer(action: Action, state: AppState?): AppState {
requireNotNull(state)
fun appReducer(action: Action, state: AppState): AppState {
if (action is AppAction.RestoreState) return action.state
return AppState(

View file

@ -0,0 +1,11 @@
package com.tangem.tap.core.ui
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.ui.DesignFeatureToggles
import javax.inject.Inject
class DefaultDesignFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : DesignFeatureToggles {
override val isRedesignEnabled: Boolean = featureTogglesManager.isFeatureEnabled("APP_REDESIGN_ENABLED")
}

View file

@ -4,6 +4,7 @@ import androidx.compose.material3.SnackbarHostState
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.DefaultUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.EventMessageHandler
@ -23,12 +24,14 @@ internal object UiDependenciesModule {
fun provideUiDependencies(
vibratorHapticManager: VibratorHapticManager,
appThemeModeHolder: AppThemeModeHolder,
designFeatureToggles: DesignFeatureToggles,
): UiDependencies {
return object : UiDependencies {
override val vibratorHapticManager = vibratorHapticManager
override val appThemeModeHolder = appThemeModeHolder
override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState()
override val eventMessageHandler: EventMessageHandler = EventMessageHandler()
override val designFeatureToggles: DesignFeatureToggles = designFeatureToggles
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.tap.di.core.ui
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.tap.core.ui.DefaultDesignFeatureToggles
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface CoreUiBindsModule {
@Binds
fun bindDesignFeatureToggles(impl: DefaultDesignFeatureToggles): DesignFeatureToggles
}

View file

@ -115,7 +115,7 @@ object MarketsDomainModule {
return FilterAvailableNetworksForWalletUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
shouldUseNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
excludedBlockchains = excludedBlockchains,
)
}

View file

@ -142,22 +142,6 @@ internal object NFTDomainModule {
return GetWalletNFTEnabledUseCase(walletsRepository)
}
@Provides
@Singleton
fun provideClearNFTCacheUseCase(
nftCleaner: NFTCleaner,
currenciesRepository: CurrenciesRepository,
accountsFeatureToggles: AccountsFeatureToggles,
singleAccountListSupplier: SingleAccountListSupplier,
): ObserveAndClearNFTCacheIfNeedUseCase {
return ObserveAndClearNFTCacheIfNeedUseCase(
nftCleaner = nftCleaner,
currenciesRepository = currenciesRepository,
accountsFeatureToggles = accountsFeatureToggles,
singleAccountListSupplier = singleAccountListSupplier,
)
}
@Provides
@Singleton
fun provideGetNftCurrencyUseCase(nftRepository: NFTRepository): GetNFTCurrencyUseCase {

View file

@ -6,39 +6,48 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object NewsDomainModule {
@Provides
@Singleton
fun provideGetNewsCategoriesUseCase(repository: NewsRepository): GetNewsCategoriesUseCase {
return GetNewsCategoriesUseCase(repository)
}
@Provides
@Singleton
fun provideObserveNewsDetailsUseCase(repository: NewsRepository): ObserveNewsDetailsUseCase {
return ObserveNewsDetailsUseCase(repository)
}
@Provides
@Singleton
fun provideObserveTrendingNewsUseCase(repository: NewsRepository): ManageTrendingNewsUseCase {
return ManageTrendingNewsUseCase(repository)
}
@Provides
@Singleton
fun provideGetNewsListBatchFlowUseCase(repository: NewsRepository): GetNewsListBatchFlowUseCase {
return GetNewsListBatchFlowUseCase(repository)
}
@Provides
@Singleton
fun provideFetchTrendingNewsUseCase(repository: NewsRepository): FetchTrendingNewsUseCase {
return FetchTrendingNewsUseCase(repository)
}
@Provides
fun provideMarkArticleAsViewedUseCase(repository: NewsRepository): MarkArticleAsViewedUseCase {
return MarkArticleAsViewedUseCase(repository)
}
@Provides
fun provideToggleArticleLikedUseCase(repository: NewsRepository): ToggleArticleLikedUseCase {
return ToggleArticleLikedUseCase(repository)
}
@Provides
fun provideGetNewsUseCase(repository: NewsRepository): GetNewsUseCase {
return GetNewsUseCase(repository)
}
}

View file

@ -83,4 +83,14 @@ internal object NotificationsDomainModule {
): GetNetworksAvailableForNotificationsUseCase {
return GetNetworksAvailableForNotificationsUseCase(pushNotificationsRepository = pushNotificationsRepository)
}
@Provides
@Singleton
fun provideClearApplicationIdUseCase(
pushNotificationsRepository: PushNotificationsRepository,
): ClearApplicationIdUseCase {
return ClearApplicationIdUseCase(
pushNotificationsRepository = pushNotificationsRepository,
)
}
}

View file

@ -94,12 +94,12 @@ internal object StakingDomainModule {
@Singleton
fun provideFetchStakingOptionsUseCase(
stakeKitRepository: StakeKitRepository,
p2pRepository: P2PEthPoolRepository,
p2pEthPoolRepository: P2PEthPoolRepository,
stakingErrorResolver: StakingErrorResolver,
): FetchStakingOptionsUseCase {
return FetchStakingOptionsUseCase(
stakeKitRepository = stakeKitRepository,
p2pRepository = p2pRepository,
p2pEthPoolRepository = p2pEthPoolRepository,
stakingErrorResolver = stakingErrorResolver,
)
}

View file

@ -282,11 +282,13 @@ internal object TokensDomainModule {
currenciesRepository: CurrenciesRepository,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
dispatchers: CoroutineDispatcherProvider,
currencyChecksRepository: CurrencyChecksRepository,
): GetBalanceNotEnoughForFeeWarningUseCase {
return GetBalanceNotEnoughForFeeWarningUseCase(
currenciesRepository = currenciesRepository,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
dispatchers = dispatchers,
currencyChecksRepository = currencyChecksRepository,
)
}
@ -294,9 +296,8 @@ internal object TokensDomainModule {
@Singleton
fun provideIsAmountSubtractAvailableUseCase(
currenciesRepository: CurrenciesRepository,
dispatchers: CoroutineDispatcherProvider,
): IsAmountSubtractAvailableUseCase {
return IsAmountSubtractAvailableUseCase(currenciesRepository, dispatchers)
return IsAmountSubtractAvailableUseCase(currenciesRepository)
}
@Provides

View file

@ -5,12 +5,18 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.transaction.WalletAddressServiceRepository
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.*
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -21,7 +27,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
@Module
@InstallIn(SingletonComponent::class)
internal object TransactionDomainModule {
@ -266,6 +272,128 @@ internal object TransactionDomainModule {
return SendLargeSolanaTransactionUseCase(cardSdkConfigRepository, walletManagersFacade)
}
@Provides
@Singleton
fun provideGetAvailableFeeTokensUseCase(
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
currencyChecksRepository: CurrencyChecksRepository,
): GetAvailableFeeTokensUseCase {
return GetAvailableFeeTokensUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
currencyChecksRepository = currencyChecksRepository,
)
}
@Provides
@Singleton
fun provideGetFeeForGaslessUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getFeeUseCase: GetFeeUseCase,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
currencyChecksRepository: CurrencyChecksRepository,
): GetFeeForGaslessUseCase {
return GetFeeForGaslessUseCase(
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
gaslessTransactionRepository = gaslessTransactionRepository,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
getFeeUseCase = getFeeUseCase,
currencyChecksRepository = currencyChecksRepository,
)
}
@Provides
@Singleton
fun provideGetFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
currencyChecksRepository: CurrencyChecksRepository,
): GetFeeForTokenUseCase {
return GetFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
currencyChecksRepository = currencyChecksRepository,
)
}
@Provides
@Singleton
fun provideIsGaslessFeeSupportedForNetwork(
currencyChecksRepository: CurrencyChecksRepository,
): IsGaslessFeeSupportedForNetwork {
return IsGaslessFeeSupportedForNetwork(currencyChecksRepository)
}
@Provides
@Singleton
fun provideCreateAndSendGaslessTransactionUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
getSingCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
cardSdkConfigRepository: CardSdkConfigRepository,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
): CreateAndSendGaslessTransactionUseCase {
return CreateAndSendGaslessTransactionUseCase(
walletManagersFacade = walletManagersFacade,
getSingleCryptoCurrencyStatusUseCase = getSingCryptoCurrencyStatusUseCase,
gaslessTransactionRepository = gaslessTransactionRepository,
cardSdkConfigRepository = cardSdkConfigRepository,
getHotWalletSigner = tangemHotWalletSignerFactory::create,
)
}
@Provides
@Singleton
fun provideEstimateFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
currencyChecksRepository: CurrencyChecksRepository,
): EstimateFeeForTokenUseCase {
return EstimateFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
currencyChecksRepository = currencyChecksRepository,
)
}
@Provides
@Singleton
fun provideEstimateFeeForGaslessTxUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
estimateFeeUseCase: EstimateFeeUseCase,
currencyChecksRepository: CurrencyChecksRepository,
): EstimateFeeForGaslessTxUseCase {
return EstimateFeeForGaslessTxUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
estimateFeeUseCase = estimateFeeUseCase,
currencyChecksRepository = currencyChecksRepository,
)
}
@Provides
@Singleton
fun provideSignCloreMessageUseCase(

View file

@ -336,10 +336,14 @@ internal object WalletsDomainModule {
fun providesUpdateRemoteWalletsInfoUseCase(
walletsRepository: WalletsRepository,
userWalletsSyncDelegate: UserWalletsSyncDelegate,
userWalletsListRepository: UserWalletsListRepository,
generateWalletNameUseCase: GenerateWalletNameUseCase,
): UpdateRemoteWalletsInfoUseCase {
return UpdateRemoteWalletsInfoUseCase(
walletsRepository = walletsRepository,
userWalletsSyncDelegate = userWalletsSyncDelegate,
generateWalletNameUseCase = generateWalletNameUseCase,
userWalletsListRepository = userWalletsListRepository,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di.domain
import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.transaction.FeeRepository
@ -14,6 +15,8 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@ -201,6 +204,16 @@ internal object YieldSupplyDomainModule {
)
}
@Provides
@Singleton
fun provideYieldSupplyEnterStatusFlowUseCase(
yieldSupplyRepository: YieldSupplyRepository,
): YieldSupplyEnterStatusFlowUseCase {
return YieldSupplyEnterStatusFlowUseCase(
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetShouldShowMainPromoUseCase(
@ -234,4 +247,18 @@ internal object YieldSupplyDomainModule {
): YieldSupplyGetAvailabilityUseCase {
return YieldSupplyGetAvailabilityUseCase(yieldSupplyRepository)
}
@Provides
@Singleton
fun provideYieldSupplyPendingProcessorUseCase(
yieldSupplyRepository: YieldSupplyRepository,
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
dispatcherProvider: CoroutineDispatcherProvider,
): YieldSupplyPendingTracker {
return YieldSupplyPendingTracker(
yieldSupplyRepository = yieldSupplyRepository,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
coroutineScope = CoroutineScope(SupervisorJob() + dispatcherProvider.io),
)
}
}

View file

@ -141,6 +141,14 @@ object WalletMockContent : MockContent {
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey(
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey(
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
),
extendedPublicKey = ExtendedPublicKey(
publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5),
@ -261,6 +269,20 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey( // Vechain
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
),
),
ByteArrayKey(
@ -318,6 +340,20 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey( // Vechain
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
),
),
@ -371,6 +407,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey( // Vechain
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
),
),
ByteArrayKey(

View file

@ -21,9 +21,11 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.notifications.ClearApplicationIdUseCase
import com.tangem.domain.notifications.GetApplicationIdUseCase
import com.tangem.domain.notifications.SendPushTokenUseCase
import com.tangem.domain.notifications.models.ApplicationId
import com.tangem.domain.notifications.models.NotificationsError
import com.tangem.domain.onramp.FetchHotCryptoUseCase
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.models.StoryContentIds
@ -72,6 +74,7 @@ internal class MainViewModel @Inject constructor(
private val getApplicationIdUseCase: GetApplicationIdUseCase,
private val subscribeOnWalletsUseCase: GetSavedWalletsCountUseCase,
private val associateWalletsWithApplicationIdUseCase: AssociateWalletsWithApplicationIdUseCase,
private val clearApplicationIdUseCase: ClearApplicationIdUseCase,
private val updateRemoteWalletsInfoUseCase: UpdateRemoteWalletsInfoUseCase,
private val sendPushTokenUseCase: SendPushTokenUseCase,
private val apiConfigsManager: ApiConfigsManager,
@ -104,7 +107,7 @@ internal class MainViewModel @Inject constructor(
launch { fetchStakingOptions() }
launch { initPushNotifications() }
launch(dispatchers.default) { initPushNotifications() }
}
viewModelScope.launch { incrementAppLaunchCounterUseCase() }
@ -398,13 +401,27 @@ internal class MainViewModel @Inject constructor(
private suspend fun initPushNotifications() {
getApplicationIdUseCase()
.onRight { applicationId ->
sendPushTokenUseCase(applicationId = applicationId)
associateWalletsWithApplicationId(applicationId = applicationId)
updateRemoteWalletsInfoUseCase(applicationId = applicationId)
sendPushTokenUseCase(applicationId = applicationId).onLeft { error ->
if (error is NotificationsError.ApplicationIdNotFound) {
clearApplicationIdUseCase()
getApplicationIdUseCase().onRight { newApplicationId ->
associateAndUpdateWallets(applicationId = newApplicationId)
}
} else {
associateAndUpdateWallets(applicationId = applicationId)
}
}.onRight {
associateAndUpdateWallets(applicationId = applicationId)
}
}
.onLeft(Timber::e)
}
private suspend fun associateAndUpdateWallets(applicationId: ApplicationId) {
associateWalletsWithApplicationId(applicationId = applicationId)
updateRemoteWalletsInfoUseCase(applicationId = applicationId)
}
private fun associateWalletsWithApplicationId(applicationId: ApplicationId) {
subscribeOnWalletsUseCase()
.onEach { wallets ->

View file

@ -59,6 +59,17 @@ internal class DefaultAuthProvider(
}
}
override fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String> {
return ProviderSuspend {
when (apiEnvironment.invoke()) {
ApiEnvironment.DEV,
-> environmentConfigStorage.getConfigSync().gaslessTxApiKeyDev
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().gaslessTxApiKey
else -> error("No gasless tx api config provided for ${apiEnvironment.invoke()}")
} ?: error("No gasless tx api config provided")
}
}
private suspend fun getWallets(): List<UserWallet> {
return if (shouldUseNewListRepository) {
userWalletsListRepository.userWalletsSync()

View file

@ -1,7 +1,7 @@
package com.tangem.tap.network.auth
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider
internal class DefaultP2PEthPoolAuthProvider(
@ -12,6 +12,6 @@ internal class DefaultP2PEthPoolAuthProvider(
val keys = environmentConfigStorage.getConfigSync().p2pApiKey
?: error("No P2P api keys provided")
return if (P2PStakingConfig.USE_TESTNET) keys.hoodi else keys.mainnet
return if (P2PEthPoolStakingConfig.USE_TESTNET) keys.hoodi else keys.mainnet
}
}

View file

@ -1,79 +0,0 @@
package com.tangem.tap.proxy
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.lib.crypto.UserWalletManager
import com.tangem.lib.crypto.models.ProxyAmount
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.math.BigDecimal
class UserWalletManagerImpl(
private val walletManagersFacade: WalletManagersFacade,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
private val dispatchers: CoroutineDispatcherProvider,
) : UserWalletManager {
override fun getWalletId(): String {
val selectedUserWallet = requireNotNull(
getSelectedWalletUseCase.sync().getOrNull(),
) { "selectedUserWallet shouldn't be null" }
return selectedUserWallet.walletId.stringValue
}
override suspend fun hideAllTokens() {
// FIXME: Used only in Tester Actions
Timber.w("Not implemented")
}
override suspend fun getWalletAddress(networkId: String, derivationPath: String?): String {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
val walletManager = getActualWalletManager(blockchain, derivationPath)
return walletManager.wallet.address
}
override suspend fun getLastTransactionHash(networkId: String, derivationPath: String?): String? {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
val walletManager = getActualWalletManager(blockchain, derivationPath)
return walletManager.wallet.recentTransactions
.lastOrNull { it.hash?.isNotEmpty() == true }
?.hash
}
override suspend fun getNativeTokenBalance(networkId: String, derivationPath: String?): ProxyAmount? {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
val walletManager = getActualWalletManager(blockchain, derivationPath)
return walletManager.wallet.amounts.firstNotNullOfOrNull { amountEntry ->
amountEntry.takeIf { amountEntry.key is AmountType.Coin }
}?.value?.let { amount ->
ProxyAmount(
amount.currencySymbol,
amount.value ?: BigDecimal.ZERO,
amount.decimals,
)
}
}
@Throws(IllegalArgumentException::class)
private suspend fun getActualWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager {
val selectedUserWallet = requireNotNull(
getSelectedWalletUseCase.sync().getOrNull(),
) { "userWallet or userWalletsListManager is null" }
val walletManager = withContext(dispatchers.io) {
walletManagersFacade.getOrCreateWalletManager(
selectedUserWallet.walletId,
blockchain,
derivationPath,
)
}
return requireNotNull(walletManager) {
"No wallet manager found"
}
}
}

View file

@ -1,11 +1,6 @@
package com.tangem.tap.proxy.di
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.lib.crypto.UserWalletManager
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.proxy.UserWalletManagerImpl
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -21,18 +16,4 @@ internal object ProxyModule {
fun provideAppStateHolder(): AppStateHolder {
return AppStateHolder()
}
@Provides
@Singleton
fun provideUserWalletManager(
walletManagersFacade: WalletManagersFacade,
getSelectedWalletUseCase: GetSelectedWalletUseCase,
dispatchers: CoroutineDispatcherProvider,
): UserWalletManager {
return UserWalletManagerImpl(
walletManagersFacade = walletManagersFacade,
getSelectedWalletUseCase = getSelectedWalletUseCase,
dispatchers = dispatchers,
)
}
}

View file

@ -16,6 +16,9 @@ import com.tangem.features.createwalletselection.CreateWalletSelectionComponent
import com.tangem.features.createwalletstart.CreateWalletStartComponent
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.*
import com.tangem.features.kyc.KycComponent
@ -38,15 +41,11 @@ import com.tangem.features.staking.api.StakingComponent
import com.tangem.features.swap.SwapComponent
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.ContinueOnboarding
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.Deeplink
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.FromBannerOnMain
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.FromBannerInSettings
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.*
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.wallet.WalletEntryComponent
import com.tangem.features.walletconnect.components.WalletConnectEntryComponent
import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent
import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent
import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent
import com.tangem.tap.features.details.ui.appcurrency.api.AppCurrencySelectorComponent
import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent
import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent
@ -118,9 +117,10 @@ internal class ChildFactory @Inject constructor(
private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory,
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory,
private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory,
private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val feedFeatureToggle: FeedFeatureToggle,
) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -147,6 +147,7 @@ internal class ChildFactory @Inject constructor(
val source = when (route.source) {
AppRoute.ManageTokens.Source.SETTINGS -> ManageTokensSource.SETTINGS
AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES
AppRoute.ManageTokens.Source.ACCOUNT -> ManageTokensSource.ACCOUNT
}
val mode = when (val portfolio = route.portfolioId) {
@ -205,21 +206,39 @@ internal class ChildFactory @Inject constructor(
)
}
is AppRoute.MarketsTokenDetails -> {
createComponentChild(
context = context,
params = MarketsTokenDetailsComponent.Params(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
MarketsTokenDetailsComponent.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = marketsTokenDetailsComponentFactory,
)
if (feedFeatureToggle.isFeedEnabled) {
createComponentChild(
context = context,
params = FeedEntryRoute.MarketTokenDetails(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
FeedEntryRoute.MarketTokenDetails.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = feedEntryComponentFactory,
)
} else {
createComponentChild(
context = context,
params = MarketsTokenDetailsComponent.Params(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
MarketsTokenDetailsComponent.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = marketsTokenDetailsComponentFactory,
)
}
}
is AppRoute.Onramp -> {
createComponentChild(
@ -312,7 +331,7 @@ internal class ChildFactory @Inject constructor(
params = StakingComponent.Params(
userWalletId = route.userWalletId,
cryptoCurrency = route.cryptoCurrency,
yieldId = route.yieldId,
integrationId = route.integrationId,
),
componentFactory = stakingComponentFactory,
)
@ -673,9 +692,7 @@ internal class ChildFactory @Inject constructor(
deeplink = mode.deeplink,
)
is AppRoute.TangemPayOnboarding.Mode.FromBannerInSettings -> FromBannerInSettings
is AppRoute.TangemPayOnboarding.Mode.FromBannerOnMain -> FromBannerOnMain(
userWalletId = mode.userWalletId,
)
is AppRoute.TangemPayOnboarding.Mode.FromBannerOnMain -> FromBannerOnMain
},
componentFactory = tangemPayOnboardingComponentFactory,
)
@ -687,25 +704,25 @@ internal class ChildFactory @Inject constructor(
componentFactory = kycComponentFactory,
)
}
is AppRoute.YieldSupplyPromo -> {
is AppRoute.YieldSupplyEntry -> {
createComponentChild(
context = context,
params = YieldSupplyPromoComponent.Params(
userWalletId = route.userWalletId,
currency = route.cryptoCurrency,
apy = route.apy,
),
componentFactory = yieldSupplyPromoComponentFactory,
)
}
is AppRoute.YieldSupplyActive -> {
createComponentChild(
context = context,
params = YieldSupplyActiveComponent.Params(
params = YieldSupplyEntryComponent.Params(
userWalletId = route.userWalletId,
cryptoCurrency = route.cryptoCurrency,
apy = route.apy,
),
componentFactory = yieldSupplyActiveComponentFactory,
componentFactory = yieldSupplyEntryComponentFactory,
)
}
is AppRoute.NewsDetails -> {
createComponentChild(
context = context,
params = FeedEntryRoute.NewsDetail(
articleId = route.newsId,
preselectedArticlesId = listOf(route.newsId),
),
componentFactory = feedEntryComponentFactory,
)
}
}

View file

@ -6,6 +6,8 @@ import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
@ -50,6 +52,8 @@ internal class DeepLinkFactory @Inject constructor(
private val swapDeepLink: SwapDeepLinkHandler.Factory,
private val promoDeepLink: PromoDeeplinkHandler.Factory,
private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory,
private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory,
private val feedFeatureToggle: FeedFeatureToggle,
) {
private val permittedAppRoute = MutableStateFlow(false)
@ -102,7 +106,7 @@ internal class DeepLinkFactory @Inject constructor(
private fun launchDeepLink(deeplinkUri: Uri, coroutineScope: CoroutineScope, isFromOnNewIntent: Boolean) {
when (deeplinkUri.scheme) {
DeepLinkScheme.Https.scheme -> handleHttpDeepLinks(deeplinkUri)
DeepLinkScheme.Https.scheme -> handleHttpDeepLinks(deeplinkUri, coroutineScope)
DeepLinkScheme.Tangem.scheme -> handleTangemDeepLinks(deeplinkUri, coroutineScope, isFromOnNewIntent)
DeepLinkScheme.WalletConnect.scheme -> walletConnectDeepLink.create(deeplinkUri)
else -> {
@ -116,10 +120,18 @@ internal class DeepLinkFactory @Inject constructor(
}
}
private fun handleHttpDeepLinks(deeplinkUri: Uri) {
if (deeplinkUri.host == DeepLinkRoute.PayApp.host && deeplinkUri.path?.startsWith("/pay-app") == true) {
onboardVisaDeepLink.create(deeplinkUri)
return
private fun handleHttpDeepLinks(deeplinkUri: Uri, coroutineScope: CoroutineScope) {
if (deeplinkUri.host == DeepLinkRoute.PayApp.host) {
when {
deeplinkUri.path?.startsWith("/pay-app") == true -> {
onboardVisaDeepLink.create(deeplinkUri)
return
}
deeplinkUri.path?.startsWith("/news") == true && feedFeatureToggle.isFeedEnabled -> {
newsDetailsDeepLink.create(coroutineScope, deeplinkUri)
return
}
}
}
}

View file

@ -16,7 +16,7 @@
</style>
<style name="SplashTheme" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/background_primary</item>
<item name="windowSplashScreenBackground">@color/background_secondary</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/inset_splash</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>

View file

@ -6,4 +6,9 @@
<certificates src="user" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>

View file

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

View file

@ -4,6 +4,8 @@ import android.net.Uri
import com.tangem.common.routing.AppRoute
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
@ -81,6 +83,12 @@ class DeepLinkFactoryTest {
private val cardSdkProvider = mockk<CardSdkProvider>(relaxed = true) {
every { sdk.uiVisibility() } returns MutableStateFlow(false)
}
private val newsDeeplink = mockk<NewsDetailsDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
}
private val feedFeatureToggle = mockk<FeedFeatureToggle>()
private val mockedUri = mockk<Uri>(relaxed = true)
private val isFromOnNewIntent: Boolean = false
@ -103,6 +111,8 @@ class DeepLinkFactoryTest {
swapDeepLink = swapDeepLinkFactory,
promoDeepLink = promoDeepLinkFactory,
onboardVisaDeepLink = onboardVisaDeepLink,
newsDetailsDeepLink = newsDeeplink,
feedFeatureToggle = feedFeatureToggle,
)
@OptIn(ExperimentalCoroutinesApi::class)

View file

@ -19,7 +19,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.staking)
implementation(projects.domain.markets.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.appCurrency.models)

View file

@ -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.domain.staking.model.StakingIntegrationID
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.feedback.models.WalletMetaInfo
@ -137,6 +138,7 @@ sealed class AppRoute(val path: String) : Route {
enum class Source {
STORIES,
SETTINGS,
ACCOUNT,
}
}
@ -209,8 +211,8 @@ sealed class AppRoute(val path: String) : Route {
data class Staking(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val yieldId: String,
) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrency.id.value}/$yieldId")
val integrationId: StakingIntegrationID,
) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrency.id.value}/${integrationId.value}")
@Serializable
data class PushNotification(
@ -443,9 +445,7 @@ sealed class AppRoute(val path: String) : Route {
) : Mode()
@Serializable
data class FromBannerOnMain(
val userWalletId: UserWalletId,
) : Mode()
data object FromBannerOnMain : Mode()
@Serializable
data object FromBannerInSettings : Mode()
@ -456,16 +456,12 @@ sealed class AppRoute(val path: String) : Route {
data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc")
@Serializable
data class YieldSupplyPromo(
data class YieldSupplyEntry(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val apy: String,
) : AppRoute(path = "/yield_supply_promo/${userWalletId.stringValue}/${cryptoCurrency.symbol}")
) : AppRoute(path = "/yield_supply_entry/${userWalletId.stringValue}/${cryptoCurrency.symbol}")
@Serializable
data class YieldSupplyActive(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val apy: String,
) : AppRoute(path = "/yield_supply_active/${userWalletId.stringValue}/${cryptoCurrency.symbol}")
data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId")
}

View file

@ -31,5 +31,9 @@ object TangemBlogUrlBuilder {
data object WhatWalletToChoose : Post {
override val path: String = "mobile-wallet"
}
data object WhatIsTransactionFee : Post {
override val path: String = "what-is-a-transaction-fee-and-why-do-we-need-it"
}
}
}

View file

@ -8,7 +8,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import java.math.BigDecimal
/**
* Factory for creating mock P2P ETH Pool account responses for testing
* Factory for creating mock P2PEthPool account responses for testing
*/
object MockP2PEthPoolAccountResponseFactory {
@ -35,7 +35,7 @@ object MockP2PEthPoolAccountResponseFactory {
availableToUnstake = stakedAmount,
availableToWithdraw = BigDecimal.ZERO,
exitQueue = P2PEthPoolExitQueueDTO(
total = 0.0,
total = BigDecimal.ZERO,
requests = emptyList(),
),
)
@ -55,7 +55,7 @@ object MockP2PEthPoolAccountResponseFactory {
availableToUnstake = BigDecimal.ZERO,
availableToWithdraw = BigDecimal.ZERO,
exitQueue = P2PEthPoolExitQueueDTO(
total = 0.0,
total = BigDecimal.ZERO,
requests = emptyList(),
),
)

View file

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

View file

@ -9,20 +9,11 @@
<ID>BooleanPropertyNaming:NotificationUM.kt$NotificationUM.Error.ExceedsBalance$val mergeFeeNetworkName: Boolean = false</ID>
<ID>BooleanPropertyNaming:NotificationsFactory.kt$NotificationsFactory$val showNotification = sendingAmount + feeAmount &gt; balance - minimumRequirement.orZero()</ID>
<ID>BooleanPropertyNaming:TokenReceiveBottomSheetConfig.kt$TokenReceiveBottomSheetConfig$val showMemoDisclaimer: Boolean</ID>
<ID>CanBeNonNullable:AmountBlockV2.kt$onClick: (() -&gt; Unit)? = null</ID>
<ID>CanBeNonNullable:NavigationButtonsBlock.kt$footerText: TextReference?</ID>
<ID>CanBeNonNullable:NavigationButtonsBlock.kt$pairButtons: Pair&lt;NavigationButton, NavigationButton&gt;?</ID>
<ID>CanBeNonNullable:NavigationButtonsBlock.kt$prevButton: NavigationButton?</ID>
<ID>CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$dustValue: BigDecimal?</ID>
<ID>CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$minAdaValue: BigDecimal?</ID>
<ID>CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$minimumSendAmount: BigDecimal?</ID>
<ID>CanBeNonNullable:NotificationsFactory.kt$NotificationsFactory$rentWarning: CryptoCurrencyWarning.Rent?</ID>
<ID>MultilineLambdaItParameter:ExpressStatusItems.kt${ val itemInfo = expressTxs[it].info val (iconRes, tint) = when (itemInfo.iconState) { ExpressTransactionStateIconUM.Warning -&gt; { R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention } ExpressTransactionStateIconUM.Error -&gt; { R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning } ExpressTransactionStateIconUM.None -&gt; null to null } ExpressStatusItem( title = itemInfo.title, fromTokenIconState = itemInfo.fromCurrencyIcon, toTokenIconState = itemInfo.toCurrencyIcon, fromAmount = itemInfo.fromAmount, fromSymbol = itemInfo.fromAmountSymbol, toAmount = itemInfo.toAmount, toSymbol = itemInfo.toAmountSymbol, onClick = itemInfo.onClick, infoIconRes = iconRes, infoIconTint = tint, modifier = modifier.animateItem(), ) }</ID>
<ID>MultilineLambdaItParameter:TokenItemStateConverter.kt$TokenItemStateConverter.Companion${ it.key.equals( other = token.yieldSupplyKey(), ignoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId), ) }</ID>
<ID>NoNameShadowing:NavigationButtonsBlock.kt$navigationUM</ID>
<ID>NoNameShadowing:UserWalletItem.kt$balance</ID>
<ID>NullableBooleanCheck:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive ?: false</ID>
<ID>NullableToStringCall:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$${id.rawCurrencyId}</ID>
<ID>ReusedModifierInstance:AddTokenContent.kt$AddButton( modifier = modifier.fillMaxWidth(), state = state.button, )</ID>
<ID>UnnecessaryEventHandlerParameter:SendDoneButtons.kt$onShareClick: (String) -&gt; Unit</ID>
<ID>UnnecessaryLet:TokenItemStateConverter.kt$TokenItemStateConverter.Companion$let(::add)</ID>

View file

@ -19,7 +19,6 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.quote.PriceChange
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
class AccountCryptoPortfolioItemStateConverter(
@ -41,14 +40,11 @@ class AccountCryptoPortfolioItemStateConverter(
private fun Account.CryptoPortfolio.mapToContentState(
fiatBalance: TotalFiatBalance.Loaded,
): TokenItemState.Content {
val subtitle2State = when (fiatBalance.amount.isZero()) {
true -> null
false -> priceChangeLce?.fold(
ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading },
ifError = { null },
ifContent = { priceChange -> priceChange.toSubtitle2State() },
)
}
val subtitle2State = priceChangeLce?.fold(
ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading },
ifError = { null },
ifContent = { priceChange -> priceChange.toSubtitle2State() },
)
return TokenItemState.Content(
id = account.accountId.toItemId(),
iconState = AccountIconItemStateConverter.convert(this),
@ -120,7 +116,7 @@ class AccountCryptoPortfolioItemStateConverter(
)
}
private fun AccountId.toItemId() = this.userWalletId.stringValue + this.value
private fun AccountId.toItemId() = this.value
private fun BigDecimal.getPriceChangeType(): PriceChangeType = PriceChangeConverter.fromBigDecimal(value = this)

View file

@ -6,6 +6,7 @@ import com.tangem.common.ui.R
import com.tangem.core.ui.components.account.AccountCharIcon
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.account.AccountResIcon
import com.tangem.core.ui.components.account.PaymentAccountIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
@ -16,7 +17,7 @@ import com.tangem.domain.models.account.CryptoPortfolioIcon.Color
* - a single character (derived from the [name]) using [AccountCharIcon], or
* - a predefined vector resource (from [icon]) using [AccountResIcon].
*
* The background color is taken from [CryptoPortfolioIconUM.color],
* The background color is taken from [AccountIconUM.CryptoPortfolio.color],
* and the icon size, text style, and container shape are adapted based on the given [size].
*
* @param name A [TextReference] used to resolve and display the first letter
@ -31,29 +32,43 @@ import com.tangem.domain.models.account.CryptoPortfolioIcon.Color
@Composable
fun AccountIcon(
name: TextReference,
icon: CryptoPortfolioIconUM,
icon: AccountIconUM.CryptoPortfolio,
size: AccountIconSize,
modifier: Modifier = Modifier,
) {
val letter = name.resolveReference().firstOrNull()
val iconColor = icon.color.getUiColor()
when {
icon.value == CryptoPortfolioIcon.Icon.Letter && letter == null -> {
AccountResIcon(
resId = R.drawable.ic_tangem_24,
color = icon.color.getUiColor(),
color = iconColor,
size = size,
modifier = modifier,
)
}
icon.value == CryptoPortfolioIcon.Icon.Letter -> AccountCharIcon(
char = letter ?: 'N',
color = icon.color.getUiColor(),
color = iconColor,
size = size,
modifier = modifier,
)
else -> AccountResIcon(
resId = icon.value.getResId(),
color = icon.color.getUiColor(),
color = iconColor,
size = size,
modifier = modifier,
)
}
}
@Composable
fun AccountIcon(name: TextReference, icon: AccountIconUM, size: AccountIconSize, modifier: Modifier = Modifier) {
when (icon) {
is AccountIconUM.Payment -> PaymentAccountIcon(size = size, modifier = modifier)
is AccountIconUM.CryptoPortfolio -> AccountIcon(
name = name,
icon = icon,
size = size,
modifier = modifier,
)
@ -62,7 +77,7 @@ fun AccountIcon(
object AccountIconPreviewData {
fun randomAccountIcon(letter: Boolean = false) = CryptoPortfolioIconUM(
fun randomAccountIcon(letter: Boolean = false) = AccountIconUM.CryptoPortfolio(
value = if (letter) CryptoPortfolioIcon.Icon.Letter else CryptoPortfolioIcon.Icon.entries.random(),
color = Color.entries.random(),
)

View file

@ -20,5 +20,6 @@ object AccountIconItemStateConverter : Converter<Account, CurrencyIconState.Cryp
isGrayscale = false,
)
}
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.common.ui.account
import com.tangem.domain.models.account.CryptoPortfolioIcon.Color
import com.tangem.domain.models.account.CryptoPortfolioIcon.Icon
sealed class AccountIconUM {
data class CryptoPortfolio(val value: Icon, val color: Color) : AccountIconUM()
data object Payment : AccountIconUM()
}

View file

@ -27,7 +27,7 @@ import com.tangem.core.ui.res.TangemTheme
@Composable
fun AccountLabel(
name: TextReference,
icon: CryptoPortfolioIconUM,
icon: AccountIconUM,
iconSize: AccountIconSize,
modifier: Modifier = Modifier,
nameStyle: TextStyle = TangemTheme.typography.subtitle2,

View file

@ -22,21 +22,24 @@ class AccountPortfolioItemUMConverter(
) : Converter<Account, UserWalletItemUM> {
override fun convert(value: Account): UserWalletItemUM {
return with(value) {
UserWalletItemUM(
id = accountId.value,
name = accountName.toUM().value,
information = getInfo(account = this),
balance = getBalanceInfo(),
isEnabled = isEnabled,
endIcon = endIcon,
onClick = onClick,
imageState = getImageState(account = this),
)
return when (value) {
is Account.CryptoPortfolio -> with(value) {
UserWalletItemUM(
id = accountId.value,
name = accountName.toUM().value,
information = getInfo(account = this),
balance = getBalanceInfo(),
isEnabled = isEnabled,
endIcon = endIcon,
onClick = onClick,
imageState = getImageState(account = this),
)
}
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
}
private fun getInfo(account: Account): UserWalletItemUM.Information.Loaded = when (account) {
private fun getInfo(account: Account.CryptoPortfolio): UserWalletItemUM.Information.Loaded = when (account) {
is Account.CryptoPortfolio -> {
val text = pluralReference(
R.plurals.common_tokens_count,
@ -45,13 +48,15 @@ class AccountPortfolioItemUMConverter(
)
UserWalletItemUM.Information.Loaded(text)
}
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
private fun getImageState(account: Account) = when (account) {
private fun getImageState(account: Account.CryptoPortfolio) = when (account) {
is Account.CryptoPortfolio -> UserWalletItemUM.ImageState.Account(
name = account.accountName.toUM().value,
icon = account.icon.toUM(),
icon = CryptoPortfolioIconConverter.convert(account.icon),
)
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
private fun getBalanceInfo(): UserWalletItemUM.Balance {

View file

@ -41,7 +41,7 @@ import com.tangem.core.ui.res.TangemThemePreview
fun AccountRow(
title: TextReference,
subtitle: TextReference,
icon: CryptoPortfolioIconUM,
icon: AccountIconUM.CryptoPortfolio,
modifier: Modifier = Modifier,
isReverse: Boolean = false,
) {

View file

@ -1,7 +1,9 @@
package com.tangem.common.ui.account
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -9,11 +11,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SendScreenTestTags
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.utils.StringsSigns
/**
* A composable function that displays an account label (icon + name) with an optional prefix.
@ -61,4 +68,28 @@ fun AccountTitle(
)
}
}
}
@Preview
@Composable
private fun PreviewAccountTitle() {
TangemThemePreview {
Column {
AccountTitle(
accountTitleUM = AccountTitleUM.Account(
prefixText = stringReference(StringsSigns.DOT),
name = stringReference("Main Wallet"),
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Bookmark,
color = CryptoPortfolioIcon.Color.Pattypan,
),
),
modifier = Modifier.padding(4.dp),
)
AccountTitle(
accountTitleUM = AccountTitleUM.Account.payment(prefixText = stringReference(StringsSigns.DOT)),
modifier = Modifier.padding(4.dp),
)
}
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.common.ui.account
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
/**
* A sealed interface representing the title of an account, which can be either a simple text
@ -19,6 +21,16 @@ sealed interface AccountTitleUM {
data class Account(
val prefixText: TextReference,
val name: TextReference,
val icon: CryptoPortfolioIconUM,
) : AccountTitleUM
val icon: AccountIconUM,
) : AccountTitleUM {
companion object {
fun payment(prefixText: TextReference = TextReference.EMPTY): Account {
return Account(
prefixText = prefixText,
name = resourceReference(R.string.tangempay_payment_account),
icon = AccountIconUM.Payment,
)
}
}
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.common.ui.account
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.utils.converter.TwoWayConverter
object CryptoPortfolioIconConverter : TwoWayConverter<CryptoPortfolioIcon, AccountIconUM.CryptoPortfolio> {
override fun convert(value: CryptoPortfolioIcon): AccountIconUM.CryptoPortfolio {
return AccountIconUM.CryptoPortfolio(value = value.value, color = value.color)
}
override fun convertBack(value: AccountIconUM.CryptoPortfolio): CryptoPortfolioIcon {
return CryptoPortfolioIcon.ofCustomAccount(value = value.value, color = value.color)
}
}

View file

@ -47,7 +47,4 @@ fun CryptoPortfolioIcon.Icon.getResId(): Int {
CryptoPortfolioIcon.Icon.Package -> R.drawable.ic_package_24
CryptoPortfolioIcon.Icon.Gift -> R.drawable.ic_gift_24
}
}
fun CryptoPortfolioIcon.toUM() = CryptoPortfolioIconUM(domainModel = this)
fun CryptoPortfolioIconUM.toDomain() = CryptoPortfolioIcon.ofCustomAccount(value = this.value, color = this.color)
}

View file

@ -1,15 +0,0 @@
package com.tangem.common.ui.account
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.CryptoPortfolioIcon.Color
import com.tangem.domain.models.account.CryptoPortfolioIcon.Icon
data class CryptoPortfolioIconUM(
val value: Icon,
val color: Color,
) {
constructor(domainModel: CryptoPortfolioIcon) : this(
value = domainModel.value,
color = domainModel.color,
)
}

View file

@ -82,7 +82,7 @@ fun PortfolioSelectRow(
@Immutable
data class PortfolioSelectUM(
val icon: CryptoPortfolioIconUM?,
val icon: AccountIconUM.CryptoPortfolio?,
val name: TextReference,
val isAccountMode: Boolean,
val isMultiChoice: Boolean,

View file

@ -1,6 +1,7 @@
package com.tangem.common.ui.amountScreen.converters
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.Account
@ -15,7 +16,7 @@ class AmountAccountConverter(
return if (value != null && isAccountsMode) {
AccountTitleUM.Account(
name = value.accountName.toUM().value,
icon = value.icon.toUM(),
icon = CryptoPortfolioIconConverter.convert(value.icon),
prefixText = prefixText,
)
} else {

View file

@ -5,7 +5,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import com.tangem.common.ui.R
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
@ -88,7 +88,7 @@ object AmountStatePreviewData {
val amountStateV2Accounts = amountState.copy(
accountTitleUM = AccountTitleUM.Account(
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
prefixText = resourceReference(R.string.common_from),
),
availableBalanceCrypto = stringReference("2 130,81231238 USDT"),

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -19,7 +20,6 @@ import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.format.bigdecimal.crypto
@ -66,11 +66,14 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis
state = amountState.tokenIconState,
iconSize = 40.dp,
)
ResizableText(
Text(
text = firstAmount,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography.h2.fontSize,
),
maxLines = 1,
modifier = Modifier
.fillMaxWidth()

View file

@ -4,6 +4,7 @@ import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -18,7 +19,6 @@ import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
@ -128,21 +128,23 @@ private fun AmountBlockV2(
.padding(top = 8.dp)
.weight(1f),
) {
ResizableText(
Text(
text = firstAmount,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.h2.fontSize),
modifier = Modifier.testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT),
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
ResizableText(
Text(
text = secondAmount,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.body2.fontSize),
modifier = Modifier.testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT),
)
extraContent()

View file

@ -1,15 +1,23 @@
package com.tangem.common.ui.bottomsheet.chooseaddress
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.bottomsheet.receive.AddressModel
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SimpleSettingsRow
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.persistentListOf
@Composable
fun ChooseAddressBottomSheet(config: TangemBottomSheetConfig) {
@ -31,4 +39,40 @@ private fun ChooseAddressBottomSheetContent(content: ChooseAddressBottomSheetCon
)
}
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun ChooseAddressBottomSheet_Preview(
@PreviewParameter(ChooseAddressBottomSheetPreviewProvider::class) params: ChooseAddressBottomSheetConfig,
) {
TangemThemePreview {
ChooseAddressBottomSheetContent(params)
}
}
private class ChooseAddressBottomSheetPreviewProvider : PreviewParameterProvider<ChooseAddressBottomSheetConfig> {
override val values: Sequence<ChooseAddressBottomSheetConfig>
get() = sequenceOf(
ChooseAddressBottomSheetConfig(
addressModels = persistentListOf(
AddressModel(
displayName = stringReference("Main address"),
fullName = stringReference("Bitcoin"),
value = "0x1234...abcd",
type = AddressModel.Type.Default,
),
AddressModel(
displayName = stringReference("Legacy address"),
fullName = stringReference("Bitcoin"),
value = "0x1234...abcd",
type = AddressModel.Type.Default,
),
),
onClick = {},
),
)
}
// endregion

View file

@ -1,10 +1,9 @@
package com.tangem.common.ui.bottomsheet.chooseaddress
import com.tangem.common.ui.bottomsheet.receive.AddressModel
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.NetworkAddress
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -14,13 +13,12 @@ class ChooseAddressBottomSheetConfig(
val onClick: (AddressModel) -> Unit,
) : TangemBottomSheetConfigContent {
constructor(
asset: TokenReceiveBottomSheetConfig.Asset,
network: Network,
currency: CryptoCurrency,
networkAddress: NetworkAddress,
onClick: (AddressModel) -> Unit,
) : this(
addressModels = networkAddress.availableAddresses
.mapToAddressModels(asset, network)
.mapToAddressModels(cryptoCurrency = currency)
.toImmutableList(),
onClick = onClick,
)

View file

@ -1,9 +1,7 @@
package com.tangem.common.ui.bottomsheet.receive
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -20,20 +18,6 @@ fun Set<NetworkAddress.Address>.mapToAddressModels(cryptoCurrency: CryptoCurrenc
cryptoCurrency.network,
)
fun Set<NetworkAddress.Address>.mapToAddressModels(
asset: TokenReceiveBottomSheetConfig.Asset,
network: Network,
): List<AddressModel> = when (asset) {
is TokenReceiveBottomSheetConfig.Asset.Currency -> mapToAddressModels(
stringReference("${asset.name} (${asset.symbol})"),
network,
)
is TokenReceiveBottomSheetConfig.Asset.NFT -> mapToAddressModels(
resourceReference(R.string.common_nft),
network,
)
}
private fun Set<NetworkAddress.Address>.mapToAddressModels(name: TextReference, network: Network): List<AddressModel> =
this
.sortedBy { it.type.ordinal }

View file

@ -1,330 +0,0 @@
package com.tangem.common.ui.bottomsheet.receive
import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.res.getStringSafe
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SecondaryButtonIconStart
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.rememberQrPainters
import com.tangem.core.ui.components.snackbar.CopiedTextSnackbarHost
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Composable
fun TokenReceiveBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { content: TokenReceiveBottomSheetConfig ->
TokenReceiveBottomSheetContent(content = content)
}
}
@Composable
private fun TokenReceiveBottomSheetContent(content: TokenReceiveBottomSheetConfig) {
var selectedAddress by remember { mutableStateOf(content.addresses.first()) }
val snackbarHostState = remember(::SnackbarHostState)
ContainerWithSnackbarHost(snackbarHostState = snackbarHostState) {
Column(
modifier = Modifier
.verticalScroll(state = rememberScrollState())
.padding(top = 24.dp, bottom = 16.dp)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(space = 24.dp),
) {
QrCodeContent(content = content, onAddressChange = { selectedAddress = it })
Info(
showMemoDisclaimer = content.showMemoDisclaimer,
notifications = content.notifications,
)
Buttons(
snackbarHostState = snackbarHostState,
onShareClick = { content.onShareClick(selectedAddress.value) },
onCopyClick = { content.onCopyClick(selectedAddress.value) },
)
}
}
}
@Composable
private fun Info(
showMemoDisclaimer: Boolean,
notifications: ImmutableList<NotificationConfig>,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(space = 16.dp),
) {
if (showMemoDisclaimer) {
Text(
modifier = Modifier
.padding(horizontal = 18.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.receive_bottom_sheet_no_memo_required_message),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
notifications.fastForEach { notificationConfig ->
key(notificationConfig.hashCode()) {
Notification(config = notificationConfig)
}
}
}
}
@Composable
private fun ContainerWithSnackbarHost(snackbarHostState: SnackbarHostState, content: @Composable () -> Unit) {
Box {
content()
CopiedTextSnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 80.dp),
)
}
}
@Composable
private fun QrCodeContent(content: TokenReceiveBottomSheetConfig, onAddressChange: (AddressModel) -> Unit) {
val qrCodes = rememberQrPainters(content.addresses.map(AddressModel::value))
val pagerState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f,
pageCount = content.addresses::count,
)
LaunchedEffect(key1 = pagerState.currentPage) {
onAddressChange.invoke(content.addresses[pagerState.currentPage])
}
HorizontalPager(state = pagerState) { currentPage ->
QrCodePage(
content = content,
qrCodePainter = qrCodes[currentPage],
currentIndex = currentPage,
)
}
if (pagerState.pageCount > 1) {
val indicatorState = rememberLazyListState()
val selectedColor = TangemTheme.colors.icon.primary1
val unselectedColor = TangemTheme.colors.icon.informative
LazyRow(
modifier = Modifier.height(20.dp),
state = indicatorState,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
repeat(pagerState.pageCount) { iteration ->
item(key = iteration) {
val color by animateColorAsState(
targetValue = if (pagerState.currentPage == iteration) selectedColor else unselectedColor,
label = "",
)
Box(
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 6.dp)
.background(color = color, shape = CircleShape)
.size(7.dp),
)
}
}
}
}
}
@Composable
private fun QrCodePage(content: TokenReceiveBottomSheetConfig, qrCodePainter: Painter, currentIndex: Int) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text(
text = stringResourceSafe(
R.string.receive_bottom_sheet_warning_message_compact,
content.addresses[currentIndex].fullName.resolveReference(),
content.network,
),
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
style = TangemTheme.typography.h3,
)
Image(
painter = qrCodePainter,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(248.dp),
)
Text(
text = content.addresses[currentIndex].value,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
style = TangemTheme.typography.subtitle1,
)
}
}
@Composable
private fun Buttons(
snackbarHostState: SnackbarHostState,
onShareClick: () -> Unit,
onCopyClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val resources = context.resources
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
SecondaryButtonIconStart(
modifier = Modifier.weight(1f),
text = stringResourceSafe(id = R.string.common_copy),
iconResId = R.drawable.ic_copy_24,
onClick = {
onCopyClick()
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = resources.getStringSafe(R.string.wallet_notification_address_copied),
)
}
},
)
SecondaryButtonIconStart(
modifier = Modifier.weight(1f),
text = stringResourceSafe(id = R.string.common_share),
iconResId = R.drawable.ic_share_24,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onShareClick()
},
)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_TokenReceiveBottomSheet(
@PreviewParameter(TokenReceiveBottomSheetConfigPreviewProvider::class) params: TokenReceiveBottomSheetConfig,
) {
TangemThemePreview {
val config = TangemBottomSheetConfig(
isShown = true,
content = params,
onDismissRequest = {},
)
TokenReceiveBottomSheet(config)
}
}
private class TokenReceiveBottomSheetConfigPreviewProvider : PreviewParameterProvider<TokenReceiveBottomSheetConfig> {
val address = AddressModel(
displayName = stringReference("Address 1"),
fullName = combinedReference(
stringReference("Address 1"),
stringReference(" Stellar"),
),
value = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d",
type = AddressModel.Type.Default,
)
private val baseConfig = TokenReceiveBottomSheetConfig(
asset = TokenReceiveBottomSheetConfig.Asset.Currency(
name = "Stellar",
symbol = "XLM",
),
network = "Ethereum",
notifications = persistentListOf(
NotificationConfig(
title = stringReference("Send only XLM on the Ethereum network"),
subtitle = resourceReference(R.string.receive_bottom_sheet_warning_message_description),
iconResId = R.drawable.ic_alert_circle_24,
),
),
addresses = persistentListOf(address),
showMemoDisclaimer = false,
onCopyClick = {},
onShareClick = {},
)
override val values: Sequence<TokenReceiveBottomSheetConfig>
get() = sequenceOf(
baseConfig,
baseConfig.copy(
addresses = persistentListOf(
address,
address.copy(displayName = stringReference("Address 2")),
),
),
baseConfig.copy(
showMemoDisclaimer = true,
),
baseConfig.copy(
showMemoDisclaimer = true,
addresses = persistentListOf(
address,
address.copy(displayName = stringReference("Address 2")),
),
),
)
}
// endregion Preview

View file

@ -1,72 +0,0 @@
package com.tangem.common.ui.bottomsheet.receive
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
data class TokenReceiveBottomSheetConfig(
val asset: Asset,
val network: String,
val addresses: ImmutableList<AddressModel>,
val showMemoDisclaimer: Boolean,
val notifications: ImmutableList<NotificationConfig>,
val onCopyClick: (String) -> Unit,
val onShareClick: (String) -> Unit,
) : TangemBottomSheetConfigContent {
constructor(
asset: Asset,
network: Network,
networkAddress: NetworkAddress,
showMemoDisclaimer: Boolean,
customNotifications: ImmutableList<NotificationConfig> = persistentListOf(),
onCopyClick: (String) -> Unit,
onShareClick: (String) -> Unit,
) : this(
asset = asset,
network = network.name,
addresses = networkAddress.availableAddresses
.mapToAddressModels(asset, network)
.toImmutableList(),
showMemoDisclaimer = showMemoDisclaimer,
notifications = persistentListOf(defaultAssetNotificationConfig(asset, network)).addAll(0, customNotifications),
onCopyClick = onCopyClick,
onShareClick = onShareClick,
)
companion object {
private fun defaultAssetNotificationConfig(asset: Asset, network: Network): NotificationConfig =
NotificationConfig(
title = resourceReference(
R.string.receive_bottom_sheet_warning_title,
wrappedList(asset.displaySymbol, network.name),
),
subtitle = resourceReference(R.string.receive_bottom_sheet_warning_message_description),
iconResId = R.drawable.ic_alert_circle_24,
iconTint = NotificationConfig.IconTint.Accent,
)
}
@Immutable
sealed class Asset {
abstract val displaySymbol: TextReference
data class Currency(val name: String, val symbol: String) : Asset() {
override val displaySymbol = stringReference(symbol)
}
data object NFT : Asset() {
override val displaySymbol = resourceReference(R.string.common_nft)
}
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.common.ui.expressStatus.state
import androidx.compose.runtime.Composable
import com.tangem.common.ui.tokendetails.TokenDetailsDialogConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import kotlinx.collections.immutable.PersistentList
data class ExpressTransactionsBlockState(
val transactions: PersistentList<ExpressTransactionStateUM>,
val bottomSheetSlot: BottomSheetSlot?,
val dialogSlot: DialogSlot?,
)
data class BottomSheetSlot(
val config: TangemBottomSheetConfig,
val content: @Composable () -> Unit,
)
data class DialogSlot(
val config: TokenDetailsDialogConfig,
val content: @Composable () -> Unit,
)

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.navigationButtons.preview.NavigationButtonsPreview
import com.tangem.core.ui.components.HoldToConfirmButton
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.SecondaryButtonIconStart
import com.tangem.core.ui.components.buttons.common.TangemButton
@ -98,34 +99,48 @@ fun NavigationPrimaryButton(primaryButton: NavigationButton?, modifier: Modifier
modifier = modifier.fillMaxWidth(),
) { button ->
if (button != null && button.textReference != TextReference.EMPTY) {
val icon = if (button.iconRes != null && button.isIconVisible) {
TangemButtonIconPosition.End(iconResId = button.iconRes)
} else {
TangemButtonIconPosition.None
}
val color = if (button.isDimmed) {
TangemButtonsDefaults.secondaryButtonColors
.copy(contentColor = TangemTheme.colors.text.tertiary)
} else {
TangemButtonsDefaults.primaryButtonColors
}
TangemButton(
text = button.textReference.resolveReference(),
enabled = button.isEnabled,
onClick = {
GlobalMultipleClickPreventer.processEvent {
if (button.isHapticClick) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
if (button.isHoldToConfirm) {
HoldToConfirmButton(
text = button.textReference.resolveReference(),
enabled = button.isEnabled,
isLoading = button.shouldShowProgress,
onConfirm = {
GlobalMultipleClickPreventer.processEvent {
button.onClick()
}
button.onClick()
}
},
showProgress = button.shouldShowProgress,
colors = color,
textStyle = TangemTheme.typography.subtitle1,
icon = icon,
modifier = Modifier.fillMaxWidth(),
)
},
modifier = Modifier.fillMaxWidth(),
)
} else {
val icon = if (button.iconRes != null && button.isIconVisible) {
TangemButtonIconPosition.End(iconResId = button.iconRes)
} else {
TangemButtonIconPosition.None
}
val color = if (button.isDimmed) {
TangemButtonsDefaults.secondaryButtonColors
.copy(contentColor = TangemTheme.colors.text.tertiary)
} else {
TangemButtonsDefaults.primaryButtonColors
}
TangemButton(
text = button.textReference.resolveReference(),
enabled = button.isEnabled,
onClick = {
GlobalMultipleClickPreventer.processEvent {
if (button.isHapticClick) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
button.onClick()
}
},
showProgress = button.shouldShowProgress,
colors = color,
textStyle = TangemTheme.typography.subtitle1,
icon = icon,
modifier = Modifier.fillMaxWidth(),
)
}
} else {
Spacer(modifier = Modifier.fillMaxWidth())
}

View file

@ -34,5 +34,6 @@ data class NavigationButton(
val isEnabled: Boolean = true,
val isDimmed: Boolean = false,
val isHapticClick: Boolean = false,
val isHoldToConfirm: Boolean = false,
val onClick: () -> Unit,
)

View file

@ -1,33 +1,37 @@
package com.tangem.common.ui.news
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CardColors
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
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.SpacerHMax
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@ -39,70 +43,137 @@ fun ArticleCard(
modifier: Modifier = Modifier,
colors: CardColors = TangemBlockCardColors,
) {
BlockCard(
modifier = modifier,
onClick = onArticleClick,
colors = colors,
if (articleConfigUM.isTrending) {
TrendingArticle(
articleConfigUM = articleConfigUM,
modifier = modifier,
onArticleClick = onArticleClick,
colors = colors,
)
} else {
DefaultArticle(
articleConfigUM = articleConfigUM,
modifier = modifier,
onArticleClick = onArticleClick,
colors = colors,
)
}
}
@Composable
private fun TrendingArticle(
articleConfigUM: ArticleConfigUM,
onArticleClick: () -> Unit,
colors: CardColors,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(colors.containerColor)
.clickable { onArticleClick() },
) {
if (articleConfigUM.isTrending) {
TrendingArticle(articleConfigUM = articleConfigUM)
} else {
DefaultArticle(articleConfigUM = articleConfigUM)
Image(
modifier = Modifier.matchParentSize(),
painter = painterResource(R.drawable.bg_trending_article),
contentDescription = null,
contentScale = ContentScale.FillWidth,
)
Column(
modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = RoundedCornerShape(8.dp),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResourceSafe(R.string.feed_trending_now),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
)
SpacerH(12.dp)
Text(
text = articleConfigUM.title,
color = if (articleConfigUM.isViewed) {
TangemTheme.colors.text.tertiary
} else {
TangemTheme.colors.text.primary1
},
style = TangemTheme.typography.h3,
textAlign = TextAlign.Center,
)
SpacerH(8.dp)
ArticleInfo(
score = articleConfigUM.score,
createdAt = articleConfigUM.createdAt.resolveReference(),
)
SpacerH(32.dp)
Tags(
modifier = Modifier.padding(horizontal = 46.dp),
tags = articleConfigUM.tags.toImmutableList(),
)
}
}
}
@Composable
private fun TrendingArticle(articleConfigUM: ArticleConfigUM) {
Column(
modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
fun ShowMoreArticlesCard(modifier: Modifier = Modifier, onClick: () -> Unit) {
BlockCard(
modifier = modifier,
onClick = onClick,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Text(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = RoundedCornerShape(8.dp),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResourceSafe(R.string.feed_trending_now),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
)
.fillMaxSize()
.padding(vertical = 31.dp, horizontal = 12.dp),
) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_show_more_news_48),
contentDescription = stringResourceSafe(R.string.common_show_more),
)
SpacerH(12.dp)
SpacerH(16.dp)
Text(
text = articleConfigUM.title,
color = if (articleConfigUM.isViewed) {
TangemTheme.colors.text.tertiary
} else {
TangemTheme.colors.text.primary1
},
style = TangemTheme.typography.h3,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResourceSafe(R.string.news_all_news),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
SpacerH(8.dp)
ArticleInfo(
score = articleConfigUM.score,
createdAt = articleConfigUM.createdAt.resolveReference(),
)
SpacerH(32.dp)
Tags(
modifier = Modifier.padding(horizontal = 46.dp),
tags = articleConfigUM.tags.toImmutableList(),
)
Text(
text = stringResourceSafe(R.string.news_stay_in_the_loop),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable
private fun DefaultArticle(articleConfigUM: ArticleConfigUM) {
Column(modifier = Modifier.padding(12.dp)) {
private fun DefaultArticle(
articleConfigUM: ArticleConfigUM,
onArticleClick: () -> Unit,
colors: CardColors,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(colors.containerColor)
.clickable { onArticleClick() }
.padding(12.dp),
) {
ArticleInfo(
score = articleConfigUM.score,
createdAt = articleConfigUM.createdAt.resolveReference(),
@ -124,28 +195,9 @@ private fun DefaultArticle(articleConfigUM: ArticleConfigUM) {
SpacerH(16.dp)
Tags(tags = articleConfigUM.tags.toImmutableList())
}
}
SpacerHMax()
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun Tags(tags: ImmutableList<LabelUM>, modifier: Modifier = Modifier) {
val expandIndicator = remember {
ContextualFlowRowOverflow.expandIndicator {
val remainingItems = tags.size - shownItemCount
Label(state = LabelUM(TextReference.Str("${StringsSigns.PLUS}$remainingItems")))
}
}
ContextualFlowRow(
itemCount = tags.size,
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
maxLines = 1,
overflow = expandIndicator,
) { index ->
Label(state = tags[index])
Tags(tags = articleConfigUM.tags.toImmutableList())
}
}

View file

@ -1,7 +1,10 @@
package com.tangem.common.ui.news
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -9,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
@ -49,6 +53,8 @@ internal fun ArticleInfo(score: Float, createdAt: String, modifier: Modifier = M
Text(
text = createdAt,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors.text.secondary,
)
}

View file

@ -41,8 +41,9 @@ fun TrendingLoadingArticle(modifier: Modifier = Modifier) {
}
@Composable
fun DefaultLoadingArticle() {
fun DefaultLoadingArticle(modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(modifier = Modifier.padding(12.dp)) {

View file

@ -0,0 +1,128 @@
package com.tangem.common.ui.news
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.layout.SubcomposeMeasureScope
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
private val TAG_SPACING = 4.dp
/**
* A layout that displays a list of tags, automatically calculating how many can fit
* in a single line. If not all tags fit, it shows an overflow indicator (e.g., "+N").
*
* @param tags The immutable list of [LabelUM] to display.
* @param modifier The modifier to be applied to the layout.
*/
@Composable
internal fun Tags(tags: ImmutableList<LabelUM>, modifier: Modifier = Modifier) {
if (tags.isEmpty()) return
SubcomposeLayout(modifier = modifier) { constraints ->
val spacingPx = TAG_SPACING.roundToPx()
val tagPlaceables = subcompose(ContentSlot.Tags) {
tags.forEach { tag ->
Label(state = tag)
}
}.map { it.measure(constraints) }
val layoutInfo = calculateLayoutInfo(
maxWidth = constraints.maxWidth,
spacingPx = spacingPx,
tagPlaceables = tagPlaceables,
)
val overflowPlaceable = layoutInfo.overflowCount.takeIf { it > 0 }?.let {
subcompose(ContentSlot.Overflow) {
OverflowLabel(count = it)
}.first().measure(constraints)
}
val visiblePlaceables = tagPlaceables.take(layoutInfo.visibleCount)
val allVisibleItems = visiblePlaceables + listOfNotNull(overflowPlaceable)
val width = calculateRowWidth(
placeables = allVisibleItems,
spacingPx = spacingPx,
).coerceIn(constraints.minWidth, constraints.maxWidth)
val height = allVisibleItems.maxOfOrNull { it.height } ?: 0
layout(width = width, height = height) {
var xPosition = 0
allVisibleItems.forEachIndexed { index, placeable ->
if (index > 0) {
xPosition += spacingPx
}
val yPosition = height - placeable.height
placeable.placeRelative(xPosition, yPosition)
xPosition += placeable.width
}
}
}
}
/**
* Calculates how many tags can be displayed and how many are overflowing.
*/
private fun SubcomposeMeasureScope.calculateLayoutInfo(
maxWidth: Int,
spacingPx: Int,
tagPlaceables: List<Placeable>,
): TagsLayoutInfo {
val totalWidthWithoutOverflow = calculateRowWidth(tagPlaceables, spacingPx)
if (totalWidthWithoutOverflow <= maxWidth) {
return TagsLayoutInfo(visibleCount = tagPlaceables.size, overflowCount = 0)
}
for (count in tagPlaceables.indices.reversed()) {
val visibleTags = tagPlaceables.take(count)
val overflowCount = tagPlaceables.size - count
val tempOverflowPlaceable = subcompose("overflow_temp_$overflowCount") {
OverflowLabel(count = overflowCount)
}.first().measure(Constraints())
val widthWithOverflow = calculateRowWidth(
placeables = visibleTags + tempOverflowPlaceable,
spacingPx = spacingPx,
)
if (widthWithOverflow <= maxWidth) {
return TagsLayoutInfo(visibleCount = count, overflowCount = overflowCount)
}
}
return TagsLayoutInfo(visibleCount = 0, overflowCount = tagPlaceables.size)
}
@Composable
private fun OverflowLabel(count: Int) {
Label(
state = LabelUM(
text = TextReference.Str("${StringsSigns.PLUS}$count"),
maxLines = 1,
),
)
}
private fun calculateRowWidth(placeables: List<Placeable>, spacingPx: Int): Int {
if (placeables.isEmpty()) return 0
val tagsWidth = placeables.sumOf { it.width }
val spacingWidth = spacingPx * (placeables.size - 1).coerceAtLeast(0)
return tagsWidth + spacingWidth
}
private data class TagsLayoutInfo(val visibleCount: Int, val overflowCount: Int)
private enum class ContentSlot { Tags, Overflow }

View file

@ -21,7 +21,7 @@ import com.tangem.utils.extensions.isZero
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
@Suppress("LargeClass")
@Suppress("LargeClass", "CanBeNonNullable")
object NotificationsFactory {
fun MutableList<NotificationUM>.addFeeUnreachableNotification(
@ -314,12 +314,14 @@ object NotificationsFactory {
}
}
@Suppress("LongParameterList")
fun MutableList<NotificationUM>.addExceedsBalanceNotification(
cryptoCurrencyWarning: CryptoCurrencyWarning?,
cryptoCurrencyStatus: CryptoCurrencyStatus,
shouldMergeFeeNetworkName: Boolean,
onClick: (CryptoCurrency) -> Unit,
onAnalyticsEvent: (CryptoCurrency) -> Unit,
onResetAnalyticsEvent: (CryptoCurrency) -> Unit,
) {
when (cryptoCurrencyWarning) {
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> {
@ -357,7 +359,7 @@ object NotificationsFactory {
)
onAnalyticsEvent(cryptoCurrencyWarning.currency)
}
else -> Unit
else -> onResetAnalyticsEvent(cryptoCurrencyStatus.currency)
}
}

View file

@ -1,9 +1,9 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.components
package com.tangem.common.ui.tokendetails
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.tokendetails.impl.R
/**
* Wallet bottom sheet config
@ -12,7 +12,7 @@ import com.tangem.features.tokendetails.impl.R
* @property onDismissRequest lambda be invoked when bottom sheet is dismissed
* @property content content config
*/
internal data class TokenDetailsDialogConfig(
data class TokenDetailsDialogConfig(
val isShow: Boolean,
val onDismissRequest: () -> Unit,
val content: DialogContentConfig,
@ -28,7 +28,7 @@ internal data class TokenDetailsDialogConfig(
data class ButtonConfig(
val text: TextReference,
val onClick: () -> Unit,
val warning: Boolean = false,
val hasWarning: Boolean = false,
)
data class ConfirmHideConfig(
@ -51,7 +51,7 @@ internal data class TokenDetailsDialogConfig(
override val confirmButtonConfig: ButtonConfig = ButtonConfig(
text = TextReference.Res(R.string.token_details_hide_alert_hide),
onClick = onConfirmClick,
warning = true,
hasWarning = true,
)
}

View file

@ -23,7 +23,8 @@ import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.StringsSigns.DASH_SIGN
@ -45,7 +46,7 @@ class TokenItemStateConverter(
private val appCurrency: AppCurrency,
private val yieldModuleApyMap: Map<String, BigDecimal> = emptyMap(),
private val stakingApyMap: Map<CryptoCurrency, StakingAvailability> = emptyMap(),
private val yieldSupplyPromoBannerKey: String? = null,
private val promoCryptoCurrencyStatus: CryptoCurrencyStatus? = null,
private val iconStateProvider: (CryptoCurrencyStatus) -> CurrencyIconState = {
CryptoCurrencyToIconStateConverter().convert(it)
},
@ -74,7 +75,7 @@ class TokenItemStateConverter(
createPromoBannerState(
status = status,
yieldModuleApyMap = yieldModuleApyMap,
yieldSupplyPromoBannerKey = yieldSupplyPromoBannerKey,
promoCryptoCurrencyStatus = promoCryptoCurrencyStatus,
onApyLabelClick = onApyLabelClick,
onYieldPromoCloseClick = onYieldPromoCloseClick,
onYieldPromoShown = onYieldPromoShown,
@ -251,9 +252,9 @@ class TokenItemStateConverter(
stakingApyMap = stakingApyMap,
)
val rewardTypeRes = when (stakingInfo.rewardType) {
Yield.RewardType.APR -> R.string.staking_apr_earn_badge
Yield.RewardType.UNKNOWN,
Yield.RewardType.APY,
RewardType.APR -> R.string.staking_apr_earn_badge
RewardType.UNKNOWN,
RewardType.APY,
null,
-> R.string.yield_module_earn_badge
}
@ -283,12 +284,14 @@ class TokenItemStateConverter(
val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool
val rateInfo = when (val stakingOptions = stakingAvailability.option) {
is StakingOption.P2P -> {
// P2P or no balance: use preferred validators
// TODO add p2p logic
null
is StakingOption.P2PEthPool -> {
RewardInfo(
rate = stakingOptions.apy,
type = RewardType.APY,
)
}
is StakingOption.StakeKit -> if (stakeKitBalance != null) {
val validatorsByAddress = stakingOptions.yield.validators.associateBy { it.address }
@ -314,7 +317,7 @@ class TokenItemStateConverter(
return StakingLocalInfo(
rate = rateInfo?.rate,
isActive = stakeKitBalance != null, // todo add p2p check
isActive = stakeKitBalance != null || p2pEthPoolBalance != null,
rewardType = rateInfo?.type,
)
}
@ -401,7 +404,7 @@ class TokenItemStateConverter(
private fun createPromoBannerState(
status: CryptoCurrencyStatus,
yieldModuleApyMap: Map<String, BigDecimal>,
yieldSupplyPromoBannerKey: String?,
promoCryptoCurrencyStatus: CryptoCurrencyStatus?,
onApyLabelClick: ((CryptoCurrencyStatus, ApySource, String) -> Unit)?,
onYieldPromoCloseClick: (() -> Unit)?,
onYieldPromoShown: ((cryptoCurrency: CryptoCurrency) -> Unit)?,
@ -411,13 +414,15 @@ class TokenItemStateConverter(
if (status.value !is CryptoCurrencyStatus.Loaded) {
return TokenItemState.PromoBannerState.Empty
}
if (yieldSupplyPromoBannerKey == null || yieldSupplyPromoBannerKey != token.yieldSupplyKey() ||
yieldModuleApyMap[token.yieldSupplyKey()] == null
) {
if (promoCryptoCurrencyStatus == null || status.currency.id != promoCryptoCurrencyStatus.currency.id) {
return TokenItemState.PromoBannerState.Empty
}
val yieldSupplyApy =
yieldModuleApyMap[token.yieldSupplyKey()] ?: return TokenItemState.PromoBannerState.Empty
val yieldSupplyApy = yieldModuleApyMap.entries.firstOrNull { (key, _) ->
key.equals(
other = token.yieldSupplyKey(),
ignoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId),
)
}?.value ?: return TokenItemState.PromoBannerState.Empty
return TokenItemState.PromoBannerState.Content(
title = resourceReference(
@ -469,7 +474,7 @@ class TokenItemStateConverter(
private data class StakingLocalInfo(
val rate: BigDecimal?,
val isActive: Boolean,
val rewardType: Yield.RewardType?,
val rewardType: RewardType?,
)
private data class EarnApyInfo(

View file

@ -35,6 +35,7 @@ class UserWalletItemUMConverter(
private val isClickableIfLocked: Boolean = false,
private val endIcon: UserWalletItemUM.EndIcon = UserWalletItemUM.EndIcon.None,
artwork: UserWalletItemUM.ImageState? = null,
private val mode: InfoField = InfoField.Devices,
) : Converter<UserWallet, UserWalletItemUM> {
private val artwork = artwork ?: UserWalletItemUM.ImageState.Loading
@ -61,17 +62,29 @@ class UserWalletItemUMConverter(
private fun getInfo(userWallet: UserWallet): UserWalletItemUM.Information.Loaded {
val text = when (userWallet) {
is UserWallet.Cold -> {
val cardCount = userWallet.getCardsCount() ?: 1
TextReference.PluralRes(
id = R.plurals.card_label_card_count,
count = cardCount,
formatArgs = wrappedList(cardCount),
)
when (mode) {
is InfoField.Devices -> {
val cardCount = userWallet.getCardsCount() ?: 1
TextReference.PluralRes(
id = R.plurals.card_label_card_count,
count = cardCount,
formatArgs = wrappedList(cardCount),
)
}
is InfoField.Tokens -> {
TextReference.PluralRes(
id = R.plurals.common_tokens_count,
count = mode.tokensCount,
formatArgs = wrappedList(mode.tokensCount),
)
}
}
}
is UserWallet.Hot -> {
TextReference.Res(R.string.hw_mobile_wallet)
}
}
return UserWalletItemUM.Information.Loaded(text)
}
@ -103,4 +116,9 @@ class UserWalletItemUMConverter(
}
}
}
sealed interface InfoField {
data object Devices : InfoField
data class Tokens(val tokensCount: Int) : InfoField
}
}

View file

@ -1,6 +1,6 @@
package com.tangem.common.ui.userwallet.state
import com.tangem.common.ui.account.CryptoPortfolioIconUM
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.artwork.ArtworkUM
import com.tangem.core.ui.extensions.TextReference
import javax.annotation.concurrent.Immutable
@ -64,7 +64,7 @@ data class UserWalletItemUM(
data class Account(
val name: TextReference,
val icon: CryptoPortfolioIconUM,
val icon: AccountIconUM.CryptoPortfolio,
) : ImageState()
data class Image(

View file

@ -3,6 +3,8 @@ package com.tangem.core.analytics.models
import com.tangem.core.analytics.models.AnalyticsParam.Key.REFERRAL
import com.tangem.core.analytics.models.AnalyticsParam.Key.REFERRAL_ID
const val IS_NOT_HTTP_ERROR = "Is not http error"
sealed class AnalyticsParam {
sealed class CardBalanceState(val value: String) {
@ -80,7 +82,9 @@ sealed class AnalyticsParam {
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")
@ -90,6 +94,9 @@ sealed class AnalyticsParam {
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")
}
sealed class TxSentFrom(val value: String) {
@ -97,24 +104,28 @@ sealed class AnalyticsParam {
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
override val feeToken: String,
) : TxSentFrom("Send"), TxData
data class Swap(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
override val feeToken: String,
) : TxSentFrom("Swap"), TxData
data class Staking(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
override val feeToken: String,
) : TxSentFrom("Staking"), TxData
data class Approve(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
override val feeToken: String,
val permissionType: String,
) : TxSentFrom("Approve"), TxData
@ -122,6 +133,7 @@ sealed class AnalyticsParam {
override val blockchain: String,
override val token: String,
override val feeType: FeeType?,
override val feeToken: String,
) : TxSentFrom("WalletConnect"), TxData
data object Sell : TxSentFrom("Sell")
@ -130,24 +142,28 @@ sealed class AnalyticsParam {
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
override val feeToken: String,
) : TxSentFrom("NFT"), TxData
data class SendWithSwap(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
override val feeToken: String,
) : TxSentFrom("Send&Swap"), TxData
data class Earning(
override val blockchain: String,
override val token: String,
override val feeType: FeeType,
override val feeToken: String,
) : TxSentFrom("Earning"), TxData
}
sealed interface TxData {
val blockchain: String
val token: String
val feeToken: String
val feeType: FeeType?
}
@ -271,6 +287,9 @@ sealed class AnalyticsParam {
const val ENS = "ENS"
const val ENS_ADDRESS = "ENS Address"
const val ACCOUNT_DERIVATION_FROM = "Account Derivation (from)"
const val ACCOUNT_DERIVATION_TO = "Account Derivation (to)"
const val FEE_TOKEN = "Fee Token"
const val ACCOUNT_DERIVATION = "Account Derivation"
const val REFERRAL = "Referral"
const val REFERRAL_ID = "Referral_ID"
}

View file

@ -87,6 +87,7 @@ sealed class Basic(
sentFrom.feeType?.value?.let {
this[AnalyticsParam.Key.FEE_TYPE] = it
}
this[AnalyticsParam.Key.FEE_TOKEN] = sentFrom.feeToken
}
if (sentFrom is AnalyticsParam.TxSentFrom.Approve) {
this[AnalyticsParam.Key.PERMISSION_TYPE] = sentFrom.permissionType

View file

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

View file

@ -6,12 +6,14 @@ import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.api.*
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.OneTimePerSessionEvent
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
[REDACTED_AUTHOR]
@ -31,6 +33,7 @@ object Analytics : GlobalAnalyticsEventHandler {
private val handlers = mutableMapOf<String, AnalyticsHandler>()
private val paramsInterceptors = ConcurrentHashMap<String, ParamsInterceptor>()
private val throttledEventsState = ConcurrentHashMap<String, Long>()
private val analyticsFilters = mutableSetOf<AnalyticsEventFilter>()
private val analyticsMutex = Mutex()
@ -85,6 +88,9 @@ object Analytics : GlobalAnalyticsEventHandler {
override fun send(event: AnalyticsEvent) {
analyticsScope.launch {
if (event is OneTimePerSessionEvent && !shouldSendThrottledEvent(event)) {
return@launch
}
event.params = applyParamsInterceptors(event)
val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(event) }
@ -130,6 +136,21 @@ object Analytics : GlobalAnalyticsEventHandler {
return interceptedParams
}
private fun shouldSendThrottledEvent(event: OneTimePerSessionEvent): Boolean {
val now = System.currentTimeMillis()
val id = event.oneTimeEventId
return when (val throttleMs = event.throttleSeconds?.let(TimeUnit.SECONDS::toMillis)) {
null -> throttledEventsState.putIfAbsent(id, now) == null
else -> throttledEventsState.compute(id) { _, lastSendTime ->
when {
lastSendTime == null -> now
now - lastSendTime >= throttleMs -> now
else -> lastSendTime
}
} == now
}
}
private fun createScope(): CoroutineScope {
val name = "Analytics"
val dispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()

View file

@ -0,0 +1,19 @@
package com.tangem.core.analytics.api
import com.tangem.core.analytics.models.AnalyticsEvent
import java.util.concurrent.ConcurrentHashMap
class ResettableOneTimeEventSender(private val analyticsEventHandler: AnalyticsEventHandler) {
private val sentEvents = ConcurrentHashMap<String, AnalyticsEvent>()
fun sendEventOnce(key: String, event: AnalyticsEvent) {
if (sentEvents.putIfAbsent(key, event) == null) {
analyticsEventHandler.send(event)
}
}
fun reset(key: String) {
sentEvents.remove(key)
}
}

View file

@ -47,28 +47,40 @@
"name": "TANGEM_PAY_ENABLED",
"version": "5.31.0"
},
{
"name": "TANGEM_PAY_ENTRYPOINT_ENABLED",
"version": "undefined"
},
{
"name": "NEW_TOKEN_RECEIVE_ENABLED",
"version": "5.28.0"
},
{
"name": "YIELD_SUPPLY_FEATURE_ENABLED",
"version": "5.30.0"
},
{
"name": "YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED",
"version": "5.33.0"
},
{
"name": "NEW_ONRAMP_MAIN_ENABLED",
"version": "5.31.0"
},
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "undefined"
"version": "5.33.0"
},
{
"name": "FEED_ENABLED",
"version": "5.33.0"
},
{
"name": "APP_REDESIGN_ENABLED",
"version": "undefined"
},
{
"name": "GASLESS_TRANSACTIONS_ENABLED",
"version": "5.33.0"
},
{
"name": "SWAP_MARKET_LIST_ENABLED",
"version": "undefined"
},
{
"name": "EARN_BLOCK_ENABLED",
"version": "undefined"
}
]

View file

@ -12,6 +12,5 @@
<ID>NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withWriteTimeout(timeout = it.duration, unit = it.unit) }</ID>
<ID>UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$apiConfigs + (apiConfig to environment)</ID>
<ID>UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$val apiConfig = apiConfigs.keys.firstOrNull { it.id.name == id } ?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI")</ID>
<ID>UnusedImports:NetworkModule.kt$import com.tangem.datasource.api.common.config.MoonPay</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -18,6 +18,8 @@ interface AuthProvider {
fun getApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String>
fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String>
/**
* Returns map where keys(cardId) associated with cardPublicKey
*/

View file

@ -31,6 +31,7 @@ sealed class ApiConfig {
YieldSupply,
MoonPay,
News,
GaslessTxService,
}
private fun initializeId(): ID {
@ -39,12 +40,13 @@ sealed class ApiConfig {
is TangemTech -> ID.TangemTech
is StakeKit -> ID.StakeKit
is P2PEthPool -> ID.P2PEthPool
is TangemPay -> ID.TangemPay
is TangemPayAuth -> ID.TangemPayAuth
is TangemPay.Bff -> ID.TangemPay
is TangemPay.Auth -> ID.TangemPayAuth
is BlockAid -> ID.BlockAid
is YieldSupply -> ID.YieldSupply
is MoonPay -> ID.MoonPay
is News -> ID.News
is GaslessTxService -> ID.GaslessTxService
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.Provider
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
/**
* Gasless transactions [ApiConfig]
*/
internal class GaslessTxService(
private val authProvider: AuthProvider,
private val appVersionProvider: AppVersionProvider,
private val appInfoProvider: AppInfoProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createDevEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = PROD_BASE_URL,
headers = createHeaders(ApiEnvironment.PROD),
)
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = DEV_BASE_URL,
headers = createHeaders(ApiEnvironment.DEV),
)
private fun createHeaders(environment: ApiEnvironment) = buildMap {
putAll(RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values)
put(
key = "Authorization",
value = ProviderSuspend {
"Bearer ${authProvider.getGaslessServiceApiKey(Provider { environment }).invoke()}"
},
)
}
private companion object {
private const val PROD_BASE_URL = "https://gasless.tangem.org/"
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
}
}

View file

@ -4,12 +4,16 @@ import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.Provider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
/**
* News [ApiConfig]
[REDACTED_AUTHOR]
*/
internal class News(
private val appVersionProvider: AppVersionProvider,
private val appInfoProvider: AppInfoProvider,
private val authProvider: AuthProvider,
) : ApiConfig() {
@ -52,6 +56,7 @@ internal class News(
apiEnvironment = Provider { environment },
).values,
)
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values)
}
private companion object {

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.utils.ProviderSuspend
@ -23,7 +23,7 @@ internal class P2PEthPool(
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
else -> if (P2PStakingConfig.USE_TESTNET) ApiEnvironment.DEV else ApiEnvironment.PROD
else -> if (P2PEthPoolStakingConfig.USE_TESTNET) ApiEnvironment.DEV else ApiEnvironment.PROD
}
}

View file

@ -1,11 +1,13 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemPay(
internal sealed class TangemPay(
private val appVersionProvider: AppVersionProvider,
private val environmentConfigStorage: EnvironmentConfigStorage,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
@ -16,6 +18,8 @@ internal class TangemPay(
createProdEnvironment(),
)
protected abstract fun getBaseUrl(apiEnvironment: ApiEnvironment): String
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
@ -31,24 +35,75 @@ internal class TangemPay(
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api.dev.us.paera.com/bff-v2/",
headers = createHeaders(),
baseUrl = getBaseUrl(ApiEnvironment.DEV),
headers = createHeaders(ApiEnvironment.DEV),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
baseUrl = getBaseUrl(ApiEnvironment.MOCK),
headers = createHeaders(ApiEnvironment.MOCK),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.us.paera.com/bff-v2/",
headers = createHeaders(),
baseUrl = getBaseUrl(ApiEnvironment.PROD),
headers = createHeaders(ApiEnvironment.PROD),
)
private fun createHeaders() = mapOf(
private fun createHeaders(apiEnvironment: ApiEnvironment) = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { getBffStaticToken(apiEnvironment) },
)
private fun getBffStaticToken(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.MOCK,
ApiEnvironment.DEV,
-> environmentConfigStorage.getConfigSync().bffStaticTokenDev
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().bffStaticToken
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
-> null
} ?: error("BffStaticToken is not provided for $apiEnvironment")
}
class Bff(
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
) : TangemPay(appVersionProvider, environmentConfigStorage) {
override fun getBaseUrl(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.DEV -> "https://api.dev.us.paera.com/bff-v2/"
ApiEnvironment.MOCK -> "[REDACTED_ENV_URL]"
ApiEnvironment.PROD -> "https://api.us.paera.com/bff-v2/"
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
-> error("Unknown environment: $apiEnvironment")
}
}
}
class Auth(
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
) : TangemPay(appVersionProvider, environmentConfigStorage) {
override fun getBaseUrl(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.DEV -> "https://api.dev.us.paera.com/"
ApiEnvironment.MOCK -> "[REDACTED_ENV_URL]"
ApiEnvironment.PROD -> "https://api.us.paera.com/"
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
-> error("Unknown environment: $apiEnvironment")
}
}
}
}

View file

@ -1,53 +0,0 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemPayAuth(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs = listOf(
createDevEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api.dev.us.paera.com/",
headers = createHeaders(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.us.paera.com/",
headers = createHeaders(),
)
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
)
}

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