Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-18 14:12:15 +03:00
commit 20279dcc7a
602 changed files with 18887 additions and 6569 deletions

View file

@ -366,6 +366,7 @@ dependencies {
implementation(deps.googlePlay.advertising)
coreLibraryDesugaring(deps.desugar)
implementation(deps.timber)
implementation(deps.kermit)
implementation(deps.reKotlin)
implementation(deps.zxing.qrCore)
implementation(deps.coil)

View file

@ -1,6 +1,7 @@
package com.tangem.common
import androidx.test.core.app.ApplicationProvider
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.tap.ApplicationEntryPoint
import com.tangem.tap.TangemApplication
import dagger.hilt.android.testing.OnComponentReadyRunner
@ -10,7 +11,7 @@ import org.junit.runners.model.Statement
import timber.log.Timber
class ApplicationInjectionExecutionRule(
private val toggleStates: Map<String, Boolean>
private val toggleStates: Map<String, Boolean>,
) : TestRule {
private val tangemApplication: TangemApplication
@ -25,7 +26,7 @@ class ApplicationInjectionExecutionRule(
overrideFeatureToggles()
OnComponentReadyRunner.addListener(
tangemApplication, ApplicationEntryPoint::class.java
tangemApplication, ApplicationEntryPoint::class.java,
) { _: ApplicationEntryPoint ->
tangemApplication.preInit()
tangemApplication.init()
@ -43,25 +44,15 @@ class ApplicationInjectionExecutionRule(
@Suppress("UNCHECKED_CAST")
private fun saveOriginalFeatureToggles() {
try {
val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles")
val valuesField = featureTogglesClass.getDeclaredField("values")
valuesField.isAccessible = true
originalFeatureTogglesValues = valuesField.get(null) as Map<String, String>
originalFeatureTogglesValues = FeatureToggles.values as Map<String, String>
} catch (e: Exception) {
Timber.e("Failed to save original toggles values: ${e.message}")
}
}
@Suppress("UNCHECKED_CAST")
private fun overrideFeatureToggles() {
try {
val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles")
val valuesField = featureTogglesClass.getDeclaredField("values")
valuesField.isAccessible = true
val originalValues = originalFeatureTogglesValues ?:
(valuesField.get(null) as Map<String, String>)
val originalValues = originalFeatureTogglesValues ?: FeatureToggles.values
val newValues = originalValues.toMutableMap()
toggleStates.forEach { (toggle, enabled) ->
@ -72,10 +63,12 @@ class ApplicationInjectionExecutionRule(
}
}
valuesField.set(null, newValues)
val companionClass = FeatureToggles.Companion::class.java
val valuesField = companionClass.getDeclaredField("values")
valuesField.isAccessible = true
valuesField.set(FeatureToggles.Companion, newValues)
Timber.i("FeatureToggles.values updated: $toggleStates")
} catch (e: Exception) {
Timber.e("FeatureToggles.values didn't change with error: ${e.message}")
}
@ -84,10 +77,10 @@ class ApplicationInjectionExecutionRule(
private fun restoreOriginalFeatureToggles() {
try {
if (originalFeatureTogglesValues != null) {
val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles")
val valuesField = featureTogglesClass.getDeclaredField("values")
val companionClass = FeatureToggles.Companion::class.java
val valuesField = companionClass.getDeclaredField("values")
valuesField.isAccessible = true
valuesField.set(null, originalFeatureTogglesValues)
valuesField.set(FeatureToggles.Companion, originalFeatureTogglesValues)
Timber.i("FeatureToggles.values restored")
}
} catch (e: Exception) {

View file

@ -30,6 +30,8 @@ import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import javax.inject.Inject
abstract class BaseTestCase : TestCase(
@ -68,6 +70,12 @@ abstract class BaseTestCase : TestCase(
*/
val composeTestRule = createEmptyComposeRule()
private val semanticTreePrinterRule = object : TestWatcher() {
override fun failed(e: Throwable?, description: Description?) {
runCatching { printAllRoots() }
}
}
@Rule
@JvmField
val ruleChain: TestRule = RuleChain
@ -76,6 +84,7 @@ abstract class BaseTestCase : TestCase(
.around(permissionRule)
.around(apiEnvironmentRule)
.around(composeTestRule)
.around(semanticTreePrinterRule)
/**
* Initialization order is important:
@ -140,6 +149,16 @@ abstract class BaseTestCase : TestCase(
.printToLog(tag, maxDepth)
}
fun printAllRoots(
tag: String = "ComposeTree",
) {
val roots = composeTestRule.onAllNodes(isRoot())
val count = roots.fetchSemanticsNodes().size
repeat(count) { index ->
roots[index].printToLog("$tag[$index]")
}
}
fun waitForIdle() = composeTestRule.waitForIdle()
private fun applicationInjectionRule(): ApplicationInjectionExecutionRule {

View file

@ -1,10 +1,14 @@
package com.tangem.common.extensions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.components.buttons.actions.HasBadgeKey
import com.tangem.core.ui.components.buttons.actions.IsDimmedKey
import io.github.kakaocup.compose.node.element.KNode
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
fun assertElementDoesNotExist(
elementProvider: () -> KNode,
@ -42,4 +46,51 @@ fun Any.assertHasBadge(expectedValue: Boolean = true) {
is KNode, is LazyListItemNode -> this.assert(matcher)
else -> throw IllegalArgumentException("Unsupported type: ${this::class}")
}
}
fun List<SemanticsNode>.assertSortedByVolumeDescending() {
val volumes = this.mapNotNull { parseVolume(it) }
assertFalse("Trading volumes list should not be empty", volumes.isEmpty())
assertTrue(
"Exchanges list should be sorted by volume in descending order",
volumes == volumes.sortedDescending(),
)
}
fun List<SemanticsNode>.assertExchangeTypesAreCexOrDex() {
assertFalse("Exchange types list should not be empty", isEmpty())
forEach { node ->
val text = extractText(node)
assertTrue(
"Exchange type should be 'CEX' or 'DEX', but found: $text",
text == "CEX" || text == "DEX",
)
}
}
fun List<SemanticsNode>.assertTrustScoresValid() {
val validScores = setOf("Risky", "Caution", "Trusted")
assertFalse("Trust scores list should not be empty", isEmpty())
forEach { node ->
val text = extractText(node)
assertTrue(
"Trust score should be one of $validScores, but found: $text",
text in validScores,
)
}
}
/**
* Extracts the text value from a semantic node's config.
*/
private fun extractText(node: SemanticsNode): String? {
if (SemanticsProperties.Text in node.config) {
return node.config[SemanticsProperties.Text].firstOrNull()?.text
}
return null
}
private fun parseVolume(node: SemanticsNode): Double? {
val text = extractText(node) ?: return null
return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull()
}

View file

@ -1,6 +1,8 @@
package com.tangem.common.extensions
import androidx.compose.ui.test.ComposeTimeoutException
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule
import io.github.kakaocup.compose.node.element.KNode
fun KNode.clickWithAssertion() {
@ -34,3 +36,34 @@ fun KNode.assertVisibility(shouldBeDisplayed: Boolean) {
assertIsNotDisplayed()
}
}
fun KNode.clickAndWaitFor(
rule: ComposeTestRule,
timeoutMs: Long = 5_000,
maxRetries: Int = 3,
expectedCondition: () -> Unit,
) {
for (attempt in 1..maxRetries) {
performClick()
rule.waitForIdle()
try {
rule.waitUntil(timeoutMs) {
runCatching { expectedCondition() }.isSuccess
}
return
} catch (_: ComposeTimeoutException) {
}
}
throw AssertionError("Condition not met after $maxRetries click attempts")
}
fun KNode.performTextInputInChunks(
text: String,
chunkSize: Int = 2
) {
text.chunked(chunkSize).forEach { chunk ->
performTextInput(chunk)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.common.extensions
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.wallet.R
@ -46,7 +47,23 @@ fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) {
}
fun BaseTestCase.openTheAppFromRecents() {
device.uiDevice.waitForIdle()
device.uiDevice.pressRecentApps()
device.uiDevice.waitForIdle()
val recentsOpened = device.uiDevice.wait(
Until.hasObject(By.res("com.android.launcher3:id/snapshot")),
3_000
)
if (!recentsOpened) {
device.uiDevice.pressRecentApps()
device.uiDevice.wait(
Until.hasObject(By.res("com.android.launcher3:id/snapshot")),
3_000
)
}
val centerX = device.uiDevice.displayWidth / 2
val centerY = device.uiDevice.displayHeight / 3
@ -54,6 +71,14 @@ fun BaseTestCase.openTheAppFromRecents() {
device.uiDevice.click(centerX, centerY)
}
fun BaseTestCase.collapseAppByHomeButton() {
device.uiDevice.pressHome()
device.uiDevice.wait(
Until.hasObject(By.pkg(device.uiDevice.launcherPackageName)),
3_000
)
}
fun BaseTestCase.disableWiFi() {
device.uiDevice.executeShellCommand("svc wifi disable")
}

View file

@ -1,8 +1,12 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeMarketsBlock
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen
import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen
import com.tangem.screens.onMarketsTokenDetailsScreen
import io.qameta.allure.kotlin.Allure.step
@ -23,4 +27,55 @@ fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName:
waitForIdle()
onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
}
}
fun BaseTestCase.assertMarketsExchangesScreen() {
step("Assert 'Title' is displayed") {
onMarketsExchangesScreen { exchangesTitle.assertIsDisplayed() }
}
step("Assert 'Exchange name' is displayed") {
onMarketsExchangesScreen { exchangeName.assertIsDisplayed() }
}
step("Assert 'Logo' is displayed") {
onMarketsExchangesScreen { exchangeLogo.assertIsDisplayed() }
}
step("Assert 'Exchange type' is displayed") {
onMarketsExchangesScreen { exchangeType.assertIsDisplayed() }
}
step("Assert 'Trust score' is displayed") {
onMarketsExchangesScreen { trustScore.assertIsDisplayed() }
}
}
fun BaseTestCase.openMarketsScreen() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
swipeMarketsBlock(SwipeDirection.UP)
waitForIdle()
}
}
fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) {
openMarketsScreen()
if (shouldClickSeeAllButton)
step("Click on 'See all' button") {
onMarketsScreen { seeAllButton.clickWithAssertion() }
}
step("Click on '$tokenName' token") {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Click on 'Listed on exchanges' block") {
onMarketsScreen { listedOnBlockContainer.performClick() }
waitForIdle()
}
}

View file

@ -0,0 +1,29 @@
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.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddTokenBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.common_add_token))
}
val addButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_add))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,71 @@
package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.features.onramp.impl.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 MarketsExchangesPageObject(private val provider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsExchangesPageObject>(semanticsProvider = provider) {
fun allTradingVolumeNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT))
.fetchSemanticsNodes()
fun allExchangeTypeNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))))
.fetchSemanticsNodes()
fun allTrustScoreNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)))
.fetchSemanticsNodes()
val exchangesTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.markets_token_details_exchanges_title))
useUnmergedTree = true
}
val exchangeName: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
}
val exchangeLogo: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_ICON)
}
val tradingVolume: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT)
}
val exchangeType: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_PRICE)
}
val trustScore: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)
}
val tryAgainButton: KNode = child {
hasText(getResourceString(R.string.alert_button_try_again))
}
val errorMessage: KNode = child {
hasText(getResourceString(R.string.markets_loading_error_title))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onMarketsExchangesScreen(function: MarketsExchangesPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -35,6 +35,25 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
useUnmergedTree = true
}
val listedOnExchangesCount: KNode = child {
hasTestTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT)
useUnmergedTree = true
}
val listedOnBlockContainer: KNode = child {
hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true)
}
val listedOnEmptyText: KNode = child {
hasText(getResourceString(R.string.markets_token_details_empty_exchanges))
useUnmergedTree = true
}
val seeAllButton: KNode = child {
hasText(getResourceString(com.tangem.core.ui.R.string.common_see_all))
useUnmergedTree = true
}
fun tokenWithTitle(title: String): KNode {
return child {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)

View file

@ -23,6 +23,11 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true
}
val addressesShimmer: KNode = child {
hasTestTag(SendAddressScreenTestTags.ADDRESSES_SHIMMER)
useUnmergedTree = true
}
val topAppBarTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(CoreUiR.string.common_address))
@ -62,7 +67,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
}
val clearTextFieldButton: KNode = child {
hasContentDescription(getResourceString(CoreUiR.string.common_close))
hasTestTag(SendAddressScreenTestTags.CROSS_ICON)
useUnmergedTree = true
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.AppBarWithSearchTestTags
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.test.MarketsTestTags
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
@ -46,6 +47,13 @@ class SwapChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv
}
useUnmergedTree = true
}
fun marketsTokenWithTitle(title: String): KNode {
return child {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)
hasText(title)
}
}
}
internal fun BaseTestCase.onSwapChooseTokenScreen(function: SwapChooseTokenPageObject.() -> Unit) =

View file

@ -3,10 +3,7 @@ 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.SearchBarTestTags
import com.tangem.core.ui.test.SwapSelectTokenScreenTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.core.ui.test.*
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
@ -48,6 +45,11 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv
useUnmergedTree = true
}
val searchBarBlock: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
useUnmergedTree = true
}
val searchBarIcon: KNode = child {
hasTestTag(SearchBarTestTags.ICON)
useUnmergedTree = true
@ -58,12 +60,28 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv
useUnmergedTree = true
}
val tryAgainButton: KNode = child {
hasText(getResourceString(R.string.alert_button_try_again))
useUnmergedTree = true
}
val unableToLoadData: KNode = child {
hasText(getResourceString(R.string.markets_loading_error_title))
useUnmergedTree = true
}
fun tokenWithName(tokenName: String): KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnyChild(withText(tokenName))
useUnmergedTree = true
}
fun marketsTokenWithName(title: String): KNode {
return child {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)
hasText(title)
}
}
}
internal fun BaseTestCase.onSwapSelectTokenScreen(function: SwapSelectTokenPageObject.() -> Unit) =

View file

@ -52,8 +52,8 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasTestTag(SwapTokenScreenTestTags.RECEIVE_AMOUNT_SHIMMER)
}
val swapTokensOnscreenButton: KNode = child {
hasTestTag(SwapTokenScreenTestTags.SWAP_BUTTON)
val replaceTokensButton: KNode = child {
hasTestTag(SwapTokenScreenTestTags.REPLACE_TOKENS_BUTTON)
}
val receiveAmount: KNode = child {

View file

@ -14,6 +14,7 @@ class TopBarPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
) {
val moreButton: KNode = child {
hasTestTag(MainScreenTestTags.MORE_BUTTON)
useUnmergedTree = true
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.redux.StateDialog
import com.tangem.scenarios.checkFailedTransactionDialog
@ -18,6 +19,7 @@ import com.tangem.tap.store
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -45,6 +47,7 @@ class FeedbackTest : BaseTestCase() {
onTopBar { moreButton.clickWithAssertion() }
}
step("Click 'Contact support' button") {
waitForIdle()
onDetailsScreen { contactSupportButton.clickWithAssertion() }
}
step("Assert 'Gmail' app is open") {
@ -53,6 +56,7 @@ class FeedbackTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("893")
@DisplayName("Send feedback: failed transaction")
@Test
@ -94,17 +98,29 @@ class FeedbackTest : BaseTestCase() {
step("Enter address") {
onSendAddressScreen { addressTextField.performTextInput(recipientAddress) }
}
step("Click 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
step("Assert text field contains text: $recipientAddress") {
onSendAddressScreen { addressTextField.assertTextEquals(recipientAddress) }
}
step("Assert sеnding text is displayed") {
onSendConfirmScreen { sendingText.assertIsDisplayed() }
step("Click 'Next' button") {
onSendAddressScreen {
nextButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onSendConfirmScreen { sendingText.assertIsDisplayed() }
}
)
}
}
step("Click 'Send' button") {
waitForIdle()
onSendConfirmScreen {
sendButton.assertIsEnabled()
sendButton.performClick()
sendButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onFailedTransactionDialog { dialogContainer.assertIsDisplayed() }
}
)
}
}
step("Check 'Failed transaction' dialog") {

View file

@ -14,6 +14,7 @@ import com.tangem.tap.domain.sdk.mocks.content.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -85,6 +86,7 @@ class ScanCardTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("870")
@DisplayName("Scan: Card with Ed25519 curve")
@Test

View file

@ -20,6 +20,7 @@ import com.tangem.tap.domain.sdk.mocks.content.TwinsMockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -460,6 +461,7 @@ class MainScreenActionButtonsTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4396")
@DisplayName("Action buttons (main screen): click on buttons without data")
@Test

View file

@ -113,6 +113,7 @@ class TotalBalanceUnavailableTest : BaseTestCase() {
val scenarioName = "user_tokens_api"
val scenarioState = "CustomTokenAdded"
val tokenTitle = "Myria"
val balance = "$3,299.18"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
@ -134,8 +135,8 @@ class TotalBalanceUnavailableTest : BaseTestCase() {
}
}
}
step("Assert dash sign is displayed in total balance") {
onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) }
step("Assert correct balance is displayed in total balance") {
onMainScreen { totalBalanceText.assertTextContains(balance) }
}
step("Assert $tokenTitle is unreachable") {
onMainScreen { tokenWithTitleAndPosition(tokenTitle, 4).assertIsUnreachable() }

View file

@ -126,7 +126,7 @@ class TotalBalanceUpdateTest : BaseTestCase() {
onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) }
}
step("Press 'Home' to collapse the app") {
device.uiDevice.pressHome()
collapseAppByHomeButton()
}
step("Open the app from recent apps") {
openTheAppFromRecents()

View file

@ -0,0 +1,200 @@
package com.tangem.tests.markets
import com.tangem.common.BaseTestCase
import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig
import com.tangem.common.extensions.*
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.assertMarketsExchangesScreen
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openMarketsExchangesScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class MarketsExchangesTest : BaseTestCase() {
@Test
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@AllureId("58")
@DisplayName("Markets: verify exchanges list screen")
fun marketsExchangesListTest() {
val tokenName = "Solana"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName = tokenName, shouldClickSeeAllButton = true)
}
step("Assert 'Exchanges' list screen is displayed") {
assertMarketsExchangesScreen()
}
}
}
@Test
@AllureId("56")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchanges block is displayed in token details")
fun marketsExchangesBlockDisplayedTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
swipeMarketsBlock(SwipeDirection.UP)
waitForIdle()
}
step("Click on '$tokenName' token") {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Assert 'Listed on exchanges' block has title") {
onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() }
}
step("Assert 'Listed on exchanges' block has exchanges count") {
onMarketsScreen { listedOnExchangesCount.assertIsDisplayed() }
}
step("Assert 'Listed on exchanges' block has arrow button") {
onMarketsScreen { listedOnBlockContainer.assertHasClickAction() }
}
step("Tap on 'Listed on exchanges' block and navigate to exchanges list") {
onMarketsScreen { listedOnBlockContainer.performClick() }
waitForIdle()
}
step("Assert 'Exchanges' list screen is displayed") {
assertMarketsExchangesScreen()
}
}
}
@Test
@AllureId("60")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchanges list is sorted by volume descending")
fun marketsExchangesListSortedByVolumeTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert exchanges are sorted by trading volume in descending order") {
flakySafely {
onMarketsExchangesScreen { allTradingVolumeNodes().assertSortedByVolumeDescending() }
}
}
}
}
@Test
@AllureId("61")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchange types are CEX or DEX")
fun marketsExchangesTypeTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert exchange types list is not empty and all types are 'CEX' or 'DEX'") {
flakySafely {
onMarketsExchangesScreen { allExchangeTypeNodes().assertExchangeTypesAreCexOrDex() }
}
}
}
}
@Test
@AllureId("62")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchange trust scores are valid")
fun marketsExchangesTrustScoreTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert trust scores list is not empty and all scores are valid") {
flakySafely {
onMarketsExchangesScreen { allTrustScoreNodes().assertTrustScoresValid() }
}
}
}
}
@Test
@AllureId("57")
@DisplayName("Markets: verify empty exchanges state")
fun marketsExchangesEmptyTest() {
val tokenName = "Bitcoin"
val scenarioName = "coins_bitcoin"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(scenarioName = scenarioName, state = "EmptyExchanges")
},
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
},
).run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert 'Listed on exchanges' block shows no exchanges") {
onMarketsScreen { listedOnEmptyText.assertIsDisplayed() }
}
}
}
@Test
@AllureId("59")
@DisplayName("Markets: verify exchanges error state and retry")
fun marketsExchangesErrorStateTest() {
val tokenName = "Bitcoin"
val scenarioName = "bitcoin_exchange"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(scenarioName = scenarioName, state = "Unreachable")
},
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
},
).run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert 'Unable to load data' error state is displayed") {
step("Assert 'Error message' is displayed") {
onMarketsExchangesScreen { errorMessage.assertIsDisplayed() }
}
step("Assert 'Try again button' is displayed") {
onMarketsExchangesScreen { tryAgainButton.assertIsDisplayed() }
}
}
step("Set WireMock scenario '$scenarioName' to state 'Started'") {
setWireMockScenarioState(scenarioName = scenarioName, state = "Started")
}
step("Tap 'Try again' button") {
onMarketsExchangesScreen { tryAgainButton.clickWithAssertion() }
waitForIdle()
}
step("Assert 'Exchanges' list screen is displayed after retry") {
assertMarketsExchangesScreen()
}
}
}
}

View file

@ -8,10 +8,12 @@ import com.tangem.common.constants.TestConstants.ENS_ETHEREUM_RECIPIENT_SHORTENE
import com.tangem.common.constants.TestConstants.ENS_NAME
import com.tangem.common.constants.TestConstants.ETHEREUM_ADDRESS
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.XRP_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.XRP_X_ADDRESS
import com.tangem.common.constants.TestConstants.XRP_X_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.XRP_X_RECIPIENT_ADDRESS_WITH_TAG
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.clearClipboard
import com.tangem.common.utils.resetWireMockScenarioState
@ -115,6 +117,9 @@ class SendAddressScreenTest : BaseTestCase() {
clearClipboard()
}
).run {
step("Set clipboard text") {
setClipboardText(context, recipientAddress)
}
step("Open 'Main Screen'") {
openMainScreen()
}
@ -124,10 +129,11 @@ class SendAddressScreenTest : BaseTestCase() {
step("Open 'Send Address' screen") {
openSendAddressScreen(tokenName, sendAmount)
}
step("Set clipboard text") {
setClipboardText(context, recipientAddress)
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Paste' button") {
waitForIdle()
onSendAddressScreen { addressPasteButton.clickWithAssertion() }
}
step("Assert address text field contains correct recipient address") {
@ -142,8 +148,22 @@ class SendAddressScreenTest : BaseTestCase() {
step("Set clipboard text") {
setClipboardText(context, invalidAddress)
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Cross' button") {
onSendAddressScreen { clearTextFieldButton.clickWithAssertion() }
waitForIdle()
onSendAddressScreen {
clearTextFieldButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onSendAddressScreen { addressPasteButton.assertIsDisplayed() }
}
)
}
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Paste' button") {
onSendAddressScreen { addressPasteButton.clickWithAssertion() }
@ -154,20 +174,38 @@ class SendAddressScreenTest : BaseTestCase() {
step("Assert invalid address text field title is displayed") {
onSendAddressScreen { addressTextFieldTitle.assertTextContains(notAValidAddress) }
}
step("Assert 'Next' button is disabled") {
onSendAddressScreen { nextButton.assertIsNotEnabled() }
}
step("Set clipboard text") {
setClipboardText(context, walletAddress)
}
step("Assert 'Next' button is disabled") {
onSendAddressScreen { nextButton.assertIsNotEnabled() }
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Cross' button") {
onSendAddressScreen { clearTextFieldButton.clickWithAssertion() }
onSendAddressScreen {
clearTextFieldButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onSendAddressScreen { addressPasteButton.assertIsDisplayed() }
}
)
}
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertDoesNotExist() }
}
step("Click on 'Paste' button") {
waitForIdle()
onSendAddressScreen { addressPasteButton.clickWithAssertion() }
}
step("Assert address text field contains invalid address") {
onSendAddressScreen { addressTextField.assertTextContains(walletAddress) }
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendAddressScreen {
addressTextField.assertTextContains(walletAddress)
}
}
}
step("Assert 'Address is the same as wallet address' error title is displayed") {
onSendAddressScreen { addressTextFieldTitle.assertTextContains(sameAsWalletAddress) }

View file

@ -267,22 +267,21 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) }
}
step("Turn off internet") {
disableWiFi()
disableMobileData()
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Turn on internet") {
enableWiFi()
enableMobileData()
}
step("Assert 'Network fee info unreachable' warning title is displayed") {
onSendConfirmScreen { warningTitle(warningTitle).assertIsDisplayed() }
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
waitForIdle()
onSendConfirmScreen { warningTitle(warningTitle).assertIsDisplayed() }
}
}
step("Assert 'Check your internet connection' warning message is displayed") {
onSendConfirmScreen { warningMessage(warningMessageResId).assertIsDisplayed() }
@ -290,6 +289,10 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Assert warning icon is displayed") {
onSendConfirmScreen { warningIcon(warningTitle).assertIsDisplayed() }
}
step("Turn on internet") {
enableWiFi()
enableMobileData()
}
step("Click on 'Refresh' button") {
waitForIdle()
onSendConfirmScreen {

View file

@ -17,6 +17,7 @@ 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.Ignore
import org.junit.Test
@HiltAndroidTest
@ -32,6 +33,7 @@ class StellarWarningsTest : BaseTestCase() {
getResourceString(R.string.send_notification_invalid_reserve_amount_title, reserveAmount)
private val warningMessage = getResourceString(R.string.send_notification_invalid_reserve_amount_text)
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4287")
@DisplayName("Warnings: check warning, when sending less than reserve")
@Test
@ -85,6 +87,7 @@ class StellarWarningsTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4286")
@DisplayName("Warnings: check warning when sending amount equal to reserve")
@Test
@ -139,6 +142,7 @@ class StellarWarningsTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4288")
@DisplayName("Warnings: check warning when sending greater than reserve")
@Test

View file

@ -0,0 +1,262 @@
package com.tangem.tests.swap
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.performTextInputInChunks
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.SwapEntryPoint
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openSwapScreen
import com.tangem.scenarios.synchronizeAddresses
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 SearchAndSwapTest : BaseTestCase() {
@AllureId("8520")
@DisplayName("Search and Swap: add token without derivation")
@Test
fun addTokenWithoutDerivationTest() {
val swapTokenName = "Ethereum"
val receiveTokenName = "Tether"
val swapTokenSymbol = "ETH"
val receiveTokenSymbol = "USDT"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on token with name '$swapTokenName'") {
onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() }
waitForIdle()
}
step("Click on 'Search' text field") {
onSwapSelectTokenScreen { searchBarPlaceholderText.performClick() }
}
step("Type '$receiveTokenName' in input text field") {
onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(receiveTokenName) }
}
step("Click on token with name '$receiveTokenName'") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
}
step("Assert swap token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() }
}
step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") {
onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
}
}
@AllureId("8519")
@DisplayName("Search and Swap: add token with derivation")
@Test
fun addTokenWithDerivationTest() {
val swapTokenName = "Ethereum"
val receiveTokenName = "TRON"
val swapTokenSymbol = "ETH"
val receiveTokenSymbol = "TRX"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on token with name '$swapTokenName'") {
onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() }
waitForIdle()
}
step("Click on 'Search' text field") {
onSwapSelectTokenScreen { searchBarPlaceholderText.performClick() }
}
step("Type '$receiveTokenSymbol' in input text field") {
onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(receiveTokenSymbol) }
}
step("Click on token with name '$receiveTokenName'") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
}
step("Assert swap token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() }
}
step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") {
onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
}
}
@AllureId("8523")
@DisplayName("Search and Swap: Markets error")
@Test
fun marketsErrorTest() {
val swapTokenName = "Ethereum"
val scenarioName = "coins_list_api"
val scenarioState = "Error"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
}
).run {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on token with name '$swapTokenName'") {
onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() }
waitForIdle()
}
step("Assert 'Unable to load data...' error is displayed") {
onSwapSelectTokenScreen { unableToLoadData.assertIsDisplayed() }
}
step("Assert 'Try again' button is displayed") {
onSwapSelectTokenScreen { tryAgainButton.assertIsDisplayed() }
}
}
}
@AllureId("8522")
@DisplayName("Search and Swap: check 'Unsupported token pair' warning")
@Test
fun unsupportedTokenPairTest() {
val swapTokenName = "Ethereum"
val receiveTokenName = "Pepe"
val warningTitle = getResourceString(R.string.warning_express_unsupported_pair_title)
val warningMessage = getResourceString(R.string.warning_express_unsupported_pair_description)
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on token with name '$swapTokenName'") {
onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() }
waitForIdle()
}
step("Click on 'Search' text field") {
onSwapSelectTokenScreen { searchBarPlaceholderText.performClick() }
}
step("Type '$receiveTokenName' in input text field") {
onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(receiveTokenName) }
}
step("Click on token with name '$receiveTokenName'") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
}
step("Assert warning title '$warningTitle' is displayed") {
onSwapTokenScreen { warningTitle(warningTitle).assertIsDisplayed() }
}
step("Assert warning message '$warningMessage' is displayed") {
onSwapTokenScreen { warningMessage(warningMessage).assertIsDisplayed() }
}
step("Assert warning icon is displayed'") {
onSwapTokenScreen { warningIcon(warningMessage).assertIsDisplayed() }
}
}
}
@AllureId("8521")
@DisplayName("Swap: search token on Swap token screen")
@Test
fun networkFeeTest() {
val tokenTitle = "Ethereum"
val swapTokenSymbol = "TRX"
val receiveTokenSymbol = "ETH"
val swapTokenName = "TRON"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails)
}
step("Click on 'Replace tokens' button") {
onSwapTokenScreen { replaceTokensButton.performClick() }
}
step("Click on 'Select token' icon") {
onSwapTokenScreen { selectTokenIcon.performClick() }
}
step("Click on 'Search' icon") {
onSwapChooseTokenScreen { searchIcon.performClick() }
}
step("Click on 'Search' text field") {
onSwapChooseTokenScreen { searchTextField.performClick() }
}
step("Type '$swapTokenSymbol' in 'Search' text field") {
onSwapChooseTokenScreen { searchTextField.performTextInputInChunks(swapTokenSymbol) }
}
step("Click on token with name: '$swapTokenName'") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onSwapChooseTokenScreen { marketsTokenWithTitle(swapTokenName).performClick() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
}
step("Assert swap token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() }
}
step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") {
onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tests.swap
import androidx.compose.ui.test.longClick
import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertHasBadge
import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState
@ -208,7 +209,10 @@ class SwapStoriesTest : BaseTestCase() {
}
step("Assert 'Swap' button has badge") {
waitForIdle()
onMainScreen { swapButton.assertHasBadge() }
flakySafely(WAIT_UNTIL_TIMEOUT) {
composeTestRule.mainClock.advanceTimeBy(500)
onMainScreen { swapButton.assertHasBadge() }
}
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)

View file

@ -63,7 +63,7 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'Swap tokens on screen' button is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
swapTokensOnscreenButton.assertIsDisplayed()
replaceTokensButton.assertIsDisplayed()
}
}
}
@ -203,7 +203,7 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'Swap tokens on screen' button is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
swapTokensOnscreenButton.assertIsDisplayed()
replaceTokensButton.assertIsDisplayed()
}
}
}
@ -307,7 +307,7 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'Swap tokens on screen' button is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
swapTokensOnscreenButton.assertIsDisplayed()
replaceTokensButton.assertIsDisplayed()
}
}
}
@ -397,7 +397,7 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'Swap tokens on screen' button is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
swapTokensOnscreenButton.assertIsDisplayed()
replaceTokensButton.assertIsDisplayed()
}
}
}
@ -442,7 +442,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
step("Click on 'Swap tokens on screen' button") {
onSwapTokenScreen { swapTokensOnscreenButton.performClick() }
onSwapTokenScreen { replaceTokensButton.performClick() }
waitForIdle()
}
step("Assert new swap token symbol: '$receiveTokenSymbol' is displayed") {
@ -557,9 +557,6 @@ class SwapTokenScreenTest : BaseTestCase() {
}
}
@ApiEnv(
ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD)
)
@AllureId("8536")
@DisplayName("Swap: check switch fee type (unable to cover 'Market' and 'Fast' fee)")
@Test

View file

@ -23,7 +23,6 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
@ -139,8 +138,6 @@ interface ApplicationEntryPoint {
fun getApiConfigsManager(): ApiConfigsManager
fun getUserTokensResponseStore(): UserTokensResponseStore
fun getUserWalletsListRepository(): UserWalletsListRepository
fun getTangemHotSdk(): TangemHotSdk

View file

@ -39,7 +39,6 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
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
@ -219,9 +218,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val apiConfigsManager: ApiConfigsManager
get() = entryPoint.getApiConfigsManager()
private val userTokensResponseStore: UserTokensResponseStore
get() = entryPoint.getUserTokensResponseStore()
private val userWalletsListRepository
get() = entryPoint.getUserWalletsListRepository()
@ -385,7 +381,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
settingsManager = settingsManager,
uiMessageSender = uiMessageSender,
coldUserWalletBuilderFactory = coldUserWalletBuilderFactory,
userTokensResponseStore = userTokensResponseStore,
userWalletsListRepository = userWalletsListRepository,
tangemHotSdk = tangemHotSdk,
trackingContextProxy = trackingContextProxy,

View file

@ -2,11 +2,9 @@ package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.utils.coroutines.AppCoroutineScope
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
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -20,10 +18,9 @@ import kotlin.contracts.contract
class AppsFlyerReferralParamsHandler @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase,
dispatchers: CoroutineDispatcherProvider,
private val coroutineScope: AppCoroutineScope,
) {
private val coroutineScope = CoroutineScope(dispatchers.io + SupervisorJob())
private val mutex = Mutex()
fun handle(deepLink: DeepLink) {

View file

@ -6,16 +6,14 @@ import com.appsflyer.attribution.AppsFlyerRequestListener
import com.tangem.core.analytics.api.EventLogger
import com.tangem.core.analytics.api.UserIdHolder
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener
import com.tangem.tap.common.analytics.appsflyer.TangemAFConversionListener
import com.tangem.tap.common.analytics.handlers.firebase.UnderscoreAnalyticsEventConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import timber.log.Timber
@ -26,15 +24,13 @@ class AppsFlyerClient @AssistedInject constructor(
@ApplicationContext private val context: Context,
appsFlyerDeepLinkListener: AppsFlyerDeepLinkListener,
tangemAFConversionListener: TangemAFConversionListener,
dispatchers: CoroutineDispatcherProvider,
private val appsFlyerStore: AppsFlyerStore,
private val coroutineScope: AppCoroutineScope,
) : AppsFlyerAnalyticsClient {
private val appsFlyerLib: AppsFlyerLib = AppsFlyerLib.getInstance()
private val eventConverter = UnderscoreAnalyticsEventConverter()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
init {
with(appsFlyerLib) {
setAppId(context.packageName)

View file

@ -1,11 +1,17 @@
package com.tangem.tap.common.log
import android.os.Build
import android.util.Log
import co.touchlab.kermit.BaseLogger
import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.orhanobut.logger.AndroidLogAdapter
import com.orhanobut.logger.Logger
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.wallet.BuildConfig
import timber.log.Timber
import java.util.regex.Pattern
import com.orhanobut.logger.Logger as PrettyLogger
/**
* Tangem app logger
@ -21,32 +27,102 @@ class TangemAppLoggerInitializer(
/** Initialize */
fun initialize() {
if (IS_LOG_ENABLED) {
Logger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy()))
PrettyLogger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy()))
}
Timber.plant(tree = createTimberTree())
Logger.setLogWriters(KermitLogWriter(::finalLogOutput))
}
private fun createTimberTree(): Timber.Tree {
return object : Timber.DebugTree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (IS_LOG_ENABLED) {
Logger.log(priority, tag, message, t)
}
if (PERMITTED_PRIORITY.contains(priority)) {
appLogsStore.saveLogMessage(
tag = tag ?: "TangemAppLogger",
message = message,
)
}
finalLogOutput(priority = priority, tag = tag, message = message, t = t)
}
}
}
private fun finalLogOutput(priority: Int, tag: String?, message: String, t: Throwable?) {
if (IS_LOG_ENABLED) {
PrettyLogger.log(priority, tag, message, t)
}
if (PERMITTED_PRIORITY.contains(priority)) {
appLogsStore.saveLogMessage(
tag = tag ?: "TangemAppLogger",
message = message,
)
}
}
@Suppress("BooleanPropertyNaming")
private companion object {
val IS_LOG_ENABLED: Boolean = BuildConfig.LOG_ENABLED
val PERMITTED_PRIORITY = listOf(Log.ERROR, Log.INFO)
}
}
private class KermitLogWriter(
private val finalLogOutput: (priority: Int, tag: String?, message: String, t: Throwable?) -> Unit,
) : LogWriter() {
private val fqcnIgnore = setOf(
LogWriter::class.java.name,
KermitLogWriter::class.java.name,
BaseLogger::class.java.name,
Logger::class.java.name,
)
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val priority = when (severity) {
Severity.Verbose -> PrettyLogger.VERBOSE
Severity.Debug -> PrettyLogger.DEBUG
Severity.Info -> PrettyLogger.INFO
Severity.Warn -> PrettyLogger.WARN
Severity.Error -> PrettyLogger.ERROR
Severity.Assert -> PrettyLogger.ASSERT
}
val finalTag = if (tag != KERMIT_LOGGER_DEFAULT_TAG) {
tag
} else {
/**
* like in [Timber.DebugTree.tag]
*/
@Suppress("UnnecessaryLet", "ThrowingExceptionsWithoutMessageOrCause")
Throwable().stackTrace
.first { it.className !in fqcnIgnore }
.let(::createStackElementTag)
}
finalLogOutput(priority, finalTag, message, throwable)
}
/**
* copy from [Timber.DebugTree.createStackElementTag]
*/
@Suppress("MagicNumber")
private fun createStackElementTag(element: StackTraceElement): String? {
var tag = element.className.substringAfterLast('.')
val m = ANONYMOUS_CLASS.matcher(tag)
if (m.find()) {
tag = m.replaceAll("")
}
// Tag length limit was removed in API 26.
return if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) {
tag
} else {
tag.substring(0, MAX_TAG_LENGTH)
}
}
private companion object {
private const val KERMIT_LOGGER_DEFAULT_TAG = ""
/**
* copy from [Timber.DebugTree.Companion]
*/
private const val MAX_TAG_LENGTH = 23
private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$")
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.tap.core
import co.touchlab.kermit.Logger
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.SupervisorJob
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
internal class DefaultAppCoroutineScope @Inject constructor(
dispatchers: CoroutineDispatcherProvider,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : AppCoroutineScope {
private val tag = "AppCoroutineScope"
override val coroutineContext: CoroutineContext = SupervisorJob() +
// keep IO dispatcher to avoid blocking Default with IO operations
dispatchers.io +
CoroutineName(tag) +
CoroutineExceptionHandler { context, throwable ->
val coroutineName = context[CoroutineName]?.name.orEmpty()
logError(throwable, coroutineName)
}
private fun logError(throwable: Throwable, coroutineName: String) {
Logger.withTag(tag).e(
messageString = "CoroutineName $coroutineName",
throwable = throwable,
)
val event = ExceptionAnalyticsEvent(
exception = throwable,
params = mapOf(
"source" to tag,
"coroutineName" to coroutineName,
),
)
analyticsExceptionHandler.sendException(event)
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.datasource.api.moonpay.MoonPayApi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -21,8 +22,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -62,8 +61,8 @@ internal object ActivityModule {
@Provides
@Singleton
@DelayedWork
fun provideActivityDelayedWorkCoroutineScope(): CoroutineScope {
return CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun provideActivityDelayedWorkCoroutineScope(appScope: AppCoroutineScope): CoroutineScope {
return appScope
}
@Provides

View file

@ -8,6 +8,7 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.domain.card.BuildConfig
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager
@ -17,7 +18,6 @@ import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -44,7 +44,7 @@ internal class TangemSdkManagerModule {
analyticsExceptionHandler: AnalyticsExceptionHandler,
blockchainToDeriveFinder: BlockchainToDeriveFinder,
analyticsEventHandler: AnalyticsEventHandler,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
MockTangemSdkManager(resources = context.resources)
@ -62,7 +62,7 @@ internal class TangemSdkManagerModule {
analyticsExceptionHandler = analyticsExceptionHandler,
blockchainToDeriveFinder = blockchainToDeriveFinder,
analyticsEventHandler = analyticsEventHandler,
dispatchers = dispatchers,
coroutineScope = appScope,
)
}
}

View file

@ -6,6 +6,7 @@ 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.components.snackbar.TangemTopSnackbarHostState
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.EventMessageHandler
import com.tangem.core.ui.theme.AppThemeModeHolder
@ -30,6 +31,7 @@ internal object UiDependenciesModule {
override val vibratorHapticManager = vibratorHapticManager
override val appThemeModeHolder = appThemeModeHolder
override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState()
override val globalTopSnackbarHostState: TangemTopSnackbarHostState = TangemTopSnackbarHostState()
override val eventMessageHandler: EventMessageHandler = EventMessageHandler()
override val designFeatureToggles: DesignFeatureToggles = designFeatureToggles
}

View file

@ -5,10 +5,13 @@ import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.tap.common.finisher.AndroidAppFinisher
import com.tangem.tap.common.settings.IntentSettingsManager
import com.tangem.tap.common.share.IntentShareManager
import com.tangem.tap.common.url.CustomTabsUrlOpener
import com.tangem.tap.core.DefaultAppCoroutineScope
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -18,21 +21,28 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object UtilsModule {
internal interface UtilsModule {
@Provides
@Singleton
fun provideShareManager(): ShareManager = IntentShareManager()
@Binds
fun provideAppScope(defaultAppScope: DefaultAppCoroutineScope): AppCoroutineScope
@Provides
@Singleton
fun provideUrlOpener(): UrlOpener = CustomTabsUrlOpener()
companion object {
@Provides
@Singleton
fun provideAppFinisher(@ApplicationContext context: Context): AppFinisher = AndroidAppFinisher(context)
@Provides
@Singleton
fun provideShareManager(): ShareManager = IntentShareManager()
@Provides
@Singleton
fun provideSettingsManager(@ApplicationContext context: Context): SettingsManager = IntentSettingsManager(context)
@Provides
@Singleton
fun provideUrlOpener(): UrlOpener = CustomTabsUrlOpener()
@Provides
@Singleton
fun provideAppFinisher(@ApplicationContext context: Context): AppFinisher = AndroidAppFinisher(context)
@Provides
@Singleton
fun provideSettingsManager(@ApplicationContext context: Context): SettingsManager =
IntentSettingsManager(context)
}
}

View file

@ -40,14 +40,6 @@ internal object ManageTokensDomainModule {
return FindTokenUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckIsCurrencyNotAddedUseCase(
customTokensRepository: CustomTokensRepository,
): CheckIsCurrencyNotAddedUseCase {
return CheckIsCurrencyNotAddedUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideGetSupportedNetworksUseCase(
@ -64,12 +56,6 @@ internal object ManageTokensDomainModule {
return ValidateDerivationPathUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckHasLinkedTokensUseCase(repository: ManageTokensRepository): CheckHasLinkedTokensUseCase {
return CheckHasLinkedTokensUseCase(repository)
}
@Provides
@Singleton
fun provideCheckCurrencyUnsupportedUseCase(repository: ManageTokensRepository): CheckCurrencyUnsupportedUseCase {

View file

@ -8,7 +8,6 @@ import com.tangem.domain.nft.utils.NFTCleaner
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -63,9 +62,7 @@ internal object NFTDomainModule {
fun providesGetNFTAvailableNetworksUseCase(
nftRepository: NFTRepository,
singleAccountListSupplier: SingleAccountListSupplier,
currenciesRepository: CurrenciesRepository,
): GetNFTNetworksUseCase = GetNFTNetworksUseCase(
currenciesRepository = currenciesRepository,
nftRepository = nftRepository,
singleAccountListSupplier = singleAccountListSupplier,
)

View file

@ -1,9 +1,12 @@
package com.tangem.tap.di.domain
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.qrscanning.usecases.ResolveQrSendTargetsUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,4 +34,18 @@ internal object QrScanningDomainModule {
fun provideParseQrCodeUseCase(repository: QrScanningEventsRepository): ParseQrCodeUseCase {
return ParseQrCodeUseCase(repository)
}
@Provides
@Singleton
fun provideResolveQrSendTargetsUseCase(
multiAccountListSupplier: MultiAccountListSupplier,
qrScanningEventsRepository: QrScanningEventsRepository,
userWalletsListRepository: UserWalletsListRepository,
): ResolveQrSendTargetsUseCase {
return ResolveQrSendTargetsUseCase(
multiAccountListSupplier = multiAccountListSupplier,
qrScanningEventsRepository = qrScanningEventsRepository,
userWalletsListRepository = userWalletsListRepository,
)
}
}

View file

@ -1,25 +1,20 @@
package com.tangem.tap.di.domain
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.staking.single.SingleStakingBalanceFetcher
import com.tangem.domain.staking.single.SingleStakingBalanceSupplier
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
@ -36,7 +31,6 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@Suppress("TooManyFunctions", "LargeClass")
internal object TokensDomainModule {
@Provides
@ -53,67 +47,6 @@ internal object TokensDomainModule {
return DefaultTokensFeatureToggles(featureTogglesManager = featureTogglesManager)
}
@Provides
@Singleton
fun provideGetCurrencyUseCase(
baseCurrencyStatusOperations: BaseCurrencyStatusOperations,
dispatchers: CoroutineDispatcherProvider,
): GetSingleCryptoCurrencyStatusUseCase {
return GetSingleCryptoCurrencyStatusUseCase(
currencyStatusOperations = baseCurrencyStatusOperations,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideGetCurrencyWarningsUseCase(
walletManagersFacade: WalletManagersFacade,
currenciesRepository: CurrenciesRepository,
currencyChecksRepository: CurrencyChecksRepository,
dispatchers: CoroutineDispatcherProvider,
baseCurrencyStatusOperations: BaseCurrencyStatusOperations,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
): GetCurrencyWarningsUseCase {
return GetCurrencyWarningsUseCase(
walletManagersFacade = walletManagersFacade,
currenciesRepository = currenciesRepository,
dispatchers = dispatchers,
currencyChecksRepository = currencyChecksRepository,
currencyStatusOperations = baseCurrencyStatusOperations,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
)
}
@Provides
@Singleton
fun provideFetchCurrencyStatusUseCase(
currenciesRepository: CurrenciesRepository,
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
singleStakingBalanceFetcher: SingleStakingBalanceFetcher,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
stakingIdFactory: StakingIdFactory,
): FetchCurrencyStatusUseCase {
return FetchCurrencyStatusUseCase(
currenciesRepository = currenciesRepository,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
singleStakingBalanceFetcher = singleStakingBalanceFetcher,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
stakingIdFactory = stakingIdFactory,
)
}
@Provides
@Singleton
fun provideGetCryptoCurrencyUseCase(
currenciesRepository: CurrenciesRepository,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
): GetCryptoCurrencyUseCase {
return GetCryptoCurrencyUseCase(currenciesRepository, multiWalletCryptoCurrenciesSupplier)
}
@Provides
@Singleton
fun provideGetCryptoCurrencyActionsUseCase(
@ -132,30 +65,6 @@ internal object TokensDomainModule {
)
}
@Provides
@Singleton
fun provideGetCurrencyStatusByNetworkUseCase(
currencyStatusOperations: BaseCurrencyStatusOperations,
dispatchers: CoroutineDispatcherProvider,
): GetNetworkCoinStatusUseCase {
return GetNetworkCoinStatusUseCase(
currencyStatusOperations = currencyStatusOperations,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideGetFeePaidCryptoCurrencyStatusSyncUseCase(
currenciesRepository: CurrenciesRepository,
currencyStatusOperations: BaseCurrencyStatusOperations,
): GetFeePaidCryptoCurrencyStatusSyncUseCase {
return GetFeePaidCryptoCurrencyStatusSyncUseCase(
currenciesRepository = currenciesRepository,
currencyStatusOperations = currencyStatusOperations,
)
}
@Provides
@Singleton
fun provideGetMinimumTransactionAmountSyncUseCase(
@ -239,37 +148,13 @@ internal object TokensDomainModule {
return GetCurrencyCheckUseCase(currencyChecksRepository, dispatchers)
}
@Provides
@Singleton
fun provideBaseCurrencyStatusOperations(
currenciesRepository: CurrenciesRepository,
quotesRepository: QuotesRepository,
singleNetworkStatusSupplier: SingleNetworkStatusSupplier,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
singleStakingBalanceSupplier: SingleStakingBalanceSupplier,
multiStakingBalanceSupplier: MultiStakingBalanceSupplier,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
stakingIdFactory: StakingIdFactory,
): BaseCurrencyStatusOperations {
return BaseCurrencyStatusOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
singleNetworkStatusSupplier = singleNetworkStatusSupplier,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleStakingBalanceSupplier = singleStakingBalanceSupplier,
multiStakingBalanceSupplier = multiStakingBalanceSupplier,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
stakingIdFactory = stakingIdFactory,
)
}
@Provides
@Singleton
fun provideWalletBalanceFetcher(
currenciesRepository: CurrenciesRepository,
multiWalletCryptoCurrenciesFetcher: MultiWalletCryptoCurrenciesFetcher,
userWalletsListRepository: UserWalletsListRepository,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
expressServiceFetcher: ExpressServiceFetcher,
multiWalletAccountListFetcher: MultiWalletAccountListFetcher,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
@ -279,8 +164,10 @@ internal object TokensDomainModule {
dispatchers: CoroutineDispatcherProvider,
): WalletBalanceFetcher {
return WalletBalanceFetcher(
currenciesRepository = currenciesRepository,
multiWalletCryptoCurrenciesFetcher = multiWalletCryptoCurrenciesFetcher,
userWalletsListRepository = userWalletsListRepository,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
expressServiceFetcher = expressServiceFetcher,
multiWalletAccountListFetcher = multiWalletAccountListFetcher,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,

View file

@ -1,16 +1,16 @@
package com.tangem.tap.di.domain
import com.tangem.data.wallets.hot.TangemHotWalletSigner
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.utils.coroutines.AppCoroutineScope
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.notifications.repository.PushNotificationsRepository
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
@ -19,13 +19,10 @@ 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
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", "LargeClass")
@ -56,8 +53,8 @@ internal object TransactionDomainModule {
walletManagersFacade: WalletManagersFacade,
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
dispatchers: CoroutineDispatcherProvider,
pushNotificationsRepository: PushNotificationsRepository,
appScope: AppCoroutineScope,
): SendTransactionUseCase {
return SendTransactionUseCase(
demoConfig = DemoConfig,
@ -65,7 +62,7 @@ internal object TransactionDomainModule {
transactionRepository = transactionRepository,
walletManagersFacade = walletManagersFacade,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.io),
parallelUpdatingScope = appScope,
getHotWalletSigner = tangemHotWalletSignerFactory::create,
pushNotificationsRepository = pushNotificationsRepository,
)
@ -279,14 +276,12 @@ internal object TransactionDomainModule {
@Singleton
fun provideGetAvailableFeeTokensUseCase(
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
): GetAvailableFeeTokensUseCase {
return GetAvailableFeeTokensUseCase(
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
gaslessTransactionRepository = gaslessTransactionRepository,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
currencyChecksRepository = currencyChecksRepository,
)
}
@ -296,17 +291,15 @@ internal object TransactionDomainModule {
fun provideGetFeeForGaslessUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getFeeUseCase: GetFeeUseCase,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
): GetFeeForGaslessUseCase {
return GetFeeForGaslessUseCase(
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
gaslessTransactionRepository = gaslessTransactionRepository,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
getFeeUseCase = getFeeUseCase,
currencyChecksRepository = currencyChecksRepository,
)
@ -317,16 +310,14 @@ internal object TransactionDomainModule {
fun provideGetFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
): GetFeeForTokenUseCase {
return GetFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository,
)
}
@ -344,13 +335,13 @@ internal object TransactionDomainModule {
fun provideCreateAndSendGaslessTransactionUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
getSingCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
singleAccountListSupplier: SingleAccountListSupplier,
cardSdkConfigRepository: CardSdkConfigRepository,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
): CreateAndSendGaslessTransactionUseCase {
return CreateAndSendGaslessTransactionUseCase(
walletManagersFacade = walletManagersFacade,
getSingleCryptoCurrencyStatusUseCase = getSingCryptoCurrencyStatusUseCase,
singleAccountListSupplier = singleAccountListSupplier,
gaslessTransactionRepository = gaslessTransactionRepository,
cardSdkConfigRepository = cardSdkConfigRepository,
getHotWalletSigner = tangemHotWalletSignerFactory::create,
@ -362,16 +353,14 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
): EstimateFeeForTokenUseCase {
return EstimateFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository,
)
}
@ -381,8 +370,7 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForGaslessTxUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
estimateFeeUseCase: EstimateFeeUseCase,
currencyChecksRepository: CurrencyChecksRepository,
): EstimateFeeForGaslessTxUseCase {
@ -390,8 +378,7 @@ internal object TransactionDomainModule {
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
estimateFeeUseCase = estimateFeeUseCase,
currencyChecksRepository = currencyChecksRepository,
)

View file

@ -1,9 +1,10 @@
package com.tangem.tap.di.domain
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.utils.coroutines.AppCoroutineScope
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
import com.tangem.domain.transaction.error.FeeErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
@ -15,8 +16,6 @@ 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")
@ -145,12 +144,12 @@ internal object YieldSupplyDomainModule {
fun provideYieldSupplyMinAmountUseCase(
feeRepository: FeeRepository,
quotesRepository: QuotesRepository,
currenciesRepository: CurrenciesRepository,
singleAccountListSupplier: SingleAccountListSupplier,
): YieldSupplyMinAmountUseCase {
return YieldSupplyMinAmountUseCase(
feeRepository = feeRepository,
quotesRepository = quotesRepository,
currenciesRepository = currenciesRepository,
singleAccountListSupplier = singleAccountListSupplier,
)
}
@ -159,12 +158,12 @@ internal object YieldSupplyDomainModule {
fun provideYieldSupplyGetCurrentFeeUseCase(
feeRepository: FeeRepository,
quotesRepository: QuotesRepository,
currenciesRepository: CurrenciesRepository,
singleAccountListSupplier: SingleAccountListSupplier,
): YieldSupplyGetCurrentFeeUseCase {
return YieldSupplyGetCurrentFeeUseCase(
feeRepository = feeRepository,
quotesRepository = quotesRepository,
currenciesRepository = currenciesRepository,
singleAccountListSupplier = singleAccountListSupplier,
)
}
@ -173,12 +172,12 @@ internal object YieldSupplyDomainModule {
fun provideYieldSupplyGetMaxFeeUseCase(
yieldSupplyRepository: YieldSupplyRepository,
quotesRepository: QuotesRepository,
currenciesRepository: CurrenciesRepository,
singleAccountListSupplier: SingleAccountListSupplier,
): YieldSupplyGetMaxFeeUseCase {
return YieldSupplyGetMaxFeeUseCase(
yieldSupplyRepository = yieldSupplyRepository,
quotesRepository = quotesRepository,
currenciesRepository = currenciesRepository,
singleAccountListSupplier = singleAccountListSupplier,
)
}
@ -253,12 +252,12 @@ internal object YieldSupplyDomainModule {
fun provideYieldSupplyPendingProcessorUseCase(
yieldSupplyRepository: YieldSupplyRepository,
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
dispatcherProvider: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): YieldSupplyPendingTracker {
return YieldSupplyPendingTracker(
yieldSupplyRepository = yieldSupplyRepository,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
coroutineScope = CoroutineScope(SupervisorJob() + dispatcherProvider.io),
coroutineScope = appScope,
)
}
}

View file

@ -34,6 +34,7 @@ import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.CardDTO
@ -65,7 +66,6 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
import com.tangem.tap.domain.twins.FinalizeTwinTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.R
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
@ -86,7 +86,7 @@ internal class DefaultTangemSdkManager(
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder,
private val analyticsEventHandler: AnalyticsEventHandler,
dispatchers: CoroutineDispatcherProvider,
private val coroutineScope: AppCoroutineScope,
) : TangemSdkManager {
private val awaitInitializationMutex = Mutex()
@ -115,8 +115,6 @@ internal class DefaultTangemSdkManager(
override val userCodeRequestPolicy: UserCodeRequestPolicy
get() = tangemSdk.config.userCodeRequestPolicy
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.io)
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean {
return try {
needEnrollBiometrics

View file

@ -10,10 +10,10 @@ import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.coil.ImagePreloader
import com.tangem.core.ui.components.bottomsheets.message.*
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.BottomSheetMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.domain.appcurrency.FetchAppCurrenciesUseCase
import com.tangem.domain.balancehiding.BalanceHidingSettings
@ -216,30 +216,28 @@ internal class MainViewModel @Inject constructor(
if (!settings.isUpdateFromToast) {
listenToFlipsUseCase.changeUpdateEnabled(false)
val message = BottomSheetMessage.invoke(
iconResId = R.drawable.ic_eye_off_outline_24,
title = resourceReference(R.string.balance_hidden_title),
message = resourceReference(R.string.balance_hidden_description),
onDismissRequest = ::onBottomSheetDismissed,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.balance_hidden_got_it_button),
onClick = {
onHiddenBalanceNotificationAction(isPermanent = false)
onDismissRequest()
},
)
},
secondActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.balance_hidden_do_not_show_button),
onClick = {
onHiddenBalanceNotificationAction(isPermanent = true)
onDismissRequest()
},
)
},
)
val message = bottomSheetMessage {
infoBlock {
title = resourceReference(R.string.balance_hidden_title)
body = resourceReference(R.string.balance_hidden_description)
icon(R.drawable.ic_eye_off_outline_24)
}
onDismiss { onBottomSheetDismissed() }
primaryButton {
text = resourceReference(R.string.balance_hidden_got_it_button)
onClick = {
onHiddenBalanceNotificationAction(isPermanent = false)
closeBs()
}
}
secondaryButton {
text = resourceReference(R.string.balance_hidden_do_not_show_button)
onClick = {
onHiddenBalanceNotificationAction(isPermanent = true)
closeBs()
}
}
}
messageSender.send(message)
}
}

View file

@ -13,7 +13,6 @@ import com.tangem.data.card.TransactionSignerFactory
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
@ -71,7 +70,6 @@ data class DaggerGraphState(
val uiMessageSender: UiMessageSender? = null,
val cardArworksProvider: CardArtworksProvider? = null,
val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory? = null,
val userTokensResponseStore: UserTokensResponseStore? = null,
val userWalletsListRepository: UserWalletsListRepository? = null,
val tangemHotSdk: TangemHotSdk? = null,
val trackingContextProxy: TrackingContextProxy? = null,

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@ -28,9 +29,12 @@ import com.arkivanov.essenty.backhandler.BackHandler
import com.tangem.common.routing.AppRoute
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost
import com.tangem.core.ui.message.EventMessageEffect
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.LocalRootBackgroundColor
import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.LocalTopSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.security.ProvideSecureFlagController
import com.tangem.tap.routing.component.RoutingComponent
@ -93,6 +97,16 @@ internal fun RootContent(
.padding(all = 16.dp),
hostState = snackbarHostState,
)
if (LocalRedesignEnabled.current) {
TangemTopSnackbarHost(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(all = 16.dp),
hostState = LocalTopSnackbarHostState.current,
)
}
}
}
EventMessageEffect()

View file

@ -349,6 +349,7 @@ internal class ChildFactory @Inject constructor(
val source = when (route.source) {
is AppRoute.QrScanning.Source.Send -> SourceType.SEND
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN
}
createComponentChild(
context = context,

View file

@ -5,8 +5,8 @@ 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 com.tangem.common.test.TestAppCoroutineScope
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
@ -30,7 +30,7 @@ class AppsFlyerReferralParamsHandlerTest {
}
private val handler = AppsFlyerReferralParamsHandler(
appsFlyerStore = appsFlyerStore,
dispatchers = TestingCoroutineDispatcherProvider(),
coroutineScope = TestAppCoroutineScope(),
setShouldShowMobileWalletPromoUseCase = setShouldShowMobileWalletPromoUseCase,
)

View file

@ -13,6 +13,7 @@ dependencies {
/* Core */
implementation(projects.core.decompose)
implementation(projects.core.configToggles)
implementation(projects.core.navigation)
/* Domain */
implementation(projects.domain.qrScanning.models)

View file

@ -167,11 +167,14 @@ sealed class AppRoute(val path: String) : Route {
get() = when (this) {
is Send -> "/$networkName"
WalletConnect -> ""
MainScreen -> ""
}
data class Send(val networkName: String) : Source()
data object WalletConnect : Source()
data object MainScreen : Source()
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.common.routing
import android.net.Uri
import timber.log.Timber
/**
* Routes in-app content links (deep links and external URLs)
* - `tangem://` scheme → parsed to AppRoute and pushed via AppRouter
* - `https://` / `http://` → opened in external browser via UrlOpener
* - Unknown scheme logged and ignored
*/
class LinkHandler(
private val appRouter: AppRouter,
) {
fun navigate(link: String) {
val uri = Uri.parse(link)
handleTangemDeepLink(uri)
}
private fun handleTangemDeepLink(uri: Uri) {
val route = parseDeepLinkToRoute(uri)
if (route != null) {
appRouter.push(route)
} else {
Timber.w("ContentLinkHandler: unrecognized tangem deep link: %s", uri)
}
}
@Suppress("UnusedParameter", "FunctionOnlyReturningConstant")
private fun parseDeepLinkToRoute(uri: Uri): AppRoute? {
// TODO [REDACTED_TASK_KEY] refactor deepling routing
return null
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.common.test
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.TestScope
import kotlin.coroutines.CoroutineContext
class TestAppCoroutineScope(override val coroutineContext: CoroutineContext = Dispatchers.Unconfined) : AppCoroutineScope {
constructor(testScope: TestScope) : this(testScope.coroutineContext)
}

View file

@ -0,0 +1,323 @@
package com.tangem.common.ui.markets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.testTag
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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.common.ui.charts.MarketChartMini
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider
import com.tangem.common.ui.tokens.TokenPriceText
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.res.LocalIsInDarkTheme
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.windowsize.WindowSizeType
import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
fun MarketsListItemV2(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketListItemContentV2(
modifier = modifier
.fillMaxWidth()
.clip(RectangleShape)
.clickable(onClick = onClick)
.testTag(MarketsTestTags.TOKENS_LIST_ITEM),
model = model,
)
}
@Composable
fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
TangemRowContainer(
modifier = modifier,
content = {
TangemIcon(
tangemIconUM = TangemIconUM.Url(model.iconUrl, fallbackRes = R.drawable.ic_custom_token_44),
modifier = Modifier
.size(40.dp)
.layoutId(layoutId = TangemRowLayoutId.HEAD),
)
TokenTitle(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.START_TOP)
.padding(horizontal = TangemTheme.dimens2.x2),
name = model.name,
currencySymbol = model.currencySymbol,
)
TokenPriceText(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.END_TOP)
.testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT),
price = model.price.text,
priceChangeType = model.price.changeType,
)
TokenSubtitle(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM)
.padding(end = TangemTheme.dimens2.x2, start = TangemTheme.dimens2.x3),
ratingPosition = model.ratingPosition,
marketCap = model.marketCap,
// stakingRate = model.stakingRate, TODO in [REDACTED_TASK_KEY]
)
PriceChangeInPercent(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM),
textStyle = TangemTheme.typography2.captionRegular12,
type = model.trendType,
valueInPercent = model.trendPercentText,
)
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Chart(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(layoutId = TangemRowLayoutId.TAIL),
chartType = model.chartType,
chartRawData = model.chartData,
)
}
},
)
}
@Composable
private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier = Modifier) {
Row(modifier = modifier) {
Text(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
text = name,
color = TangemTheme.colors2.text.neutral.primary,
style = TangemTheme.typography2.bodySemibold16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerW4()
Text(
modifier = Modifier.alignByBaseline(),
text = currencySymbol,
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun TokenSubtitle(
ratingPosition: String?,
marketCap: String?,
// stakingRate: TextReference?, TODO in [REDACTED_TASK_KEY]
modifier: Modifier = Modifier,
) {
val ratingColor = mapRatingToColor(ratingPosition)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
TokenRatingPlace(
ratingPosition = ratingPosition,
ratingColor = ratingColor,
)
if (marketCap != null) {
TokenMarketCapText(
ratingColor = ratingColor,
modifier = Modifier.weight(1f, fill = false),
text = marketCap,
)
}
}
}
@Composable
private fun RowScope.TokenRatingPlace(ratingPosition: String?, ratingColor: Color) {
Row(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens2.x4),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Icon(
modifier = Modifier.size(height = TangemTheme.dimens2.x4, width = TangemTheme.dimens2.x2),
imageVector = ImageVector.vectorResource(R.drawable.ic_laurel_left),
tint = ratingColor,
contentDescription = null,
)
Text(
textAlign = TextAlign.Center,
text = ratingPosition ?: MINUS,
color = ratingColor,
style = TangemTheme.typography2.captionSemibold12.copy(letterSpacing = 0.sp),
maxLines = 1,
)
Icon(
modifier = Modifier.size(height = TangemTheme.dimens2.x4, width = TangemTheme.dimens2.x2),
imageVector = ImageVector.vectorResource(R.drawable.ic_laurel_right),
tint = ratingColor,
contentDescription = null,
)
}
}
@Composable
private fun RowScope.TokenMarketCapText(text: String, ratingColor: Color, modifier: Modifier = Modifier) {
Text(
modifier = modifier.alignByBaseline(),
text = text,
color = ratingColor,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.padding(vertical = 6.dp)
.size(height = TangemTheme.dimens2.x6, width = TangemTheme.dimens2.x12),
) {
if (chartRawData != null) {
MarketChartMini(
rawData = chartRawData,
type = chartType,
)
} else {
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens2.x3)
.align(Alignment.Center),
radius = 3.dp,
)
}
}
}
@Composable
private fun mapRatingToColor(rating: String?): Color {
val isDarkTheme = LocalIsInDarkTheme.current
return when (rating) {
"1" -> if (isDarkTheme) Color(GOLD_PLACE_COLOR_NIGHT) else Color(GOLD_PLACE_COLOR_LIGHT)
"2" -> if (isDarkTheme) Color(SILVER_PLACE_COLOR_NIGHT) else Color(SILVER_PLACE_COLOR_LIGHT)
"3" -> if (isDarkTheme) Color(BRONZE_PLACE_COLOR_NIGHT) else Color(BRONZE_PLACE_COLOR_LIGHT)
else -> TangemTheme.colors2.text.neutral.secondary
}
}
private const val GOLD_PLACE_COLOR_NIGHT = 0xFFFBEE76
private const val GOLD_PLACE_COLOR_LIGHT = 0xFFD9B900
private const val SILVER_PLACE_COLOR_NIGHT = 0xFFAABEF7
private const val SILVER_PLACE_COLOR_LIGHT = 0xFF6680CC
private const val BRONZE_PLACE_COLOR_NIGHT = 0xFFFF9976
private const val BRONZE_PLACE_COLOR_LIGHT = 0xFFCC7F66
// region preview
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Composable
private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM) {
TangemThemePreviewRedesign {
var state1 by remember { mutableStateOf(state) }
var state2 by remember { mutableStateOf(state) }
var prices by remember {
mutableStateOf(
listOf(
100 to PriceChangeType.NEUTRAL,
200 to PriceChangeType.NEUTRAL,
),
)
}
Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
MarketsListItemV2(
modifier = Modifier,
model = state1,
)
MarketsListItemV2(
modifier = Modifier,
model = state2,
)
Row {
Button(
onClick = {
state1 = state1.copy(
trendType = PriceChangeType.entries.random(),
)
state2 = state2.copy(
trendType = PriceChangeType.entries.random(),
)
},
) { Text(text = "trend") }
Button(
onClick = {
prices = prices.map { (price, _) ->
if (Random.nextBoolean()) {
price.inc() to PriceChangeType.UP
} else {
price.dec() to PriceChangeType.DOWN
}
}
state1 = state1.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[0].first}023 $",
changeType = prices[0].second,
),
)
state2 = state2.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[1].first}023 $",
changeType = prices[1].second,
),
)
},
) { Text(text = "price") }
}
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.common.ui.markets
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.res.LocalRedesignEnabled
@Composable
fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
if (LocalRedesignEnabled.current) {
MarketsListItemV2(
model = model,
modifier = modifier,
onClick = onClick,
)
} else {
MarketsListItemV1(
model = model,
modifier = modifier,
onClick = onClick,
)
}
}

View file

@ -38,8 +38,8 @@ import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContent(
fun MarketsListItemV1(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContentV1(
modifier = modifier
.fillMaxWidth()
.clip(RectangleShape)
@ -50,7 +50,7 @@ fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onC
}
@Composable
private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier = Modifier) {
private fun MarketsListItemContentV1(model: MarketsListItemUM, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
Row(
@ -273,11 +273,11 @@ private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::cl
}
Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
MarketsListItem(
MarketsListItemV1(
modifier = Modifier,
model = state1,
)
MarketsListItem(
MarketsListItemV1(
modifier = Modifier,
model = state2,
)

View file

@ -16,7 +16,7 @@ class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvide
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = "",
ratingPosition = "10",
ratingPosition = "1",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
@ -33,7 +33,7 @@ class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvide
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
ratingPosition = "2",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",

View file

@ -6,8 +6,12 @@ import androidx.compose.animation.core.tween
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
/**
@ -18,6 +22,23 @@ import com.tangem.core.ui.res.TangemTheme
*/
@Composable
fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
if (LocalRedesignEnabled.current) {
TokenPriceTextV2(
price = price,
modifier = modifier,
priceChangeType = priceChangeType,
)
} else {
TokenPriceTextV1(
price = price,
modifier = modifier,
priceChangeType = priceChangeType,
)
}
}
@Composable
private fun TokenPriceTextV1(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
val growColor = TangemTheme.colors.text.accent
val fallColor = TangemTheme.colors.text.warning
val generalColor = TangemTheme.colors.text.primary1
@ -52,4 +73,60 @@ fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType
style = TangemTheme.typography.body2,
overflow = TextOverflow.Visible,
)
}
@Composable
private fun TokenPriceTextV2(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
val growColor = TangemTheme.colors2.text.status.accent
val fallColor = TangemTheme.colors2.text.status.warning
val generalColor = TangemTheme.colors2.text.neutral.primary
val decimalColor = TangemTheme.colors2.text.neutral.secondary
val color = remember(generalColor) { Animatable(generalColor) }
var isAnimationSkipped by remember { mutableStateOf(false) }
LaunchedEffect(price) {
if (!isAnimationSkipped) {
isAnimationSkipped = true
return@LaunchedEffect
}
if (priceChangeType != null) {
val nextColor = when (priceChangeType) {
PriceChangeType.UP -> growColor
PriceChangeType.DOWN -> fallColor
PriceChangeType.NEUTRAL -> return@LaunchedEffect
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
}
}
val annotatedText = remember(price) {
buildAnnotatedString {
val dotIndex = price.indexOf(".")
if (dotIndex == -1) {
append(price)
} else {
append(price.take(dotIndex))
withStyle(
style = SpanStyle(color = decimalColor),
) {
append(price.substring(dotIndex))
}
}
}
}
Text(
modifier = modifier,
text = annotatedText,
color = color.value,
maxLines = 1,
style = TangemTheme.typography2.bodySemibold16,
overflow = TextOverflow.Visible,
)
}

View file

@ -6,13 +6,11 @@ import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.abtests.manager.impl.AmplitudeABTestsManager
import com.tangem.core.abtests.manager.impl.StubABTestsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.AppCoroutineScope
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
@Module
@ -24,7 +22,7 @@ internal object ABTestsManagerModule {
fun provideABTestsManager(
application: Application,
environmentConfig: EnvironmentConfig,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): ABTestsManager {
return if (BuildConfig.AB_TESTS_ENABLED) {
StubABTestsManager()
@ -32,7 +30,7 @@ internal object ABTestsManagerModule {
AmplitudeABTestsManager(
application = application,
apiKey = environmentConfig.amplitudeApiKey,
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
)
}
}

View file

@ -7,14 +7,14 @@ import com.amplitude.experiment.ExperimentConfig
import com.amplitude.experiment.ExperimentUser
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.models.AnalyticsParam
import kotlinx.coroutines.CoroutineScope
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
internal class AmplitudeABTestsManager(
val application: Application,
val apiKey: String,
val scope: CoroutineScope,
val scope: AppCoroutineScope,
) : ABTestsManager {
private lateinit var client: ExperimentClient

View file

@ -1,6 +1,4 @@
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import org.json.JSONArray
import com.tangem.plugin.configuration.configurations.TogglesGenerator
plugins {
alias(deps.plugins.android.library)
@ -11,10 +9,31 @@ plugins {
id("configuration")
}
buildscript {
dependencies {
classpath("com.squareup:kotlinpoet:1.15.0")
classpath("org.json:json:20231013")
abstract class GenerateTogglesTask : DefaultTask() {
@get:InputFiles
abstract val configFiles: ConfigurableFileCollection
@get:Input
abstract val fileNames: ListProperty<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val files = configFiles.files.toList()
val names = fileNames.get()
require(files.size == names.size) {
"configFiles (${files.size}) and fileNames (${names.size}) must have the same size"
}
files.zip(names).forEach { (inputFile, fileName) ->
require(inputFile.exists()) { "Config file not found: ${inputFile.absolutePath}" }
logger.lifecycle("Generating toggles from ${inputFile.name}")
TogglesGenerator.generate(inputFile, outputDir.get().asFile, fileName)
}
}
}
@ -23,8 +42,20 @@ android {
sourceSets["main"].java.srcDir("build/generated/source/toggles")
}
/** Config file to generated enum class name mapping */
val toggles = mapOf(
file("src/main/assets/configs/feature_toggles_config.json") to "FeatureToggles",
file("src/main/assets/configs/excluded_blockchains_config.json") to "ExcludedBlockchainToggles",
)
val generateToggles = tasks.register<GenerateTogglesTask>("generateToggles") {
configFiles.from(toggles.keys)
fileNames.set(toggles.values.toList())
outputDir.set(layout.buildDirectory.dir("generated/source/toggles"))
}
tasks.named("preBuild") {
dependsOn(generateFeatureToggles, generateExcludedBlockchainToggles)
dependsOn(generateToggles)
}
tasks.withType<Test>().configureEach {
@ -51,77 +82,4 @@ dependencies {
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}
val generateFeatureToggles by tasks.registering {
generateToggles(
inputFilePath = "src/main/assets/configs/feature_toggles_config.json",
generatedFileName = "FeatureToggles",
)
}
val generateExcludedBlockchainToggles by tasks.registering {
generateToggles(
inputFilePath = "src/main/assets/configs/excluded_blockchains_config.json",
generatedFileName = "ExcludedBlockchainToggles",
)
}
fun Task.generateToggles(inputFilePath: String, generatedFileName: String) {
val inputFile = file(inputFilePath)
val outputDir = file("build/generated/source/toggles")
inputs.file(inputFile)
outputs.dir(outputDir)
doLast {
val jsonText = inputFile.readText()
val jsonArray = JSONArray(jsonText)
val entries = (0 until jsonArray.length()).map { i ->
val obj = jsonArray.getJSONObject(i)
val name = obj.getString("name")
val version = obj.getString("version")
CodeBlock.of("%S to %S", name, version)
}
val mapInitializer = CodeBlock.builder()
.add("mapOf(\n")
.indent()
.apply {
entries.forEach { entry ->
add(entry)
add(",\n")
}
}
.unindent()
.add(")")
.build()
val objectBuilder = TypeSpec.objectBuilder(name = generatedFileName)
.addKdoc("Generated from $inputFilePath")
.addProperty(
PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING))
.initializer(mapInitializer)
.build()
)
val fileSpec = FileSpec.builder(packageName = "com.tangem.core.configtoggle", fileName = generatedFileName)
.addType(objectBuilder.build())
.build()
val outputPackageDir = File(outputDir, "")
outputPackageDir.mkdirs()
fileSpec.writeTo(outputPackageDir)
// Remove redundant public visibility modifiers
val generatedFile = File(outputPackageDir, "com/tangem/core/configtoggle/$generatedFileName.kt")
if (generatedFile.exists()) {
val content = generatedFile.readText()
val fixedContent = content
.replace("public object ", "object ")
.replace("public val ", "val ")
generatedFile.writeText(fixedContent)
}
}
}

View file

@ -59,5 +59,13 @@
{
"name": "CUSTOMER_IO_ENABLED",
"version": "5.35"
},
{
"name": "MAIN_SCREEN_QR_SCANNING_ENABLED",
"version": "undefined"
},
{
"name": "NEW_PROMO_BANNERS_ENABLED",
"version": "undefined"
}
]

View file

@ -7,9 +7,7 @@ import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMap
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.flow.*
/**
@ -22,7 +20,7 @@ import kotlinx.coroutines.flow.*
internal class DevApiConfigsManager(
private val apiConfigs: ApiConfigs,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val appScope: AppCoroutineScope,
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
@ -51,7 +49,7 @@ internal class DevApiConfigsManager(
notifyListeners(apiConfigs = apiConfigs, savedEnvironments = savedEnvironments)
}
.launchIn(CoroutineScope(SupervisorJob() + dispatchers.default))
.launchIn(appScope)
}
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {

View file

@ -4,10 +4,22 @@ import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.flow.*
import kotlin.Boolean
import kotlin.String
import kotlin.Unit
import kotlin.collections.Map
import kotlin.collections.any
import kotlin.collections.associateWith
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.first
import kotlin.collections.firstOrNull
import kotlin.collections.mapValues
import kotlin.collections.plus
import kotlin.error
import kotlin.to
/**
* Implementation of [ApiConfigsManager] in MOCK environment
@ -18,7 +30,7 @@ import kotlinx.coroutines.flow.*
*/
internal class MockApiConfigsManager(
private val apiConfigs: ApiConfigs,
dispatchers: CoroutineDispatcherProvider,
private val coroutineScope: AppCoroutineScope,
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
@ -26,8 +38,6 @@ internal class MockApiConfigsManager(
override val initializedState: StateFlow<Boolean> = MutableStateFlow(value = true)
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
override fun initialize() = Unit
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {

View file

@ -41,7 +41,8 @@ interface TangemExpressApi {
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: String,
@Query("fromAmount") fromAmount: String?,
@Query("toAmount") toAmount: String? = null,
@Query("fromDecimals") fromDecimals: Int,
@Query("toDecimals") toDecimals: Int,
@Query("providerId") providerId: String,
@ -57,7 +58,8 @@ interface TangemExpressApi {
@Query("toContractAddress") toContractAddress: String,
@Query("fromAddress") fromAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: String,
@Query("fromAmount") fromAmount: String?,
@Query("toAmount") toAmount: String? = null,
@Query("fromDecimals") fromDecimals: Int,
@Query("toDecimals") toDecimals: Int,
@Query("providerId") providerId: String,

View file

@ -5,6 +5,9 @@ import com.tangem.datasource.api.promotion.models.PromoBannerResponse
import com.tangem.datasource.api.promotion.models.PromoBannerV2Response
import com.tangem.datasource.api.promotion.models.StoryContentResponse
import com.tangem.datasource.api.tangemTech.models.*
import com.tangem.datasource.api.tangemTech.models.promobanners.PromoBannerDisplaysResponse
import com.tangem.datasource.api.tangemTech.models.promobanners.DismissPromoBannerRequest
import com.tangem.datasource.api.tangemTech.models.promobanners.DismissPromoBannerResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletArchivedAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
@ -42,15 +45,6 @@ interface TangemTechApi {
@GET("v1/geo")
suspend fun getUserCountryCode(): GeoResponse
@GET("v1/user-tokens/{user-id}")
suspend fun getUserTokens(@Path(value = "user-id") userId: String): ApiResponse<UserTokensResponse>
@PUT("v1/user-tokens/{user-id}")
suspend fun saveUserTokens(
@Path(value = "user-id") userId: String,
@Body userTokens: UserTokensResponse,
): ApiResponse<Unit>
@PUT("/v1/wallets/{walletId}/tokens")
suspend fun saveTokens(
@Path(value = "walletId") userId: String,
@ -135,14 +129,8 @@ interface TangemTechApi {
@PATCH("v1/user-wallets/wallets/{wallet_id}")
suspend fun updateWallet(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse<Unit>
@POST("v1/user-wallets/wallets/create-and-connect-by-appuid/{application_id}")
suspend fun associateApplicationIdWithWallets(
@Path("application_id") applicationId: String,
@Body body: List<WalletIdBody>,
): ApiResponse<Unit>
@PUT("/v1/user-wallets/applications/{application_id}/wallets")
suspend fun associateApplicationIdWithWalletsV2(
suspend fun associateApplicationIdWithWallets(
@Path("application_id") applicationId: String,
@Body body: AssociateApplicationIdWithWalletsBody,
): ApiResponse<Unit>
@ -197,6 +185,21 @@ interface TangemTechApi {
): ApiResponse<PromoBannerV2Response>
// endregion
// region promo banners
@GET("v1/banner/displays")
suspend fun getPromoBannerDisplays(
@Query("walletId") walletId: String,
@Query("placeholder") placeholder: String,
@Query("locale") locale: String,
): ApiResponse<PromoBannerDisplaysResponse>
@PATCH("v1/displays/{displayId}")
suspend fun dismissPromoBannerDisplay(
@Path("displayId") displayId: String,
@Body body: DismissPromoBannerRequest,
): ApiResponse<DismissPromoBannerResponse>
// endregion
/**
* Stores transaction hash in cache to prevent duplicate push
* notifications for yield operations (deposit, withdraw, send).

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.tangemTech.models.promobanners
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DismissPromoBannerRequest(
@Json(name = "walletId") val walletId: String,
@Json(name = "isDismissed") val isDismissed: Boolean,
)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.tangemTech.models.promobanners
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DismissPromoBannerResponse(
@Json(name = "displayId") val displayId: String,
@Json(name = "walletId") val walletId: String,
@Json(name = "isDismissed") val isDismissed: Boolean,
@Json(name = "dismissedAt") val dismissedAt: String?,
)

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.api.tangemTech.models.promobanners
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PromoBannerDisplaysResponse(
@Json(name = "items") val items: List<PromoBannerDisplayDTO>,
)
@Suppress("BooleanPropertyNaming")
@JsonClass(generateAdapter = true)
data class PromoBannerDisplayDTO(
@Json(name = "id") val id: String,
@Json(name = "placeholder") val placeholder: String,
@Json(name = "priority") val priority: String,
@Json(name = "title") val title: String,
@Json(name = "subtitle") val subtitle: String,
@Json(name = "iconUrl") val iconUrl: String?,
@Json(name = "deeplink") val deeplink: String?,
@Json(name = "buttonEnabled") val buttonEnabled: Boolean,
@Json(name = "buttonText") val buttonText: String?,
@Json(name = "dismissable") val dismissable: Boolean,
)

View file

@ -3,6 +3,7 @@ package com.tangem.datasource.di
import android.content.Context
import com.squareup.moshi.Moshi
import com.tangem.datasource.local.preferences.*
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -20,10 +21,11 @@ internal object AppPreferencesStoreModule {
fun provideAppPreferencesStore(
@ApplicationContext appContext: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
@SdkMoshi moshi: Moshi,
): AppPreferencesStore {
return AppPreferencesStore(
preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io),
preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, appScope = appScope),
moshi = moshi,
dispatchers = dispatchers,
)

View file

@ -11,14 +11,12 @@ import com.tangem.datasource.local.token.ExpressAssetsStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -30,7 +28,7 @@ internal object ExpressAssetsStoreModule {
fun provideExpressAssetsStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): ExpressAssetsStore {
return DefaultExpressAssetsStore(
persistenceStore = DataStoreFactory.create(
@ -40,7 +38,7 @@ internal object ExpressAssetsStoreModule {
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile("express_assets") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
),
runtimeStore = RuntimeDataStore(),
)

View file

@ -25,7 +25,7 @@ import com.tangem.datasource.api.visa.VisaApi
import com.tangem.datasource.di.utils.RetrofitApiBuilder
import com.tangem.datasource.di.utils.RetrofitApiBuilder.Timeouts
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -47,11 +47,11 @@ internal object NetworkModule {
fun provideApiConfigManager(
apiConfigs: ApiConfigs,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): ApiConfigsManager {
return when {
BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs, dispatchers)
BuildConfig.TESTER_MENU_ENABLED -> DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers)
BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs, appScope)
BuildConfig.TESTER_MENU_ENABLED -> DevApiConfigsManager(apiConfigs, appPreferencesStore, appScope)
else -> ProdApiConfigsManager(apiConfigs)
}
}

View file

@ -20,14 +20,12 @@ import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.datasource.utils.setTypes
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -39,7 +37,7 @@ internal object StakingStoreModule {
fun provideStakingTokensStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): StakingYieldsStore {
return DefaultStakingYieldsStore(
dataStore = DataStoreFactory.create(
@ -49,7 +47,7 @@ internal object StakingStoreModule {
defaultValue = emptyList(),
),
produceFile = { context.dataStoreFile(fileName = "yields_cache") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
),
)
}
@ -59,7 +57,7 @@ internal object StakingStoreModule {
fun provideYieldsBalancesPersistenceStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): DataStore<Map<String, Set<YieldBalanceWrapperDTO>>> {
return DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
@ -68,7 +66,7 @@ internal object StakingStoreModule {
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "yield_balances") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
)
}
@ -83,7 +81,7 @@ internal object StakingStoreModule {
fun provideP2PEthPoolBalancesPersistenceStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): DataStore<Map<String, Set<P2PEthPoolAccountResponse>>> {
return DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
@ -92,7 +90,7 @@ internal object StakingStoreModule {
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "p2p_eth_pool_balances") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
)
}
@ -101,7 +99,7 @@ internal object StakingStoreModule {
fun provideP2PEthPoolVaultsStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): P2PEthPoolVaultsStore {
return DefaultP2PEthPoolVaultsStore(
dataStore = DataStoreFactory.create(
@ -111,7 +109,7 @@ internal object StakingStoreModule {
defaultValue = emptyList(),
),
produceFile = { context.dataStoreFile(fileName = "p2p_eth_pool_vaults") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
),
)
}

View file

@ -8,14 +8,12 @@ import com.tangem.datasource.local.token.DefaultTokenReceiveWarningActionStore
import com.tangem.datasource.local.token.TokenReceiveWarningActionStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.setTypes
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -27,7 +25,7 @@ object TokenReceiveWarningModule {
fun provideTokenReceiveWarningStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): TokenReceiveWarningActionStore {
return DefaultTokenReceiveWarningActionStore(
persistenceStore = DataStoreFactory.create(
@ -37,7 +35,7 @@ object TokenReceiveWarningModule {
defaultValue = emptySet(),
),
produceFile = { context.dataStoreFile(fileName = "token_receive_warnings_viewed") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
),
)
}

View file

@ -8,16 +8,14 @@ import com.tangem.datasource.local.walletconnect.DefaultWalletConnectStore
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.setTypes
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO
import com.tangem.domain.walletconnect.model.WcSessionDTO
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -29,9 +27,8 @@ object WalletConnectModule {
fun provideWalletConnectStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
scope: AppCoroutineScope,
): WalletConnectStore {
val scope = CoroutineScope(context = dispatchers.io + SupervisorJob())
return DefaultWalletConnectStore(
persistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(

View file

@ -9,14 +9,12 @@ import com.tangem.datasource.local.yieldsupply.DefaultYieldMarketsStore
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -28,7 +26,7 @@ object YieldSupplyModule {
fun provideYieldMarketsStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): YieldMarketsStore {
return DefaultYieldMarketsStore(
persistenceStore = DataStoreFactory.create(
@ -38,7 +36,7 @@ object YieldSupplyModule {
defaultValue = emptyList(),
),
produceFile = { context.dataStoreFile(fileName = "yield_markets_cache") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
),
)
}

View file

@ -1,21 +0,0 @@
package com.tangem.datasource.di.local
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.DefaultUserTokensResponseStore
import com.tangem.datasource.local.token.UserTokensResponseStore
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 LocalTokenModule {
@Provides
@Singleton
fun provideUserTokensResponseStore(appPreferencesStore: AppPreferencesStore): UserTokensResponseStore {
return DefaultUserTokensResponseStore(appPreferencesStore = appPreferencesStore)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.local.logs
import android.content.Context
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.*
@ -27,13 +28,9 @@ import javax.inject.Singleton
class AppLogsStore @Inject constructor(
@ApplicationContext private val applicationContext: Context,
private val dispatchers: CoroutineDispatcherProvider,
private val scope: AppCoroutineScope,
) {
private val scope = CoroutineScope(
context = SupervisorJob() + dispatchers.io +
CoroutineExceptionHandler { _, error -> Timber.e("AppLogsStore.scope is failed $error") },
)
private val mutex = Mutex()
private val zipMutex = Mutex()

View file

@ -10,12 +10,10 @@ import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.nft.custom.NFTPriceId
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.lang.reflect.ParameterizedType
import javax.inject.Inject
import javax.inject.Singleton
@ -24,7 +22,7 @@ import javax.inject.Singleton
class NFTPersistenceStoreFactory @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatcherProvider,
private val appScope: AppCoroutineScope,
) {
fun provide(userWalletId: UserWalletId, network: Network): NFTPersistenceStore {
@ -61,7 +59,7 @@ class NFTPersistenceStoreFactory @Inject constructor(
defaultValue = defaultValue,
),
produceFile = { context.dataStoreFile(fileName = fileName) },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
)
private fun Network.ID.formatted(): String = rawId.value

View file

@ -16,10 +16,8 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PR
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_RING_PROMO_KEY
import com.tangem.datasource.local.preferences.utils.CleanupKeyMigration
import com.tangem.datasource.local.preferences.utils.SharedPreferencesKeyMigration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import com.tangem.utils.coroutines.AppCoroutineScope
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
/**
* Application preferences data store 'DataStore<Preferences>'.
@ -35,15 +33,15 @@ internal object PreferencesDataStore {
private var INSTANCE: DataStore<Preferences>? = null
fun getInstance(context: Context, dispatcher: CoroutineContext): DataStore<Preferences> {
return INSTANCE ?: create(context, dispatcher).also { INSTANCE = it }
fun getInstance(context: Context, appScope: AppCoroutineScope): DataStore<Preferences> {
return INSTANCE ?: create(context, appScope).also { INSTANCE = it }
}
private fun create(context: Context, dispatcher: CoroutineContext): DataStore<Preferences> {
private fun create(context: Context, appScope: AppCoroutineScope): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
corruptionHandler = createCorruptionHandler(),
migrations = createMigrations(context = context),
scope = CoroutineScope(context = dispatcher + SupervisorJob()),
scope = appScope,
produceFile = { context.preferencesDataStoreFile(name = PREFERENCES_FILE_NAME) },
)
}

View file

@ -1,22 +0,0 @@
package com.tangem.datasource.local.token
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Store of [UserTokensResponse]
*
[REDACTED_AUTHOR]
*/
interface UserTokensResponseStore {
fun get(userWalletId: UserWalletId): Flow<UserTokensResponse?>
/** Get [UserTokensResponse] synchronously by [userWalletId] or null */
suspend fun getSyncOrNull(userWalletId: UserWalletId): UserTokensResponse?
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse)
suspend fun clear(userWalletId: UserWalletId)
}

View file

@ -217,6 +217,7 @@
<string name="common_add">Hinzufügen</string>
<string name="common_add_to_portfolio">Zum Portfolio hinzufügen</string>
<string name="common_add_token">Token hinzufügen</string>
<string name="common_add_tokens">Token hinzufügen</string>
<string name="common_added">Hinzugefügt</string>
<string name="common_address">Vertragsadresse</string>
<string name="common_all">Alle</string>
@ -399,6 +400,7 @@
<string name="common_unstake">Staking beenden</string>
<string name="common_utxo_validate_withdrawal_message_warning">Aufgrund der Beschränkungen von %1$s können nur %2$d UTXOs in eine einzige Transaktion passen. Das bedeutet, dass du nur %3$s oder weniger senden kannst. Du musst den Betrag reduzieren.</string>
<string name="common_value_copied">Wert kopiert</string>
<string name="common_wallets">Meine Wallet</string>
<string name="common_week">Woche</string>
<string name="common_with">mit</string>
<string name="common_yes">Ja</string>
@ -469,9 +471,12 @@
<string name="domain_receive_assets_onboarding_title">Sende Geld nur mit</string>
<string name="earn_best_opportunities">Beste Gelegenheiten</string>
<string name="earn_clear_filter">Filter löschen</string>
<string name="earn_empty">Die Liste ist vorübergehend leer, da sie gerade aktualisiert wird. Schauen Sie in Kürze wieder rein.</string>
<string name="earn_filter_all_networks">Alle Netzwerke</string>
<string name="earn_filter_all_types">Alle Arten</string>
<string name="earn_filter_by">Filtern nach</string>
<string name="earn_filter_my_networks">Meine Netzwerke</string>
<string name="earn_filter_networks">Netzwerke</string>
<string name="earn_mostly_used">Meist verwendet</string>
<string name="earn_no_results">Keine Ergebnisse</string>
<string name="earn_title">Verdienen</string>
@ -826,6 +831,7 @@
<string name="markets_token_details_volume">Volumen</string>
<string name="markets_tooltip_message">Rufe dies auf oder tippe auf die Suchleiste, um Token direkt vom Markt hinzuzufügen</string>
<string name="markets_tooltip_title">Token hinzufügen</string>
<string name="markets_tooltip_v2_title">Token hinzufügen</string>
<string name="markets_yield_supply_banner_description">Steiger die Leistung Deiner Assets und ermögliche Dir gleichzeitig den sofortigen Zugriff. %s</string>
<string name="markets_yield_supply_banner_title">Yield-Modus aktivieren</string>
<string name="mobile_wallet_requires_min_os_warning_body">Du musst auf die folgende Version aktualisieren: %1$s um eine mobile Wallet zu erstellen</string>
@ -845,6 +851,7 @@
<string name="news_related_tokens">Verwandte Token</string>
<string name="news_sources">Verwandte Nachrichten</string>
<string name="news_stay_in_the_loop">Auf dem Laufenden bleiben</string>
<string name="news_trending_score">Trend-Score</string>
<string name="nfc_error_unavailable">NFC ist auf deinem Gerät nicht verfügbar</string>
<string name="nft_about_title">Über NFT</string>
<string name="nft_asset">NFT-Vermögenswert</string>
@ -963,12 +970,12 @@
<string name="onboarding_navbar_upgrade_wallet_biometrics">Biometrische Daten</string>
<string name="onboarding_seed_button_read_more">Lese mehr über die Seed-Phrase</string>
<plurals name="onboarding_seed_generate_message_words_count">
<item quantity="one"></item>
<item quantity="one">.</item>
<item quantity="other">Schreibe diese %d-Wörter in der unten angegebenen Reihenfolge auf und bewahre sie an einem sicheren und geheimen Ort auf.</item>
</plurals>
<string name="onboarding_seed_generate_title">Deine Seed-Phrase</string>
<plurals name="onboarding_seed_generate_words_count">
<item quantity="one"></item>
<item quantity="one">.</item>
<item quantity="other">%d Wörter</item>
</plurals>
<string name="onboarding_seed_import_message">Um deine Wallets zu importieren, gib bitte deine Seed-Phrase in das folgende Feld ein</string>
@ -1101,9 +1108,9 @@
<item quantity="one">für %d Wallet</item>
<item quantity="other">für %d Wallets</item>
</plurals>
<string name="referral_point_currencies_description">Du bekommst ^^%1$s^^ für jede von einem Freund gekaufte Wallet auf deine %2$s Netzwerkadresse %3$s ^^30 Tage nach^^ dem</string>
<string name="referral_point_currencies_title">Du</string>
<string name="referral_point_discount_description_prefix">Bekommt eine</string>
<string name="referral_point_currencies_description">Du erhältst ^^%1$s^^ für jede Wallet, die ein Freund kauft.\nDie Auszahlung erfolgt ^^30 Tage^^ nach dem Kauf an deine%2$s Adresse.%3$s</string>
<string name="referral_point_currencies_title">Du erhältst</string>
<string name="referral_point_discount_description_prefix">Er erhält</string>
<string name="referral_point_discount_description_suffix">beim Kauf einer Wallet auf tangem.com</string>
<string name="referral_point_discount_description_value">%s Rabatt</string>
<string name="referral_point_discount_title">Dein Freund</string>
@ -1431,6 +1438,7 @@
<string name="swap_give_permission_fee_footer">Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Sie die Verwendung Ihres Tokens für den Swap genehmigen.</string>
<string name="swap_promo_text">Tausche mehr Token zu besseren Kursen direkt in deiner Brieftasche.</string>
<string name="swap_promo_title">Neuer Swap-Anbieter verfügbar!</string>
<string name="swap_search_suggestion_hint">Suchen Sie etwas anderes? Versuchen Sie es mit der Suche oder erkunden Sie eine andere Kryptowährung!</string>
<string name="swap_search_tooltip_description">Suchen Sie nach einem beliebigen Token, auch wenn es noch nicht in Ihrer Liste ist.</string>
<string name="swap_search_tooltip_title">Nutzen Sie die Suche, um zu finden, was Sie benötigen.</string>
<string name="swap_story_fifth_subtitle">Vertraue auf den rund um die Uhr verfügbaren Support bei allen Problemen</string>

View file

@ -217,6 +217,7 @@
<string name="common_add">Agregar</string>
<string name="common_add_to_portfolio">Añadir al portafolio</string>
<string name="common_add_token">Agregar token</string>
<string name="common_add_tokens">Añada tokens</string>
<string name="common_added">Agregado</string>
<string name="common_address">Dirección</string>
<string name="common_all">Todos</string>

View file

@ -217,6 +217,7 @@
<string name="common_add">Ajouter</string>
<string name="common_add_to_portfolio">Ajouter au portfolio</string>
<string name="common_add_token">Ajouter un jeton</string>
<string name="common_add_tokens">Ajouter des jetons</string>
<string name="common_added">Ajouté</string>
<string name="common_address">Adresse</string>
<string name="common_all">Tous</string>

View file

@ -214,6 +214,7 @@
<string name="common_add">追加</string>
<string name="common_add_to_portfolio">ポートフォリオに追加</string>
<string name="common_add_token">トークンを追加</string>
<string name="common_add_tokens">トークンの追加</string>
<string name="common_added">追加済み</string>
<string name="common_address">アドレス</string>
<string name="common_all">すべて</string>
@ -692,6 +693,7 @@
<string name="koinos_mana_level_title">Manaレベル</string>
<string name="main_empty_tokens_list_message">暗号資産および取引の追跡を開始するには、トークンを追加してください</string>
<string name="main_manage_tokens">トークンの管理</string>
<string name="main_qr_scan_hint">QRコードをスキャンして送金するか、アプリに接続します。</string>
<string name="main_scan_card_warning_view_subtitle">すべてのネットワークにアクセスするには、カードをスキャンする必要があります。</string>
<string name="main_scan_card_warning_view_title">カードまたはリングをスキャンする</string>
<string name="main_swap_changelly_promotion_message">2月%2$s - %3$sの期間、Changelly経由のスワップは%1$sのサービス手数料となります。</string>
@ -839,6 +841,7 @@
<string name="news_related_tokens">関連トークン</string>
<string name="news_sources">関連ニュース</string>
<string name="news_stay_in_the_loop">最新情報を入手</string>
<string name="news_trending_score">トレンドスコア</string>
<string name="nfc_error_unavailable">お使いのデバイスではNFCが使用できません</string>
<string name="nft_about_title">NFTについて</string>
<string name="nft_asset">NFTアセット</string>
@ -1488,6 +1491,7 @@
<string name="tangempay_cancel_kyc">メイン画面からKYCを非表示にする</string>
<string name="tangempay_card_details_add_funds">資金を追加</string>
<string name="tangempay_card_details_add_funds_subtitle">入金オプション</string>
<string name="tangempay_card_details_add_to_wallet_button_text">Googleウォレットに追加</string>
<string name="tangempay_card_details_card_number">カード番号</string>
<string name="tangempay_card_details_change_pin">PINを変更する</string>
<string name="tangempay_card_details_change_pin_success_description">カードは支払いの準備が整いました。</string>
@ -1876,6 +1880,8 @@
<string name="warning_express_refresh_required_title">サービスは一時的に利用できません</string>
<string name="warning_express_too_maximum_amount_title">スワップするトークンの量は %s を超えないでください</string>
<string name="warning_express_too_minimal_amount_title">スワップ金額は %s 以上である必要があります</string>
<string name="warning_express_unsupported_pair_description">このペアではスワップを利用できません。別のトークンを選択して、もう一度お試しください。</string>
<string name="warning_express_unsupported_pair_title">スワップ非対応のペアです</string>
<string name="warning_express_wrong_amount_description">スワップの金額を変更してください</string>
<string name="warning_failed_to_verify_card_message">このカードは、サンプル品または偽造品である可能性があります</string>
<string name="warning_failed_to_verify_card_title">真正性チェックに失敗しました</string>

View file

@ -223,6 +223,7 @@
<string name="common_add">Добавить</string>
<string name="common_add_to_portfolio">Добавить в портфель</string>
<string name="common_add_token">Добавить токен</string>
<string name="common_add_tokens">Добавьте токены</string>
<string name="common_added">Добавлен</string>
<string name="common_address">Адрес</string>
<string name="common_all">Все</string>
@ -408,6 +409,7 @@
<string name="common_understand">Я понял</string>
<string name="common_understand_continue">Я понимаю, продолжить</string>
<string name="common_unknown_error">Произошла ошибка. Пожалуйста, попробуйте снова.</string>
<string name="common_unlock">Разблокировать</string>
<string name="common_unreachable">Недоступно</string>
<string name="common_unstake">Завершить стейкинг</string>
<string name="common_utxo_validate_withdrawal_message_warning">Из-за ограничений %1$s в одну транзакцию может поместиться только %2$d UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму.</string>
@ -733,13 +735,13 @@
<string name="manage_tokens_remove">Удалить</string>
<string name="manage_tokens_search_placeholder">Например Bitcoin</string>
<string name="manage_tokens_toast_portfolio_updated">Ваш портфель был обновлен</string>
<string name="manage_tokens_unavailable_description">Выбранный токен не доступен в кошельке на данный момент. Но не переживайте, вы можете выразить свой интерес проголосовав за его добавление.</string>
<string name="manage_tokens_unavailable_description">Выбранный токен недоступен в кошельке на данный момент. Но не переживайте, вы можете выразить свой интерес проголосовав за его добавление.</string>
<string name="manage_tokens_unavailable_vote">Голосовать</string>
<string name="manage_tokens_wallet_support_only_one_network_title">Кошелёк не поддерживает более одной сети</string>
<string name="markets_about_coin_header">О монете</string>
<string name="markets_add_to_my_portfolio_description">Чтобы купить, обменять или получить данный токен, вам нужно добавить его к себе в портфель</string>
<string name="markets_add_to_my_portfolio_unavailable_description">Этот актив в настоящее время не поддерживается в кошельке</string>
<string name="markets_add_to_my_portfolio_unavailable_for_wallet_description">Этот токен не доступен для данного кошелька</string>
<string name="markets_add_to_my_portfolio_unavailable_for_wallet_description">Этот токен недоступен для данного кошелька</string>
<string name="markets_add_token">Добавить</string>
<string name="markets_apy_placeholder">APY %s</string>
<string name="markets_common_my_portfolio">Мой портфель</string>
@ -843,6 +845,7 @@
<string name="markets_token_details_trading_volume">Объем торгов (24ч)</string>
<string name="markets_token_details_trading_volume_24h_description">Общая сумма криптовалюты, которая была продана за последние 24 часа, показывающая уровень активности и ликвидности на рынке.</string>
<string name="markets_token_details_trading_volume_full">Объем торгов (24ч)</string>
<string name="markets_token_details_valuation_value_in_total">%s в сумме</string>
<string name="markets_token_details_volume">Объем</string>
<string name="markets_tooltip_message">Потяните вверх или коснитесь поисковой строки, чтобы добавить токены напрямую из рынка</string>
<string name="markets_tooltip_title">Добавить токены</string>
@ -1499,7 +1502,7 @@
<string name="swapping_swap_action_in_progress">Обмен…</string>
<string name="swapping_to_title">Вы получите</string>
<string name="swapping_token_list_title">Выберите токен</string>
<string name="swapping_token_not_available">не доступен</string>
<string name="swapping_token_not_available">недоступен</string>
<string name="tangem_pay_beta_notification_subtitle">Будем рады вашей обратной связи</string>
<string name="tangem_pay_beta_notification_title">Tangem Pay в режиме beta</string>
<string name="tangem_pay_card_frozen">Карта заморожена</string>

View file

@ -223,6 +223,7 @@
<string name="common_add">Додати</string>
<string name="common_add_to_portfolio">Додати у портфель</string>
<string name="common_add_token">Додати токен</string>
<string name="common_add_tokens">Додайте токени</string>
<string name="common_added">Додано</string>
<string name="common_address">Адреса</string>
<string name="common_all">Усе</string>

View file

@ -217,6 +217,7 @@
<string name="common_add">Add</string>
<string name="common_add_to_portfolio">Add to portfolio</string>
<string name="common_add_token">Add token</string>
<string name="common_add_tokens">Add tokens</string>
<string name="common_added">Added</string>
<string name="common_address">Address</string>
<string name="common_all">All</string>
@ -325,6 +326,7 @@
<string name="common_nft">NFT</string>
<string name="common_no">No</string>
<string name="common_no_address">No address</string>
<string name="common_no_results">No results</string>
<string name="common_not_added">Not Added</string>
<string name="common_not_available">Not available</string>
<string name="common_not_now">Not now</string>
@ -383,6 +385,7 @@
<string name="common_to">To</string>
<string name="common_to_wallet_name">To %s</string>
<string name="common_today">Today</string>
<string name="common_token_send">Token to send</string>
<plurals name="common_tokens_count">
<item quantity="one">%d token</item>
<item quantity="other">%d tokens</item>
@ -395,6 +398,7 @@
<string name="common_understand">I understand</string>
<string name="common_understand_continue">I understand, continue</string>
<string name="common_unknown_error">There was an error. Please try again.</string>
<string name="common_unlock">Unlock</string>
<string name="common_unreachable">Unreachable</string>
<string name="common_unstake">Unstake</string>
<string name="common_utxo_validate_withdrawal_message_warning">Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount.</string>
@ -699,6 +703,7 @@
<string name="koinos_mana_level_title">Mana level</string>
<string name="main_empty_tokens_list_message">To begin tracking your crypto assets and transactions, add tokens</string>
<string name="main_manage_tokens">Manage tokens</string>
<string name="main_qr_scan_hint">Scan QR code to send funds or connect to an app</string>
<string name="main_scan_card_warning_view_subtitle">To access all the networks you need to scan the card</string>
<string name="main_scan_card_warning_view_title">Scan your card or ring</string>
<string name="main_swap_changelly_promotion_message">Enjoy %1$s service fees on swaps via Changelly from February %2$s-%3$s</string>
@ -736,6 +741,8 @@
<string name="markets_earn_common_title">Earn with Tangem</string>
<string name="markets_generate_addresses_notification">To generate addresses for selected networks, you must scan your Tangem Wallet card or ring</string>
<string name="markets_hint">To add tokens pull this up or tap the search bar</string>
<string name="markets_hint_part_one">Swipe up to explore the market</string>
<string name="markets_hint_part_two">Find new hidden gems</string>
<string name="markets_insights_info_description_message">This sections data is sourced from the following networks: %s</string>
<string name="markets_loading_error_title">Unable to load the data…</string>
<string name="markets_loading_no_data_title">No data</string>
@ -798,6 +805,10 @@
<string name="markets_token_details_holders">Holders</string>
<string name="markets_token_details_holders_description">The change in the number of token holders within a specific timeframe</string>
<string name="markets_token_details_holders_full">Holders</string>
<string name="markets_token_details_insight_day_timeline">D</string>
<string name="markets_token_details_insight_month_timeline">M</string>
<string name="markets_token_details_insight_week_timeline">W</string>
<string name="markets_token_details_insight_year_timeline">Y</string>
<string name="markets_token_details_insights">Insights</string>
<string name="markets_token_details_links">Links</string>
<string name="markets_token_details_liquidity">Liquidity</string>
@ -825,9 +836,11 @@
<string name="markets_token_details_total_supply">Total supply</string>
<string name="markets_token_details_total_supply_description">The maximum number of coins or tokens that can ever exist for a particular cryptocurrency</string>
<string name="markets_token_details_total_supply_full">Total supply</string>
<string name="markets_token_details_trading_interval">24h</string>
<string name="markets_token_details_trading_volume">Trading volume (24h)</string>
<string name="markets_token_details_trading_volume_24h_description">The total amount of a cryptocurrency that has been traded within the last 24 hours, indicating the level of activity and liquidity in the market</string>
<string name="markets_token_details_trading_volume_full">Trading volume (24h)</string>
<string name="markets_token_details_valuation_value_in_total">%s in total</string>
<string name="markets_token_details_volume">Volume</string>
<string name="markets_tooltip_message">Pull this up or tap the search bar to add tokens directly from the market</string>
<string name="markets_tooltip_title">Add tokens</string>
@ -1202,6 +1215,10 @@
<string name="send_memo">Memo: %s</string>
<string name="send_memo_destination_tag_error">Invalid Memo</string>
<string name="send_network_fee_warning_title">Network fee coverage</string>
<plurals name="send_network_selection_hidden_tokens">
<item quantity="one">%d token isn\'t compatible with this address</item>
<item quantity="other">%d tokens aren\'t compatible with this address</item>
</plurals>
<string name="send_nonce">Nonce</string>
<string name="send_nonce_footer">Unique number for each transaction. Use it to resend or cancel a pending transaction.</string>
<string name="send_nonce_hint">Enter nonce…</string>
@ -1509,6 +1526,7 @@
<string name="tangempay_cancel_kyc">Hide KYC from main screen</string>
<string name="tangempay_card_details_add_funds">Add funds</string>
<string name="tangempay_card_details_add_funds_subtitle">Top-up options</string>
<string name="tangempay_card_details_add_to_wallet_button_text">Add to Google Wallet</string>
<string name="tangempay_card_details_card_number">Card Number</string>
<string name="tangempay_card_details_change_pin">Change PIN</string>
<string name="tangempay_card_details_change_pin_success_description">The card is fully ready for payments.</string>

View file

@ -24,7 +24,7 @@ dependencies {
/** Project - Core */
implementation(projects.core.res)
implementation(projects.core.utils)
implementation(projects.core.decompose)
api(projects.core.decompose)
implementation(projects.core.error)
/** AndroidX libraries */

View file

@ -2,6 +2,7 @@ package com.tangem.core.ui
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Stable
import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHostState
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.EventMessageHandler
import com.tangem.core.ui.theme.AppThemeModeHolder
@ -15,6 +16,8 @@ interface UiDependencies {
val globalSnackbarHostState: SnackbarHostState
val globalTopSnackbarHostState: TangemTopSnackbarHostState
val eventMessageHandler: EventMessageHandler
val designFeatureToggles: DesignFeatureToggles

View file

@ -39,22 +39,18 @@ fun BottomFade(modifier: Modifier = Modifier, backgroundColor: Color = TangemThe
}
/**
* A composable that draws a fade effect at the right end of the screen. Same as [BottomFade]
* but with a horizontal gradient.
* A composable that draws a fade effect at the bottom of the screen. Used on screens with a list of repeating
* elements and floating button at the bottom of the screen.
*/
@Composable
fun HorizontalFade(modifier: Modifier = Modifier, backgroundColor: Color = TangemTheme.colors.background.secondary) {
fun BottomFade(gradientBrush: Brush, modifier: Modifier = Modifier) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
Box(
modifier = modifier
.fillMaxHeight()
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color.Transparent,
backgroundColor,
),
),
),
.fillMaxWidth()
.height(TangemTheme.dimens.size100 + bottomBarHeight)
.background(gradientBrush),
)
}
@ -84,8 +80,11 @@ fun BottomFadeWithBlur(backgroundColor: Color, modifier: Modifier = Modifier) {
backgroundColor = Color.Transparent,
),
) {
progressive =
HazeProgressive.verticalGradient(startIntensity = 0f, endIntensity = 1f)
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f,
preferPerformance = true,
)
},
)
}
@ -114,7 +113,11 @@ fun HorizontalFadeWithBlur(backgroundColor: Color, modifier: Modifier = Modifier
),
) {
progressive =
HazeProgressive.horizontalGradient(startIntensity = 0f, endIntensity = 1f)
HazeProgressive.horizontalGradient(
startIntensity = 0f,
endIntensity = 1f,
preferPerformance = true,
)
},
)
}

View file

@ -16,16 +16,18 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import com.tangem.core.ui.res.LocalIsInDarkTheme
@Suppress("LongMethod")
@Composable
internal fun MovingColorfulBlubsBackground(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "FluidMeshGradient")
val isDark = LocalIsInDarkTheme.current
// ── Circle 1 (left) ──────────────────────────────────────────────────────
// ── Circle 1 (left) — matches dc1 / lc1 from shader version ─────────────
val color1 by transition.animateColor(
initialValue = Color(0xFF3355EE),
targetValue = Color(0xFF5577FF),
initialValue = if (isDark) Color(0xFF0D0D3A) else Color(0xFFCCB8EE),
targetValue = if (isDark) Color(0xFF1C1C6E) else Color(0xFFBBA0E8),
animationSpec = infiniteRepeatable(
animation = tween(4_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
@ -51,10 +53,10 @@ internal fun MovingColorfulBlubsBackground(modifier: Modifier = Modifier) {
label = "y1",
)
// ── Circle 2 (right) ─────────────────────────────────────────────────────
// ── Circle 2 (right) — matches dc2 / lc2 from shader version ────────────
val color2 by transition.animateColor(
initialValue = Color(0xFF7733CC),
targetValue = Color(0xFF4455EE),
initialValue = if (isDark) Color(0xFF0A1238) else Color(0xFFB8C8F0),
targetValue = if (isDark) Color(0xFF112266) else Color(0xFF9AAEE8),
animationSpec = infiniteRepeatable(
animation = tween(5_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
@ -82,10 +84,10 @@ internal fun MovingColorfulBlubsBackground(modifier: Modifier = Modifier) {
label = "y2",
)
// ── Oval (center) ────────────────────────────────────────────────────────
// ── Oval (center) — matches dc4 / lc4 from shader version ───────────────
val ovalColor by transition.animateColor(
initialValue = Color(0xFF5533CC),
targetValue = Color(0xFF8844EE),
initialValue = if (isDark) Color(0xFF081A30) else Color(0xFFB8C4EE),
targetValue = if (isDark) Color(0xFF113355) else Color(0xFFA8B4E8),
animationSpec = infiniteRepeatable(
animation = tween(7_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
@ -93,10 +95,10 @@ internal fun MovingColorfulBlubsBackground(modifier: Modifier = Modifier) {
),
label = "ovalColor",
)
// ── Circle 3 (center) ────────────────────────────────────────────────────
// ── Circle 3 (center) — matches dc3 / lc3 from shader version ───────────
val color3 by transition.animateColor(
initialValue = Color(0xFF9933BB),
targetValue = Color(0xFFBB44DD),
initialValue = if (isDark) Color(0xFF110A38) else Color(0xFFDDC8F5),
targetValue = if (isDark) Color(0xFF2A1666) else Color(0xFFCCB0EE),
animationSpec = infiniteRepeatable(
animation = tween(6_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.background.shaderBackground
import com.tangem.core.ui.res.LocalIsInDarkTheme
import com.tangem.core.ui.res.LocalPowerSavingState
import com.tangem.core.ui.shader.NorthernLightsMeshGradientShader
@ -36,99 +37,58 @@ fun NorthernLightsBackground(
}
}
@Suppress("LongMethod")
@Suppress("LongMethod", "NamedArguments")
@Composable
private fun NorthernLightsBackgroundWithShader(containerColor: Color, modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "FluidMeshGradientV2")
val isDark = LocalIsInDarkTheme.current
// Each track cycles through 4 states (matching the screenshot frames):
// deep/dark → saturated+bright → light/pastel → vibrant/vivid → back
// 16 s total per track, staggered so no two tracks peak simultaneously.
// ── Color 1 indigo → bright blue → lavender → hot violet ──────────────
val color1 by transition.animateColor(
initialValue = Color(0xFF2A1480),
targetValue = Color(0xFF2A1480),
@Composable
fun anim(a: Color, b: Color, c: Color, d: Color, offset: Int = 0) = transition.animateColor(
initialValue = a,
targetValue = a,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 16_000
Color(0xFF2A1480) at 0 using FastOutSlowInEasing
Color(0xFF4477EE) at 4_000 using FastOutSlowInEasing
Color(0xFFBBAAEE) at 8_000 using FastOutSlowInEasing
Color(0xFF8833EE) at 12_000 using FastOutSlowInEasing
durationMillis = 40_000
a at 0 using FastOutSlowInEasing
b at 10_000 using FastOutSlowInEasing
c at 20_000 using FastOutSlowInEasing
d at 30_000 using FastOutSlowInEasing
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(offset),
),
label = "color1",
label = "c$offset",
)
// ── Color 2 dark blue → cyan-blue → sky → teal ─────────────────────────
val color2 by transition.animateColor(
initialValue = Color(0xFF1444AA),
targetValue = Color(0xFF1444AA),
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 16_000
Color(0xFF1444AA) at 0 using FastOutSlowInEasing
Color(0xFF22AADD) at 4_000 using FastOutSlowInEasing
Color(0xFF99BBDD) at 8_000 using FastOutSlowInEasing
Color(0xFF44DDCC) at 12_000 using FastOutSlowInEasing
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(4_000),
),
label = "color2",
)
val dc1 by anim(Color(0xFF0D0D3A), Color(0xFF141455), Color(0xFF1C1C6E), Color(0xFF111148), 0)
val dc2 by anim(Color(0xFF0A1238), Color(0xFF0D1D55), Color(0xFF112266), Color(0xFF0E1A4A), 10_000)
val dc3 by anim(Color(0xFF110A38), Color(0xFF1C1050), Color(0xFF2A1666), Color(0xFF180E48), 20_000)
val dc4 by anim(Color(0xFF081A30), Color(0xFF0D2844), Color(0xFF113355), Color(0xFF0D2240), 5_000)
// ── Color 3 dark purple → medium purple → rose pink → magenta ──────────
val color3 by transition.animateColor(
initialValue = Color(0xFF4422BB),
targetValue = Color(0xFF4422BB),
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 16_000
Color(0xFF4422BB) at 0 using FastOutSlowInEasing
Color(0xFF7733CC) at 4_000 using FastOutSlowInEasing
Color(0xFFDD88BB) at 8_000 using FastOutSlowInEasing
Color(0xFFEE44AA) at 12_000 using FastOutSlowInEasing
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(8_000),
),
label = "color3",
)
val lc1 by anim(Color(0xFFCCB8EE), Color(0xFFBBA0E8), Color(0xFFCCB0F0), Color(0xFFC4AAEC), 0)
val lc2 by anim(Color(0xFFB8C8F0), Color(0xFF9AAEE8), Color(0xFFAABEF0), Color(0xFFA0B8EE), 10_000)
val lc3 by anim(Color(0xFFDDC8F5), Color(0xFFCCB0EE), Color(0xFFD8BEF5), Color(0xFFD0B8F2), 20_000)
val lc4 by anim(Color(0xFFB8C4EE), Color(0xFFA8B4E8), Color(0xFFB4C0EE), Color(0xFFAABCEC), 5_000)
// ── Color 4 dark violet → medium violet → light pink → hot pink ────────
val color4 by transition.animateColor(
initialValue = Color(0xFF331199),
targetValue = Color(0xFF331199),
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 16_000
Color(0xFF331199) at 0 using FastOutSlowInEasing
Color(0xFF6644CC) at 4_000 using FastOutSlowInEasing
Color(0xFFCC77DD) at 8_000 using FastOutSlowInEasing
Color(0xFFFF66CC) at 12_000 using FastOutSlowInEasing
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(2_000),
),
label = "color4",
)
val color1 = if (isDark) dc1 else lc1
val color2 = if (isDark) dc2 else lc2
val color3 = if (isDark) dc3 else lc3
val color4 = if (isDark) dc4 else lc4
// Keep a stable shader instance so the RuntimeShader is never recreated.
// Colors are pushed each recomposition via updateColors().
val shader = remember {
NorthernLightsMeshGradientShader(
colors = arrayOf(
Color(0xFF2A1480),
Color(0xFF1444AA),
Color(0xFF4422BB),
Color(0xFF331199),
color1,
color2,
color3,
color4,
containerColor,
),
speed = 0.5f,
scale = 4f,
speed = 0.07f,
scale = 1.8f,
)
}
val colorsArray = remember { Array(5) { Color.Unspecified } }

View file

@ -40,7 +40,7 @@ inline fun <T : Any> InformationBlockContentScope.ListItems(
}
@Composable
inline fun <T : Any> InformationBlockContentScope.GridItems(
inline fun <T : Any> GridItems(
items: ImmutableList<T>,
modifier: Modifier = Modifier,
verticalAlignment: Alignment.Vertical = Alignment.Top,

View file

@ -0,0 +1,430 @@
package com.tangem.core.ui.components.bottomsheets
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
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.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType.Default
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType.Modal
import com.tangem.core.ui.components.bottomsheets.internal.InternalBottomSheet
import com.tangem.core.ui.components.bottomsheets.internal.collapse
import com.tangem.core.ui.components.bottomsheets.modal.MODAL_SHEET_MAX_HEIGHT
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeader
import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.utils.WindowInsetsZero
/**
* Type of [TangemBottomSheet] that defines its behavior and appearance.
* - [Default]: Standard bottom sheet with a draggable header
* - [Modal]: Modal bottom sheet without a draggable header
*/
enum class TangemBottomSheetType {
Default, Modal;
fun getDragHandle(): (@Composable (() -> Unit))? = when (this) {
Default -> {
{ TangemBottomSheetDraggableHeader() }
}
Modal -> null
}
}
/**
* Modal bottom sheet with [content] and optional [title] and [footer].
*
* @param config Configuration for the bottom sheet, including visibility and content data.
* @param type Type of the bottom sheet that defines its behavior and appearance.
* @param containerColor Background color of the bottom sheet container.
* @param skipPartiallyExpanded Whether to skip the partially expanded state when dragging the sheet.
* @param onBack Optional callback for handling back press when the sheet is visible.
* @param title Optional composable for rendering the title section of the sheet, receiving the content
* model as a parameter.
* @param content Composable for rendering the main content of the sheet, receiving the content model
* as a parameter.
* @param footer Optional composable for rendering the footer section of the sheet, receiving the content
* model as a parameter.
*
* [Show in Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8454-23749&m=dev)
*/
@Composable
inline fun <reified T : TangemBottomSheetConfigContent> TangemBottomSheet(
config: TangemBottomSheetConfig,
type: TangemBottomSheetType = Default,
containerColor: Color = TangemTheme.colors2.surface.level2,
skipPartiallyExpanded: Boolean = true,
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit = {},
crossinline content: @Composable (T) -> Unit,
noinline footer: @Composable (BoxScope.(T) -> Unit)? = null,
) {
val isAlwaysVisible = LocalBottomSheetAlwaysVisible.current
if (isAlwaysVisible) {
PreviewModalBottomSheetWithFooter<T>(
config = config,
containerColor = containerColor,
type = type,
title = title,
content = content,
footer = footer,
skipPartiallyExpanded = skipPartiallyExpanded,
)
} else {
DefaultModalBottomSheetWithFooter<T>(
config = config,
containerColor = containerColor,
type = type,
title = title,
content = content,
footer = footer,
onBack = onBack,
skipPartiallyExpanded = skipPartiallyExpanded,
)
}
}
@Suppress("LongParameterList")
@Composable
@OptIn(ExperimentalMaterial3Api::class)
inline fun <reified T : TangemBottomSheetConfigContent> DefaultModalBottomSheetWithFooter(
config: TangemBottomSheetConfig,
containerColor: Color,
type: TangemBottomSheetType,
skipPartiallyExpanded: Boolean = true,
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
var isVisible by remember { mutableStateOf(value = config.isShown) }
val sheetState = if (config.dismissOnClickOutside == null) {
rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded)
} else {
rememberModalBottomSheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = { sheetValue ->
if (config.dismissOnClickOutside().not()) {
// Ignore transitions to hidden (prevents dismiss on outside click/back press)
sheetValue != SheetValue.Hidden
} else {
true
}
},
)
}
if (isVisible && config.content is T) {
BasicBottomSheet<T>(
config = config,
sheetState = sheetState,
containerColor = containerColor,
type = type,
title = title,
onBack = onBack,
content = content,
footer = footer,
)
}
LaunchedEffect(key1 = config.isShown) {
if (config.isShown) {
isVisible = true
} else {
sheetState.collapse { isVisible = false }
}
}
}
@Suppress("LongParameterList")
@Composable
@OptIn(ExperimentalMaterial3Api::class)
inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheetWithFooter(
config: TangemBottomSheetConfig,
containerColor: Color,
type: TangemBottomSheetType,
skipPartiallyExpanded: Boolean = true,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
BasicBottomSheet<T>(
config = config,
sheetState = SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
initialValue = Expanded,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
),
onBack = null,
containerColor = containerColor,
type = type,
title = title,
content = content,
footer = footer,
)
}
@Suppress("LongParameterList", "LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
inline fun <reified T : TangemBottomSheetConfigContent> BasicBottomSheet(
config: TangemBottomSheetConfig,
sheetState: SheetState,
containerColor: Color,
type: TangemBottomSheetType,
modifier: Modifier = Modifier,
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
val model = config.content as? T ?: return
val windowSize = LocalWindowSize.current
val bsContent: @Composable ColumnScope.() -> Unit = {
val maxHeight = when (type) {
Default -> Dp.Unspecified
Modal -> windowSize.height * MODAL_SHEET_MAX_HEIGHT
}
val contentModifier = when (type) {
Default -> Modifier.clip(
RoundedCornerShape(
topStart = TangemTheme.dimens2.x8,
topEnd = TangemTheme.dimens2.x8,
),
)
Modal -> Modifier
.padding(
start = TangemTheme.dimens2.x2,
end = TangemTheme.dimens2.x2,
bottom = TangemTheme.dimens2.x2,
)
.clip(RoundedCornerShape(TangemTheme.dimens2.x8))
}
Column(
modifier = contentModifier
.background(containerColor)
.heightIn(max = maxHeight),
) {
Box(modifier = Modifier.fillMaxWidth()) {
title(model)
}
Box(modifier = Modifier.fillMaxWidth()) {
content(model)
if (footer != null) {
BottomFade(
modifier = Modifier.align(Alignment.BottomCenter),
gradientBrush = Brush.verticalGradient(
listOf(
TangemTheme.colors2.shadow.fadeMin,
TangemTheme.colors2.shadow.fadeMax,
),
),
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
) {
if (footer != null) {
footer(model)
}
}
}
}
}
InternalBottomSheet(
modifier = modifier.statusBarsPadding(),
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = Color.Transparent,
contentWindowInsets = { WindowInsetsZero },
onBack = onBack,
dragHandle = type.getDragHandle(),
content = bsContent,
scrimColor = TangemTheme.colors2.overlay.overlaySecondary,
)
}
// region Preview
@Suppress("LongMethod")
@Composable
@Preview(showBackground = true, widthDp = 360, heightDp = 800)
@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TangemModalBottomSheetWithFooter_Preview(
@PreviewParameter(TangemBottomSheetPreviewProvider::class) params: TangemBottomSheetType,
) {
TangemThemePreviewRedesign {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
type = params,
title = { TangemModalBottomSheetTitle(endIconRes = R.drawable.ic_close_24, onEndClick = {}) },
content = {
Column(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(100))
.background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f))
.padding(12.dp),
painter = rememberVectorPainter(
ImageVector.vectorResource(R.drawable.ic_alert_24),
),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
SpacerH24()
Text(
text = "Unsuported networks",
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
SpacerH8()
Text(
text = "Tangem does not currently support a required network by React App.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerH(48.dp)
Text(
text = "Long text to show scrollable content and bottom fade." +
"\nLorem ipsum dolor sit amet, consectetur adipiscing elit. In imperdiet metus non leo " +
"ultricies pulvinar. Pellentesque sed condimentum odio. Sed venenatis ac felis non " +
"consequat. Nunc erat dolor, maximus nec mattis a, tempus at eros. Duis sit amet neque " +
"dui. Donec consectetur nisl id dui convallis, in posuere dolor eleifend. Pellentesque " +
"habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. " +
"Pellentesque consequat scelerisque justo quis tristique. Mauris laoreet venenatis " +
"pharetra. Morbi sed faucibus leo. Praesent elementum pretium posuere. Morbi et felis a " +
"turpis pellentesque rhoncus.",
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
},
footer = {
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
text = "Go it",
onClick = {},
)
},
)
}
}
@Suppress("LongMethod")
@Composable
@Preview(showBackground = true, widthDp = 360, heightDp = 800)
@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TangemModalBottomSheetWithFooter_Preview2(
@PreviewParameter(TangemBottomSheetPreviewProvider::class) params: TangemBottomSheetType,
) {
TangemThemePreviewRedesign {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
type = params,
title = { TangemModalBottomSheetTitle(endIconRes = R.drawable.ic_close_24, onEndClick = {}) },
content = {
Column(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(100))
.background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f))
.padding(12.dp),
painter = rememberVectorPainter(
ImageVector.vectorResource(R.drawable.ic_alert_24),
),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
SpacerH24()
Text(
text = "Unsuported networks",
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
SpacerH8()
Text(
text = "Tangem does not currently support a required network by React App.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerH(48.dp)
Text(
text = "Long text to show scrollable content and bottom fade." +
"\nLorem ipsum dolor sit amet, consectetur adipiscing elit. In imperdiet metus non leo " +
"ultricies pulvinar. Pellentesque sed condimentum odio. Sed venenatis ac felis non " +
"consequat. Nunc erat dolor, maximus nec mattis a, tempus at eros. Duis sit amet neque " +
"dui. Donec consectetur nisl id dui convallis, in posuere dolor eleifend. Pellentesque " +
"habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. " +
"Pellentesque consequat scelerisque justo quis tristique. Mauris laoreet venenatis " +
"pharetra. Morbi sed faucibus leo. Praesent elementum pretium posuere. Morbi et felis a " +
"turpis pellentesque rhoncus.",
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
},
)
}
}
private class TangemBottomSheetPreviewProvider : PreviewParameterProvider<TangemBottomSheetType> {
override val values: Sequence<TangemBottomSheetType>
get() = sequenceOf(
Default,
Modal,
)
}
// endregion

View file

@ -0,0 +1,88 @@
package com.tangem.core.ui.components.bottomsheets.internal
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalTopSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InternalBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = 0.dp,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
content: @Composable ColumnScope.() -> Unit,
) {
val topSnackbarHostState = LocalTopSnackbarHostState.current
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
sheetState = sheetState,
sheetMaxWidth = sheetMaxWidth,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
scrimColor = scrimColor,
dragHandle = dragHandle,
contentWindowInsets = contentWindowInsets,
properties = ModalBottomSheetProperties(
shouldDismissOnBackPress = onBack == null,
),
content = {
Box {
val hazeState = rememberHazeState()
Column(Modifier.hazeSourceTangem(hazeState)) {
content()
}
CompositionLocalProvider(LocalHazeState provides hazeState) {
TangemTopSnackbarHost(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(horizontal = TangemTheme.dimens2.x4)
.padding(top = TangemTheme.dimens2.x6),
hostState = topSnackbarHostState,
)
}
}
BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) {
onBack?.invoke()
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
suspend fun SheetState.collapse(onCollapsed: () -> Unit) {
coroutineScope {
launch { hide() }.invokeOnCompletion { onCollapsed() }
}
}

View file

@ -8,11 +8,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -48,17 +45,9 @@ fun ModalBottomSheetWithBackHandling(
),
content = {
content()
BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) {
onBack?.invoke()
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
suspend fun SheetState.collapse(onCollapsed: () -> Unit) {
coroutineScope {
launch { hide() }.invokeOnCompletion { onCollapsed() }
}
}

View file

@ -1,156 +1,293 @@
package com.tangem.core.ui.components.bottomsheets.message
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.Color
import androidx.compose.ui.res.painterResource
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.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.isNullOrEmpty
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM.Button.IconOrder
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.icons.HighlightedIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Composable
fun MessageBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { notification: MessageBottomSheetUM ->
Content(model = notification)
fun MessageBottomSheet(state: MessageBottomSheetUM, onDismissRequest: () -> Unit) {
val stateWithOnDismiss = remember(state) {
state.copy(
onDismissRequest = {
state.onDismissRequest.invoke()
onDismissRequest()
},
)
}
val config = TangemBottomSheetConfig(
isShown = true,
content = stateWithOnDismiss,
onDismissRequest = stateWithOnDismiss.onDismissRequest,
)
TangemModalBottomSheet(
config = config,
title = {
TangemModalBottomSheetTitle(
endIconRes = R.drawable.ic_close_24,
onEndClick = stateWithOnDismiss.onDismissRequest,
)
},
content = { content: MessageBottomSheetUM -> MessageBottomSheetContent(content) },
)
}
@Composable
fun MessageBottomSheetContent(state: MessageBottomSheetUM, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
state.elements.fastForEach { element ->
when (element) {
is MessageBottomSheetUM.InfoBlock -> {
ContentContainer(
modifier = Modifier
.heightIn(min = TangemTheme.dimens.size180)
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(bottom = 32.dp),
state = element,
)
}
else -> Unit
}
}
ButtonsContainer(
modifier = Modifier.fillMaxWidth(),
closeScope = state.closeScope,
buttons = state.elements.filterIsInstance<MessageBottomSheetUM.Button>().toPersistentList(),
)
}
}
@Composable
internal fun Content(model: MessageBottomSheetUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(modifier = Modifier)
if (model.iconResId != null) {
Icon(
modifier = Modifier.size(48.dp),
painter = painterResource(model.iconResId),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
private fun ContentContainer(state: MessageBottomSheetUM.InfoBlock, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
BottomSheetIconContainer(state.icon, state.iconImage)
state.title?.let { title ->
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing24),
text = title.resolveReference(),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
}
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!model.title.isNullOrEmpty()) {
Text(
text = model.title.resolveReference(),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
}
state.body?.let { body ->
Text(
text = model.message.resolveReference(),
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing8),
text = body.resolveAnnotatedReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
Column(
modifier = Modifier
.padding(bottom = 16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (model.primaryAction != null) {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = model.primaryAction.text.resolveReference(),
enabled = model.primaryAction.isEnabled,
onClick = model.primaryAction.onClick,
)
}
if (model.secondaryAction != null) {
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = model.secondaryAction.text.resolveReference(),
enabled = model.secondaryAction.isEnabled,
onClick = model.secondaryAction.onClick,
)
}
state.chip?.let { chip ->
BottomSheetChip(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing16),
chip = chip,
)
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_NotificationBottomSheet(
@PreviewParameter(MessageBottomSheetUMPreviewProvider::class) params: MessageBottomSheetUM,
private fun BottomSheetIconContainer(
icon: MessageBottomSheetUM.Icon?,
iconImage: MessageBottomSheetUM.IconImage?,
modifier: Modifier = Modifier,
) {
TangemThemePreview {
MessageBottomSheet(
config = TangemBottomSheetConfig(
content = params,
isShown = true,
onDismissRequest = {},
),
if (icon != null) {
BottomSheetIcon(icon, modifier)
} else if (iconImage != null) {
Image(
modifier = modifier
.size(TangemTheme.dimens.size56)
.clip(CircleShape),
painter = painterResource(id = iconImage.res),
contentDescription = null,
)
}
}
private class MessageBottomSheetUMPreviewProvider : PreviewParameterProvider<MessageBottomSheetUM> {
val balancesHiddenMessage = MessageBottomSheetUM(
iconResId = R.drawable.ic_eye_off_outline_24,
title = resourceReference(R.string.balance_hidden_title),
message = resourceReference(R.string.balance_hidden_description),
primaryAction = MessageBottomSheetUM.ActionUM(
text = resourceReference(R.string.balance_hidden_got_it_button),
onClick = {},
),
secondaryAction = MessageBottomSheetUM.ActionUM(
text = resourceReference(R.string.balance_hidden_do_not_show_button),
onClick = {},
),
)
@Composable
private fun BottomSheetIcon(icon: MessageBottomSheetUM.Icon, modifier: Modifier = Modifier) {
val tint = when (icon.type) {
MessageBottomSheetUM.Icon.Type.Unspecified -> Color.Unspecified
MessageBottomSheetUM.Icon.Type.Accent -> TangemTheme.colors.icon.accent
MessageBottomSheetUM.Icon.Type.Informative -> TangemTheme.colors.icon.informative
MessageBottomSheetUM.Icon.Type.Attention -> TangemTheme.colors.icon.attention
MessageBottomSheetUM.Icon.Type.Warning -> TangemTheme.colors.icon.warning
}
override val values: Sequence<MessageBottomSheetUM>
get() = sequenceOf(
balancesHiddenMessage,
balancesHiddenMessage.copy(title = null),
balancesHiddenMessage.copy(iconResId = null),
balancesHiddenMessage.copy(primaryAction = null),
balancesHiddenMessage.copy(secondaryAction = null),
balancesHiddenMessage.copy(
primaryAction = null,
secondaryAction = null,
),
balancesHiddenMessage.copy(
title = null,
iconResId = null,
primaryAction = null,
secondaryAction = null,
),
)
val backgroundColor = when (icon.backgroundType) {
MessageBottomSheetUM.Icon.BackgroundType.Unspecified -> TangemTheme.colors.icon.informative
MessageBottomSheetUM.Icon.BackgroundType.SameAsTint -> tint
MessageBottomSheetUM.Icon.BackgroundType.Accent -> TangemTheme.colors.icon.accent
MessageBottomSheetUM.Icon.BackgroundType.Informative -> TangemTheme.colors.icon.informative
MessageBottomSheetUM.Icon.BackgroundType.Attention -> TangemTheme.colors.icon.attention
MessageBottomSheetUM.Icon.BackgroundType.Warning -> TangemTheme.colors.icon.warning
}
HighlightedIcon(
modifier = modifier,
icon = icon.res,
iconTint = tint,
backgroundColor = backgroundColor,
)
}
// endregion Preview
@Composable
private fun BottomSheetChip(chip: MessageBottomSheetUM.Chip, modifier: Modifier = Modifier) {
val color = when (chip.type) {
MessageBottomSheetUM.Chip.Type.Unspecified -> TangemTheme.colors.text.primary1
MessageBottomSheetUM.Chip.Type.Warning -> TangemTheme.colors.text.warning
}
Text(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
color = color.copy(alpha = 0.1F),
)
.padding(vertical = TangemTheme.dimens.spacing4, horizontal = TangemTheme.dimens.spacing12),
text = chip.text.resolveReference(),
style = TangemTheme.typography.caption1,
color = color,
)
}
@Suppress("LongMethod")
@Composable
private fun ButtonsContainer(
buttons: ImmutableList<MessageBottomSheetUM.Button>,
closeScope: MessageBottomSheetUM.CloseScope,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(all = TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
buttons.fastForEach { button ->
val icon = button.icon?.let { iconResId ->
when (button.iconOrder) {
IconOrder.Start -> TangemButtonIconPosition.Start(iconResId)
IconOrder.End -> TangemButtonIconPosition.End(iconResId)
}
} ?: TangemButtonIconPosition.None
TangemButton(
modifier = Modifier.fillMaxWidth(),
text = button.text?.resolveReference().orEmpty(),
icon = icon,
onClick = { button.onClick?.invoke(closeScope) },
colors = if (button.isPrimary) {
TangemButtonsDefaults.primaryButtonColors
} else {
TangemButtonsDefaults.secondaryButtonColors
},
enabled = true,
showProgress = false,
textStyle = TangemTheme.typography.subtitle1,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
MessageBottomSheet(
messageBottomSheetUM {
infoBlock {
icon(R.drawable.img_knight_shield_32) {
type = MessageBottomSheetUM.Icon.Type.Attention
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint
}
title = TextReference.Str("Title Title Title")
body = TextReference.Str("Body")
chip(text = TextReference.Str("Some chip information"))
}
primaryButton {
text = TextReference.Str("Test")
icon = R.drawable.ic_tangem_24
}
secondaryButton {
icon = R.drawable.ic_tangem_24
text = TextReference.Str("asdasd")
onClick {
closeBs()
}
}
},
onDismissRequest = {},
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview2() {
TangemThemePreview {
MessageBottomSheet(
messageBottomSheetUM {
infoBlock {
iconImage = MessageBottomSheetUM.IconImage(R.drawable.img_visa_notification)
title = TextReference.Str("Title Title Title")
body = TextReference.Str("Body")
chip(text = TextReference.Str("Some chip information"))
}
primaryButton {
text = TextReference.Str("Test")
icon = R.drawable.ic_tangem_24
}
secondaryButton {
icon = R.drawable.ic_tangem_24
text = TextReference.Str("asdasd")
onClick {
closeBs()
}
}
},
onDismissRequest = {},
)
}
}

View file

@ -1,20 +1,143 @@
package com.tangem.core.ui.components.bottomsheets.message
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Immutable
data class MessageBottomSheetUM(
@DrawableRes val iconResId: Int?,
val title: TextReference?,
val message: TextReference,
val primaryAction: ActionUM?,
val secondaryAction: ActionUM?,
var elements: ImmutableList<Element> = persistentListOf(),
var onDismissRequest: () -> Unit = {},
) : TangemBottomSheetConfigContent {
data class ActionUM(
val text: TextReference,
val isEnabled: Boolean = true,
val onClick: () -> Unit,
)
}
@Immutable
inner class CloseScope {
fun closeBs() {
onDismissRequest()
}
}
val closeScope = CloseScope()
@Immutable
sealed interface Element
@Immutable
data class Icon(
@DrawableRes internal var res: Int,
var type: Type = Type.Unspecified,
var backgroundType: BackgroundType = BackgroundType.Unspecified,
) : Element {
enum class Type {
Unspecified, Accent, Informative, Attention, Warning,
}
enum class BackgroundType {
Unspecified, SameAsTint, Accent, Informative, Attention, Warning,
}
}
@Immutable
data class IconImage(@DrawableRes internal var res: Int) : Element
@Immutable
data class Chip(
internal var text: TextReference,
var type: Type = Type.Unspecified,
) : Element {
enum class Type {
Unspecified, Warning
}
}
@Immutable
data class InfoBlock(
internal var icon: Icon? = null,
internal var iconImage: IconImage? = null,
internal var chip: Chip? = null,
var title: TextReference? = null,
var body: TextReference? = null,
) : Element
@Immutable
data class Button(
internal var isPrimary: Boolean = false,
var text: TextReference? = null,
@DrawableRes internal var iconInternal: Int? = null,
internal var iconOrder: IconOrder = IconOrder.Start,
var onClick: (CloseScope.() -> Unit)? = null,
) : Element {
var icon: Int? = iconInternal
set(value) {
iconOrder = if (text == null) {
IconOrder.Start
} else {
IconOrder.End
}
field = value
}
enum class IconOrder {
Start, End
}
}
}
@Target(AnnotationTarget.TYPE)
@DslMarker
annotation class MessageBottomSheetDsl
// region: DSL
fun messageBottomSheetUM(init: @MessageBottomSheetDsl MessageBottomSheetUM.() -> Unit) =
MessageBottomSheetUM().apply(init)
fun MessageBottomSheetUM.onDismiss(block: () -> Unit) = apply { onDismissRequest = block }
@Suppress("NestedScopeFunctions")
fun MessageBottomSheetUM.infoBlock(init: @MessageBottomSheetDsl MessageBottomSheetUM.InfoBlock.() -> Unit) = apply {
val element = MessageBottomSheetUM.InfoBlock().apply(init)
elements = (elements + element).toPersistentList()
}
@Suppress("NestedScopeFunctions")
fun MessageBottomSheetUM.InfoBlock.icon(@DrawableRes res: Int, init: MessageBottomSheetUM.Icon.() -> Unit = {}) =
apply {
icon = MessageBottomSheetUM.Icon(res).apply(init)
}
fun MessageBottomSheetUM.InfoBlock.iconImage(@DrawableRes res: Int) = apply {
iconImage = MessageBottomSheetUM.IconImage(res)
}
@Suppress("NestedScopeFunctions")
fun MessageBottomSheetUM.InfoBlock.chip(text: TextReference, init: MessageBottomSheetUM.Chip.() -> Unit = {}) = apply {
chip = MessageBottomSheetUM.Chip(text).apply(init)
}
@Suppress("NestedScopeFunctions")
internal fun MessageBottomSheetUM.button(init: @MessageBottomSheetDsl MessageBottomSheetUM.Button.() -> Unit) = apply {
val element = MessageBottomSheetUM.Button().apply(init)
elements = (elements + element).toPersistentList()
}
@Suppress("NestedScopeFunctions")
fun MessageBottomSheetUM.primaryButton(init: @MessageBottomSheetDsl MessageBottomSheetUM.Button.() -> Unit) = apply {
button { isPrimary = true; apply(init) }
}
@Suppress("NestedScopeFunctions")
fun MessageBottomSheetUM.secondaryButton(init: @MessageBottomSheetDsl MessageBottomSheetUM.Button.() -> Unit) = apply {
button { isPrimary = false; apply(init) }
}
fun MessageBottomSheetUM.Button.onClick(block: MessageBottomSheetUM.CloseScope.() -> Unit) = apply {
onClick = block
}
// endregion

View file

@ -1,142 +0,0 @@
package com.tangem.core.ui.components.bottomsheets.message
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Immutable
data class MessageBottomSheetUMV2(
var elements: ImmutableList<Element> = persistentListOf(),
var onDismissRequest: () -> Unit = {},
) : TangemBottomSheetConfigContent {
@Immutable
inner class CloseScope {
fun closeBs() {
onDismissRequest()
}
}
val closeScope = CloseScope()
@Immutable
sealed interface Element
@Immutable
data class Icon(
@DrawableRes internal var res: Int,
var type: Type = Type.Unspecified,
var backgroundType: BackgroundType = BackgroundType.Unspecified,
) : Element {
enum class Type {
Unspecified, Accent, Informative, Attention, Warning,
}
enum class BackgroundType {
Unspecified, SameAsTint, Accent, Informative, Attention, Warning,
}
}
@Immutable
data class IconImage(@DrawableRes internal var res: Int) : Element
@Immutable
data class Chip(
internal var text: TextReference,
var type: Type = Type.Unspecified,
) : Element {
enum class Type {
Unspecified, Warning
}
}
@Immutable
data class InfoBlock(
internal var icon: Icon? = null,
internal var iconImage: IconImage? = null,
internal var chip: Chip? = null,
var title: TextReference? = null,
var body: TextReference? = null,
) : Element
@Immutable
data class Button(
internal var isPrimary: Boolean = false,
var text: TextReference? = null,
@DrawableRes internal var iconInternal: Int? = null,
internal var iconOrder: IconOrder = IconOrder.Start,
var onClick: (CloseScope.() -> Unit)? = null,
) : Element {
var icon: Int? = iconInternal
set(value) {
iconOrder = if (text == null) {
IconOrder.Start
} else {
IconOrder.End
}
field = value
}
enum class IconOrder {
Start, End
}
}
}
@Target(AnnotationTarget.TYPE)
@DslMarker
annotation class MessageBottomSheetV2Dsl
// region: DSL
fun messageBottomSheetUM(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.() -> Unit) =
MessageBottomSheetUMV2().apply(init)
fun MessageBottomSheetUMV2.onDismiss(block: () -> Unit) = MessageBottomSheetUMV2().apply { onDismissRequest = block }
fun MessageBottomSheetUMV2.infoBlock(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.InfoBlock.() -> Unit) =
apply {
val element = MessageBottomSheetUMV2.InfoBlock().apply(init)
elements = (elements + element).toPersistentList()
}
fun MessageBottomSheetUMV2.InfoBlock.icon(@DrawableRes res: Int, init: MessageBottomSheetUMV2.Icon.() -> Unit = {}) =
apply {
icon = MessageBottomSheetUMV2.Icon(res).apply(init)
}
fun MessageBottomSheetUMV2.InfoBlock.iconImage(@DrawableRes res: Int) = apply {
iconImage = MessageBottomSheetUMV2.IconImage(res)
}
fun MessageBottomSheetUMV2.InfoBlock.chip(text: TextReference, init: MessageBottomSheetUMV2.Chip.() -> Unit = {}) =
apply {
chip = MessageBottomSheetUMV2.Chip(text).apply(init)
}
internal fun MessageBottomSheetUMV2.button(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.Button.() -> Unit) =
apply {
val element = MessageBottomSheetUMV2.Button().apply(init)
elements = (elements + element).toPersistentList()
}
fun MessageBottomSheetUMV2.primaryButton(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.Button.() -> Unit) =
apply {
button { isPrimary = true; apply(init) }
}
fun MessageBottomSheetUMV2.secondaryButton(init: @MessageBottomSheetV2Dsl MessageBottomSheetUMV2.Button.() -> Unit) =
apply {
button { isPrimary = false; apply(init) }
}
fun MessageBottomSheetUMV2.Button.onClick(block: MessageBottomSheetUMV2.CloseScope.() -> Unit) = apply {
onClick = block
}
// endregion

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