Updated on 2026-08-14
This commit is contained in:
commit
19df6fefae
194 changed files with 5193 additions and 1945 deletions
|
|
@ -113,6 +113,7 @@ dependencies {
|
||||||
implementation(projects.domain.account)
|
implementation(projects.domain.account)
|
||||||
implementation(projects.domain.account.status)
|
implementation(projects.domain.account.status)
|
||||||
implementation(projects.domain.addressBook)
|
implementation(projects.domain.addressBook)
|
||||||
|
implementation(projects.domain.appsflyer)
|
||||||
implementation(projects.domain.models)
|
implementation(projects.domain.models)
|
||||||
implementation(projects.domain.core)
|
implementation(projects.domain.core)
|
||||||
api(projects.domain.common)
|
api(projects.domain.common)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.tangem.scenarios
|
||||||
|
|
||||||
|
import com.tangem.common.BaseTestCase
|
||||||
|
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||||
|
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
|
||||||
|
import com.tangem.common.extensions.clickWithAssertion
|
||||||
|
import com.tangem.common.extensions.isDisplayedSafely
|
||||||
|
import com.tangem.screens.*
|
||||||
|
import com.tangem.tap.domain.sdk.mocks.MockContent
|
||||||
|
import com.tangem.tap.domain.sdk.mocks.MockProvider
|
||||||
|
import io.qameta.allure.kotlin.Allure.step
|
||||||
|
|
||||||
|
/** 'Add Wallet' scans a card immediately (no type chooser), so [mockContent] must be set before the click. */
|
||||||
|
fun BaseTestCase.addNewCardWallet(mockContent: MockContent) {
|
||||||
|
step("Click 'More' button on TopBar") {
|
||||||
|
onMainScreenTopBar { moreButton.clickWithAssertion() }
|
||||||
|
}
|
||||||
|
MockProvider.setMocks(mockContent)
|
||||||
|
step("Click on 'Add Wallet' button (scans a new hardware wallet)") {
|
||||||
|
onDetailsScreen { addWalletButton.clickWithAssertion() }
|
||||||
|
}
|
||||||
|
// Gate on the top-bar More button, not the container — the bottom Markets sheet can leave the container un-"displayed".
|
||||||
|
step("Assert 'Main' screen is displayed with the new wallet") {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
|
||||||
|
runCatching { onMainScreenTopBar { moreButton.assertIsDisplayed() } }.isSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The added card is the newest pager page; its "Synchronize addresses" prompt is off-screen until swiped to.
|
||||||
|
step("Synchronize the new card wallet's addresses") {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
|
var shown = false
|
||||||
|
onMainScreen { shown = synchronizeAddressesButton.isDisplayedSafely() }
|
||||||
|
if (!shown) onMainScreen { swipeToAdjacentWallet(toPrevious = false) }
|
||||||
|
shown
|
||||||
|
}
|
||||||
|
// Let the pager fling settle — a click mid-animation is eaten by the button's clickableSingle debounce.
|
||||||
|
waitForIdle()
|
||||||
|
onMainScreen { synchronizeAddressesButton.clickWithAssertion() }
|
||||||
|
// The prompt clears once the card's addresses are derived (re-scan + reload over many, some failing, RPCs).
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
|
||||||
|
var generated = false
|
||||||
|
onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() }
|
||||||
|
generated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) {
|
||||||
|
step("Click on token '$tokenName' on the visible wallet") {
|
||||||
|
onMainScreen { clickDisplayedToken(tokenName) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BaseTestCase.switchToPreviousWallet() {
|
||||||
|
step("Swipe wallet card to the previous wallet") {
|
||||||
|
onMainScreen { swipeToAdjacentWallet(toPrevious = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks a [token] the recipient [walletName] already holds, via the wallet tab. */
|
||||||
|
fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) {
|
||||||
|
step("Click on 'Choose token' button") {
|
||||||
|
onSwapTokenScreen { chooseTokenButton.performClick() }
|
||||||
|
}
|
||||||
|
step("Select wallet tab '$walletName'") {
|
||||||
|
onBuyTokenScreen { walletTab(walletName).performClick() }
|
||||||
|
}
|
||||||
|
step("Click on token with name '$token'") {
|
||||||
|
onBuyTokenScreen { tokenWithTitle(token).performClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds [token] to [recipientWalletName] which lacks it, via market search. */
|
||||||
|
fun BaseTestCase.addMissingReceiveTokenToWallet(token: String, recipientWalletName: String) {
|
||||||
|
step("Click on 'Choose token' button") {
|
||||||
|
onSwapTokenScreen { chooseTokenButton.performClick() }
|
||||||
|
}
|
||||||
|
step("Type '$token' in search field") {
|
||||||
|
onSwapSelectTokenScreen {
|
||||||
|
searchBarBlock.performClick()
|
||||||
|
searchBarBlock.performTextInput(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step("Click on market token '$token'") {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
|
runCatching { onSwapSelectTokenScreen { marketsTokenWithName(token).performClick() } }.isSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The 'Add token' sheet pre-selects the recipient (the only wallet missing the token, since the source already holds it).
|
||||||
|
step("Assert recipient wallet '$recipientWalletName' is selected") {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
|
runCatching { onAddToPortfolioScreen { walletName(recipientWalletName).assertIsDisplayed() } }.isSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step("Click on 'Add' button") {
|
||||||
|
onAddToPortfolioScreen { addButton.performClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -490,11 +490,12 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Holds the last BASE_BUTTON to confirm a transfer; the caller asserts the outcome (transfer mode has no in-progress marker to wait on). */
|
// Caller asserts the outcome — transfer mode has no in-progress marker to wait on.
|
||||||
fun BaseTestCase.holdToConfirmTransfer() {
|
fun BaseTestCase.holdToConfirmTransfer() {
|
||||||
val buttons = composeTestRule.onAllNodes(hasTestTag(BaseButtonTestTags.BUTTON))
|
composeTestRule.onNode(
|
||||||
val confirmButton = buttons[buttons.fetchSemanticsNodes().lastIndex]
|
hasTestTag(BaseButtonTestTags.BUTTON) and
|
||||||
confirmButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
|
hasText(getResourceString(CoreUiR.string.swapping_transfer_action)),
|
||||||
|
).performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
|
||||||
waitForIdle()
|
waitForIdle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.tangem.screens
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||||
|
import androidx.compose.ui.test.hasClickAction
|
||||||
|
import com.tangem.common.BaseTestCase
|
||||||
|
import com.tangem.wallet.R
|
||||||
|
import io.github.kakaocup.compose.node.element.ComposeScreen
|
||||||
|
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
|
||||||
|
import io.github.kakaocup.compose.node.element.KNode
|
||||||
|
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||||
|
|
||||||
|
class AddToPortfolioPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||||
|
ComposeScreen<AddToPortfolioPageObject>(semanticsProvider = semanticsProvider) {
|
||||||
|
|
||||||
|
fun walletName(walletName: String): KNode = child {
|
||||||
|
hasText(walletName)
|
||||||
|
useUnmergedTree = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val addButton: KNode = child {
|
||||||
|
hasText(getResourceString(R.string.common_add))
|
||||||
|
hasClickAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BaseTestCase.onAddToPortfolioScreen(function: AddToPortfolioPageObject.() -> Unit) =
|
||||||
|
onComposeScreen(composeTestRule, function)
|
||||||
|
|
@ -15,6 +15,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
|
||||||
import io.github.kakaocup.compose.node.element.KNode
|
import io.github.kakaocup.compose.node.element.KNode
|
||||||
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
|
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
|
||||||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||||
|
import androidx.compose.ui.test.hasText as withText
|
||||||
|
|
||||||
class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||||
ComposeScreen<BuyTokenPageObject>(semanticsProvider = semanticsProvider) {
|
ComposeScreen<BuyTokenPageObject>(semanticsProvider = semanticsProvider) {
|
||||||
|
|
@ -48,6 +49,19 @@ class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||||
useUnmergedTree = true
|
useUnmergedTree = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun walletTab(walletName: String): KNode = child {
|
||||||
|
hasTestTag(BuyTokenScreenTestTags.WALLET_TAB)
|
||||||
|
hasAnyDescendant(withText(walletName))
|
||||||
|
useUnmergedTree = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
fun tokenWithTitle(tokenTitle: String): LazyListItemNode = lazyList.childWith<LazyListItemNode> {
|
||||||
|
hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
|
||||||
|
hasText(tokenTitle)
|
||||||
|
useUnmergedTree = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun BaseTestCase.onBuyTokenScreen(function: BuyTokenPageObject.() -> Unit) =
|
internal fun BaseTestCase.onBuyTokenScreen(function: BuyTokenPageObject.() -> Unit) =
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||||
useUnmergedTree = true
|
useUnmergedTree = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val addWalletButton: KNode = child {
|
||||||
|
hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)
|
||||||
|
}
|
||||||
|
|
||||||
val buyTangemButton: KNode = child {
|
val buyTangemButton: KNode = child {
|
||||||
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM)
|
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM)
|
||||||
hasText(getResourceString(R.string.details_buy_wallet))
|
hasText(getResourceString(R.string.details_buy_wallet))
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,28 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wallet pager keeps the adjacent page composed (beyondViewportPageCount=1), so the token is mounted on two pages — click the displayed copy.
|
||||||
|
fun clickDisplayedToken(tokenName: String) {
|
||||||
|
val matcher = withTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(withText(tokenName))
|
||||||
|
val nodes = semanticsProvider.onAllNodes(matcher, useUnmergedTree = true)
|
||||||
|
for (i in 0 until nodes.fetchSemanticsNodes().size) {
|
||||||
|
if (runCatching { nodes[i].assertIsDisplayed(); nodes[i].performClick() }.isSuccess) return
|
||||||
|
}
|
||||||
|
error("Token '$tokenName' is not displayed on the current wallet page")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjacent pager pages stay mounted; swipe the wallet card that's actually on-screen.
|
||||||
|
fun swipeToAdjacentWallet(toPrevious: Boolean) {
|
||||||
|
val nodes = semanticsProvider.onAllNodes(withTestTag(MainScreenTestTags.WALLET_LIST_ITEM))
|
||||||
|
for (i in 0 until nodes.fetchSemanticsNodes().size) {
|
||||||
|
val swiped = runCatching {
|
||||||
|
nodes[i].assertIsDisplayed()
|
||||||
|
nodes[i].performTouchInput { if (toPrevious) swipeRight() else swipeLeft() }
|
||||||
|
}.isSuccess
|
||||||
|
if (swiped) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val restoringProgressText: KNode = child {
|
val restoringProgressText: KNode = child {
|
||||||
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
|
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
|
||||||
useUnmergedTree = true
|
useUnmergedTree = true
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,18 @@ import com.tangem.common.extensions.clickWithAssertion
|
||||||
import com.tangem.common.extensions.extractText
|
import com.tangem.common.extensions.extractText
|
||||||
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
|
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
|
||||||
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
|
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
|
||||||
|
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO
|
||||||
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
|
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
|
||||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||||
|
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
|
||||||
import com.tangem.common.utils.resetWireMockScenarioState
|
import com.tangem.common.utils.resetWireMockScenarioState
|
||||||
import com.tangem.common.utils.setWireMockScenarioState
|
import com.tangem.common.utils.setWireMockScenarioState
|
||||||
import com.tangem.core.ui.R as CoreUiR
|
import com.tangem.core.ui.R as CoreUiR
|
||||||
import com.tangem.scenarios.*
|
import com.tangem.scenarios.*
|
||||||
import com.tangem.screens.*
|
import com.tangem.screens.*
|
||||||
|
import com.tangem.screens.tangempay.*
|
||||||
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent
|
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent
|
||||||
|
import com.tangem.tap.domain.sdk.mocks.content.WalletMockContent
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||||
import io.qameta.allure.kotlin.Allure.step
|
import io.qameta.allure.kotlin.Allure.step
|
||||||
|
|
@ -808,6 +812,333 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AllureId("10005")
|
||||||
|
@DisplayName("App transfers: Tron network fee")
|
||||||
|
@Test
|
||||||
|
fun tronNetworkFeeTest() {
|
||||||
|
val token = "Tron"
|
||||||
|
val amount = "0.001"
|
||||||
|
val userTokensState = "TwoAccountsSameTron"
|
||||||
|
val networksProvidersScenario = "networks_providers"
|
||||||
|
val appTransfersNetworksState = "AppTransfersNetworks"
|
||||||
|
|
||||||
|
setupHooks(
|
||||||
|
additionalBeforeAppLaunchSection = {
|
||||||
|
setWireMockScenarioState(storiesScenario, storiesErrorState)
|
||||||
|
// networks_providers configures SDK RPC hosts at launch — must be set before the activity starts.
|
||||||
|
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
|
||||||
|
},
|
||||||
|
additionalAfterSection = {
|
||||||
|
resetWireMockScenarioState(storiesScenario)
|
||||||
|
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||||
|
resetWireMockScenarioState(networksProvidersScenario)
|
||||||
|
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||||
|
}
|
||||||
|
).run {
|
||||||
|
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||||
|
}
|
||||||
|
// Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
|
||||||
|
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
|
||||||
|
}
|
||||||
|
|
||||||
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockchain SDK TonProvidersBuilder drops public providers, so TON has no provider in the mocked build.
|
||||||
|
@Ignore("[REDACTED_JIRA]")
|
||||||
|
@AllureId("10012")
|
||||||
|
@DisplayName("App transfers: TON network fee")
|
||||||
|
@Test
|
||||||
|
fun tonNetworkFeeTest() {
|
||||||
|
// The SDK names TON's coin "Gram" (Blockchain.TON.getCoinName), so the portfolio row shows "Gram", not "Toncoin".
|
||||||
|
val token = "Gram"
|
||||||
|
val amount = "0.001"
|
||||||
|
val userTokensState = "TwoAccountsSameTON"
|
||||||
|
val networksProvidersScenario = "networks_providers"
|
||||||
|
val appTransfersNetworksState = "AppTransfersNetworks"
|
||||||
|
|
||||||
|
setupHooks(
|
||||||
|
additionalBeforeAppLaunchSection = {
|
||||||
|
setWireMockScenarioState(storiesScenario, storiesErrorState)
|
||||||
|
// networks_providers configures SDK RPC hosts at launch — must be set before the activity starts.
|
||||||
|
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
|
||||||
|
},
|
||||||
|
additionalAfterSection = {
|
||||||
|
resetWireMockScenarioState(storiesScenario)
|
||||||
|
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||||
|
resetWireMockScenarioState(networksProvidersScenario)
|
||||||
|
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||||
|
}
|
||||||
|
).run {
|
||||||
|
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||||
|
}
|
||||||
|
// Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
|
||||||
|
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
|
||||||
|
}
|
||||||
|
|
||||||
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllureId("10013")
|
||||||
|
@DisplayName("App transfers: Cosmos network fee")
|
||||||
|
@Test
|
||||||
|
fun cosmosNetworkFeeTest() {
|
||||||
|
val token = "Cosmos"
|
||||||
|
val amount = "0.001"
|
||||||
|
val userTokensState = "TwoAccountsSameCosmos"
|
||||||
|
val networksProvidersScenario = "networks_providers"
|
||||||
|
val appTransfersNetworksState = "AppTransfersNetworks"
|
||||||
|
|
||||||
|
setupHooks(
|
||||||
|
additionalBeforeAppLaunchSection = {
|
||||||
|
setWireMockScenarioState(storiesScenario, storiesErrorState)
|
||||||
|
// networks_providers configures SDK RPC hosts at launch — must be set before the activity starts.
|
||||||
|
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
|
||||||
|
},
|
||||||
|
additionalAfterSection = {
|
||||||
|
resetWireMockScenarioState(storiesScenario)
|
||||||
|
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||||
|
resetWireMockScenarioState(networksProvidersScenario)
|
||||||
|
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||||
|
}
|
||||||
|
).run {
|
||||||
|
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||||
|
}
|
||||||
|
// Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
|
||||||
|
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
|
||||||
|
}
|
||||||
|
|
||||||
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllureId("10015")
|
||||||
|
@DisplayName("App transfers: Aptos network fee")
|
||||||
|
@Test
|
||||||
|
fun aptosNetworkFeeTest() {
|
||||||
|
val token = "Aptos"
|
||||||
|
val amount = "0.001"
|
||||||
|
val userTokensState = "TwoAccountsSameAptos"
|
||||||
|
val networksProvidersScenario = "networks_providers"
|
||||||
|
val appTransfersNetworksState = "AppTransfersNetworks"
|
||||||
|
|
||||||
|
setupHooks(
|
||||||
|
additionalBeforeAppLaunchSection = {
|
||||||
|
setWireMockScenarioState(storiesScenario, storiesErrorState)
|
||||||
|
// networks_providers configures SDK RPC hosts at launch — must be set before the activity starts.
|
||||||
|
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
|
||||||
|
},
|
||||||
|
additionalAfterSection = {
|
||||||
|
resetWireMockScenarioState(storiesScenario)
|
||||||
|
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||||
|
resetWireMockScenarioState(networksProvidersScenario)
|
||||||
|
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||||
|
}
|
||||||
|
).run {
|
||||||
|
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||||
|
}
|
||||||
|
// Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
|
||||||
|
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
|
||||||
|
}
|
||||||
|
|
||||||
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllureId("9856")
|
||||||
|
@DisplayName("App transfers: Transfer mode is available from a Tangem Pay account")
|
||||||
|
@Test
|
||||||
|
fun transferModeAvailableFromTangemPayAccountTest() {
|
||||||
|
val token = "USDC"
|
||||||
|
val receiveAccountName = "Main account"
|
||||||
|
val eligibilityState = "PaeraCustomer"
|
||||||
|
val balanceScenario = "tangem_pay_balance_update"
|
||||||
|
val balanceInitialState = "InitialBalance"
|
||||||
|
val historyScenario = "tangem_pay_transaction_history"
|
||||||
|
val historyInitialState = "InitialEmpty"
|
||||||
|
val userTokensState = "TangemPayTransferUsdc"
|
||||||
|
|
||||||
|
setupHooks(
|
||||||
|
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
|
||||||
|
additionalAfterSection = {
|
||||||
|
resetWireMockScenarioState(storiesScenario)
|
||||||
|
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
|
||||||
|
resetWireMockScenarioState(balanceScenario)
|
||||||
|
resetWireMockScenarioState(historyScenario)
|
||||||
|
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||||
|
}
|
||||||
|
).run {
|
||||||
|
step("Set WireMock scenario: '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to state: '$eligibilityState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = TANGEM_PAY_ELIGIBILITY_SCENARIO, state = eligibilityState)
|
||||||
|
}
|
||||||
|
step("Set WireMock scenario: '$balanceScenario' to state: '$balanceInitialState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = balanceScenario, state = balanceInitialState)
|
||||||
|
}
|
||||||
|
step("Set WireMock scenario: '$historyScenario' to state: '$historyInitialState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = historyScenario, state = historyInitialState)
|
||||||
|
}
|
||||||
|
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||||
|
}
|
||||||
|
|
||||||
|
step("Open Tangem Pay") { openTangemPay() }
|
||||||
|
step("Click on 'Withdraw' button") {
|
||||||
|
onTangemPayMainScreen { withdrawButton.clickWithAssertion() }
|
||||||
|
}
|
||||||
|
step("Acknowledge withdrawal note sheet") {
|
||||||
|
onTangemPayWithdrawNoteSheet {
|
||||||
|
title.assertIsDisplayed()
|
||||||
|
gotItButton.clickWithAssertion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step("Choose identical receive token '$token' from '$receiveAccountName'") {
|
||||||
|
chooseIdenticalReceiveToken(tokenName = token, receiveAccountName = receiveAccountName)
|
||||||
|
}
|
||||||
|
// Withdraw-entry swap keeps recalculating — use flakySafely rather than assertTransferReady's waitUntil.
|
||||||
|
step("Assert Transfer mode is ready") {
|
||||||
|
flakySafely(WAIT_UNTIL_TIMEOUT_VERY_LONG) {
|
||||||
|
onSwapTokenScreen { transferTitle.assertIsDisplayed() }
|
||||||
|
}
|
||||||
|
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllureId("9995")
|
||||||
|
@DisplayName("App transfers: transfer between different wallets reaches 'Transfer in progress' screen")
|
||||||
|
@Test
|
||||||
|
fun transferBetweenDifferentWalletsReachesFinishTest() {
|
||||||
|
val token = "Ethereum"
|
||||||
|
val amount = "0.001"
|
||||||
|
val secondWalletName = "Wallet 2"
|
||||||
|
val userTokensState = "EthereumWithSecondToken"
|
||||||
|
|
||||||
|
setupHooks(
|
||||||
|
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
|
||||||
|
additionalAfterSection = {
|
||||||
|
resetWireMockScenarioState(storiesScenario)
|
||||||
|
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||||
|
resetWireMockScenarioState(ethCallScenario)
|
||||||
|
resetWireMockScenarioState(ethBalanceScenario)
|
||||||
|
}
|
||||||
|
).run {
|
||||||
|
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||||
|
}
|
||||||
|
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
|
||||||
|
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
|
||||||
|
}
|
||||||
|
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
|
||||||
|
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
|
||||||
|
}
|
||||||
|
|
||||||
|
step("Open 'Main' screen with existing hot wallet") {
|
||||||
|
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12)
|
||||||
|
}
|
||||||
|
step("Generate missing addresses") { generateMissingHotWalletAddresses() }
|
||||||
|
step("Wait for addresses to be generated") { waitForAddressesGenerated() }
|
||||||
|
step("Add a second card wallet '$secondWalletName'") {
|
||||||
|
addNewCardWallet(WalletMockContent)
|
||||||
|
}
|
||||||
|
step("Switch back to the hot wallet") { switchToPreviousWallet() }
|
||||||
|
step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) }
|
||||||
|
step("Open 'Swap' screen") {
|
||||||
|
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
|
||||||
|
}
|
||||||
|
step("Select identical receive token '$token' on '$secondWalletName'") {
|
||||||
|
selectReceiveTokenOnWallet(token = token, walletName = secondWalletName)
|
||||||
|
}
|
||||||
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
|
step("Hold to confirm the transfer") { holdToConfirmTransfer() }
|
||||||
|
step("Assert 'Transfer in progress' screen is displayed") {
|
||||||
|
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
|
onSwapSuccessScreen { transferInProgressTitle.assertIsDisplayed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllureId("9996")
|
||||||
|
@DisplayName("App transfers: adding a missing token to the recipient wallet enables Transfer")
|
||||||
|
@Test
|
||||||
|
fun addMissingTokenToRecipientWalletEnablesTransferTest() {
|
||||||
|
val token = "Ethereum"
|
||||||
|
val bitcoinToken = "Bitcoin"
|
||||||
|
val recipientWalletName = "Wallet"
|
||||||
|
val recipientWithoutEthereumState = "RecipientWithoutEthereum"
|
||||||
|
val ethereumWithSecondTokenState = "EthereumWithSecondToken"
|
||||||
|
|
||||||
|
setupHooks(
|
||||||
|
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
|
||||||
|
additionalAfterSection = {
|
||||||
|
resetWireMockScenarioState(storiesScenario)
|
||||||
|
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||||
|
resetWireMockScenarioState(ethCallScenario)
|
||||||
|
resetWireMockScenarioState(ethBalanceScenario)
|
||||||
|
}
|
||||||
|
).run {
|
||||||
|
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$recipientWithoutEthereumState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = recipientWithoutEthereumState)
|
||||||
|
}
|
||||||
|
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
|
||||||
|
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
|
||||||
|
}
|
||||||
|
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
|
||||||
|
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
|
||||||
|
}
|
||||||
|
|
||||||
|
step("Open 'Main' screen with existing hot wallet") {
|
||||||
|
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12)
|
||||||
|
}
|
||||||
|
step("Generate missing addresses") { generateMissingHotWalletAddresses() }
|
||||||
|
step("Wait for addresses to be generated") { waitForAddressesGenerated() }
|
||||||
|
step("Assert token '$bitcoinToken' is displayed") {
|
||||||
|
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
|
onMainScreen { tokenWithTitleAndAddress(bitcoinToken).assertIsDisplayed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Switch the user-tokens mock so the second wallet loads with Ethereum while the recipient stays Ethereum-less.
|
||||||
|
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$ethereumWithSecondTokenState'") {
|
||||||
|
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = ethereumWithSecondTokenState)
|
||||||
|
}
|
||||||
|
step("Add a second card wallet") {
|
||||||
|
addNewCardWallet(WalletMockContent)
|
||||||
|
}
|
||||||
|
step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) }
|
||||||
|
step("Open 'Swap' screen") {
|
||||||
|
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
|
||||||
|
}
|
||||||
|
step("Add missing token '$token' to recipient wallet '$recipientWalletName'") {
|
||||||
|
addMissingReceiveTokenToWallet(token = token, recipientWalletName = recipientWalletName)
|
||||||
|
}
|
||||||
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows.
|
// [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows.
|
||||||
@Ignore("[REDACTED_JIRA]")
|
@Ignore("[REDACTED_JIRA]")
|
||||||
@AllureId("9852")
|
@AllureId("9852")
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit a7b32c766817076c6346156390c135a3dae1b6ce
|
Subproject commit f983c6defd0b2240eb6f134aefe30f7e93696795
|
||||||
|
|
@ -4,13 +4,10 @@ import com.appsflyer.deeplink.DeepLink
|
||||||
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
|
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
|
||||||
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
||||||
import com.tangem.domain.wallets.models.AppsFlyerConversionData
|
import com.tangem.domain.wallets.models.AppsFlyerConversionData
|
||||||
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
|
|
||||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.contracts.ExperimentalContracts
|
import kotlin.contracts.ExperimentalContracts
|
||||||
|
|
@ -19,11 +16,9 @@ import kotlin.contracts.contract
|
||||||
@Singleton
|
@Singleton
|
||||||
class AppsFlyerReferralParamsHandler @Inject constructor(
|
class AppsFlyerReferralParamsHandler @Inject constructor(
|
||||||
private val appsFlyerStore: AppsFlyerStore,
|
private val appsFlyerStore: AppsFlyerStore,
|
||||||
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase,
|
|
||||||
private val coroutineScope: AppCoroutineScope,
|
private val coroutineScope: AppCoroutineScope,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private val deepLinkDeferred = CompletableDeferred<String?>()
|
private val deepLinkDeferred = CompletableDeferred<String?>()
|
||||||
|
|
||||||
fun handle(params: Map<String?, Any?>) {
|
fun handle(params: Map<String?, Any?>) {
|
||||||
|
|
@ -48,15 +43,17 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? {
|
suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? {
|
||||||
val deeplinkFromCache = appsFlyerStore.getDeeplink(deeplinkSource)
|
appsFlyerStore.getDeeplink(deeplinkSource)?.let { return it }
|
||||||
return if (deeplinkFromCache == null) {
|
|
||||||
val value = when (deeplinkSource) {
|
val expectedValue = when (deeplinkSource) {
|
||||||
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE
|
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE
|
||||||
}
|
AppsFlyerDeeplinkSource.Referral -> REFERRAL_DEEP_LINK_VALUE
|
||||||
deepLinkDeferred.await().takeIf { it == value }
|
|
||||||
} else {
|
|
||||||
deeplinkFromCache
|
|
||||||
}
|
}
|
||||||
|
val resolvedValue = deepLinkDeferred.await().takeIf { it == expectedValue }
|
||||||
|
|
||||||
|
// The deep link may have been persisted to the store while we were awaiting (e.g. from
|
||||||
|
// conversion-data handling, which stores the deep link but doesn't complete the deferred).
|
||||||
|
return resolvedValue ?: appsFlyerStore.getDeeplink(deeplinkSource)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
|
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
|
||||||
|
|
@ -79,6 +76,10 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
|
||||||
@Suppress("NullableToStringCall")
|
@Suppress("NullableToStringCall")
|
||||||
TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
|
TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
|
||||||
|
|
||||||
|
coroutineScope.launch {
|
||||||
|
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, REFERRAL_DEEP_LINK_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
if (!isValidParam(deepLinkSub1)) {
|
if (!isValidParam(deepLinkSub1)) {
|
||||||
TangemLogger.e("Deeplink conversion data is invalid")
|
TangemLogger.e("Deeplink conversion data is invalid")
|
||||||
return
|
return
|
||||||
|
|
@ -98,13 +99,9 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
|
||||||
|
|
||||||
private fun storeConversionData(refcode: String, campaign: String?) {
|
private fun storeConversionData(refcode: String, campaign: String?) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
mutex.withLock {
|
appsFlyerStore.storeIfAbsent(
|
||||||
setShouldShowMobileWalletPromoUseCase(true)
|
value = AppsFlyerConversionData(refcode = refcode, campaign = campaign),
|
||||||
.onLeft { TangemLogger.e("Error", it) }
|
)
|
||||||
appsFlyerStore.storeIfAbsent(
|
|
||||||
value = AppsFlyerConversionData(refcode = refcode, campaign = campaign),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,15 @@ internal interface CardSdkModule {
|
||||||
sdkRepository: CardSdkConfigRepository,
|
sdkRepository: CardSdkConfigRepository,
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): CardArtworksProvider {
|
): CardArtworksProvider {
|
||||||
|
// Use internal storage (always mounted) instead of external files dir. External
|
||||||
|
// storage can be transiently unavailable/unmounted or cleared after this singleton
|
||||||
|
// is constructed, leaving the directory missing when the SDK later writes to it —
|
||||||
|
// ArtworksStorage.store() opens a FileOutputStream without re-creating the parent,
|
||||||
|
// which crashes with ENOENT. Artwork is only a cache, so internal storage is fine.
|
||||||
|
val artworksDirectory = File(context.filesDir, "card_artworks").apply { mkdirs() }
|
||||||
return CardArtworksProvider(
|
return CardArtworksProvider(
|
||||||
tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl },
|
tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl },
|
||||||
artworksDirectory = File(
|
artworksDirectory = artworksDirectory,
|
||||||
context.getExternalFilesDir(null) ?: context.filesDir,
|
|
||||||
"card_artworks",
|
|
||||||
).apply { mkdirs() },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,10 @@ object WalletMockContent : MockContent {
|
||||||
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
|
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
|
||||||
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
|
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
|
||||||
),
|
),
|
||||||
|
DerivationPath("m/44'/118'/0'/0/0") to ExtendedPublicKey( // Cosmos
|
||||||
|
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
|
||||||
|
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
|
||||||
|
),
|
||||||
DerivationPath("m/1852'/1815'/0'/0/0") to ExtendedPublicKey(
|
DerivationPath("m/1852'/1815'/0'/0/0") to ExtendedPublicKey(
|
||||||
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
|
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
|
||||||
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
|
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
|
||||||
|
|
@ -181,6 +185,22 @@ object WalletMockContent : MockContent {
|
||||||
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
|
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
|
||||||
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
|
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
|
||||||
),
|
),
|
||||||
|
DerivationPath("m/44'/607'/0'/0/0") to ExtendedPublicKey( // TON (account 1)
|
||||||
|
publicKey = byteArrayOf(30, -109, -39, 33, -94, -73, 121, 50, -75, 86, 102, -2, -74, -23, 63, -3, 79, -82, -103, 106, 82, -86, -107, -63, -46, 104, 7, 18, -41, 15, -87, -43),
|
||||||
|
chainCode = byteArrayOf(15, 61, -29, 22, 30, 45, -51, -60, 5, 62, -87, -35, 54, -97, -5, -44, -54, -107, -14, -119, -3, 92, 91, 75, -66, 26, 112, 83, 122, -25, -64, 40),
|
||||||
|
),
|
||||||
|
DerivationPath("m/44'/607'/1'/0/0") to ExtendedPublicKey( // TON (account 2)
|
||||||
|
publicKey = byteArrayOf(-51, 62, 97, 25, 83, 75, -79, 23, 6, -42, -94, 45, 91, -66, 57, -80, -75, -39, 19, -88, 95, -124, 50, 39, 114, -118, 27, -122, 48, -69, 7, -111),
|
||||||
|
chainCode = byteArrayOf(77, 63, 69, -114, -25, 105, -123, -42, -87, 107, 86, -43, 46, 92, -78, -107, -72, -81, -102, 45, 75, 97, -120, -10, 118, 27, -34, -50, -92, 3, 47, -126),
|
||||||
|
),
|
||||||
|
DerivationPath("m/44'/637'/0'/0'/0'") to ExtendedPublicKey( // Aptos (account 1)
|
||||||
|
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
|
||||||
|
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
|
||||||
|
),
|
||||||
|
DerivationPath("m/44'/637'/1'/0'/0'") to ExtendedPublicKey( // Aptos (account 2)
|
||||||
|
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
|
||||||
|
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
extendedPublicKey = ExtendedPublicKey(
|
extendedPublicKey = ExtendedPublicKey(
|
||||||
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
|
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
|
||||||
|
|
@ -290,6 +310,20 @@ object WalletMockContent : MockContent {
|
||||||
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
||||||
childNumber = 0,
|
childNumber = 0,
|
||||||
),
|
),
|
||||||
|
DerivationPath("m/44'/118'/0'/0/0") to ExtendedPublicKey( // Cosmos (account 1)
|
||||||
|
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
|
||||||
|
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
|
||||||
|
depth = 0,
|
||||||
|
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
||||||
|
childNumber = 0,
|
||||||
|
),
|
||||||
|
DerivationPath("m/44'/118'/1'/0/0") to ExtendedPublicKey( // Cosmos (account 2)
|
||||||
|
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
|
||||||
|
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
|
||||||
|
depth = 0,
|
||||||
|
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
||||||
|
childNumber = 0,
|
||||||
|
),
|
||||||
DerivationPath("m/44'/144'/0'/0/0") to ExtendedPublicKey( // XRP
|
DerivationPath("m/44'/144'/0'/0/0") to ExtendedPublicKey( // XRP
|
||||||
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
|
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
|
||||||
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
|
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
|
||||||
|
|
@ -424,6 +458,34 @@ object WalletMockContent : MockContent {
|
||||||
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
||||||
childNumber = 0,
|
childNumber = 0,
|
||||||
),
|
),
|
||||||
|
DerivationPath("m/44'/607'/0'/0/0") to ExtendedPublicKey( // TON (account 1)
|
||||||
|
publicKey = byteArrayOf(30, -109, -39, 33, -94, -73, 121, 50, -75, 86, 102, -2, -74, -23, 63, -3, 79, -82, -103, 106, 82, -86, -107, -63, -46, 104, 7, 18, -41, 15, -87, -43),
|
||||||
|
chainCode = byteArrayOf(15, 61, -29, 22, 30, 45, -51, -60, 5, 62, -87, -35, 54, -97, -5, -44, -54, -107, -14, -119, -3, 92, 91, 75, -66, 26, 112, 83, 122, -25, -64, 40),
|
||||||
|
depth = 0,
|
||||||
|
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
||||||
|
childNumber = 0,
|
||||||
|
),
|
||||||
|
DerivationPath("m/44'/607'/1'/0/0") to ExtendedPublicKey( // TON (account 2)
|
||||||
|
publicKey = byteArrayOf(-51, 62, 97, 25, 83, 75, -79, 23, 6, -42, -94, 45, 91, -66, 57, -80, -75, -39, 19, -88, 95, -124, 50, 39, 114, -118, 27, -122, 48, -69, 7, -111),
|
||||||
|
chainCode = byteArrayOf(77, 63, 69, -114, -25, 105, -123, -42, -87, 107, 86, -43, 46, 92, -78, -107, -72, -81, -102, 45, 75, 97, -120, -10, 118, 27, -34, -50, -92, 3, 47, -126),
|
||||||
|
depth = 0,
|
||||||
|
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
||||||
|
childNumber = 0,
|
||||||
|
),
|
||||||
|
DerivationPath("m/44'/637'/0'/0'/0'") to ExtendedPublicKey( // Aptos (account 1)
|
||||||
|
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
|
||||||
|
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
|
||||||
|
depth = 0,
|
||||||
|
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
||||||
|
childNumber = 0,
|
||||||
|
),
|
||||||
|
DerivationPath("m/44'/637'/1'/0'/0'") to ExtendedPublicKey( // Aptos (account 2)
|
||||||
|
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
|
||||||
|
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
|
||||||
|
depth = 0,
|
||||||
|
parentFingerprint = byteArrayOf(0, 0, 0, 0),
|
||||||
|
childNumber = 0,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import com.tangem.common.services.secure.SecureStorage
|
||||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||||
import com.tangem.core.analytics.utils.TrackingContextProxy
|
import com.tangem.core.analytics.utils.TrackingContextProxy
|
||||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||||
|
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
||||||
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
|
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
|
||||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||||
import com.tangem.domain.hotwallet.repository.HotWalletRepository
|
import com.tangem.domain.hotwallet.repository.HotWalletRepository
|
||||||
|
|
@ -17,7 +18,6 @@ import com.tangem.domain.visa.model.VisaActivationRemoteState
|
||||||
import com.tangem.domain.visa.model.VisaCardActivationStatus
|
import com.tangem.domain.visa.model.VisaCardActivationStatus
|
||||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
||||||
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
|
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
|
||||||
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
|
|
||||||
import com.tangem.hot.sdk.TangemHotSdk
|
import com.tangem.hot.sdk.TangemHotSdk
|
||||||
import com.tangem.sdk.storage.AndroidSecureStorage
|
import com.tangem.sdk.storage.AndroidSecureStorage
|
||||||
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
||||||
|
|
@ -57,7 +57,7 @@ internal object UserWalletsListRepositoryModule {
|
||||||
trackingContextProxy: TrackingContextProxy,
|
trackingContextProxy: TrackingContextProxy,
|
||||||
analyticsEventHandler: AnalyticsEventHandler,
|
analyticsEventHandler: AnalyticsEventHandler,
|
||||||
hotWalletRepository: HotWalletRepository,
|
hotWalletRepository: HotWalletRepository,
|
||||||
mobileWalletPromoRepository: MobileWalletPromoRepository,
|
clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
|
||||||
userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
|
userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
|
||||||
): UserWalletsListRepository {
|
): UserWalletsListRepository {
|
||||||
val moshi = buildMoshi()
|
val moshi = buildMoshi()
|
||||||
|
|
@ -109,7 +109,7 @@ internal object UserWalletsListRepositoryModule {
|
||||||
trackingContextProxy = trackingContextProxy,
|
trackingContextProxy = trackingContextProxy,
|
||||||
analyticsEventHandler = analyticsEventHandler,
|
analyticsEventHandler = analyticsEventHandler,
|
||||||
hotWalletRepository = hotWalletRepository,
|
hotWalletRepository = hotWalletRepository,
|
||||||
mobileWalletPromoRepository = mobileWalletPromoRepository,
|
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
|
||||||
userWalletSelectedHandler = userWalletSelectedHandler,
|
userWalletSelectedHandler = userWalletSelectedHandler,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import com.tangem.core.analytics.utils.TrackingContextProxy
|
||||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||||
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
|
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
|
||||||
|
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
|
||||||
|
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
||||||
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
|
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
|
||||||
import com.tangem.domain.common.wallets.UserWalletTransformAction
|
import com.tangem.domain.common.wallets.UserWalletTransformAction
|
||||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||||
|
|
@ -26,7 +28,6 @@ import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
|
||||||
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
|
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
|
||||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
||||||
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
|
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
|
||||||
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
|
|
||||||
import com.tangem.hot.sdk.TangemHotSdk
|
import com.tangem.hot.sdk.TangemHotSdk
|
||||||
import com.tangem.hot.sdk.model.HotWalletId
|
import com.tangem.hot.sdk.model.HotWalletId
|
||||||
import com.tangem.sdk.api.TangemSdkManager
|
import com.tangem.sdk.api.TangemSdkManager
|
||||||
|
|
@ -60,7 +61,7 @@ internal class DefaultUserWalletsListRepository(
|
||||||
private val trackingContextProxy: TrackingContextProxy,
|
private val trackingContextProxy: TrackingContextProxy,
|
||||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||||
private val hotWalletRepository: HotWalletRepository,
|
private val hotWalletRepository: HotWalletRepository,
|
||||||
private val mobileWalletPromoRepository: MobileWalletPromoRepository,
|
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
|
||||||
private val userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
|
private val userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
|
||||||
) : UserWalletsListRepository {
|
) : UserWalletsListRepository {
|
||||||
|
|
||||||
|
|
@ -620,14 +621,13 @@ internal class DefaultUserWalletsListRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onFirstWalletCreated() {
|
private suspend fun onFirstWalletCreated() {
|
||||||
// reset flag (that is set from AF deeplink) after creating a new wallet
|
// reset the referral attribution (set from AF deeplink) after creating a new wallet
|
||||||
mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false)
|
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onAllWalletsDeleted() {
|
private suspend fun onAllWalletsDeleted() {
|
||||||
// reset flag (that is set from AF deeplink) after removing the last wallet
|
// reset the referral attribution (set from AF deeplink) after removing the last wallet
|
||||||
mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false)
|
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
|
||||||
// wipe the Usedesk support-chat clientId so a fresh UUID is generated for the next wallet
|
|
||||||
appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) }
|
appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,4 +166,5 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency?
|
||||||
Adi, AdiTestnet -> null
|
Adi, AdiTestnet -> null
|
||||||
SeiEvm, SeiEvmTestnet -> null
|
SeiEvm, SeiEvmTestnet -> null
|
||||||
Monad, MonadTestnet -> null
|
Monad, MonadTestnet -> null
|
||||||
|
Gonka -> null
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +43,6 @@ import com.tangem.domain.onboarding.repository.OnboardingRepository
|
||||||
import com.tangem.domain.settings.NeverRequestPermissionUseCase
|
import com.tangem.domain.settings.NeverRequestPermissionUseCase
|
||||||
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
|
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
|
||||||
import com.tangem.domain.settings.ShouldInitiallyAskPermissionUseCase
|
import com.tangem.domain.settings.ShouldInitiallyAskPermissionUseCase
|
||||||
import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase
|
|
||||||
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
|
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
|
||||||
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
|
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
|
||||||
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
|
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
|
||||||
|
|
@ -69,6 +68,8 @@ import com.tangem.wallet.R
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -102,7 +103,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
||||||
private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase,
|
private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase,
|
||||||
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase,
|
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase,
|
||||||
private val featureTogglesManager: FeatureTogglesManager,
|
private val featureTogglesManager: FeatureTogglesManager,
|
||||||
private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase,
|
|
||||||
) : RoutingComponent,
|
) : RoutingComponent,
|
||||||
AppComponentContext by context,
|
AppComponentContext by context,
|
||||||
SnackbarHandler {
|
SnackbarHandler {
|
||||||
|
|
@ -206,23 +206,8 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun navigateForEmptyWallets(): AppRoute {
|
private suspend fun navigateForEmptyWallets(): AppRoute {
|
||||||
val isHotWalletOnboardingEnabled = featureTogglesManager.isFeatureEnabled(
|
val afterEmptyRoute = resolveAppsFlyerOnboardingRoute()
|
||||||
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
|
?: AppRoute.Home(launchMode = launchMode)
|
||||||
)
|
|
||||||
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled")
|
|
||||||
val afterEmptyRoute: AppRoute = if (isHotWalletOnboardingEnabled) {
|
|
||||||
val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) {
|
|
||||||
appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
|
|
||||||
}
|
|
||||||
TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}")
|
|
||||||
if (tangemPayHotWalletOnboardingDeepLink != null) {
|
|
||||||
AppRoute.TangemPayHotWalletOnboarding
|
|
||||||
} else {
|
|
||||||
getDefaultRoute()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
getDefaultRoute()
|
|
||||||
}
|
|
||||||
|
|
||||||
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
|
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
|
||||||
?: return afterEmptyRoute
|
?: return afterEmptyRoute
|
||||||
|
|
@ -242,16 +227,41 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getDefaultRoute(): AppRoute {
|
private suspend fun resolveAppsFlyerOnboardingRoute(): AppRoute? = coroutineScope {
|
||||||
val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled(
|
val tangemPayRoute = async { resolveTangemPayHotWalletOnboardingRoute() }
|
||||||
|
val referralRoute = async { resolveReferralRoute() }
|
||||||
|
tangemPayRoute.await() ?: referralRoute.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveTangemPayHotWalletOnboardingRoute(): AppRoute? {
|
||||||
|
val isEnabled = featureTogglesManager.isFeatureEnabled(
|
||||||
|
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
|
||||||
|
)
|
||||||
|
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isEnabled")
|
||||||
|
if (!isEnabled) return null
|
||||||
|
|
||||||
|
val deepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
|
||||||
|
TangemLogger.i("[TangemPay][HWO] Deep link present=${deepLink != null}")
|
||||||
|
return if (deepLink != null) AppRoute.TangemPayHotWalletOnboarding else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveReferralRoute(): AppRoute? {
|
||||||
|
val isEnabled = featureTogglesManager.isFeatureEnabled(
|
||||||
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
|
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
|
||||||
)
|
)
|
||||||
// Referral users skip the Home stories screen and land directly on the
|
if (!isEnabled) return null
|
||||||
// mobile wallet creation flow.
|
|
||||||
return if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) {
|
val referralDeepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.Referral)
|
||||||
|
return if (referralDeepLink != null) {
|
||||||
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
|
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
|
||||||
} else {
|
} else {
|
||||||
AppRoute.Home(launchMode = launchMode)
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitAppsFlyerDeeplink(source: AppsFlyerDeeplinkSource): String? {
|
||||||
|
return withTimeoutOrNull(2.seconds) {
|
||||||
|
appsFlyerReferralParamsHandler.waitForDeeplink(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
|
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
|
||||||
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
||||||
import com.tangem.domain.wallets.models.AppsFlyerConversionData
|
import com.tangem.domain.wallets.models.AppsFlyerConversionData
|
||||||
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
|
|
||||||
import com.tangem.test.core.ProvideTestModels
|
import com.tangem.test.core.ProvideTestModels
|
||||||
import arrow.core.right
|
|
||||||
import com.tangem.test.core.TestAppCoroutineScope
|
import com.tangem.test.core.TestAppCoroutineScope
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
|
@ -28,13 +26,9 @@ import org.junit.jupiter.params.ParameterizedTest
|
||||||
class AppsFlyerReferralParamsHandlerTest {
|
class AppsFlyerReferralParamsHandlerTest {
|
||||||
|
|
||||||
private val appsFlyerStore: AppsFlyerStore = mockk(relaxUnitFun = true)
|
private val appsFlyerStore: AppsFlyerStore = mockk(relaxUnitFun = true)
|
||||||
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase = mockk {
|
|
||||||
coEvery { this@mockk.invoke(true) } returns Unit.right()
|
|
||||||
}
|
|
||||||
private val handler = AppsFlyerReferralParamsHandler(
|
private val handler = AppsFlyerReferralParamsHandler(
|
||||||
appsFlyerStore = appsFlyerStore,
|
appsFlyerStore = appsFlyerStore,
|
||||||
coroutineScope = TestAppCoroutineScope(),
|
coroutineScope = TestAppCoroutineScope(),
|
||||||
setShouldShowMobileWalletPromoUseCase = setShouldShowMobileWalletPromoUseCase,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
|
@ -175,7 +169,6 @@ class AppsFlyerReferralParamsHandlerTest {
|
||||||
private val localHandler = AppsFlyerReferralParamsHandler(
|
private val localHandler = AppsFlyerReferralParamsHandler(
|
||||||
appsFlyerStore = localStore,
|
appsFlyerStore = localStore,
|
||||||
coroutineScope = TestAppCoroutineScope(),
|
coroutineScope = TestAppCoroutineScope(),
|
||||||
setShouldShowMobileWalletPromoUseCase = mockk { coEvery { this@mockk.invoke(true) } returns Unit.right() },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -240,6 +233,93 @@ class AppsFlyerReferralParamsHandlerTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class WaitForReferralDeeplink {
|
||||||
|
|
||||||
|
private val localStore: AppsFlyerStore = mockk(relaxUnitFun = true)
|
||||||
|
private val localHandler = AppsFlyerReferralParamsHandler(
|
||||||
|
appsFlyerStore = localStore,
|
||||||
|
coroutineScope = TestAppCoroutineScope(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN cached referral deeplink WHEN waitForDeeplink THEN returns cached value`() = runTest {
|
||||||
|
// GIVEN
|
||||||
|
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns "referral"
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertThat(result).isEqualTo("referral")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no cache and referral deeplink WHEN handleDeeplink then waitForDeeplink THEN returns referral value`() =
|
||||||
|
runTest {
|
||||||
|
// GIVEN
|
||||||
|
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null
|
||||||
|
val deepLink = mockk<DeepLink> {
|
||||||
|
every { deepLinkValue } returns "referral"
|
||||||
|
every { getStringValue(any()) } returns SUCCESS_REFCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
localHandler.handleDeeplink(deepLink)
|
||||||
|
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertThat(result).isEqualTo("referral")
|
||||||
|
coVerify { localStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, "referral") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no cache and non-referral deeplink WHEN handleDeeplink then waitForDeeplink THEN returns null`() =
|
||||||
|
runTest {
|
||||||
|
// GIVEN
|
||||||
|
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null
|
||||||
|
val deepLink = mockk<DeepLink> {
|
||||||
|
every { deepLinkValue } returns "tpay_mobileonboard"
|
||||||
|
every { getStringValue(any()) } returns null
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
localHandler.handleDeeplink(deepLink)
|
||||||
|
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertThat(result).isNull()
|
||||||
|
coVerify(inverse = true) { localStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no cache WHEN handleNoDeeplink then waitForDeeplink THEN returns null`() = runTest {
|
||||||
|
// GIVEN
|
||||||
|
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
localHandler.handleNoDeeplink()
|
||||||
|
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertThat(result).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN deeplink stored during wait WHEN waitForDeeplink THEN returns stored value`() = runTest {
|
||||||
|
// GIVEN cache is empty on the initial read but populated (e.g. from conversion-data
|
||||||
|
// handling) by the time we re-check after awaiting the deferred
|
||||||
|
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null andThen "referral"
|
||||||
|
|
||||||
|
// WHEN the deferred resolves without a matching value (UDL reported no deep link)
|
||||||
|
localHandler.handleNoDeeplink()
|
||||||
|
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
|
||||||
|
|
||||||
|
// THEN the value persisted during the wait is preferred
|
||||||
|
assertThat(result).isEqualTo("referral")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private companion object Companion {
|
private companion object Companion {
|
||||||
const val SUCCESS_REFCODE = "valid_refcode"
|
const val SUCCESS_REFCODE = "valid_refcode"
|
||||||
const val SUCCESS_CAMPAIGN = "valid_campaign"
|
const val SUCCESS_CAMPAIGN = "valid_campaign"
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@ import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||||
import com.tangem.core.analytics.utils.TrackingContextProxy
|
import com.tangem.core.analytics.utils.TrackingContextProxy
|
||||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||||
|
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
||||||
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
|
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
|
||||||
import com.tangem.domain.hotwallet.repository.HotWalletRepository
|
import com.tangem.domain.hotwallet.repository.HotWalletRepository
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
||||||
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
|
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
|
||||||
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
|
|
||||||
import com.tangem.hot.sdk.TangemHotSdk
|
import com.tangem.hot.sdk.TangemHotSdk
|
||||||
import com.tangem.sdk.api.TangemSdkManager
|
import com.tangem.sdk.api.TangemSdkManager
|
||||||
import com.tangem.utils.Provider
|
import com.tangem.utils.Provider
|
||||||
|
|
@ -45,7 +45,7 @@ internal class DefaultUserWalletsListRepositoryTest {
|
||||||
private val trackingContextProxy: TrackingContextProxy = mockk(relaxed = true)
|
private val trackingContextProxy: TrackingContextProxy = mockk(relaxed = true)
|
||||||
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||||
private val hotWalletRepository: HotWalletRepository = mockk(relaxed = true)
|
private val hotWalletRepository: HotWalletRepository = mockk(relaxed = true)
|
||||||
private val mobileWalletPromoRepository: MobileWalletPromoRepository = mockk(relaxed = true)
|
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk(relaxed = true)
|
||||||
private val userWalletSelectedHandler: UserWalletSelectedHandler = mockk(relaxed = true)
|
private val userWalletSelectedHandler: UserWalletSelectedHandler = mockk(relaxed = true)
|
||||||
|
|
||||||
private val walletA = MockUserWalletFactory.create().copy(walletId = UserWalletId("0011"), name = "Wallet A")
|
private val walletA = MockUserWalletFactory.create().copy(walletId = UserWalletId("0011"), name = "Wallet A")
|
||||||
|
|
@ -61,7 +61,7 @@ internal class DefaultUserWalletsListRepositoryTest {
|
||||||
selectedUserWalletRepository,
|
selectedUserWalletRepository,
|
||||||
userWalletEncryptionKeysRepository,
|
userWalletEncryptionKeysRepository,
|
||||||
trackingContextProxy,
|
trackingContextProxy,
|
||||||
mobileWalletPromoRepository,
|
clearAppsFlyerDeeplinkUseCase,
|
||||||
userWalletSelectedHandler,
|
userWalletSelectedHandler,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -82,7 +82,7 @@ internal class DefaultUserWalletsListRepositoryTest {
|
||||||
trackingContextProxy = trackingContextProxy,
|
trackingContextProxy = trackingContextProxy,
|
||||||
analyticsEventHandler = analyticsEventHandler,
|
analyticsEventHandler = analyticsEventHandler,
|
||||||
hotWalletRepository = hotWalletRepository,
|
hotWalletRepository = hotWalletRepository,
|
||||||
mobileWalletPromoRepository = mobileWalletPromoRepository,
|
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
|
||||||
userWalletSelectedHandler = Lazy { userWalletSelectedHandler },
|
userWalletSelectedHandler = Lazy { userWalletSelectedHandler },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,8 @@ private fun iconSetOf(blockchain: Blockchain): IconSet? = when (blockchain) {
|
||||||
-> IconSet(active = R.drawable.img_flare_22, greyedOut = R.drawable.ic_flare_22)
|
-> IconSet(active = R.drawable.img_flare_22, greyedOut = R.drawable.ic_flare_22)
|
||||||
Blockchain.Gnosis,
|
Blockchain.Gnosis,
|
||||||
-> IconSet(active = R.drawable.img_gnosis_22, greyedOut = R.drawable.ic_gnosis_22)
|
-> IconSet(active = R.drawable.img_gnosis_22, greyedOut = R.drawable.ic_gnosis_22)
|
||||||
|
Blockchain.Gonka,
|
||||||
|
-> IconSet(active = R.drawable.img_gonka_22, greyedOut = R.drawable.ic_gonka_22)
|
||||||
Blockchain.Hedera,
|
Blockchain.Hedera,
|
||||||
Blockchain.HederaTestnet,
|
Blockchain.HederaTestnet,
|
||||||
-> IconSet(active = R.drawable.img_hedera_22, greyedOut = R.drawable.ic_hedera_22)
|
-> IconSet(active = R.drawable.img_hedera_22, greyedOut = R.drawable.ic_hedera_22)
|
||||||
|
|
|
||||||
|
|
@ -179,11 +179,15 @@ sealed class NotificationUM(val config: NotificationConfig) {
|
||||||
subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text),
|
subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class NetworkAccountNotFunded(val coinName: String) : Error(
|
data class NetworkAccountNotFunded(
|
||||||
|
val coinName: String,
|
||||||
|
val reserveAmount: String,
|
||||||
|
val reserveSymbol: String,
|
||||||
|
) : Error(
|
||||||
title = resourceReference(R.string.alert_failed_to_send_transaction_title),
|
title = resourceReference(R.string.alert_failed_to_send_transaction_title),
|
||||||
subtitle = resourceReference(
|
subtitle = resourceReference(
|
||||||
id = R.string.no_account_generic,
|
id = R.string.no_account_generic,
|
||||||
formatArgs = wrappedList(coinName),
|
formatArgs = wrappedList(coinName, reserveAmount, reserveSymbol),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -191,6 +195,11 @@ sealed class NotificationUM(val config: NotificationConfig) {
|
||||||
title = resourceReference(id = R.string.send_validation_destination_tag_required_title),
|
title = resourceReference(id = R.string.send_validation_destination_tag_required_title),
|
||||||
subtitle = resourceReference(id = R.string.send_validation_destination_tag_required_description),
|
subtitle = resourceReference(id = R.string.send_validation_destination_tag_required_description),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data object RequiredTrustline : Error(
|
||||||
|
title = resourceReference(id = R.string.common_error),
|
||||||
|
subtitle = resourceReference(id = R.string.no_trustline_xlm_asset),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
open class Warning(
|
open class Warning(
|
||||||
|
|
|
||||||
|
|
@ -120,40 +120,66 @@ object NotificationsFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
fun MutableList<NotificationUM>.addReserveAmountErrorNotification(
|
fun MutableList<NotificationUM>.addReserveAmountErrorNotification(
|
||||||
reserveAmount: BigDecimal?,
|
reserveAmount: BigDecimal?,
|
||||||
sendingAmount: BigDecimal,
|
sendingAmount: BigDecimal,
|
||||||
cryptoCurrency: CryptoCurrency,
|
cryptoCurrency: CryptoCurrency,
|
||||||
feeCryptoCurrency: CryptoCurrency?,
|
feeCryptoCurrency: CryptoCurrency?,
|
||||||
isAccountFunded: Boolean,
|
isAccountFunded: Boolean,
|
||||||
|
hasRequiredTrustline: Boolean,
|
||||||
) {
|
) {
|
||||||
val sendingCoinAmount = when (cryptoCurrency) {
|
val sendingCoinAmount = when (cryptoCurrency) {
|
||||||
is CryptoCurrency.Coin -> sendingAmount
|
is CryptoCurrency.Coin -> sendingAmount
|
||||||
is CryptoCurrency.Token -> BigDecimal.ZERO
|
is CryptoCurrency.Token -> BigDecimal.ZERO
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feeCryptoCurrency == null && cryptoCurrency is CryptoCurrency.Token) {
|
when {
|
||||||
// No need to show reserve amount warning if fee currency is unknown for token transfer
|
// No need to show reserve amount warning if fee currency is unknown for token transfer
|
||||||
return
|
feeCryptoCurrency == null && cryptoCurrency is CryptoCurrency.Token -> Unit
|
||||||
} else if (!isAccountFunded && reserveAmount != null && reserveAmount > sendingCoinAmount) {
|
|
||||||
// account not funded, sending coin amount < reserve (send coin with less amount OR send any token)
|
// account not funded, sending coin amount < reserve (send coin with less amount OR send any token)
|
||||||
|
!isAccountFunded && reserveAmount != null && reserveAmount > sendingCoinAmount ->
|
||||||
|
addAccountNotFundedNotification(
|
||||||
|
reserveAmount = reserveAmount,
|
||||||
|
cryptoCurrency = cryptoCurrency,
|
||||||
|
feeCryptoCurrency = feeCryptoCurrency,
|
||||||
|
)
|
||||||
|
hasRequiredTrustline -> addTrustlineRequiredNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (cryptoCurrency is CryptoCurrency.Coin) {
|
private fun MutableList<NotificationUM>.addAccountNotFundedNotification(
|
||||||
// Try to send coin amount less than reserve amount (e.g. less than 1 XLM in Stellar)
|
reserveAmount: BigDecimal,
|
||||||
|
cryptoCurrency: CryptoCurrency,
|
||||||
|
feeCryptoCurrency: CryptoCurrency?,
|
||||||
|
) {
|
||||||
|
when (cryptoCurrency) {
|
||||||
|
// Try to send coin amount less than reserve amount (e.g. less than 1 XLM in Stellar)
|
||||||
|
is CryptoCurrency.Coin -> add(
|
||||||
|
NotificationUM.Error.ReserveAmount(
|
||||||
|
reserveAmount.format {
|
||||||
|
crypto(feeCryptoCurrency ?: cryptoCurrency)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Try to send any token (e.g. USDC in Stellar, but account not funded -> user must send XLM at first)
|
||||||
|
is CryptoCurrency.Token -> {
|
||||||
|
checkNotNull(feeCryptoCurrency)
|
||||||
add(
|
add(
|
||||||
NotificationUM.Error.ReserveAmount(
|
NotificationUM.Error.NetworkAccountNotFunded(
|
||||||
reserveAmount.format {
|
coinName = feeCryptoCurrency.name,
|
||||||
crypto(feeCryptoCurrency ?: cryptoCurrency)
|
reserveAmount = reserveAmount.format {
|
||||||
|
crypto(symbol = "", decimals = feeCryptoCurrency.decimals)
|
||||||
},
|
},
|
||||||
|
reserveSymbol = feeCryptoCurrency.symbol,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
checkNotNull(feeCryptoCurrency)
|
|
||||||
// Try to send any token (e.g. USDC in Stellar, but account not funded -> user must send XLM at first)
|
|
||||||
add(NotificationUM.Error.NetworkAccountNotFunded(coinName = feeCryptoCurrency.name))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO: check the RECEIVER account trustline before sending
|
}
|
||||||
|
|
||||||
|
private fun MutableList<NotificationUM>.addTrustlineRequiredNotification() {
|
||||||
|
add(NotificationUM.Error.RequiredTrustline)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MutableList<NotificationUM>.addMinimumAmountErrorNotification(
|
fun MutableList<NotificationUM>.addMinimumAmountErrorNotification(
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ internal class BlockchainIconsTest {
|
||||||
Blockchain.Filecoin -> R.drawable.img_filecoin_22
|
Blockchain.Filecoin -> R.drawable.img_filecoin_22
|
||||||
Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.img_flare_22
|
Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.img_flare_22
|
||||||
Blockchain.Gnosis -> R.drawable.img_gnosis_22
|
Blockchain.Gnosis -> R.drawable.img_gnosis_22
|
||||||
|
Blockchain.Gonka -> R.drawable.img_gonka_22
|
||||||
Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.img_hedera_22
|
Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.img_hedera_22
|
||||||
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.img_hyperliquid_22
|
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.img_hyperliquid_22
|
||||||
Blockchain.InternetComputer -> R.drawable.img_icp_22
|
Blockchain.InternetComputer -> R.drawable.img_icp_22
|
||||||
|
|
@ -198,6 +199,7 @@ internal class BlockchainIconsTest {
|
||||||
Blockchain.Filecoin -> R.drawable.ic_filecoin_22
|
Blockchain.Filecoin -> R.drawable.ic_filecoin_22
|
||||||
Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.ic_flare_22
|
Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.ic_flare_22
|
||||||
Blockchain.Gnosis -> R.drawable.ic_gnosis_22
|
Blockchain.Gnosis -> R.drawable.ic_gnosis_22
|
||||||
|
Blockchain.Gonka -> R.drawable.ic_gonka_22
|
||||||
Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.ic_hedera_22
|
Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.ic_hedera_22
|
||||||
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.ic_hyperliquid_22
|
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.ic_hyperliquid_22
|
||||||
Blockchain.InternetComputer -> R.drawable.ic_icp_22
|
Blockchain.InternetComputer -> R.drawable.ic_icp_22
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.tangem.datasource.api.ethpool
|
package com.tangem.datasource.api.ethpool
|
||||||
|
|
||||||
import com.tangem.datasource.api.common.response.ApiResponse
|
import com.tangem.datasource.api.common.response.ApiResponse
|
||||||
|
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolAccountsListRequest
|
||||||
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
|
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
|
||||||
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest
|
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest
|
||||||
import com.tangem.datasource.api.ethpool.models.response.*
|
import com.tangem.datasource.api.ethpool.models.response.*
|
||||||
|
|
@ -94,4 +95,20 @@ interface P2PEthPoolApi {
|
||||||
@Path("delegatorAddress") delegatorAddress: String,
|
@Path("delegatorAddress") delegatorAddress: String,
|
||||||
@Path("vaultAddress") vaultAddress: String,
|
@Path("vaultAddress") vaultAddress: String,
|
||||||
): ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountResponse>>
|
): ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountResponse>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account summaries for multiple delegators in a vault (batch).
|
||||||
|
*
|
||||||
|
* Designed to be called once per client to avoid rate-limit bursts.
|
||||||
|
*
|
||||||
|
* @param network Ethereum pool network: "mainnet" or "hoodi"
|
||||||
|
* @param vaultAddress Ethereum address of the vault
|
||||||
|
* @param body Delegator addresses to fetch (up to 255)
|
||||||
|
*/
|
||||||
|
@POST("api/v1/staking/pool/{network}/vaults/{vaultAddress}/accounts/list")
|
||||||
|
suspend fun getAccountsList(
|
||||||
|
@Path("network") network: String,
|
||||||
|
@Path("vaultAddress") vaultAddress: String,
|
||||||
|
@Body body: P2PEthPoolAccountsListRequest,
|
||||||
|
): ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountsListResponse>>
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.tangem.datasource.api.ethpool.models.request
|
||||||
|
|
||||||
|
import com.squareup.moshi.Json
|
||||||
|
import com.squareup.moshi.JsonClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for POST /api/v1/staking/pool/{network}/vaults/{vaultAddress}/accounts/list
|
||||||
|
*
|
||||||
|
* Batch fetch of staking balances for multiple delegator addresses within a single vault.
|
||||||
|
* Limit: up to 255 addresses per request. Addresses are deduplicated server-side.
|
||||||
|
*/
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class P2PEthPoolAccountsListRequest(
|
||||||
|
@Json(name = "delegatorAddresses")
|
||||||
|
val delegatorAddresses: List<String>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.tangem.datasource.api.ethpool.models.response
|
||||||
|
|
||||||
|
import com.squareup.moshi.Json
|
||||||
|
import com.squareup.moshi.JsonClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for POST /api/v1/staking/pool/{network}/vaults/{vaultAddress}/accounts/list
|
||||||
|
*
|
||||||
|
* Each item is keyed by delegatorAddress and carries either a non-null [account]
|
||||||
|
* or a per-address [error] (e.g. code 127108 — invalid delegator address).
|
||||||
|
*/
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class P2PEthPoolAccountsListResponse(
|
||||||
|
@Json(name = "list")
|
||||||
|
val list: List<P2PEthPoolAccountListItem>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class P2PEthPoolAccountListItem(
|
||||||
|
@Json(name = "delegatorAddress")
|
||||||
|
val delegatorAddress: String,
|
||||||
|
@Json(name = "account")
|
||||||
|
val account: P2PEthPoolAccountResponse?,
|
||||||
|
@Json(name = "error")
|
||||||
|
val error: P2PEthPoolErrorDetailsDTO?,
|
||||||
|
)
|
||||||
|
|
@ -23,7 +23,7 @@ data class P2PEthPoolErrorDetailsDTO(
|
||||||
@Json(name = "message")
|
@Json(name = "message")
|
||||||
val message: String, // Human-readable error message
|
val message: String, // Human-readable error message
|
||||||
@Json(name = "name")
|
@Json(name = "name")
|
||||||
val name: String, // Error name/type
|
val name: String?, // Error name/type
|
||||||
@Json(name = "errors")
|
@Json(name = "errors")
|
||||||
val errors: List<String>? = null, // Optional validation errors array
|
val errors: List<String>? = null, // Optional validation errors array
|
||||||
)
|
)
|
||||||
|
|
@ -23,9 +23,11 @@ interface AppsFlyerStore {
|
||||||
|
|
||||||
enum class AppsFlyerDeeplinkSource {
|
enum class AppsFlyerDeeplinkSource {
|
||||||
TangemPayHotWalletOnboarding,
|
TangemPayHotWalletOnboarding,
|
||||||
|
Referral,
|
||||||
;
|
;
|
||||||
|
|
||||||
fun toStoreKey() = when (this) {
|
fun toStoreKey() = when (this) {
|
||||||
TangemPayHotWalletOnboarding -> "tangem_pay_hot_wallet_onboarding"
|
TangemPayHotWalletOnboarding -> "tangem_pay_hot_wallet_onboarding"
|
||||||
|
Referral -> "referral"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package com.tangem.datasource.api.ethpool
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.squareup.moshi.Types
|
||||||
|
import com.tangem.datasource.api.common.MoshiConverter
|
||||||
|
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountsListResponse
|
||||||
|
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
internal class P2PEthPoolAccountsListResponseTest {
|
||||||
|
|
||||||
|
private val adapter = MoshiConverter.networkMoshi.adapter<P2PEthPoolResponse<P2PEthPoolAccountsListResponse>>(
|
||||||
|
Types.newParameterizedType(
|
||||||
|
P2PEthPoolResponse::class.java,
|
||||||
|
P2PEthPoolAccountsListResponse::class.java,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decode batch payload with valid account and per-address error`() {
|
||||||
|
val response = requireNotNull(adapter.fromJson(SAMPLE_JSON))
|
||||||
|
|
||||||
|
val list = requireNotNull(response.result).list
|
||||||
|
assertThat(list).hasSize(2)
|
||||||
|
|
||||||
|
val good = list.first { it.account != null }
|
||||||
|
val account = requireNotNull(good.account)
|
||||||
|
assertThat(account.stake.assets.compareTo(BigDecimal("1.2345"))).isEqualTo(0)
|
||||||
|
assertThat(account.availableToWithdraw).isGreaterThan(BigDecimal(15049))
|
||||||
|
assertThat(account.exitQueue.requests).isEmpty()
|
||||||
|
|
||||||
|
val bad = list.first { it.account == null }
|
||||||
|
assertThat(requireNotNull(bad.error).code).isEqualTo(127108)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private val SAMPLE_JSON = """
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"result": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"delegatorAddress": "0x008d3cd3e349Cd3D5F7c287b3BaF9e4f3E4ba99b",
|
||||||
|
"account": {
|
||||||
|
"delegatorAddress": "0x008d3cd3e349Cd3D5F7c287b3BaF9e4f3E4ba99b",
|
||||||
|
"vaultAddress": "0x4c09BC47db288F998b33CD63BCc1b6ddCCe13F33",
|
||||||
|
"stake": { "assets": "1.234500000000000000", "totalEarnedAssets": 0.0191 },
|
||||||
|
"availableToUnstake": "0.000000000000000005",
|
||||||
|
"availableToWithdraw": 15049.547647281135,
|
||||||
|
"exitQueue": { "total": 0, "requests": [] }
|
||||||
|
},
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"delegatorAddress": "0xBADADDRESS",
|
||||||
|
"account": null,
|
||||||
|
"error": {
|
||||||
|
"code": 127108,
|
||||||
|
"message": "The provided delegator address is invalid or not properly formatted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -83,12 +83,16 @@ fun TangemMessage(
|
||||||
trailingContent = if (isIconLeading) null else icon,
|
trailingContent = if (isIconLeading) null else icon,
|
||||||
contentColor = contentColor,
|
contentColor = contentColor,
|
||||||
onCloseClick = messageUM.onCloseClick,
|
onCloseClick = messageUM.onCloseClick,
|
||||||
buttons = {
|
buttons = if (messageUM.buttonsUM.isEmpty()) {
|
||||||
messageUM.buttonsUM.fastForEach { buttonUM ->
|
null
|
||||||
TangemButton(
|
} else {
|
||||||
buttonUM = buttonUM.tangemButtonUM,
|
{
|
||||||
modifier = Modifier.weight(1f),
|
messageUM.buttonsUM.fastForEach { buttonUM ->
|
||||||
)
|
TangemButton(
|
||||||
|
buttonUM = buttonUM.tangemButtonUM,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,20 @@ import com.tangem.core.ui.extensions.*
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||||
import com.tangem.core.ui.test.TokenElementsTestTags
|
import com.tangem.core.ui.test.TokenElementsTestTags
|
||||||
import org.burnoutcrew.reorderable.ReorderableLazyListState
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI model for header row component
|
* UI model for header row component
|
||||||
*
|
*
|
||||||
* @param headerRowUM UI model for the header row
|
* @param headerRowUM UI model for the header row
|
||||||
* @param modifier Modifier for the composable
|
* @param modifier Modifier for the composable
|
||||||
|
* @param dragHandleModifier Modifier applied to the drag handle in the row's tail (e.g. a reorderable
|
||||||
|
* drag-handle modifier). Defaults to [Modifier] for non-reorderable rows.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TangemHeaderRow(
|
fun TangemHeaderRow(
|
||||||
headerRowUM: TangemHeaderRowUM,
|
headerRowUM: TangemHeaderRowUM,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
reorderableState: ReorderableLazyListState? = null,
|
dragHandleModifier: Modifier = Modifier,
|
||||||
isBalanceHidden: Boolean = false,
|
isBalanceHidden: Boolean = false,
|
||||||
) {
|
) {
|
||||||
TangemHeaderRow(
|
TangemHeaderRow(
|
||||||
|
|
@ -48,7 +49,7 @@ fun TangemHeaderRow(
|
||||||
title = headerRowUM.title,
|
title = headerRowUM.title,
|
||||||
subtitle = headerRowUM.subtitle,
|
subtitle = headerRowUM.subtitle,
|
||||||
isBalanceHidden = isBalanceHidden,
|
isBalanceHidden = isBalanceHidden,
|
||||||
reorderableState = reorderableState,
|
dragHandleModifier = dragHandleModifier,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +130,7 @@ fun TangemHeaderRow(
|
||||||
subtitle: TextReference? = null,
|
subtitle: TextReference? = null,
|
||||||
headTangemIconUM: TangemIconUM? = null,
|
headTangemIconUM: TangemIconUM? = null,
|
||||||
tailUM: TangemRowTailUM = TangemRowTailUM.Empty,
|
tailUM: TangemRowTailUM = TangemRowTailUM.Empty,
|
||||||
reorderableState: ReorderableLazyListState? = null,
|
dragHandleModifier: Modifier = Modifier,
|
||||||
isEnabled: Boolean = false,
|
isEnabled: Boolean = false,
|
||||||
onItemClick: (() -> Unit)? = null,
|
onItemClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
|
@ -181,7 +182,7 @@ fun TangemHeaderRow(
|
||||||
SpacerWMax()
|
SpacerWMax()
|
||||||
TangemRowTail(
|
TangemRowTail(
|
||||||
tangemRowTailUM = tailUM,
|
tangemRowTailUM = tailUM,
|
||||||
reorderableState = reorderableState,
|
dragHandleModifier = dragHandleModifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,12 @@ import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.core.ui.extensions.resolveAnnotatedReference
|
import com.tangem.core.ui.extensions.resolveAnnotatedReference
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.test.OrganizeTokensScreenTestTags
|
import com.tangem.core.ui.test.OrganizeTokensScreenTestTags
|
||||||
import org.burnoutcrew.reorderable.ReorderableLazyListState
|
|
||||||
import org.burnoutcrew.reorderable.detectReorder
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TangemRowTail(
|
fun TangemRowTail(
|
||||||
tangemRowTailUM: TangemRowTailUM,
|
tangemRowTailUM: TangemRowTailUM,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
reorderableState: ReorderableLazyListState? = null,
|
dragHandleModifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = tangemRowTailUM,
|
targetState = tangemRowTailUM,
|
||||||
|
|
@ -39,7 +37,7 @@ fun TangemRowTail(
|
||||||
TangemRowTailUM.Empty -> Unit
|
TangemRowTailUM.Empty -> Unit
|
||||||
is TangemRowTailUM.Draggable -> DraggableImage(
|
is TangemRowTailUM.Draggable -> DraggableImage(
|
||||||
iconRes = animatedState.iconRes,
|
iconRes = animatedState.iconRes,
|
||||||
reorderableState = reorderableState,
|
dragHandleModifier = dragHandleModifier,
|
||||||
modifier = innerModifier,
|
modifier = innerModifier,
|
||||||
)
|
)
|
||||||
is TangemRowTailUM.Text -> ContentText(text = animatedState.text, modifier = innerModifier)
|
is TangemRowTailUM.Text -> ContentText(text = animatedState.text, modifier = innerModifier)
|
||||||
|
|
@ -54,21 +52,11 @@ fun TangemRowTail(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DraggableImage(
|
private fun DraggableImage(@DrawableRes iconRes: Int, dragHandleModifier: Modifier, modifier: Modifier = Modifier) {
|
||||||
@DrawableRes iconRes: Int,
|
|
||||||
reorderableState: ReorderableLazyListState?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(size = TangemTheme.dimens2.x6)
|
.size(size = TangemTheme.dimens2.x6)
|
||||||
.then(
|
.then(dragHandleModifier)
|
||||||
other = if (reorderableState != null) {
|
|
||||||
Modifier.detectReorder(reorderableState)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.testTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE),
|
.testTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -21,24 +21,24 @@ import com.tangem.core.ui.extensions.clickableSingle
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||||
import com.tangem.core.ui.test.TokenElementsTestTags
|
import com.tangem.core.ui.test.TokenElementsTestTags
|
||||||
import org.burnoutcrew.reorderable.ReorderableLazyListState
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable function that represents a Tangem token row in a list.
|
* Composable function that represents a Tangem token row in a list.
|
||||||
*
|
*
|
||||||
* [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4)
|
* [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4)
|
||||||
*
|
*
|
||||||
* @param tokenRowUM The user model containing the data for the token row.
|
* @param tokenRowUM The user model containing the data for the token row.
|
||||||
* @param isBalanceHidden A boolean indicating whether the balance should be hidden.
|
* @param isBalanceHidden A boolean indicating whether the balance should be hidden.
|
||||||
* @param reorderableState The state of the reorderable lazy list, if applicable.
|
* @param dragHandleModifier Modifier applied to the drag handle in the row's tail (e.g. a reorderable
|
||||||
* @param modifier The modifier to be applied to the row.
|
* drag-handle modifier). Defaults to [Modifier] for non-reorderable rows.
|
||||||
|
* @param modifier The modifier to be applied to the row.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TangemTokenRow(
|
fun TangemTokenRow(
|
||||||
tokenRowUM: TangemTokenRowUM,
|
tokenRowUM: TangemTokenRowUM,
|
||||||
isBalanceHidden: Boolean,
|
isBalanceHidden: Boolean,
|
||||||
reorderableState: ReorderableLazyListState?,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
dragHandleModifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
TangemRowContainer(
|
TangemRowContainer(
|
||||||
content = {
|
content = {
|
||||||
|
|
@ -91,7 +91,7 @@ fun TangemTokenRow(
|
||||||
|
|
||||||
TangemRowTail(
|
TangemRowTail(
|
||||||
tangemRowTailUM = tokenRowUM.tailUM,
|
tangemRowTailUM = tokenRowUM.tailUM,
|
||||||
reorderableState = reorderableState,
|
dragHandleModifier = dragHandleModifier,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutId(layoutId = TangemRowLayoutId.TAIL)
|
.layoutId(layoutId = TangemRowLayoutId.TAIL)
|
||||||
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
|
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
|
||||||
|
|
@ -116,18 +116,19 @@ fun TangemTokenRow(
|
||||||
* [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4)
|
* [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4)
|
||||||
*
|
*
|
||||||
* @param tokenRowUM The user model containing the data for the token row.
|
* @param tokenRowUM The user model containing the data for the token row.
|
||||||
* @param headComponent The composable function representing the head component.
|
* @param headComponent The composable function representing the head component.
|
||||||
* @param titleComponent The composable function representing the title component.
|
* @param titleComponent The composable function representing the title component.
|
||||||
* @param isBalanceHidden A boolean indicating whether the balance should be hidden.
|
* @param isBalanceHidden A boolean indicating whether the balance should be hidden.
|
||||||
* @param reorderableState The state of the reorderable lazy list, if applicable.
|
* @param dragHandleModifier Modifier applied to the drag handle in the row's tail (e.g. a reorderable
|
||||||
* @param modifier The modifier to be applied to the row.
|
* drag-handle modifier). Defaults to [Modifier] for non-reorderable rows.
|
||||||
|
* @param modifier The modifier to be applied to the row.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TangemTokenRow(
|
fun TangemTokenRow(
|
||||||
tokenRowUM: TangemTokenRowUM,
|
tokenRowUM: TangemTokenRowUM,
|
||||||
isBalanceHidden: Boolean,
|
isBalanceHidden: Boolean,
|
||||||
reorderableState: ReorderableLazyListState?,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
dragHandleModifier: Modifier = Modifier,
|
||||||
headComponent: @Composable (Modifier) -> Unit,
|
headComponent: @Composable (Modifier) -> Unit,
|
||||||
titleComponent: @Composable (Modifier) -> Unit,
|
titleComponent: @Composable (Modifier) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -190,7 +191,7 @@ fun TangemTokenRow(
|
||||||
|
|
||||||
TangemRowTail(
|
TangemRowTail(
|
||||||
tangemRowTailUM = tokenRowUM.tailUM,
|
tangemRowTailUM = tokenRowUM.tailUM,
|
||||||
reorderableState = reorderableState,
|
dragHandleModifier = dragHandleModifier,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutId(layoutId = TangemRowLayoutId.TAIL)
|
.layoutId(layoutId = TangemRowLayoutId.TAIL)
|
||||||
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
|
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
|
||||||
|
|
@ -210,7 +211,6 @@ private fun TangemTokenRow_Preview(
|
||||||
TangemTokenRow(
|
TangemTokenRow(
|
||||||
tokenRowUM = tokenRowUM,
|
tokenRowUM = tokenRowUM,
|
||||||
isBalanceHidden = false,
|
isBalanceHidden = false,
|
||||||
reorderableState = null,
|
|
||||||
modifier = Modifier.background(TangemTheme.colors2.surface.level1),
|
modifier = Modifier.background(TangemTheme.colors2.surface.level1),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.drop
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
|
private const val LIST_FLING_DAMPING = 0.1f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A scroll behavior for a collapsing top app bar that collapses when scrolling up and expands when scrolling down.
|
* A scroll behavior for a collapsing top app bar that collapses when scrolling up and expands when scrolling down.
|
||||||
* When the user stops scrolling, the app bar will settle to either fully collapsed or fully expanded state
|
* When the user stops scrolling, the app bar will settle to either fully collapsed or fully expanded state
|
||||||
|
|
@ -176,7 +178,8 @@ private fun exitUntilCollapsedScrollBehavior(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Velocity(0f, available.y - remainingVelocity)
|
val passedVelocity = remainingVelocity * LIST_FLING_DAMPING
|
||||||
|
return Velocity(0f, available.y - passedVelocity)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.NonRestartableComposable
|
import androidx.compose.runtime.NonRestartableComposable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||||
import com.tangem.core.ui.res.generated.icons.Icons
|
import com.tangem.core.ui.res.generated.icons.Icons
|
||||||
import com.tangem.core.ui.res.generated.icons.ic_arrow_left_20
|
import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20
|
||||||
import com.tangem.core.ui.res.generated.icons.ic_cross_20
|
import com.tangem.core.ui.res.generated.icons.ic_cross_20
|
||||||
import com.tangem.core.ui.test.TopNavigationTestTags
|
import com.tangem.core.ui.test.TopNavigationTestTags
|
||||||
|
|
||||||
|
|
@ -16,7 +17,8 @@ fun TangemButton.Back(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
TangemButton(
|
TangemButton(
|
||||||
modifier = modifier.testTag(TopNavigationTestTags.BACK_BUTTON),
|
modifier = modifier.testTag(TopNavigationTestTags.BACK_BUTTON),
|
||||||
variant = TangemButton.Variant.Material,
|
variant = TangemButton.Variant.Material,
|
||||||
iconStart = TangemIconUM.Icon(Icons.ic_arrow_left_20),
|
size = TangemButton.Size.X11,
|
||||||
|
iconStart = TangemIconUM.Icon(Icons.ic_chevron_left_20),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -27,7 +29,26 @@ fun TangemButton.Close(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
TangemButton(
|
TangemButton(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
variant = TangemButton.Variant.Material,
|
variant = TangemButton.Variant.Material,
|
||||||
|
size = TangemButton.Size.X11,
|
||||||
iconStart = TangemIconUM.Icon(Icons.ic_cross_20),
|
iconStart = TangemIconUM.Icon(Icons.ic_cross_20),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@NonRestartableComposable
|
||||||
|
fun TangemButton.GroupEntry(iconUM: TangemIconUM, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
TangemButton(
|
||||||
|
modifier = modifier,
|
||||||
|
variant = TangemButton.Variant.Ghost,
|
||||||
|
size = TangemButton.Size.X9,
|
||||||
|
iconStart = iconUM,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@NonRestartableComposable
|
||||||
|
fun TangemButton.GroupEntry(imageVector: ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
GroupEntry(iconUM = TangemIconUM.Icon(imageVector), modifier = modifier, onClick = onClick)
|
||||||
}
|
}
|
||||||
|
|
@ -1,27 +1,13 @@
|
||||||
package com.tangem.core.ui.ds2.button
|
package com.tangem.core.ui.ds2.button
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.EnterTransition
|
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.expandHorizontally
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.foundation.text.TextAutoSize
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkHorizontally
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
|
@ -38,11 +24,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import com.tangem.core.ui.ds.image.TangemIcon
|
import com.tangem.core.ui.ds.image.TangemIcon
|
||||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||||
import com.tangem.core.ui.ds2.loader.TangemLoader
|
import com.tangem.core.ui.ds2.loader.TangemLoader
|
||||||
import com.tangem.core.ui.extensions.ColorReference2
|
import com.tangem.core.ui.extensions.*
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
|
||||||
import com.tangem.core.ui.extensions.conditionalCompose
|
|
||||||
import com.tangem.core.ui.extensions.rememberLastNonNull
|
|
||||||
import com.tangem.core.ui.extensions.resolveReference
|
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -168,6 +150,10 @@ private fun ContentRow(
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
autoSize = TextAutoSize.StepBased(
|
||||||
|
minFontSize = TangemTheme.typography3.caption.medium.fontSize,
|
||||||
|
maxFontSize = TangemTheme.typography3.body.medium.fontSize,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,11 @@ private fun SearchField(state: TangemSearch.State, focusRequester: FocusRequeste
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
imageVector = Icons.ic_search_20,
|
imageVector = Icons.ic_search_20,
|
||||||
tint = TangemTheme.colors3.icon.primary,
|
tint = if (state.isActive) {
|
||||||
|
TangemTheme.colors3.icon.secondary
|
||||||
|
} else {
|
||||||
|
TangemTheme.colors3.icon.primary
|
||||||
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
QueryTextField(state = state, focusRequester = focusRequester)
|
QueryTextField(state = state, focusRequester = focusRequester)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
|
@ -14,7 +15,7 @@ import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -24,25 +25,17 @@ import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Design-system rectangle shimmer placeholder.
|
* Design-system v2 shimmer placeholder — a rounded rectangle with a sweeping highlight.
|
||||||
*
|
*
|
||||||
* A rounded rectangle painted with `bg.opaque.secondary`. A tilted band sweeps across it where
|
|
||||||
* the base color's alpha is gradually dimmed toward the center of the band and restored at the
|
|
||||||
* edges, producing a soft "blade" highlight passing through the placeholder. The alpha profile
|
|
||||||
* matches [com.tangem.core.ui.components.text.BladeAnimation].
|
|
||||||
*
|
|
||||||
* Cycle: 1.5s hold → 0.8s linear sweep → restart.
|
|
||||||
*
|
|
||||||
* Version 1.0
|
|
||||||
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3398-625&p=f&m=dev)
|
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3398-625&p=f&m=dev)
|
||||||
*
|
*
|
||||||
* Sizing is the caller's responsibility — set width and height via [modifier].
|
* For a placeholder sized after a typography line, use the [TangemShimmer] text overload instead.
|
||||||
*
|
*
|
||||||
* @param modifier Modifier applied to the shimmer's root.
|
* @param modifier Modifier applied to the shimmer's root. Set the width and height here.
|
||||||
* @param radius Corner radius of the rectangle.
|
* @param radius Corner radius of the rectangle.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RectangleShimmer(modifier: Modifier = Modifier, radius: Dp = 6.dp) {
|
fun TangemShimmer(modifier: Modifier = Modifier, radius: Dp = TangemShimmer.DefaultRadius) {
|
||||||
val baseColor = TangemTheme.colors3.bg.opaque.secondary
|
val baseColor = TangemTheme.colors3.bg.opaque.secondary
|
||||||
val progress = LocalTangemShimmerProgress.current ?: rememberShimmerProgressInstance()
|
val progress = LocalTangemShimmerProgress.current ?: rememberShimmerProgressInstance()
|
||||||
val colorStops = remember(baseColor) { buildColorStops(baseColor) }
|
val colorStops = remember(baseColor) { buildColorStops(baseColor) }
|
||||||
|
|
@ -77,63 +70,76 @@ fun RectangleShimmer(modifier: Modifier = Modifier, radius: Dp = 6.dp) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text-sized shimmer placeholder. Sizes itself to the bounding box of the [text] measured in the
|
* Text-line shimmer placeholder, sized and styled after the typography line described by [style].
|
||||||
* typography preset selected by [style], plus the preset's vertical padding (top + bottom).
|
|
||||||
*
|
*
|
||||||
* @param text Text used to determine the shimmer's size. Not drawn.
|
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3398-625&p=f&m=dev)
|
||||||
* @param style Typography preset — drives both the measurement style and the vertical padding.
|
*
|
||||||
* @param radius Corner radius of the rectangle.
|
* @param style A [TangemTheme.typography3] style (e.g. `TangemTheme.typography3.body.medium`) the
|
||||||
|
* placeholder is sized after. Unrecognized styles fall back to `body.medium`.
|
||||||
* @param modifier Modifier applied to the shimmer's root.
|
* @param modifier Modifier applied to the shimmer's root.
|
||||||
|
* @param textAlign Horizontal position of the block within the parent width.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TextShimmer(text: String, style: TextShimmerStyle, radius: Dp, modifier: Modifier = Modifier) {
|
fun TangemShimmer(style: TextStyle, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start) {
|
||||||
val textStyle = style.toTextStyle()
|
val preset = TangemShimmer.TextPreset.forStyle(style)
|
||||||
val measurer = rememberTextMeasurer()
|
val lineHeightDp = with(LocalDensity.current) { style.lineHeight.toDp() }
|
||||||
val density = LocalDensity.current
|
val alignment = when (textAlign) {
|
||||||
val (widthDp, heightDp) = remember(text, textStyle, measurer, density) {
|
TextAlign.Center -> Alignment.Center
|
||||||
val measured = measurer.measure(text = text, style = textStyle)
|
TextAlign.End -> Alignment.CenterEnd
|
||||||
with(density) { measured.size.width.toDp() to measured.size.height.toDp() }
|
else -> Alignment.CenterStart
|
||||||
}
|
}
|
||||||
|
|
||||||
RectangleShimmer(
|
Box(
|
||||||
modifier = modifier.size(
|
modifier = modifier.fillMaxWidth(),
|
||||||
width = widthDp,
|
contentAlignment = alignment,
|
||||||
height = heightDp + style.verticalPadding * 2,
|
) {
|
||||||
),
|
TangemShimmer(
|
||||||
radius = radius,
|
modifier = Modifier
|
||||||
)
|
.fillMaxWidth(preset.widthFraction)
|
||||||
|
.height(lineHeightDp)
|
||||||
|
.padding(vertical = preset.verticalPadding),
|
||||||
|
radius = preset.radius,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public API namespace for [TangemShimmer]. */
|
||||||
|
object TangemShimmer {
|
||||||
|
|
||||||
|
/** Default corner radius of the rectangle shimmer. */
|
||||||
|
val DefaultRadius: Dp = 6.dp
|
||||||
|
|
||||||
|
/** Per-typography sizing for the [TangemShimmer] text overload. */
|
||||||
|
internal enum class TextPreset(val widthFraction: Float, val verticalPadding: Dp, val radius: Dp) {
|
||||||
|
Display(widthFraction = 0.5f, verticalPadding = 4.dp, radius = 12.dp),
|
||||||
|
HeadingMedium(widthFraction = 0.7f, verticalPadding = 2.dp, radius = 8.dp),
|
||||||
|
HeadingSmall(widthFraction = 0.6f, verticalPadding = 2.dp, radius = 16.dp),
|
||||||
|
Body(widthFraction = 0.5f, verticalPadding = 2.dp, radius = 16.dp),
|
||||||
|
Subheading(widthFraction = 0.4f, verticalPadding = 2.dp, radius = 16.dp),
|
||||||
|
Caption(widthFraction = 0.3f, verticalPadding = 2.dp, radius = 16.dp),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
fun forStyle(style: TextStyle): TextPreset {
|
||||||
|
val typography = TangemTheme.typography3
|
||||||
|
return when (style) {
|
||||||
|
typography.display.medium -> Display
|
||||||
|
typography.heading.medium -> HeadingMedium
|
||||||
|
typography.heading.small -> HeadingSmall
|
||||||
|
typography.subheading.medium -> Subheading
|
||||||
|
typography.caption.medium -> Caption
|
||||||
|
else -> Body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typography preset for [TextShimmer]. Each preset maps to a [TangemTheme.typography3] style
|
* Wraps [content] so every [TangemShimmer] inside shares a single, in-phase animation driver —
|
||||||
* and contributes additional [verticalPadding] applied to both top and bottom — the shimmer
|
* use it around lists of shimmers. Safe to nest; safe to omit.
|
||||||
* block ends up `2 * verticalPadding` taller than the raw measured text.
|
|
||||||
*/
|
|
||||||
enum class TextShimmerStyle(val verticalPadding: Dp) {
|
|
||||||
DISPLAY(verticalPadding = 4.dp),
|
|
||||||
HEADING_MEDIUM(verticalPadding = 2.dp),
|
|
||||||
HEADING_SMALL(verticalPadding = 2.dp),
|
|
||||||
BODY(verticalPadding = 2.dp),
|
|
||||||
SUBHEADING(verticalPadding = 2.dp),
|
|
||||||
CAPTION(verticalPadding = 2.dp),
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@ReadOnlyComposable
|
|
||||||
private fun TextShimmerStyle.toTextStyle(): TextStyle = when (this) {
|
|
||||||
TextShimmerStyle.DISPLAY -> TangemTheme.typography3.display.medium
|
|
||||||
TextShimmerStyle.HEADING_MEDIUM -> TangemTheme.typography3.heading.medium
|
|
||||||
TextShimmerStyle.HEADING_SMALL -> TangemTheme.typography3.heading.small
|
|
||||||
TextShimmerStyle.BODY -> TangemTheme.typography3.body.medium
|
|
||||||
TextShimmerStyle.SUBHEADING -> TangemTheme.typography3.subheading.medium
|
|
||||||
TextShimmerStyle.CAPTION -> TangemTheme.typography3.caption.medium
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps [content] so every [RectangleShimmer] / [TextShimmer] inside reuses a single shimmer
|
|
||||||
* animation driver. Without this provider each shimmer creates its own
|
|
||||||
* [rememberInfiniteTransition] — that scales poorly in lists and lets sweeps drift out of phase.
|
|
||||||
* Safe to nest; safe to omit (each shimmer falls back to its own driver).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ProvideTangemShimmer(content: @Composable () -> Unit) {
|
fun ProvideTangemShimmer(content: @Composable () -> Unit) {
|
||||||
|
|
@ -198,24 +204,16 @@ private fun TangemShimmerPreview() {
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
RectangleShimmer(
|
TangemShimmer(
|
||||||
modifier = Modifier.size(width = 200.dp, height = 24.dp),
|
modifier = Modifier.size(width = 200.dp, height = 24.dp),
|
||||||
radius = 6.dp,
|
radius = 6.dp,
|
||||||
)
|
)
|
||||||
RectangleShimmer(
|
TangemShimmer(
|
||||||
modifier = Modifier.size(width = 120.dp, height = 16.dp),
|
modifier = Modifier.size(width = 120.dp, height = 16.dp),
|
||||||
radius = 4.dp,
|
radius = 4.dp,
|
||||||
)
|
)
|
||||||
TextShimmer(
|
TangemShimmer(style = TangemTheme.typography3.body.medium)
|
||||||
text = "Account balance",
|
TangemShimmer(style = TangemTheme.typography3.heading.medium)
|
||||||
style = TextShimmerStyle.BODY,
|
|
||||||
radius = 4.dp,
|
|
||||||
)
|
|
||||||
TextShimmer(
|
|
||||||
text = "$12,345.67",
|
|
||||||
style = TextShimmerStyle.HEADING_MEDIUM,
|
|
||||||
radius = 6.dp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ fun TangemSurface(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
CompositionLocalProvider(LocalRippleConfiguration provides tangemSurfaceRipple()) {
|
CompositionLocalProvider(LocalRippleConfiguration provides tangemSurfaceRipple(color)) {
|
||||||
surface()
|
surface()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -117,14 +117,17 @@ fun TangemSurface(
|
||||||
* `isAlphaContentClip`) to avoid the dark blur bleeding through the surface.
|
* `isAlphaContentClip`) to avoid the dark blur bleeding through the surface.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier = softLayerShadow(
|
private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier {
|
||||||
radius = radius,
|
val isBlurEnabled = LocalHazeState.current.blurEnabled
|
||||||
color = Color.Black.copy(alpha = 0.12f),
|
return softLayerShadow(
|
||||||
shape = shape,
|
radius = radius,
|
||||||
spread = 0.dp,
|
color = Color.Black.copy(alpha = 0.12f),
|
||||||
offset = DpOffset(x = 0.dp, y = 8.dp),
|
shape = shape,
|
||||||
isAlphaContentClip = true,
|
spread = 0.dp,
|
||||||
)
|
offset = DpOffset(x = 0.dp, y = 8.dp),
|
||||||
|
isAlphaContentClip = isBlurEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Diagonal gradient stroke that wraps the material variant. */
|
/** Diagonal gradient stroke that wraps the material variant. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -193,8 +196,12 @@ private fun materialBorderBrush(): Brush {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ReadOnlyComposable
|
@ReadOnlyComposable
|
||||||
private fun tangemSurfaceRipple(): RippleConfiguration = RippleConfiguration(
|
private fun tangemSurfaceRipple(backgroundColor: Color): RippleConfiguration = RippleConfiguration(
|
||||||
color = TangemTheme.colors3.interaction.press.default,
|
color = if (backgroundColor == TangemTheme.colors3.bg.inverse) {
|
||||||
|
TangemTheme.colors3.interaction.press.inverse
|
||||||
|
} else {
|
||||||
|
TangemTheme.colors3.interaction.press.default
|
||||||
|
},
|
||||||
rippleAlpha = RippleAlpha(
|
rippleAlpha = RippleAlpha(
|
||||||
draggedAlpha = 0f,
|
draggedAlpha = 0f,
|
||||||
focusedAlpha = 0f,
|
focusedAlpha = 0f,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ fun TangemNavigationText(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
color = navigationTextColor(role),
|
color = navigationTextColor(role),
|
||||||
style = navigationTextStyle(role),
|
style = navigationTextStyle(role),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Start,
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
overflow = overflow,
|
overflow = overflow,
|
||||||
)
|
)
|
||||||
|
|
@ -73,7 +73,7 @@ fun TangemNavigationText(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
color = navigationTextColor(role),
|
color = navigationTextColor(role),
|
||||||
style = navigationTextStyle(role),
|
style = navigationTextStyle(role),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Start,
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
overflow = overflow,
|
overflow = overflow,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ private enum class SlotId { Start, Content, Group, End }
|
||||||
* @param blurBackground Whether the fade behind the row should blur the content below.
|
* @param blurBackground Whether the fade behind the row should blur the content below.
|
||||||
* @param startButton Leading slot. Typically a back button (see [TangemButton.Back]).
|
* @param startButton Leading slot. Typically a back button (see [TangemButton.Back]).
|
||||||
* @param endButtonsGroup Optional pill-grouped secondary actions placed just before [endButton].
|
* @param endButtonsGroup Optional pill-grouped secondary actions placed just before [endButton].
|
||||||
* @param endButton Trailing slot. Typically a close button (see [TangemButton.Close]).
|
* @param endButton Trailing slot. Typically, a close button (see [TangemButton.Close]).
|
||||||
* @param contentColumn Center slot. Place title/subtitle children here.
|
* @param contentColumn Center slot. Place title/subtitle children here.
|
||||||
*/
|
*/
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod")
|
||||||
|
|
@ -88,7 +88,6 @@ fun TangemTopNavigation(
|
||||||
blur = blurBackground,
|
blur = blurBackground,
|
||||||
)
|
)
|
||||||
|
|
||||||
val groupSpacing = 8.dp
|
|
||||||
Layout(
|
Layout(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -108,7 +107,7 @@ fun TangemTopNavigation(
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 12.dp)
|
.padding(start = if (startButton != null) 12.dp else 0.dp, end = 12.dp)
|
||||||
.layoutId(SlotId.Content),
|
.layoutId(SlotId.Content),
|
||||||
horizontalAlignment = when (contentAlign) {
|
horizontalAlignment = when (contentAlign) {
|
||||||
TangemTopNavigation.ContentAlign.Start -> Alignment.Start
|
TangemTopNavigation.ContentAlign.Start -> Alignment.Start
|
||||||
|
|
@ -148,7 +147,7 @@ fun TangemTopNavigation(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { measurables, constraints ->
|
) { measurables, constraints ->
|
||||||
val groupSpacingPx = groupSpacing.roundToPx()
|
val groupSpacingPx = 8.dp.roundToPx()
|
||||||
val totalWidth = constraints.maxWidth
|
val totalWidth = constraints.maxWidth
|
||||||
|
|
||||||
val startM = measurables.first { it.layoutId == SlotId.Start }
|
val startM = measurables.first { it.layoutId == SlotId.Start }
|
||||||
|
|
@ -161,13 +160,15 @@ fun TangemTopNavigation(
|
||||||
val endP = endM.measure(slotConstraints)
|
val endP = endM.measure(slotConstraints)
|
||||||
val groupP = groupM.measure(slotConstraints)
|
val groupP = groupM.measure(slotConstraints)
|
||||||
|
|
||||||
|
val endGap = if (endP.width > 0) groupSpacingPx else 0
|
||||||
|
val groupOccupiedWidth = if (groupP.width > 0) endGap + groupP.width else 0
|
||||||
|
val trailingWidth = endP.width + groupOccupiedWidth
|
||||||
|
|
||||||
val contentMaxWidth = when (contentAlign) {
|
val contentMaxWidth = when (contentAlign) {
|
||||||
// Symmetric band so the content can be visually centered within `totalWidth`
|
|
||||||
// without colliding with the start/end slots.
|
|
||||||
TangemTopNavigation.ContentAlign.Center ->
|
TangemTopNavigation.ContentAlign.Center ->
|
||||||
(totalWidth - 2 * maxOf(startP.width, endP.width)).coerceAtLeast(0)
|
(totalWidth - 2 * maxOf(startP.width, trailingWidth)).coerceAtLeast(0)
|
||||||
TangemTopNavigation.ContentAlign.Start ->
|
TangemTopNavigation.ContentAlign.Start ->
|
||||||
(totalWidth - startP.width - endP.width).coerceAtLeast(0)
|
(totalWidth - startP.width - trailingWidth).coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
val contentP = contentM.measure(slotConstraints.copy(maxWidth = contentMaxWidth))
|
val contentP = contentM.measure(slotConstraints.copy(maxWidth = contentMaxWidth))
|
||||||
|
|
||||||
|
|
@ -183,15 +184,14 @@ fun TangemTopNavigation(
|
||||||
((totalWidth - contentP.width) / 2)
|
((totalWidth - contentP.width) / 2)
|
||||||
.coerceIn(
|
.coerceIn(
|
||||||
startP.width,
|
startP.width,
|
||||||
(totalWidth - endP.width - contentP.width).coerceAtLeast(startP.width),
|
(totalWidth - trailingWidth - contentP.width).coerceAtLeast(startP.width),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
contentP.placeRelative(x = contentX, y = centerY(contentP.height))
|
contentP.placeRelative(x = contentX, y = centerY(contentP.height))
|
||||||
|
|
||||||
endP.placeRelative(x = totalWidth - endP.width, y = centerY(endP.height))
|
endP.placeRelative(x = totalWidth - endP.width, y = centerY(endP.height))
|
||||||
// Group floats to the left of endButton with `groupSpacing` gap, overlaying the
|
|
||||||
// tail of the content band if necessary.
|
val groupX = (totalWidth - endP.width - endGap - groupP.width)
|
||||||
val groupX = (totalWidth - endP.width - groupSpacingPx - groupP.width)
|
|
||||||
.coerceAtLeast(0)
|
.coerceAtLeast(0)
|
||||||
groupP.placeRelative(x = groupX, y = centerY(groupP.height))
|
groupP.placeRelative(x = groupX, y = centerY(groupP.height))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import com.tangem.core.ui.components.haze.ProvideHaze
|
import com.tangem.core.ui.components.haze.ProvideHaze
|
||||||
|
import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer
|
||||||
import com.tangem.core.ui.res.generated.TangemTypography3
|
import com.tangem.core.ui.res.generated.TangemTypography3
|
||||||
import com.tangem.core.ui.res.generated.darkColors3
|
import com.tangem.core.ui.res.generated.darkColors3
|
||||||
import com.tangem.core.ui.res.generated.lightColors3
|
import com.tangem.core.ui.res.generated.lightColors3
|
||||||
|
|
@ -48,8 +49,10 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalTextSelectionColors provides TangemTextSelectionColors2,
|
LocalTextSelectionColors provides TangemTextSelectionColors2,
|
||||||
) {
|
) {
|
||||||
ProvideHaze {
|
ProvideTangemShimmer {
|
||||||
content()
|
ProvideHaze {
|
||||||
|
content()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ package com.tangem.core.ui.test
|
||||||
object BuyTokenScreenTestTags {
|
object BuyTokenScreenTestTags {
|
||||||
const val LAZY_LIST = "BUY_TOKEN_SCREEN_LAZY_LIST"
|
const val LAZY_LIST = "BUY_TOKEN_SCREEN_LAZY_LIST"
|
||||||
const val LAZY_LIST_ITEM = "BUY_TOKEN_SCREEN_LAZY_LIST_ITEM"
|
const val LAZY_LIST_ITEM = "BUY_TOKEN_SCREEN_LAZY_LIST_ITEM"
|
||||||
|
const val WALLET_TAB = "BUY_TOKEN_SCREEN_WALLET_TAB"
|
||||||
}
|
}
|
||||||
|
|
@ -5,4 +5,5 @@ object DetailsScreenTestTags {
|
||||||
const val SCREEN_ITEM = "DETAILS_SCREEN_ITEM"
|
const val SCREEN_ITEM = "DETAILS_SCREEN_ITEM"
|
||||||
const val VERSION_NAME = "DETAILS_SCREEN_VERSION_NAME"
|
const val VERSION_NAME = "DETAILS_SCREEN_VERSION_NAME"
|
||||||
const val USER_WALLET_ITEM = "DETAILS_SCREEN_USER_WALLET_ITEM"
|
const val USER_WALLET_ITEM = "DETAILS_SCREEN_USER_WALLET_ITEM"
|
||||||
|
const val ADD_WALLET_BUTTON = "DETAILS_SCREEN_ADD_WALLET_BUTTON"
|
||||||
}
|
}
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="20dp"
|
android:width="20dp"
|
||||||
android:height="20dp"
|
android:height="20dp"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="20dp"
|
android:width="20dp"
|
||||||
android:height="20dp"
|
android:height="20dp"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
|
|
|
||||||
21
core/ui/src/main/res/drawable/ic_gonka_22.xml
Normal file
21
core/ui/src/main/res/drawable/ic_gonka_22.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="22dp"
|
||||||
|
android:height="22dp"
|
||||||
|
android:viewportWidth="44"
|
||||||
|
android:viewportHeight="44">
|
||||||
|
<path
|
||||||
|
android:pathData="M22.0779,0.0009C22.2017,6.0786 22.4682,12.1564 22.8218,18.234C23.2874,26.2385 26.7009,34.2436 29.0827,42.2481C29.1354,42.4251 29.185,42.6029 29.2367,42.7799C26.9701,43.5691 24.5355,44 22,44C19.4649,44 17.0305,43.5698 14.7642,42.7808C14.816,42.6034 14.8663,42.4255 14.9191,42.2481C17.3009,34.2436 20.7144,26.2385 21.18,18.234C21.5336,12.1564 21.7992,6.0786 21.923,0.0009C21.9487,0.0008 21.9743,0 22,0C22.026,0 22.0519,0.0008 22.0779,0.0009Z"
|
||||||
|
android:fillColor="#000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M20.7262,21.2364C19.4639,28.1647 15.6505,35.0935 12.8718,42.0216C12.0526,41.6475 11.2603,41.2248 10.4987,40.7568C14.3095,34.2502 19.1923,27.7431 20.7262,21.2364Z"
|
||||||
|
android:fillColor="#000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M23.2747,21.2364C24.8085,27.7426 29.6898,34.2498 33.5004,40.7559C32.739,41.2237 31.9472,41.6467 31.1282,42.0208C28.3495,35.093 24.5369,28.1642 23.2747,21.2364Z"
|
||||||
|
android:fillColor="#000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M20.6062,0.0448C20.5973,6.1451 20.5901,12.2455 20.5641,18.3459C20.5354,25.059 13.5075,31.7743 7.4363,38.4875C2.8764,34.4566 0,28.5647 0,22C0,10.3179 9.1054,0.7638 20.6062,0.0448Z"
|
||||||
|
android:fillColor="#000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M23.3938,0.0448C34.8946,0.7638 44,10.3179 44,22C44,28.5649 41.123,34.4565 36.5628,38.4875C30.4919,31.7747 23.4655,25.0585 23.4368,18.3459C23.4108,12.2455 23.4027,6.1451 23.3938,0.0448Z"
|
||||||
|
android:fillColor="#000000" />
|
||||||
|
</vector>
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="20dp"
|
android:width="20dp"
|
||||||
android:height="20dp"
|
android:height="20dp"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="20dp"
|
android:width="20dp"
|
||||||
android:height="20dp"
|
android:height="20dp"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="32" android:viewportWidth="32" android:width="24dp">
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="32" android:viewportWidth="32" android:width="24dp">
|
||||||
|
|
||||||
<path android:fillColor="#EBEBEB" android:pathData="M16,23.697L22.663,27.802C23.715,28.45 25.011,27.488 24.733,26.268L22.964,18.528L28.852,13.321C29.78,12.5 29.284,10.944 28.061,10.838L20.31,10.167L17.278,2.864C16.799,1.712 15.201,1.712 14.722,2.864L11.69,10.167L3.939,10.838C2.716,10.944 2.22,12.5 3.148,13.321L9.036,18.528L7.267,26.268C6.988,27.488 8.285,28.45 9.336,27.802L16,23.697Z"/>
|
<path android:fillColor="#EBEBEB" android:pathData="M16,23.697L22.663,27.802C23.715,28.45 25.011,27.488 24.733,26.268L22.964,18.528L28.852,13.321C29.78,12.5 29.284,10.944 28.061,10.838L20.31,10.167L17.278,2.864C16.799,1.712 15.201,1.712 14.722,2.864L11.69,10.167L3.939,10.838C2.716,10.944 2.22,12.5 3.148,13.321L9.036,18.528L7.267,26.268C6.988,27.488 8.285,28.45 9.336,27.802L16,23.697Z"/>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="128dp"
|
android:width="128dp"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="22dp"
|
android:width="22dp"
|
||||||
android:height="8dp"
|
android:height="8dp"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,3 @@
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="20dp"
|
android:width="20dp"
|
||||||
android:height="20dp"
|
android:height="20dp"
|
||||||
|
|
|
||||||
13
core/ui/src/main/res/drawable/img_gonka_22.xml
Normal file
13
core/ui/src/main/res/drawable/img_gonka_22.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="22dp"
|
||||||
|
android:height="22dp"
|
||||||
|
android:viewportWidth="44"
|
||||||
|
android:viewportHeight="44">
|
||||||
|
<path
|
||||||
|
android:pathData="M22,0C34.15,0 44,9.85 44,22C44,34.15 34.15,44 22,44C9.85,44 0,34.15 0,22C0,9.85 9.85,0 22,0Z"
|
||||||
|
android:fillColor="#242424" />
|
||||||
|
<path
|
||||||
|
android:pathData="M22,0C34.15,0 44,9.85 44,22C44,34.15 34.15,44 22,44C9.85,44 0,34.15 0,22C0,9.85 9.85,0 22,0ZM22.0779,0.0009C22.2017,6.0786 22.4682,12.1564 22.8218,18.234C23.2874,26.2385 26.7009,34.2436 29.0827,42.2481C29.1354,42.4251 29.185,42.6029 29.2367,42.7799C26.9701,43.5691 24.5355,44 22,44C19.4649,44 17.0305,43.5698 14.7642,42.7808C14.816,42.6034 14.8663,42.4255 14.9191,42.2481C17.3009,34.2436 20.7144,26.2385 21.18,18.234C21.5336,12.1564 21.7992,6.0786 21.923,0.0009C21.9487,0.0008 21.9743,0 22,0C22.026,0 22.0519,0.0008 22.0779,0.0009ZM20.7262,21.2364C19.4639,28.1647 15.6505,35.0935 12.8718,42.0216C12.0526,41.6475 11.2603,41.2248 10.4987,40.7568C14.3095,34.2502 19.1923,27.7431 20.7262,21.2364ZM23.2747,21.2364C24.8085,27.7426 29.6898,34.2498 33.5004,40.7559C32.739,41.2237 31.9472,41.6467 31.1282,42.0208C28.3495,35.093 24.5369,28.1642 23.2747,21.2364ZM20.6062,0.0448C20.5973,6.1451 20.5901,12.2455 20.5641,18.3459C20.5354,25.059 13.5075,31.7743 7.4363,38.4875C2.8764,34.4566 0,28.5647 0,22C0,10.3179 9.1054,0.7638 20.6062,0.0448ZM23.3938,0.0448C34.8946,0.7638 44,10.3179 44,22C44,28.5649 41.123,34.4565 36.5628,38.4875C30.4919,31.7747 23.4655,25.0585 23.4368,18.3459C23.4108,12.2455 23.4027,6.1451 23.3938,0.0448Z"
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="evenOdd" />
|
||||||
|
</vector>
|
||||||
|
|
@ -25,6 +25,7 @@ import com.tangem.datasource.api.tangemTech.models.orDefault
|
||||||
import com.tangem.datasource.utils.getSyncOrNull
|
import com.tangem.datasource.utils.getSyncOrNull
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -143,6 +144,11 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
|
||||||
apiResponse.bind().enrichByAccountId()
|
apiResponse.bind().enrichByAccountId()
|
||||||
},
|
},
|
||||||
onError = { error ->
|
onError = { error ->
|
||||||
|
TangemLogger.e(
|
||||||
|
"pushInternal wallet=$userWalletId: PUT /accounts failed, " +
|
||||||
|
"isPreconditionFailed=${error.isNetworkError(code = Code.PRECONDITION_FAILED)}, error=$error",
|
||||||
|
)
|
||||||
|
|
||||||
if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) {
|
if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
|
||||||
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
|
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
|
||||||
): FetchResult {
|
): FetchResult {
|
||||||
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
|
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
|
||||||
|
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
|
||||||
|
|
||||||
if (isResponseUpToDate) {
|
if (isResponseUpToDate) {
|
||||||
TangemLogger.e("ETag is up to date, no need to update accounts for wallet: $userWalletId")
|
TangemLogger.e("ETag is up to date, no need to update accounts for wallet: $userWalletId")
|
||||||
val response = requireNotNull(savedAccountsResponse) {
|
val response = requireNotNull(savedAccountsResponse) {
|
||||||
|
|
@ -71,13 +73,16 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
|
||||||
val response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
|
val response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
|
||||||
val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse()
|
val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse()
|
||||||
|
|
||||||
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
|
|
||||||
if (isNotFoundError) {
|
if (isNotFoundError) {
|
||||||
val eTag = createWallet(userWalletId)
|
val eTag = createWallet(userWalletId)
|
||||||
|
|
||||||
if (eTag != null) {
|
if (eTag != null) {
|
||||||
pushWalletAccounts(accountDTOs, eTag)
|
pushWalletAccounts(accountDTOs, eTag)
|
||||||
userTokensSaver.pushWithRetryer(userWalletId, userTokensResponse)
|
userTokensSaver.pushWithRetryer(userWalletId, userTokensResponse)
|
||||||
|
} else {
|
||||||
|
TangemLogger.e(
|
||||||
|
"handle wallet=$userWalletId: account creation skipped, createWallet returned null eTag",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,10 +117,17 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
|
||||||
private suspend fun createWallet(userWalletId: UserWalletId): String? {
|
private suspend fun createWallet(userWalletId: UserWalletId): String? {
|
||||||
val creationResponse = walletServerBinder.bind(userWalletId)
|
val creationResponse = walletServerBinder.bind(userWalletId)
|
||||||
|
|
||||||
return if (creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED) {
|
val isCreated = creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED
|
||||||
|
val eTag = if (isCreated) {
|
||||||
creationResponse.headers[ETAG_HEADER]?.firstOrNull()
|
creationResponse.headers[ETAG_HEADER]?.firstOrNull()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eTag == null) {
|
||||||
|
TangemLogger.e("ETag is null for wallet: $userWalletId, isCreated: $isCreated")
|
||||||
|
}
|
||||||
|
|
||||||
|
return eTag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,11 +10,16 @@ internal class DefaultAppsFlyerRepository @Inject constructor(
|
||||||
private val appsFlyerStore: AppsFlyerStore,
|
private val appsFlyerStore: AppsFlyerStore,
|
||||||
) : AppsFlyerRepository {
|
) : AppsFlyerRepository {
|
||||||
|
|
||||||
|
override suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? {
|
||||||
|
return appsFlyerStore.getDeeplink(source.toStoreSource())
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
|
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
|
||||||
appsFlyerStore.clearDeeplink(source.toStoreSource())
|
appsFlyerStore.clearDeeplink(source.toStoreSource())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
|
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
|
||||||
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding
|
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding
|
||||||
|
AppsFlyerDeeplinkSource.Referral -> StoreDeeplinkSource.Referral
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package com.tangem.data.appsflyer.di
|
||||||
import com.tangem.data.appsflyer.DefaultAppsFlyerRepository
|
import com.tangem.data.appsflyer.DefaultAppsFlyerRepository
|
||||||
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
|
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
|
||||||
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
||||||
|
import com.tangem.domain.appsflyer.usecase.IsReferralInstallUseCase
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
|
@ -26,5 +27,10 @@ internal interface AppsFlyerDataModule {
|
||||||
): ClearAppsFlyerDeeplinkUseCase {
|
): ClearAppsFlyerDeeplinkUseCase {
|
||||||
return ClearAppsFlyerDeeplinkUseCase(appsFlyerRepository)
|
return ClearAppsFlyerDeeplinkUseCase(appsFlyerRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideIsReferralInstallUseCase(appsFlyerRepository: AppsFlyerRepository): IsReferralInstallUseCase {
|
||||||
|
return IsReferralInstallUseCase(appsFlyerRepository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.tangem.data.common.wallet
|
package com.tangem.data.common.wallet
|
||||||
|
|
||||||
import com.tangem.datasource.api.common.response.ApiResponse
|
import com.tangem.datasource.api.common.response.ApiResponse
|
||||||
|
import com.tangem.datasource.api.common.response.ETAG_HEADER
|
||||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||||
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
|
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
|
||||||
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
||||||
|
|
@ -9,6 +10,7 @@ import com.tangem.domain.common.wallets.getSyncOrNull
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
internal class DefaultWalletServerBinder(
|
internal class DefaultWalletServerBinder(
|
||||||
|
|
@ -19,7 +21,12 @@ internal class DefaultWalletServerBinder(
|
||||||
) : WalletServerBinder {
|
) : WalletServerBinder {
|
||||||
|
|
||||||
override suspend fun bind(userWalletId: UserWalletId): ApiResponse<Unit>? {
|
override suspend fun bind(userWalletId: UserWalletId): ApiResponse<Unit>? {
|
||||||
val userWallet = userWalletsListRepository.getSyncOrNull(id = userWalletId) ?: return null
|
val userWallet = userWalletsListRepository.getSyncOrNull(id = userWalletId)
|
||||||
|
|
||||||
|
if (userWallet == null) {
|
||||||
|
TangemLogger.e("bind wallet=$userWalletId: user wallet not found locally, skipping createWallet call")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return bind(userWallet)
|
return bind(userWallet)
|
||||||
}
|
}
|
||||||
|
|
@ -31,6 +38,12 @@ internal class DefaultWalletServerBinder(
|
||||||
tangemTechApi.createWallet(
|
tangemTechApi.createWallet(
|
||||||
body = WalletIdBodyConverter.convert(userWallet, conversionData),
|
body = WalletIdBodyConverter.convert(userWallet, conversionData),
|
||||||
)
|
)
|
||||||
|
}.also { response ->
|
||||||
|
val eTag = response.headers[ETAG_HEADER]?.firstOrNull()
|
||||||
|
TangemLogger.i(
|
||||||
|
"bind wallet=${userWallet.walletId}: createWallet code=${(response as? ApiResponse.Success)?.code}, " +
|
||||||
|
"hasETag=${eTag != null}, eTagNotEmpty=${!eTag.isNullOrEmpty()}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -387,6 +387,7 @@ class NetworkFactoryTest {
|
||||||
Blockchain.CosmosTestnet,
|
Blockchain.CosmosTestnet,
|
||||||
Blockchain.Dogecoin,
|
Blockchain.Dogecoin,
|
||||||
Blockchain.Ducatus,
|
Blockchain.Ducatus,
|
||||||
|
Blockchain.Gonka,
|
||||||
Blockchain.Ethereum, Blockchain.EthereumTestnet,
|
Blockchain.Ethereum, Blockchain.EthereumTestnet,
|
||||||
Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet,
|
Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet,
|
||||||
Blockchain.Fantom, Blockchain.FantomTestnet,
|
Blockchain.Fantom, Blockchain.FantomTestnet,
|
||||||
|
|
|
||||||
|
|
@ -166,5 +166,6 @@ public val Blockchain.mercuryoNetwork: String?
|
||||||
Blockchain.Adi, Blockchain.AdiTestnet -> null
|
Blockchain.Adi, Blockchain.AdiTestnet -> null
|
||||||
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet -> null
|
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet -> null
|
||||||
Blockchain.Monad, Blockchain.MonadTestnet -> null
|
Blockchain.Monad, Blockchain.MonadTestnet -> null
|
||||||
|
Blockchain.Gonka -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +8,6 @@ import com.tangem.data.common.api.safeApiCall
|
||||||
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
|
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
|
||||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||||
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
|
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
|
||||||
import com.tangem.datasource.api.common.response.ApiResponse
|
|
||||||
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
||||||
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
|
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
|
||||||
import com.tangem.datasource.api.stakekit.StakeKitApi
|
import com.tangem.datasource.api.stakekit.StakeKitApi
|
||||||
|
|
@ -25,7 +24,6 @@ import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||||
import com.tangem.domain.staking.model.StakingIntegrationID
|
import com.tangem.domain.staking.model.StakingIntegrationID
|
||||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
|
|
||||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
||||||
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
|
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
|
@ -65,6 +63,11 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
||||||
private val dispatchers: CoroutineDispatcherProvider,
|
private val dispatchers: CoroutineDispatcherProvider,
|
||||||
) : MultiStakingBalanceFetcher {
|
) : MultiStakingBalanceFetcher {
|
||||||
|
|
||||||
|
private val p2pAccountsFetcher = P2PEthPoolAccountsFetcher(
|
||||||
|
p2pEthPoolApi = p2pEthPoolApi,
|
||||||
|
dispatchers = dispatchers,
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun invoke(params: MultiStakingBalanceFetcher.Params): Either<Throwable, Unit> {
|
override suspend fun invoke(params: MultiStakingBalanceFetcher.Params): Either<Throwable, Unit> {
|
||||||
TangemLogger.i("Start fetching staking balances for params:\n$params")
|
TangemLogger.i("Start fetching staking balances for params:\n$params")
|
||||||
|
|
||||||
|
|
@ -195,50 +198,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
||||||
vaults: List<P2PEthPoolVault>,
|
vaults: List<P2PEthPoolVault>,
|
||||||
addresses: Set<String>,
|
addresses: Set<String>,
|
||||||
): Set<P2PEthPoolAccountResponse> {
|
): Set<P2PEthPoolAccountResponse> {
|
||||||
val responses = mutableSetOf<P2PEthPoolAccountResponse>()
|
return p2pAccountsFetcher.fetchBatch(vaults = vaults, addresses = addresses)
|
||||||
|
|
||||||
for (vault in vaults) {
|
|
||||||
for (address in addresses) {
|
|
||||||
runSuspendCatching {
|
|
||||||
val response = p2pEthPoolApi.getAccountInfo(
|
|
||||||
network = P2PEthPoolStakingConfig.activeNetwork.value,
|
|
||||||
delegatorAddress = address,
|
|
||||||
vaultAddress = vault.vaultAddress,
|
|
||||||
)
|
|
||||||
|
|
||||||
when (response) {
|
|
||||||
is ApiResponse.Success -> {
|
|
||||||
val data = response.data
|
|
||||||
if (data.error != null) {
|
|
||||||
TangemLogger.w(
|
|
||||||
"P2PEthPool API returned error for vault ${vault.vaultAddress}, " +
|
|
||||||
"address $address: ${data.error ?: "error"}",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val result = requireNotNull(data.result) {
|
|
||||||
"Result is null in successful response"
|
|
||||||
}
|
|
||||||
responses.add(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is ApiResponse.Error -> {
|
|
||||||
TangemLogger.w(
|
|
||||||
"Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, " +
|
|
||||||
"address $address",
|
|
||||||
response.cause,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onFailure { error ->
|
|
||||||
TangemLogger.w(
|
|
||||||
"Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, address $address",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {
|
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
package com.tangem.data.staking.multi
|
||||||
|
|
||||||
|
import com.tangem.datasource.api.common.response.ApiResponse
|
||||||
|
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
||||||
|
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolAccountsListRequest
|
||||||
|
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
|
||||||
|
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountsListResponse
|
||||||
|
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
|
||||||
|
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
|
||||||
|
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
||||||
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import com.tangem.utils.coroutines.runSuspendCatching
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches P2P ETH Pool account responses via the batch strategy:
|
||||||
|
* one POST per vault sending all delegator addresses at once.
|
||||||
|
*
|
||||||
|
* @property p2pEthPoolApi P2PEthPool API
|
||||||
|
* @property dispatchers coroutine dispatcher provider
|
||||||
|
*
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
internal class P2PEthPoolAccountsFetcher(
|
||||||
|
private val p2pEthPoolApi: P2PEthPoolApi,
|
||||||
|
private val dispatchers: CoroutineDispatcherProvider,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun fetchBatch(vaults: List<P2PEthPoolVault>, addresses: Set<String>): Set<P2PEthPoolAccountResponse> =
|
||||||
|
coroutineScope {
|
||||||
|
val request = P2PEthPoolAccountsListRequest(delegatorAddresses = addresses.toList())
|
||||||
|
|
||||||
|
vaults
|
||||||
|
.map { vault ->
|
||||||
|
async(dispatchers.io) {
|
||||||
|
runSuspendCatching {
|
||||||
|
val response = p2pEthPoolApi.getAccountsList(
|
||||||
|
network = P2PEthPoolStakingConfig.activeNetwork.value,
|
||||||
|
vaultAddress = vault.vaultAddress,
|
||||||
|
body = request,
|
||||||
|
)
|
||||||
|
|
||||||
|
mapBatchVaultResponse(vault = vault, response = response)
|
||||||
|
}.getOrElse { error ->
|
||||||
|
TangemLogger.w(
|
||||||
|
"Failed to fetch P2PEthPool batch balances for vault ${vault.vaultAddress}",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
|
.flatten()
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapBatchVaultResponse(
|
||||||
|
vault: P2PEthPoolVault,
|
||||||
|
response: ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountsListResponse>>,
|
||||||
|
): List<P2PEthPoolAccountResponse> {
|
||||||
|
return when (response) {
|
||||||
|
is ApiResponse.Success -> {
|
||||||
|
val data = response.data
|
||||||
|
if (data.error != null) {
|
||||||
|
TangemLogger.w(
|
||||||
|
"P2PEthPool batch API returned error for vault " +
|
||||||
|
"${vault.vaultAddress}: ${data.error}",
|
||||||
|
)
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
val result = requireNotNull(data.result) {
|
||||||
|
"Result is null in successful response"
|
||||||
|
}
|
||||||
|
result.list.mapNotNull { item ->
|
||||||
|
if (item.error != null) {
|
||||||
|
TangemLogger.w(
|
||||||
|
"P2PEthPool batch item error for vault " +
|
||||||
|
"${vault.vaultAddress}, address " +
|
||||||
|
"${item.delegatorAddress}: ${item.error}",
|
||||||
|
)
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
item.account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResponse.Error -> {
|
||||||
|
TangemLogger.w(
|
||||||
|
"Failed to fetch P2PEthPool batch balances for vault " +
|
||||||
|
"${vault.vaultAddress}",
|
||||||
|
response.cause,
|
||||||
|
)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,12 +10,15 @@ import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
|
||||||
import com.tangem.datasource.api.common.response.ApiResponse
|
import com.tangem.datasource.api.common.response.ApiResponse
|
||||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||||
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
||||||
|
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolAccountsListRequest
|
||||||
|
import com.tangem.datasource.api.ethpool.models.response.*
|
||||||
import com.tangem.datasource.api.stakekit.StakeKitApi
|
import com.tangem.datasource.api.stakekit.StakeKitApi
|
||||||
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
|
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
|
||||||
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
|
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
|
||||||
import com.tangem.datasource.local.token.StakingYieldsStore
|
import com.tangem.datasource.local.token.StakingYieldsStore
|
||||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||||
import com.tangem.domain.models.staking.StakingID
|
import com.tangem.domain.models.staking.StakingID
|
||||||
|
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
||||||
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
|
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
|
||||||
import com.tangem.test.core.assertEitherLeft
|
import com.tangem.test.core.assertEitherLeft
|
||||||
import com.tangem.test.core.assertEitherRight
|
import com.tangem.test.core.assertEitherRight
|
||||||
|
|
@ -26,6 +29,7 @@ import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.TestInstance
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
[REDACTED_AUTHOR]
|
[REDACTED_AUTHOR]
|
||||||
|
|
@ -54,7 +58,14 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun resetMocks() {
|
fun resetMocks() {
|
||||||
clearMocks(userWalletsListRepository, stakingYieldsStore, stakeKitBalancesStore, stakeKitApi)
|
clearMocks(
|
||||||
|
userWalletsListRepository,
|
||||||
|
stakingYieldsStore,
|
||||||
|
stakeKitBalancesStore,
|
||||||
|
stakeKitApi,
|
||||||
|
p2pEthPoolApi,
|
||||||
|
p2pEthPoolVaultsStore,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -359,6 +370,88 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
||||||
assertEitherLeft(actual, expected)
|
assertEitherLeft(actual, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fetch P2P balances via batch endpoint`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val params = MultiStakingBalanceFetcher.Params(userWalletId, setOf(p2pId1, p2pId2))
|
||||||
|
|
||||||
|
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
|
||||||
|
coEvery { p2pEthPoolVaultsStore.getSync() } returns listOf(vault(VAULT_A), vault(VAULT_B))
|
||||||
|
|
||||||
|
coEvery { p2pEthPoolApi.getAccountsList(any(), VAULT_A, any()) } returns
|
||||||
|
accountsListSuccess(accountResponse(ADDR_1, VAULT_A))
|
||||||
|
coEvery { p2pEthPoolApi.getAccountsList(any(), VAULT_B, any()) } returns
|
||||||
|
accountsListSuccess(accountResponse(ADDR_2, VAULT_B))
|
||||||
|
|
||||||
|
// Actual
|
||||||
|
val actual = fetcher.invoke(params)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
p2pEthPoolApi.getAccountsList(
|
||||||
|
network = any(),
|
||||||
|
vaultAddress = VAULT_A,
|
||||||
|
body = match { it.delegatorAddresses.containsAll(listOf(ADDR_1, ADDR_2)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
p2pEthPoolApi.getAccountsList(network = any(), vaultAddress = VAULT_B, body = any())
|
||||||
|
}
|
||||||
|
coVerify(inverse = true) { p2pEthPoolApi.getAccountInfo(any(), any(), any()) }
|
||||||
|
coVerify { p2PEthPoolBalancesStore.storeActual(userWalletId = userWalletId, values = any()) }
|
||||||
|
|
||||||
|
assertEitherRight(actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fetch P2P batch maps per-item error to missing stakingId`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val params = MultiStakingBalanceFetcher.Params(userWalletId, setOf(p2pId1, p2pId2))
|
||||||
|
|
||||||
|
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
|
||||||
|
coEvery { p2pEthPoolVaultsStore.getSync() } returns listOf(vault(VAULT_A))
|
||||||
|
|
||||||
|
coEvery { p2pEthPoolApi.getAccountsList(any(), VAULT_A, any()) } returns
|
||||||
|
ApiResponse.Success(
|
||||||
|
P2PEthPoolResponse(
|
||||||
|
error = null,
|
||||||
|
result = P2PEthPoolAccountsListResponse(
|
||||||
|
list = listOf(
|
||||||
|
P2PEthPoolAccountListItem(
|
||||||
|
delegatorAddress = ADDR_1,
|
||||||
|
account = accountResponse(ADDR_1, VAULT_A),
|
||||||
|
error = null,
|
||||||
|
),
|
||||||
|
P2PEthPoolAccountListItem(
|
||||||
|
delegatorAddress = ADDR_2,
|
||||||
|
account = null,
|
||||||
|
error = P2PEthPoolErrorDetailsDTO(
|
||||||
|
code = 127108,
|
||||||
|
message = "invalid",
|
||||||
|
name = null,
|
||||||
|
errors = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Actual
|
||||||
|
val actual = fetcher.invoke(params)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
coVerify { p2PEthPoolBalancesStore.storeActual(userWalletId = userWalletId, values = any()) }
|
||||||
|
coVerify {
|
||||||
|
p2PEthPoolBalancesStore.storeError(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
stakingIds = match { it == setOf(p2pId2) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEitherRight(actual)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val userWallet = MockUserWalletFactory.create()
|
val userWallet = MockUserWalletFactory.create()
|
||||||
val userWalletId = userWallet.walletId
|
val userWalletId = userWallet.walletId
|
||||||
|
|
@ -370,5 +463,55 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
val tonAndSolanaIds = setOf(tonId, solanaId)
|
val tonAndSolanaIds = setOf(tonId, solanaId)
|
||||||
|
|
||||||
|
const val ADDR_1 = "0x1111111111111111111111111111111111111111"
|
||||||
|
const val ADDR_2 = "0x2222222222222222222222222222222222222222"
|
||||||
|
const val VAULT_A = "0xVaultAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
const val VAULT_B = "0xVaultBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||||
|
|
||||||
|
val p2pId1 = StakingID(integrationId = "p2p-ethereum-pooled", address = ADDR_1)
|
||||||
|
val p2pId2 = StakingID(integrationId = "p2p-ethereum-pooled", address = ADDR_2)
|
||||||
|
|
||||||
|
fun vault(address: String) = P2PEthPoolVault(
|
||||||
|
vaultAddress = address,
|
||||||
|
displayName = "Vault",
|
||||||
|
apy = BigDecimal("4.5"),
|
||||||
|
baseApy = BigDecimal("4.0"),
|
||||||
|
capacity = BigDecimal("1000"),
|
||||||
|
totalAssets = BigDecimal("100"),
|
||||||
|
feePercent = BigDecimal("10"),
|
||||||
|
isPrivate = false,
|
||||||
|
isGenesis = false,
|
||||||
|
isSmoothingPool = true,
|
||||||
|
isErc20 = false,
|
||||||
|
tokenName = null,
|
||||||
|
tokenSymbol = null,
|
||||||
|
createdAt = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun accountResponse(address: String, vaultAddress: String) = P2PEthPoolAccountResponse(
|
||||||
|
delegatorAddress = address,
|
||||||
|
vaultAddress = vaultAddress,
|
||||||
|
stake = P2PEthPoolStakeDTO(assets = BigDecimal("1.5"), totalEarnedAssets = BigDecimal("0.1")),
|
||||||
|
availableToUnstake = BigDecimal.ZERO,
|
||||||
|
availableToWithdraw = BigDecimal.ZERO,
|
||||||
|
exitQueue = P2PEthPoolExitQueueDTO(total = BigDecimal.ZERO, requests = emptyList()),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun accountsListSuccess(vararg accounts: P2PEthPoolAccountResponse) =
|
||||||
|
ApiResponse.Success(
|
||||||
|
P2PEthPoolResponse(
|
||||||
|
error = null,
|
||||||
|
result = P2PEthPoolAccountsListResponse(
|
||||||
|
list = accounts.map {
|
||||||
|
P2PEthPoolAccountListItem(
|
||||||
|
delegatorAddress = it.delegatorAddress,
|
||||||
|
account = it,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -346,6 +346,7 @@ internal class DefaultTransactionRepository(
|
||||||
Blockchain.Binance -> BinanceTransactionExtras(memo)
|
Blockchain.Binance -> BinanceTransactionExtras(memo)
|
||||||
Blockchain.XRP -> memo.toLongOrNull()?.let { XrpTransactionBuilder.XrpTransactionExtras(it) }
|
Blockchain.XRP -> memo.toLongOrNull()?.let { XrpTransactionBuilder.XrpTransactionExtras(it) }
|
||||||
Blockchain.Cosmos,
|
Blockchain.Cosmos,
|
||||||
|
Blockchain.Gonka,
|
||||||
Blockchain.Sei,
|
Blockchain.Sei,
|
||||||
Blockchain.TerraV1,
|
Blockchain.TerraV1,
|
||||||
Blockchain.TerraV2,
|
Blockchain.TerraV2,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ internal class WcSolanaSignAndSendTransactionUseCase @AssistedInject constructor
|
||||||
val formattedHash = getFormattedHash(hash) // uses for flow sendLargeSolanaTransaction
|
val formattedHash = getFormattedHash(hash) // uses for flow sendLargeSolanaTransaction
|
||||||
if (context.session.wallet is UserWallet.Cold && isLargeHash(formattedHash)) {
|
if (context.session.wallet is UserWallet.Cold && isLargeHash(formattedHash)) {
|
||||||
// workaround for large transactions that cannot be signed directly by card
|
// workaround for large transactions that cannot be signed directly by card
|
||||||
TangemLogger.w("The transaction hash is too large to be signed directly: ${formattedHash.size} bytes")
|
TangemLogger.i("The transaction hash is too large to be signed directly: ${formattedHash.size} bytes")
|
||||||
sendLargeSolanaTransactionUseCase(context.session.wallet as UserWallet.Cold, context.network, formattedHash)
|
sendLargeSolanaTransactionUseCase(context.session.wallet as UserWallet.Cold, context.network, formattedHash)
|
||||||
.fold(
|
.fold(
|
||||||
ifLeft = { error ->
|
ifLeft = { error ->
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@ import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||||
import com.tangem.domain.account.supplier.SingleAccountListSupplier
|
import com.tangem.domain.account.supplier.SingleAccountListSupplier
|
||||||
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
|
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
|
||||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||||
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
|
||||||
import com.tangem.domain.express.ExpressServiceFetcher
|
import com.tangem.domain.express.ExpressServiceFetcher
|
||||||
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
|
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
|
||||||
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
|
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
|
||||||
import com.tangem.domain.networks.utils.NetworksCleaner
|
import com.tangem.domain.networks.utils.NetworksCleaner
|
||||||
import com.tangem.domain.nft.utils.NFTCleaner
|
import com.tangem.domain.nft.utils.NFTCleaner
|
||||||
|
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
||||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||||
import com.tangem.domain.staking.StakingIdFactory
|
import com.tangem.domain.staking.StakingIdFactory
|
||||||
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
|
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
|
||||||
|
|
@ -71,10 +71,12 @@ internal object AccountStatusUseCaseModule {
|
||||||
fun provideIsAccountsModeEnabledUseCase(
|
fun provideIsAccountsModeEnabledUseCase(
|
||||||
multiAccountListSupplier: MultiAccountListSupplier,
|
multiAccountListSupplier: MultiAccountListSupplier,
|
||||||
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
||||||
|
appCoroutineScope: AppCoroutineScope,
|
||||||
): IsAccountsModeEnabledUseCase {
|
): IsAccountsModeEnabledUseCase {
|
||||||
return IsAccountsModeEnabledUseCase(
|
return IsAccountsModeEnabledUseCase(
|
||||||
multiAccountListSupplier = multiAccountListSupplier,
|
multiAccountListSupplier = multiAccountListSupplier,
|
||||||
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
|
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
|
||||||
|
appCoroutineScope = appCoroutineScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class GetAccountCurrencyByAddressUseCase(
|
||||||
params = MultiNetworkStatusProducer.Params(userWalletId = id),
|
params = MultiNetworkStatusProducer.Params(userWalletId = id),
|
||||||
timeMillis = 1000L,
|
timeMillis = 1000L,
|
||||||
)
|
)
|
||||||
?.firstOrNull { it.getAddress() == address }
|
?.firstOrNull { it.getAddress().equals(address, ignoreCase = true) }
|
||||||
|
|
||||||
if (networkStatus != null) {
|
if (networkStatus != null) {
|
||||||
pair = id to networkStatus.network.id
|
pair = id to networkStatus.network.id
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import com.tangem.domain.account.models.AccountList
|
||||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||||
import com.tangem.domain.models.account.Account
|
import com.tangem.domain.models.account.Account
|
||||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||||
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
|
|
||||||
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
||||||
|
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use case to determine if the accounts mode is enabled.
|
* Use case to determine if the accounts mode is enabled.
|
||||||
|
|
@ -24,10 +26,23 @@ import kotlinx.coroutines.flow.*
|
||||||
class IsAccountsModeEnabledUseCase(
|
class IsAccountsModeEnabledUseCase(
|
||||||
private val multiAccountListSupplier: MultiAccountListSupplier,
|
private val multiAccountListSupplier: MultiAccountListSupplier,
|
||||||
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
||||||
|
appCoroutineScope: AppCoroutineScope,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val flow = createFlow()
|
||||||
|
.retry {
|
||||||
|
delay(timeMillis = TimeUnit.SECONDS.toMillis(1))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.shareIn(
|
||||||
|
scope = appCoroutineScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(0, 0),
|
||||||
|
replay = 1,
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
operator fun invoke(): Flow<Boolean> {
|
private fun createFlow(): Flow<Boolean> {
|
||||||
TangemLogger.i("$TAG: invoke() started")
|
TangemLogger.i("$TAG: invoke() started")
|
||||||
|
|
||||||
val cryptoMode = multiAccountListSupplier.invoke()
|
val cryptoMode = multiAccountListSupplier.invoke()
|
||||||
|
|
@ -73,25 +88,14 @@ class IsAccountsModeEnabledUseCase(
|
||||||
TangemLogger.i("$TAG: final combine crypto=$crypto, payment=$payment, result=$isEnabled")
|
TangemLogger.i("$TAG: final combine crypto=$crypto, payment=$payment, result=$isEnabled")
|
||||||
isEnabled
|
isEnabled
|
||||||
}
|
}
|
||||||
.distinctUntilChanged()
|
}
|
||||||
|
|
||||||
|
operator fun invoke(): Flow<Boolean> {
|
||||||
|
return flow
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun invokeSync(): Boolean {
|
suspend fun invokeSync(): Boolean {
|
||||||
val accountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty()
|
return flow.first()
|
||||||
if (accountLists.any { it.hasMultipleCryptoPortfolios() }) return true
|
|
||||||
|
|
||||||
val walletIdsWithPayment = accountLists.mapNotNull { list ->
|
|
||||||
if (list.accounts.any { it is Account.Payment }) list.userWalletId else null
|
|
||||||
}
|
|
||||||
return walletIdsWithPayment.any { walletId ->
|
|
||||||
paymentAccountStatusSupplier
|
|
||||||
.getSyncOrNull(
|
|
||||||
params = PaymentAccountStatusProducer.Params(walletId),
|
|
||||||
timeMillis = PAYMENT_STATUS_SYNC_TIMEOUT_MS,
|
|
||||||
)
|
|
||||||
?.value
|
|
||||||
?.isActivePayment() == true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AccountList.hasMultipleCryptoPortfolios(): Boolean =
|
private fun AccountList.hasMultipleCryptoPortfolios(): Boolean =
|
||||||
|
|
@ -113,7 +117,6 @@ class IsAccountsModeEnabledUseCase(
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val PAYMENT_STATUS_SYNC_TIMEOUT_MS = 1_000L
|
|
||||||
const val TAG = "IsAccountsModeEnabledUseCase"
|
const val TAG = "IsAccountsModeEnabledUseCase"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -280,6 +280,35 @@ class GetAccountCurrencyByAddressUseCaseTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns Some when query address case differs from stored network address`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val currency = MockCryptoCurrencyFactory().ethereum
|
||||||
|
val networkStatus = NetworkStatus(
|
||||||
|
network = currency.network,
|
||||||
|
// Stored address is lower-case
|
||||||
|
value = NetworkStatus.Unreachable(address = validNetworkAddress),
|
||||||
|
)
|
||||||
|
val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(currency))
|
||||||
|
|
||||||
|
every { userWalletsListRepository.userWallets.value } returns listOf(multiUserWallet)
|
||||||
|
coEvery {
|
||||||
|
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
|
||||||
|
} returns setOf(networkStatus)
|
||||||
|
coEvery {
|
||||||
|
singleAccountListSupplier.getSyncOrNull(
|
||||||
|
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
|
||||||
|
)
|
||||||
|
} returns accountList
|
||||||
|
|
||||||
|
// Act — query with an EIP-55 checksummed (mixed/upper-case) variant of the same address
|
||||||
|
val actual = useCase(validAddress.uppercase())
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val expected = AccountCryptoCurrency(account = accountList.mainAccount, cryptoCurrency = currency)
|
||||||
|
assertSome(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object Companion {
|
private companion object Companion {
|
||||||
const val validAddress = "0x1234567890abcdef"
|
const val validAddress = "0x1234567890abcdef"
|
||||||
val validNetworkAddress = NetworkAddress.Single(
|
val validNetworkAddress = NetworkAddress.Single(
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,16 @@ import com.tangem.domain.models.account.Account
|
||||||
import com.tangem.domain.models.account.AccountStatus
|
import com.tangem.domain.models.account.AccountStatus
|
||||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
|
|
||||||
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
||||||
|
import com.tangem.test.core.TestAppCoroutineScope
|
||||||
|
import com.tangem.test.core.getEmittedValues
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.last
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Nested
|
import org.junit.jupiter.api.Nested
|
||||||
|
|
@ -27,11 +29,6 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
|
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
|
||||||
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
|
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
|
||||||
|
|
||||||
private val useCase = IsAccountsModeEnabledUseCase(
|
|
||||||
multiAccountListSupplier = multiAccountListSupplier,
|
|
||||||
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
|
|
||||||
)
|
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
clearMocks(multiAccountListSupplier, paymentAccountStatusSupplier)
|
clearMocks(multiAccountListSupplier, paymentAccountStatusSupplier)
|
||||||
|
|
@ -43,9 +40,9 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `returns false when supplier emits empty list`() = runTest {
|
fun `returns false when supplier emits empty list`() = runTest {
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(emptyList())
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isFalse()
|
Truth.assertThat(actual).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -53,9 +50,9 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns false when single crypto portfolio account`() = runTest {
|
fun `returns false when single crypto portfolio account`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isFalse()
|
Truth.assertThat(actual).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -63,9 +60,9 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns true when two crypto portfolio accounts`() = runTest {
|
fun `returns true when two crypto portfolio accounts`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -73,10 +70,10 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns true when payment account is Loaded`() = runTest {
|
fun `returns true when payment account is Loaded`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
|
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -84,10 +81,10 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns false when payment account is NotCreated`() = runTest {
|
fun `returns false when payment account is NotCreated`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.NotCreated)
|
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.NotCreated)
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isFalse()
|
Truth.assertThat(actual).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -95,10 +92,10 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns false when payment account is Empty`() = runTest {
|
fun `returns false when payment account is Empty`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Empty)
|
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Empty)
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isFalse()
|
Truth.assertThat(actual).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -106,10 +103,10 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns true when payment account is UnderReview`() = runTest {
|
fun `returns true when payment account is UnderReview`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.UnderReview>())
|
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.UnderReview>())
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -117,10 +114,10 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns true when payment account is IssuingCard`() = runTest {
|
fun `returns true when payment account is IssuingCard`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.IssuingCard>())
|
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.IssuingCard>())
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -128,10 +125,10 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns true when payment account is Loading`() = runTest {
|
fun `returns true when payment account is Loading`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Loading)
|
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Loading)
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -139,10 +136,10 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns true when payment account is Deactivated`() = runTest {
|
fun `returns true when payment account is Deactivated`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Deactivated>())
|
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Deactivated>())
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -151,9 +148,9 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
fun `returns true when multiple wallets and one has two crypto portfolios`() = runTest {
|
fun `returns true when multiple wallets and one has two crypto portfolios`() = runTest {
|
||||||
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
|
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
|
||||||
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
|
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list1, list2))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list1, list2))
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -162,10 +159,10 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
fun `returns true when multiple wallets and one has active payment`() = runTest {
|
fun `returns true when multiple wallets and one has active payment`() = runTest {
|
||||||
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
|
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
|
||||||
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list1, list2))
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list1, list2))
|
||||||
mockPaymentStatus(WALLET_ID_2, mockk<PaymentAccountStatusValue.Loaded>())
|
mockPaymentStatus(WALLET_ID_2, mockk<PaymentAccountStatusValue.Loaded>())
|
||||||
|
|
||||||
val actual = useCase.invoke().last()
|
val actual = settledValue(createUseCase())
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -176,29 +173,9 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
inner class InvokeSync {
|
inner class InvokeSync {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `returns false when getSyncOrNull returns null`() = runTest {
|
fun `returns false when supplier emits empty list`() = runTest {
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns null
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(emptyList())
|
||||||
|
val actual = invokeSyncSettled(createUseCase())
|
||||||
val actual = useCase.invokeSync()
|
|
||||||
|
|
||||||
Truth.assertThat(actual).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `returns false when getSyncOrNull returns empty list`() = runTest {
|
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns emptyList()
|
|
||||||
|
|
||||||
val actual = useCase.invokeSync()
|
|
||||||
|
|
||||||
Truth.assertThat(actual).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `returns false when single crypto portfolio account`() = runTest {
|
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
|
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
|
|
||||||
|
|
||||||
val actual = useCase.invokeSync()
|
|
||||||
|
|
||||||
Truth.assertThat(actual).isFalse()
|
Truth.assertThat(actual).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -206,9 +183,8 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns true when two crypto portfolio accounts`() = runTest {
|
fun `returns true when two crypto portfolio accounts`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
|
val actual = invokeSyncSettled(createUseCase())
|
||||||
val actual = useCase.invokeSync()
|
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -216,69 +192,48 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `returns true when payment account is Loaded`() = runTest {
|
fun `returns true when payment account is Loaded`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
|
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
|
||||||
|
val actual = invokeSyncSettled(createUseCase())
|
||||||
val actual = useCase.invokeSync()
|
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
Truth.assertThat(actual).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `returns false when payment account is NotCreated`() = runTest {
|
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
|
|
||||||
mockPaymentStatusSync(WALLET_ID_1, PaymentAccountStatusValue.NotCreated)
|
|
||||||
|
|
||||||
val actual = useCase.invokeSync()
|
|
||||||
|
|
||||||
Truth.assertThat(actual).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `returns false when payment account is Empty`() = runTest {
|
fun `returns false when payment account is Empty`() = runTest {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
|
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
|
||||||
mockPaymentStatusSync(WALLET_ID_1, PaymentAccountStatusValue.Empty)
|
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Empty)
|
||||||
|
val actual = invokeSyncSettled(createUseCase())
|
||||||
val actual = useCase.invokeSync()
|
|
||||||
|
|
||||||
Truth.assertThat(actual).isFalse()
|
Truth.assertThat(actual).isFalse()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
fun `returns true when payment account is UnderReview`() = runTest {
|
private fun TestScope.createUseCase(): IsAccountsModeEnabledUseCase = IsAccountsModeEnabledUseCase(
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
multiAccountListSupplier = multiAccountListSupplier,
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
|
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
|
||||||
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.UnderReview>())
|
appCoroutineScope = TestAppCoroutineScope(
|
||||||
|
backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
val actual = useCase.invokeSync()
|
/**
|
||||||
|
* Returns the resolved value of the shared [IsAccountsModeEnabledUseCase.invoke] flow. The flow is
|
||||||
|
* a hot [kotlinx.coroutines.flow.SharedFlow] (replay = 1) that never completes, so we read the
|
||||||
|
* last value emitted while a subscriber is active.
|
||||||
|
*/
|
||||||
|
private fun TestScope.settledValue(useCase: IsAccountsModeEnabledUseCase): Boolean =
|
||||||
|
getEmittedValues(useCase.invoke()).last()
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
/**
|
||||||
}
|
* Samples via [IsAccountsModeEnabledUseCase.invokeSync] while a subscriber keeps the shared flow
|
||||||
|
* warm — mirroring production, where a screen already observes the flow before invokeSync reads it.
|
||||||
@Test
|
*/
|
||||||
fun `returns true when payment account is Deactivated`() = runTest {
|
private suspend fun TestScope.invokeSyncSettled(useCase: IsAccountsModeEnabledUseCase): Boolean {
|
||||||
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
getEmittedValues(useCase.invoke())
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
|
return useCase.invokeSync()
|
||||||
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.Deactivated>())
|
|
||||||
|
|
||||||
val actual = useCase.invokeSync()
|
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `returns true when multiple wallets and one has loaded payment`() = runTest {
|
|
||||||
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
|
|
||||||
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
|
|
||||||
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list1, list2)
|
|
||||||
mockPaymentStatusSync(WALLET_ID_2, mockk<PaymentAccountStatusValue.Loaded>())
|
|
||||||
|
|
||||||
val actual = useCase.invokeSync()
|
|
||||||
|
|
||||||
Truth.assertThat(actual).isTrue()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mockCryptoPortfolio(): Account.CryptoPortfolio = mockk()
|
private fun mockCryptoPortfolio(): Account.CryptoPortfolio = mockk()
|
||||||
|
|
@ -287,14 +242,7 @@ class IsAccountsModeEnabledUseCaseTest {
|
||||||
|
|
||||||
private fun mockPaymentStatus(walletId: UserWalletId, value: PaymentAccountStatusValue) {
|
private fun mockPaymentStatus(walletId: UserWalletId, value: PaymentAccountStatusValue) {
|
||||||
val status = mockk<AccountStatus.Payment> { every { this@mockk.value } returns value }
|
val status = mockk<AccountStatus.Payment> { every { this@mockk.value } returns value }
|
||||||
every { paymentAccountStatusSupplier.invoke(walletId) } returns flowOf(status)
|
every { paymentAccountStatusSupplier.invoke(walletId) } returns MutableStateFlow(status)
|
||||||
}
|
|
||||||
|
|
||||||
private fun mockPaymentStatusSync(walletId: UserWalletId, value: PaymentAccountStatusValue) {
|
|
||||||
val status = mockk<AccountStatus.Payment> { every { this@mockk.value } returns value }
|
|
||||||
coEvery {
|
|
||||||
paymentAccountStatusSupplier.getSyncOrNull(PaymentAccountStatusProducer.Params(walletId), any())
|
|
||||||
} returns status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createAccountList(walletId: UserWalletId, accounts: List<Account>): AccountList = mockk {
|
private fun createAccountList(walletId: UserWalletId, accounts: List<Account>): AccountList = mockk {
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@ package com.tangem.domain.appsflyer
|
||||||
|
|
||||||
enum class AppsFlyerDeeplinkSource {
|
enum class AppsFlyerDeeplinkSource {
|
||||||
TangemPayHotWalletOnboarding,
|
TangemPayHotWalletOnboarding,
|
||||||
|
Referral,
|
||||||
}
|
}
|
||||||
|
|
@ -4,5 +4,7 @@ import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
|
||||||
|
|
||||||
interface AppsFlyerRepository {
|
interface AppsFlyerRepository {
|
||||||
|
|
||||||
|
suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String?
|
||||||
|
|
||||||
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
|
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.tangem.domain.appsflyer.usecase
|
||||||
|
|
||||||
|
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
|
||||||
|
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the app install was attributed to an AppsFlyer referral deep link.
|
||||||
|
*/
|
||||||
|
class IsReferralInstallUseCase(
|
||||||
|
private val appsFlyerRepository: AppsFlyerRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Boolean {
|
||||||
|
return appsFlyerRepository.getDeeplink(AppsFlyerDeeplinkSource.Referral) != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,8 @@ interface CardTypesResolver {
|
||||||
|
|
||||||
fun isSingleWalletWithToken(): Boolean
|
fun isSingleWalletWithToken(): Boolean
|
||||||
|
|
||||||
|
fun isSingleCurrency(): Boolean
|
||||||
|
|
||||||
fun isMultiwalletAllowed(): Boolean
|
fun isMultiwalletAllowed(): Boolean
|
||||||
|
|
||||||
fun getBlockchain(): Blockchain
|
fun getBlockchain(): Blockchain
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ internal class TangemCardTypesResolver(
|
||||||
|
|
||||||
override fun isSingleWalletWithToken(): Boolean = walletData?.token != null && !isMultiwalletAllowed()
|
override fun isSingleWalletWithToken(): Boolean = walletData?.token != null && !isMultiwalletAllowed()
|
||||||
|
|
||||||
|
override fun isSingleCurrency(): Boolean = isSingleWallet() || isSingleWalletWithToken()
|
||||||
|
|
||||||
override fun isMultiwalletAllowed(): Boolean {
|
override fun isMultiwalletAllowed(): Boolean {
|
||||||
return !isTangemTwins() &&
|
return !isTangemTwins() &&
|
||||||
!card.isStart2Coin &&
|
!card.isStart2Coin &&
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ data object Wallet2CardConfig : CardConfig {
|
||||||
Blockchain.BitcoinCashTestnet -> EllipticCurve.Secp256k1
|
Blockchain.BitcoinCashTestnet -> EllipticCurve.Secp256k1
|
||||||
Blockchain.Cardano -> EllipticCurve.Ed25519
|
Blockchain.Cardano -> EllipticCurve.Ed25519
|
||||||
Blockchain.Cosmos -> EllipticCurve.Secp256k1
|
Blockchain.Cosmos -> EllipticCurve.Secp256k1
|
||||||
|
Blockchain.Gonka -> EllipticCurve.Secp256k1
|
||||||
Blockchain.CosmosTestnet -> EllipticCurve.Secp256k1
|
Blockchain.CosmosTestnet -> EllipticCurve.Secp256k1
|
||||||
Blockchain.Dogecoin -> EllipticCurve.Secp256k1
|
Blockchain.Dogecoin -> EllipticCurve.Secp256k1
|
||||||
Blockchain.Ducatus -> EllipticCurve.Secp256k1
|
Blockchain.Ducatus -> EllipticCurve.Secp256k1
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class Wallet2CardConfigTest {
|
||||||
Blockchain.BitcoinCashTestnet to EllipticCurve.Secp256k1,
|
Blockchain.BitcoinCashTestnet to EllipticCurve.Secp256k1,
|
||||||
Blockchain.Cardano to EllipticCurve.Ed25519,
|
Blockchain.Cardano to EllipticCurve.Ed25519,
|
||||||
Blockchain.Cosmos to EllipticCurve.Secp256k1,
|
Blockchain.Cosmos to EllipticCurve.Secp256k1,
|
||||||
|
Blockchain.Gonka to EllipticCurve.Secp256k1,
|
||||||
Blockchain.CosmosTestnet to EllipticCurve.Secp256k1,
|
Blockchain.CosmosTestnet to EllipticCurve.Secp256k1,
|
||||||
Blockchain.Dogecoin to EllipticCurve.Secp256k1,
|
Blockchain.Dogecoin to EllipticCurve.Secp256k1,
|
||||||
Blockchain.Ducatus to EllipticCurve.Secp256k1,
|
Blockchain.Ducatus to EllipticCurve.Secp256k1,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.tangem.domain.tokens.actions
|
package com.tangem.domain.tokens.actions
|
||||||
|
|
||||||
|
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||||
import com.tangem.domain.exchange.RampStateManager
|
import com.tangem.domain.exchange.RampStateManager
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
|
|
@ -65,6 +66,12 @@ internal open class BaseActionsFactory(
|
||||||
currency: CryptoCurrency,
|
currency: CryptoCurrency,
|
||||||
requirementsDeferred: Deferred<AssetRequirementsCondition?>?,
|
requirementsDeferred: Deferred<AssetRequirementsCondition?>?,
|
||||||
): ScenarioUnavailabilityReason {
|
): ScenarioUnavailabilityReason {
|
||||||
|
// Start2Coin (S2C) are legacy single-currency cards that do not support buying crypto in-app
|
||||||
|
// (historically only Receive/Send were offered for them).
|
||||||
|
if (userWallet is UserWallet.Cold && userWallet.cardTypesResolver.isStart2Coin()) {
|
||||||
|
return ScenarioUnavailabilityReason.BuyUnavailable(currency.name)
|
||||||
|
}
|
||||||
|
|
||||||
val onrampUnavailabilityReason = rampStateManager.availableForBuy(
|
val onrampUnavailabilityReason = rampStateManager.availableForBuy(
|
||||||
userWallet = userWallet,
|
userWallet = userWallet,
|
||||||
cryptoCurrency = currency,
|
cryptoCurrency = currency,
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tangem.core.ui.R
|
import com.tangem.core.ui.R
|
||||||
|
import com.tangem.core.ui.components.RectangleShimmer
|
||||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||||
import com.tangem.core.ui.ds2.button.TangemButton
|
import com.tangem.core.ui.ds2.button.TangemButton
|
||||||
import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer
|
import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer
|
||||||
import com.tangem.core.ui.ds2.shimmers.RectangleShimmer
|
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ internal class AddTokenUiBuilder @Inject constructor(
|
||||||
isEnabled = isAvailableNetwork,
|
isEnabled = isAvailableNetwork,
|
||||||
showProgress = false,
|
showProgress = false,
|
||||||
isTangemIconVisible = isTangemIconVisible,
|
isTangemIconVisible = isTangemIconVisible,
|
||||||
text = resourceReference(R.string.common_add),
|
text = resourceReference(R.string.common_confirm),
|
||||||
onConfirmClick = onConfirmClick,
|
onConfirmClick = onConfirmClick,
|
||||||
)
|
)
|
||||||
val networkUM = createNetwork(selectedNetwork)
|
val networkUM = createNetwork(selectedNetwork)
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,7 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
|
||||||
.clip(RoundedCornerShape(percent = 50))
|
.clip(RoundedCornerShape(percent = 50))
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(onClick = state.onClick)
|
.clickable(onClick = state.onClick)
|
||||||
|
.testTag(BuyTokenScreenTestTags.WALLET_TAB)
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
|
||||||
|
|
@ -190,8 +190,10 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onCloseClick: () -> Unit,
|
onCloseClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val spec = route.uiSpec(model.flowType)
|
||||||
TangemTopBar(
|
TangemTopBar(
|
||||||
title = route.uiSpec(model.flowType).title,
|
title = spec.title,
|
||||||
|
subtitle = spec.subtitle,
|
||||||
type = TangemTopBarType.BottomSheet,
|
type = TangemTopBarType.BottomSheet,
|
||||||
startContent = if (canGoBack) {
|
startContent = if (canGoBack) {
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import com.tangem.features.commonfeatures.impl.R
|
||||||
|
|
||||||
internal data class ManageFundsRouteUiSpec(
|
internal data class ManageFundsRouteUiSpec(
|
||||||
val title: TextReference,
|
val title: TextReference,
|
||||||
|
val subtitle: TextReference?,
|
||||||
val shouldApplyHorizontalPadding: Boolean,
|
val shouldApplyHorizontalPadding: Boolean,
|
||||||
val shouldFillHeight: Boolean,
|
val shouldFillHeight: Boolean,
|
||||||
)
|
)
|
||||||
|
|
@ -16,21 +17,25 @@ internal fun ManageFundsModel.UiRoute.uiSpec(flowType: ManageFundsComponent.Flow
|
||||||
return when (this) {
|
return when (this) {
|
||||||
ManageFundsModel.UiRoute.Loading -> ManageFundsRouteUiSpec(
|
ManageFundsModel.UiRoute.Loading -> ManageFundsRouteUiSpec(
|
||||||
title = resourceReference(if (isTransfer) R.string.common_choose_token else R.string.common_add_funds),
|
title = resourceReference(if (isTransfer) R.string.common_choose_token else R.string.common_add_funds),
|
||||||
|
subtitle = null,
|
||||||
shouldApplyHorizontalPadding = false,
|
shouldApplyHorizontalPadding = false,
|
||||||
shouldFillHeight = false,
|
shouldFillHeight = false,
|
||||||
)
|
)
|
||||||
ManageFundsModel.UiRoute.ChooseToken -> ManageFundsRouteUiSpec(
|
ManageFundsModel.UiRoute.ChooseToken -> ManageFundsRouteUiSpec(
|
||||||
title = resourceReference(R.string.common_choose_token),
|
title = resourceReference(R.string.common_choose_token),
|
||||||
|
subtitle = null,
|
||||||
shouldApplyHorizontalPadding = false,
|
shouldApplyHorizontalPadding = false,
|
||||||
shouldFillHeight = true,
|
shouldFillHeight = true,
|
||||||
)
|
)
|
||||||
ManageFundsModel.UiRoute.UserPortfolio -> ManageFundsRouteUiSpec(
|
ManageFundsModel.UiRoute.UserPortfolio -> ManageFundsRouteUiSpec(
|
||||||
title = resourceReference(R.string.common_add_funds),
|
title = resourceReference(R.string.common_add_funds),
|
||||||
|
subtitle = resourceReference(R.string.common_choose_token),
|
||||||
shouldApplyHorizontalPadding = false,
|
shouldApplyHorizontalPadding = false,
|
||||||
shouldFillHeight = false,
|
shouldFillHeight = false,
|
||||||
)
|
)
|
||||||
ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec(
|
ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec(
|
||||||
title = resourceReference(if (isTransfer) R.string.common_transfer else R.string.common_get_token),
|
title = resourceReference(if (isTransfer) R.string.common_transfer else R.string.common_get_token),
|
||||||
|
subtitle = null,
|
||||||
shouldApplyHorizontalPadding = true,
|
shouldApplyHorizontalPadding = true,
|
||||||
shouldFillHeight = true,
|
shouldFillHeight = true,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ private fun AddWalletButton(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BlockCard(
|
BlockCard(
|
||||||
modifier = modifier,
|
modifier = modifier.testTag(DetailsScreenTestTags.ADD_WALLET_BUTTON),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = !isInProgress,
|
enabled = !isInProgress,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
||||||
|
|
||||||
private fun onChildBack() {
|
private fun onChildBack() {
|
||||||
if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) {
|
if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) {
|
||||||
stackNavigation.pop()
|
if (stack.value.backStack.isEmpty()) {
|
||||||
|
router.pop()
|
||||||
|
} else {
|
||||||
|
stackNavigation.pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,11 @@
|
||||||
package com.tangem.features.feed.components.market.details
|
package com.tangem.features.feed.components.market.details
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.vectorResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.arkivanov.decompose.ComponentContext
|
import com.arkivanov.decompose.ComponentContext
|
||||||
|
|
@ -27,32 +19,24 @@ import com.tangem.core.decompose.context.child
|
||||||
import com.tangem.core.decompose.context.childByContext
|
import com.tangem.core.decompose.context.childByContext
|
||||||
import com.tangem.core.decompose.model.getOrCreateModel
|
import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
import com.tangem.core.ui.DesignFeatureToggles
|
import com.tangem.core.ui.DesignFeatureToggles
|
||||||
import com.tangem.core.ui.R
|
|
||||||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||||
import com.tangem.core.ui.components.haze.hazeEffectTangem
|
|
||||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
|
||||||
import com.tangem.core.ui.ds.topbar.TangemTopBarType
|
|
||||||
import com.tangem.core.ui.extensions.clickableSingle
|
|
||||||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
|
||||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||||
import com.tangem.domain.markets.PreselectedTokenDetailsSection
|
import com.tangem.domain.markets.PreselectedTokenDetailsSection
|
||||||
import com.tangem.domain.markets.TokenMarketParams
|
import com.tangem.domain.markets.TokenMarketParams
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
|
|
||||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
|
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
|
||||||
|
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
|
||||||
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
|
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
|
||||||
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
|
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
|
||||||
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents
|
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents
|
||||||
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
|
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
|
||||||
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
|
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
|
||||||
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
|
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
|
||||||
import com.tangem.features.feed.ui.LocalIsOpenedInBottomSheet
|
|
||||||
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent
|
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent
|
||||||
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar
|
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTitle
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -92,7 +76,7 @@ internal class DefaultMarketsTokenDetailsComponent(
|
||||||
}
|
}
|
||||||
|
|
||||||
private val portfolioBlockComponent: PortfolioBlockComponent? =
|
private val portfolioBlockComponent: PortfolioBlockComponent? =
|
||||||
if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) {
|
if (designFeatureToggles.isRedesignEnabled) {
|
||||||
portfolioBlockComponentFactory.create(
|
portfolioBlockComponentFactory.create(
|
||||||
context = child("portfolio_block"),
|
context = child("portfolio_block"),
|
||||||
params = PortfolioBlockComponent.Params(token = updatedParams.token),
|
params = PortfolioBlockComponent.Params(token = updatedParams.token),
|
||||||
|
|
@ -187,58 +171,12 @@ internal class DefaultMarketsTokenDetailsComponent(
|
||||||
@Composable
|
@Composable
|
||||||
override fun Title(bottomSheetState: State<BottomSheetState>) {
|
override fun Title(bottomSheetState: State<BottomSheetState>) {
|
||||||
val state by model.state.collectAsStateWithLifecycle()
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
val background = LocalMainBottomSheetColor.current.value
|
MarketsTokenDetailsTitle(
|
||||||
if (LocalRedesignEnabled.current) {
|
state = state,
|
||||||
TangemTopBar(
|
backgroundColor = LocalMainBottomSheetColor.current.value,
|
||||||
startContent = {
|
isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
|
||||||
Icon(
|
onBackClick = { params.onBackClicked() },
|
||||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
|
)
|
||||||
contentDescription = null,
|
|
||||||
tint = TangemTheme.colors2.graphic.neutral.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(TangemTheme.dimens2.x11)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.hazeEffectTangem { blurRadius = 8.dp }
|
|
||||||
.clickableSingle(
|
|
||||||
onClick = { params.onBackClicked() },
|
|
||||||
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
|
|
||||||
)
|
|
||||||
.padding(TangemTheme.dimens2.x2),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
endContent = {
|
|
||||||
Icon(
|
|
||||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_share_new_24),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = TangemTheme.colors2.graphic.neutral.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(TangemTheme.dimens2.x11)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.hazeEffectTangem { blurRadius = 8.dp }
|
|
||||||
.clickableSingle(
|
|
||||||
onClick = state.onShareClick,
|
|
||||||
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
|
|
||||||
)
|
|
||||||
.padding(TangemTheme.dimens2.x2_5),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
type = if (LocalIsOpenedInBottomSheet.current) {
|
|
||||||
TangemTopBarType.BottomSheet
|
|
||||||
} else {
|
|
||||||
TangemTopBarType.Default
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
MarketsTokenDetailsTopBar(
|
|
||||||
onBackClick = { params.onBackClicked() },
|
|
||||||
isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
|
|
||||||
shouldShowPriceSubtitle = state.shouldShowPriceSubtitle,
|
|
||||||
tokenName = state.tokenName,
|
|
||||||
tokenPrice = state.priceText,
|
|
||||||
backgroundColor = background,
|
|
||||||
onShareClick = state.onShareClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ internal class PortfolioBlockModel @Inject constructor(
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
val state: StateFlow<PortfolioBlockUM>
|
val state: StateFlow<PortfolioBlockUM>
|
||||||
field = MutableStateFlow<PortfolioBlockUM>(PortfolioBlockUM.Loading)
|
field = MutableStateFlow<PortfolioBlockUM>(PortfolioBlockUM.Hidden)
|
||||||
|
|
||||||
val cryptoCurrencyIdState: StateFlow<CryptoCurrency.ID?>
|
val cryptoCurrencyIdState: StateFlow<CryptoCurrency.ID?>
|
||||||
field = MutableStateFlow(null)
|
field = MutableStateFlow(null)
|
||||||
|
|
@ -86,7 +86,7 @@ internal class PortfolioBlockModel @Inject constructor(
|
||||||
private fun combineData(): Flow<PortfolioBlockUM> {
|
private fun combineData(): Flow<PortfolioBlockUM> {
|
||||||
return availableNetworks.transformLatest { networks ->
|
return availableNetworks.transformLatest { networks ->
|
||||||
if (networks.isEmpty()) {
|
if (networks.isEmpty()) {
|
||||||
emit(PortfolioBlockUM.Hidden)
|
emit(PortfolioBlockUM.Unsupported(tokenIcon = tokenIcon))
|
||||||
} else {
|
} else {
|
||||||
emitAll(portfolioFlow().distinctUntilChanged())
|
emitAll(portfolioFlow().distinctUntilChanged())
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +145,7 @@ internal class PortfolioBlockModel @Inject constructor(
|
||||||
onAddClick = { parentRouter?.openAddToPortfolioDirect() },
|
onAddClick = { parentRouter?.openAddToPortfolioDirect() },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
PortfolioBlockUM.Hidden
|
PortfolioBlockUM.Unsupported(tokenIcon = tokenIcon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,11 @@ import com.tangem.features.feed.impl.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifier) {
|
internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifier) {
|
||||||
val isVisible = state is PortfolioBlockUM.AddToken || state is PortfolioBlockUM.Content
|
|
||||||
val screenHeightPx = with(LocalDensity.current) { LocalWindowSize.current.height.toPx() }
|
val screenHeightPx = with(LocalDensity.current) { LocalWindowSize.current.height.toPx() }
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isVisible,
|
visible = state !is PortfolioBlockUM.Hidden,
|
||||||
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
|
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
|
||||||
) {
|
) {
|
||||||
TangemFade(
|
TangemFade(
|
||||||
|
|
@ -63,7 +62,7 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
visible = isVisible,
|
visible = state !is PortfolioBlockUM.Hidden,
|
||||||
enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)),
|
enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)),
|
||||||
exit = fadeOut(animationSpec = tween(durationMillis = 300)),
|
exit = fadeOut(animationSpec = tween(durationMillis = 300)),
|
||||||
) {
|
) {
|
||||||
|
|
@ -87,9 +86,8 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi
|
||||||
when (state) {
|
when (state) {
|
||||||
is PortfolioBlockUM.AddToken -> AddTokenBlock(state)
|
is PortfolioBlockUM.AddToken -> AddTokenBlock(state)
|
||||||
is PortfolioBlockUM.Content -> ContentBlock(state)
|
is PortfolioBlockUM.Content -> ContentBlock(state)
|
||||||
is PortfolioBlockUM.Hidden,
|
is PortfolioBlockUM.Unsupported -> UnsupportedTokenBlock(state.tokenIcon)
|
||||||
is PortfolioBlockUM.Loading,
|
is PortfolioBlockUM.Hidden -> Unit
|
||||||
-> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,13 +156,19 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier =
|
||||||
FloatingCard(modifier = modifier) {
|
FloatingCard(modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(TangemTheme.dimens2.x3)
|
.padding(
|
||||||
|
vertical = 12.dp,
|
||||||
|
horizontal = 16.dp,
|
||||||
|
)
|
||||||
.clickableSingle(onClick = state.onAddClick),
|
.clickableSingle(onClick = state.onAddClick),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
CurrencyIcon(state.tokenIcon)
|
CurrencyIcon(
|
||||||
|
state = state.tokenIcon,
|
||||||
|
iconSize = 40.dp,
|
||||||
|
)
|
||||||
|
|
||||||
SpacerW(TangemTheme.dimens2.x3)
|
SpacerW(8.dp)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = formatAnnotatedWithBoldColor(
|
text = formatAnnotatedWithBoldColor(
|
||||||
|
|
@ -192,6 +196,39 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UnsupportedTokenBlock(icon: CurrencyIconState, modifier: Modifier = Modifier) {
|
||||||
|
FloatingCard(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(
|
||||||
|
vertical = 12.dp,
|
||||||
|
horizontal = 16.dp,
|
||||||
|
)
|
||||||
|
.clickableSingle(onClick = {}), // just intercept
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CurrencyIcon(
|
||||||
|
state = icon,
|
||||||
|
iconSize = 40.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
SpacerW(8.dp)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = formatAnnotatedWithBoldColor(
|
||||||
|
rawString = stringResourceSafe(R.string.markets_portfolio_block_token_unsupported),
|
||||||
|
boldColor = TangemTheme.colors2.text.neutral.primary,
|
||||||
|
),
|
||||||
|
style = TangemTheme.typography2.captionMedium12,
|
||||||
|
color = TangemTheme.colors2.text.neutral.secondary,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FloatingCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
private fun FloatingCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,14 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
internal sealed class PortfolioBlockUM {
|
internal sealed interface PortfolioBlockUM {
|
||||||
|
|
||||||
data object Loading : PortfolioBlockUM()
|
data object Hidden : PortfolioBlockUM
|
||||||
data object Hidden : PortfolioBlockUM()
|
|
||||||
|
|
||||||
data class AddToken(
|
data class AddToken(
|
||||||
val tokenIcon: CurrencyIconState,
|
val tokenIcon: CurrencyIconState,
|
||||||
val onAddClick: () -> Unit,
|
val onAddClick: () -> Unit,
|
||||||
) : PortfolioBlockUM()
|
) : PortfolioBlockUM
|
||||||
|
|
||||||
data class Content(
|
data class Content(
|
||||||
val totalBalance: TextReference,
|
val totalBalance: TextReference,
|
||||||
|
|
@ -24,5 +23,9 @@ internal sealed class PortfolioBlockUM {
|
||||||
val isBalanceHidden: Boolean,
|
val isBalanceHidden: Boolean,
|
||||||
val onRowClick: () -> Unit,
|
val onRowClick: () -> Unit,
|
||||||
val onAddFundsClick: () -> Unit,
|
val onAddFundsClick: () -> Unit,
|
||||||
) : PortfolioBlockUM()
|
) : PortfolioBlockUM
|
||||||
|
|
||||||
|
data class Unsupported(
|
||||||
|
val tokenIcon: CurrencyIconState,
|
||||||
|
) : PortfolioBlockUM
|
||||||
}
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -24,6 +25,7 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||||
import com.tangem.core.ui.components.haze.hazeEffectTangem
|
import com.tangem.core.ui.components.haze.hazeEffectTangem
|
||||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||||
import com.tangem.core.ui.extensions.clickableSingle
|
import com.tangem.core.ui.extensions.clickableSingle
|
||||||
|
import com.tangem.core.ui.extensions.conditional
|
||||||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
|
|
@ -56,11 +58,13 @@ internal class DefaultMarketsTokenListComponent(
|
||||||
val bsState by bottomSheetState
|
val bsState by bottomSheetState
|
||||||
|
|
||||||
if (LocalRedesignEnabled.current) {
|
if (LocalRedesignEnabled.current) {
|
||||||
val background = LocalMainBottomSheetColor.current.value
|
val bottomSheetColor = LocalMainBottomSheetColor.current.value
|
||||||
FeedSearchBar(
|
FeedSearchBar(
|
||||||
isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED,
|
isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED,
|
||||||
feedListSearchBar = state.feedListSearchBar,
|
feedListSearchBar = state.feedListSearchBar,
|
||||||
modifier = Modifier.background(background.copy(alpha = .95f)),
|
modifier = Modifier.conditional(bottomSheetColor.isSpecified) {
|
||||||
|
background(bottomSheetColor.copy(alpha = .95f))
|
||||||
|
},
|
||||||
startContent = {
|
startContent = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
|
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ internal class EarnTokenWithCurrencyToListItemUMConverter(
|
||||||
|
|
||||||
override fun convert(value: EarnTokenWithCurrency): EarnListItemUM {
|
override fun convert(value: EarnTokenWithCurrency): EarnListItemUM {
|
||||||
return EarnListItemUM(
|
return EarnListItemUM(
|
||||||
|
id = "${value.cryptoCurrency.id.value}_${value.earnToken.type}",
|
||||||
network = TextReference.Str(value.networkName),
|
network = TextReference.Str(value.networkName),
|
||||||
symbol = TextReference.Str(value.earnToken.tokenSymbol),
|
symbol = TextReference.Str(value.earnToken.tokenSymbol),
|
||||||
tokenName = TextReference.Str(value.earnToken.tokenName),
|
tokenName = TextReference.Str(value.earnToken.tokenName),
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
||||||
onScroll = {},
|
onScroll = {},
|
||||||
),
|
),
|
||||||
onShareClick = ::onShareClick,
|
onShareClick = ::onShareClick,
|
||||||
|
isAddToPortfolioButtonVisible = false,
|
||||||
|
onAddToPortfolioClick = ::openAddToPortfolio,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -344,6 +346,18 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isAddToPortfolioAvailable) {
|
||||||
|
addToPortfolioManager.setTokenParams(params.token)
|
||||||
|
addToPortfolioManager.state
|
||||||
|
.map { managerState ->
|
||||||
|
managerState is AddToPortfolioManager.State.Ready && managerState.isAvailableToAdd
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { isVisible ->
|
||||||
|
state.update { it.copy(isAddToPortfolioButtonVisible = isVisible) }
|
||||||
|
}
|
||||||
|
.launchIn(modelScope)
|
||||||
|
}
|
||||||
addToPortfolioManager.onDismiss.receiveAsFlow()
|
addToPortfolioManager.onDismiss.receiveAsFlow()
|
||||||
.onEach { addToPortfolioSheetNavigation.dismiss() }
|
.onEach { addToPortfolioSheetNavigation.dismiss() }
|
||||||
.launchIn(modelScope)
|
.launchIn(modelScope)
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ internal class UserAssetSearchItemConverter(
|
||||||
val entryCurrencyStatus = item.entries.first().currencyStatus
|
val entryCurrencyStatus = item.entries.first().currencyStatus
|
||||||
|
|
||||||
return UserAssetItemUM.Grouped(
|
return UserAssetItemUM.Grouped(
|
||||||
id = "grouped_${item.tokenName}_${item.tokenSymbol}",
|
id = "grouped_${firstCurrency.id.value}",
|
||||||
icon = TangemIconUM.Currency(
|
icon = TangemIconUM.Currency(
|
||||||
currencyIconState = CurrencyIconState.CoinIcon(
|
currencyIconState = CurrencyIconState.CoinIcon(
|
||||||
url = entryCurrencyStatus.currency.iconUrl,
|
url = entryCurrencyStatus.currency.iconUrl,
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = animatedState.items,
|
items = animatedState.items,
|
||||||
key = { _, item -> "${item.tokenName}-${item.network}" },
|
key = { _, item -> item.id },
|
||||||
) { index, item ->
|
) { index, item ->
|
||||||
val cardModifier = Modifier.conditional(
|
val cardModifier = Modifier.conditional(
|
||||||
condition = index == FOURTH_ITEM_INDEX,
|
condition = index == FOURTH_ITEM_INDEX,
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,5 @@ internal data class EarnListItemUM(
|
||||||
val earnType: EarnType,
|
val earnType: EarnType,
|
||||||
val earnTypeTitle: TextReference,
|
val earnTypeTitle: TextReference,
|
||||||
val onItemClick: () -> Unit,
|
val onItemClick: () -> Unit,
|
||||||
|
val id: String = "$tokenName-$symbol-$network-$earnType",
|
||||||
)
|
)
|
||||||
|
|
@ -154,7 +154,7 @@ private fun EarnContentBlock(items: ImmutableList<EarnListItemUM>) {
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = items,
|
items = items,
|
||||||
key = { item -> "${item.tokenName}-${item.network}" },
|
key = { item -> item.id },
|
||||||
) { item ->
|
) { item ->
|
||||||
MostlyUsedCard(
|
MostlyUsedCard(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import com.tangem.core.ui.test.MarketsTestTags
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
@ -41,6 +41,7 @@ import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreview
|
import com.tangem.core.ui.res.TangemThemePreview
|
||||||
|
import com.tangem.core.ui.test.MarketsTestTags
|
||||||
import com.tangem.domain.markets.PriceChangeInterval
|
import com.tangem.domain.markets.PriceChangeInterval
|
||||||
import com.tangem.features.feed.impl.R
|
import com.tangem.features.feed.impl.R
|
||||||
import com.tangem.features.feed.ui.market.detailed.components.*
|
import com.tangem.features.feed.ui.market.detailed.components.*
|
||||||
|
|
@ -267,14 +268,26 @@ private fun HeaderV2(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
|
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
modifier = Modifier.weight(weight = 1f, fill = false),
|
||||||
text = state.tokenName,
|
text = state.tokenName,
|
||||||
style = TangemTheme.typography2.bodySemibold16,
|
style = TangemTheme.typography2.bodySemibold16,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.MiddleEllipsis,
|
||||||
color = TangemTheme.colors2.text.neutral.primary,
|
color = TangemTheme.colors2.text.neutral.primary,
|
||||||
|
autoSize = TextAutoSize.StepBased(
|
||||||
|
minFontSize = TangemTheme.typography2.captionSemibold12.fontSize,
|
||||||
|
maxFontSize = TangemTheme.typography2.bodySemibold16.fontSize,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
maxLines = 1,
|
||||||
text = state.symbol,
|
text = state.symbol,
|
||||||
style = TangemTheme.typography2.captionMedium12,
|
style = TangemTheme.typography2.captionMedium12,
|
||||||
color = TangemTheme.colors2.text.neutral.tertiary,
|
color = TangemTheme.colors2.text.neutral.tertiary,
|
||||||
|
autoSize = TextAutoSize.StepBased(
|
||||||
|
minFontSize = TangemTheme.typography2.captionRegular11.fontSize,
|
||||||
|
maxFontSize = TangemTheme.typography2.captionMedium12.fontSize,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SpacerH(TangemTheme.dimens2.x1)
|
SpacerH(TangemTheme.dimens2.x1)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue