Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-07 13:28:43 +04:00
commit 51eeaead1b
56 changed files with 852 additions and 252 deletions

View file

@ -188,6 +188,7 @@ abstract class BaseTestCase : TestCase(
"ACCOUNTS_FEATURE_ENABLED" to true,
"GASLESS_APPROVAL_ENABLED" to true,
"MAIN_SCREEN_QR_SCANNING_ENABLED" to true,
"ADD_AND_MANAGE_TOKENS_ENABLED" to true,
)
)
}

View file

@ -30,11 +30,6 @@ fun BaseTestCase.verifyAddresses(seedPhrase: String, apiAddressesJson: String) {
runCatching { onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } }.isSuccess
}
}
step("Assert 'Organize tokens' button is enabled") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
runCatching { onMainScreen { organizeTokensButton().assertIsEnabled() } }.isSuccess
}
}
step("Wait for all wallet managers to initialize") {
awaitWalletManagersStabilized()
}

View file

@ -72,8 +72,8 @@ fun BaseTestCase.checkSingleCurrencyMainScreen(
onMainScreen { emptyTransactionBlockExploreButton.assertIsDisplayed() }
}
}
step("Assert 'Organize tokens' button is not displayed") {
onMainScreen { organizeTokensButtonWithoutLazySearch.assertIsNotDisplayed() }
step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() }
}
}
@ -112,8 +112,8 @@ fun BaseTestCase.checkMultiCurrencyMainScreen(
step("Assert 'Receive' button is not displayed") {
onMainScreen { receiveButton.assertIsNotDisplayed() }
}
step("Assert 'Organize tokens' button is displayed") {
onMainScreen { organizeTokensButton().assertIsDisplayed() }
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButton().assertIsDisplayed() }
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onAddAndManageBottomSheet
import com.tangem.screens.onMainScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openOrganizeTokensScreen() {
step("Swipe to 'Add & Manage' button") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Click on 'Add & Manage' button") {
onMainScreen { addAndManageButton().clickWithAssertion() }
}
step("Click on 'Organize tokens' button in bottom sheet") {
onAddAndManageBottomSheet { organizeTokensButton.clickWithAssertion() }
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import com.tangem.core.res.R as CoreResR
class AddAndManageBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddAndManageBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasText(getResourceString(CoreResR.string.main_add_and_manage_tokens))
useUnmergedTree = true
}
val addTokensButton: KNode = child {
hasText(getResourceString(CoreResR.string.add_and_manage_sheet_manage_title))
useUnmergedTree = true
}
val organizeTokensButton: KNode = child {
hasText(getResourceString(CoreResR.string.add_and_manage_sheet_organize_title))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddAndManageBottomSheet(function: AddAndManageBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -19,6 +19,7 @@ import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.R as CoreUiR
class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
@ -214,8 +215,8 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasText(getResourceString(CoreUiR.string.wallet_notification_address_copied))
}
val organizeTokensButtonNode: KNode = child {
hasTestTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON)
val addAndManageButtonNode: KNode = child {
hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON)
useUnmergedTree = true
}
@ -265,18 +266,18 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
@OptIn(ExperimentalTestApi::class)
fun organizeTokensButton(): KNode {
fun addAndManageButton(): KNode {
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON)
hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON)
}.child<KNode> {
hasText(getResourceString(R.string.organize_tokens_title))
hasText(getResourceString(CoreResR.string.main_add_and_manage_tokens))
useUnmergedTree = true
}
}
val organizeTokensButtonWithoutLazySearch: KNode = child {
hasTestTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON)
hasText(getResourceString(R.string.organize_tokens_title))
val addAndManageButtonWithoutLazySearch: KNode = child {
hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON)
hasText(getResourceString(CoreResR.string.main_add_and_manage_tokens))
useUnmergedTree = true
}

View file

@ -2,10 +2,9 @@ package com.tangem.tests
import androidx.compose.ui.test.onAllNodesWithText
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openOrganizeTokensScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import com.tangem.screens.onOrganizeTokensScreen
@ -31,12 +30,8 @@ class OrganizeTokensTest : BaseTestCase() {
step("Click on 'Synchronize addresses' button") {
onMainScreen { synchronizeAddressesButton.clickWithAssertion() }
}
step("Swipe to 'Organize tokens' button") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Click 'Organize tokens' button") {
onMainScreen { organizeTokensButton().clickWithAssertion() }
step("Open 'Organize tokens' screen") {
openOrganizeTokensScreen()
}
step("Assert 'Organize tokens' screen is opened") {
onOrganizeTokensScreen {
@ -56,12 +51,8 @@ class OrganizeTokensTest : BaseTestCase() {
step("Assert tokens were grouped on 'Main screen'") {
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() }
}
step("Swipe to 'Organize tokens' button") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Click 'Organize tokens' button") {
onMainScreen { organizeTokensButton().clickWithAssertion() }
step("Open 'Organize tokens' screen") {
openOrganizeTokensScreen()
}
step("Assert 'Organize tokens' screen is opened") {
onOrganizeTokensScreen {
@ -104,12 +95,8 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed()
}
}
step("Swipe to 'Organize tokens' button") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Click 'Organize tokens' button") {
onMainScreen { organizeTokensButton().clickWithAssertion() }
step("Open 'Organize tokens' screen") {
openOrganizeTokensScreen()
}
step("Check positions of tokens on 'Organize tokens' screen") {
onOrganizeTokensScreen {
@ -141,12 +128,8 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed()
}
}
step("Swipe to 'Organize tokens' button") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Click 'Organize tokens' button") {
onMainScreen { organizeTokensButton().clickWithAssertion() }
step("Open 'Organize tokens' screen") {
openOrganizeTokensScreen()
}
step("Drag $bitcoinTitle down on 'Organize tokens' screen") {
composeTestRule.waitUntil(timeoutMillis = 100_000) {
@ -191,12 +174,8 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitleAndPosition(polygonTitle, 2).assertIsDisplayed()
}
}
step("Swipe to 'Organize tokens' button") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Click 'Organize tokens' button") {
onMainScreen { organizeTokensButton().clickWithAssertion() }
step("Open 'Organize tokens' screen") {
openOrganizeTokensScreen()
}
step("Check positions of tokens on 'Organize tokens' screen") {
onOrganizeTokensScreen {

View file

@ -40,8 +40,8 @@ class StakingTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Organize tokens' button is displayed") {
onMainScreen { organizeTokensButton().assertIsDisplayed() }
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButton().assertIsDisplayed() }
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP)
@ -98,8 +98,8 @@ class StakingTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Organize tokens' button is displayed") {
onMainScreen { organizeTokensButton().assertIsDisplayed() }
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButton().assertIsDisplayed() }
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP)
@ -156,8 +156,8 @@ class StakingTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Organize tokens' button is displayed") {
onMainScreen { organizeTokensButton().assertIsDisplayed() }
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButton().assertIsDisplayed() }
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP)

View file

@ -28,8 +28,8 @@ class MainScreenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Organize tokens' button is displayed") {
onMainScreen { organizeTokensButton().assertIsDisplayed() }
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButton().assertIsDisplayed() }
}
}
}
@ -56,8 +56,8 @@ class MainScreenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Organize tokens' button is not displayed") {
onMainScreen { organizeTokensButtonNode.assertIsNotDisplayed()}
step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()}
}
}
}
@ -81,8 +81,8 @@ class MainScreenTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Assert 'Organize tokens' button is not displayed") {
onMainScreen { organizeTokensButtonNode.assertIsNotDisplayed()}
step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()}
}
}
}
@ -106,8 +106,8 @@ class MainScreenTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Assert 'Organize tokens' button is not displayed") {
onMainScreen { organizeTokensButtonNode.assertIsDisplayed()}
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed()}
}
}
}

View file

@ -57,7 +57,7 @@
},
{
"name": "ADD_AND_MANAGE_TOKENS_ENABLED",
"version": "undefined"
"version": "5.38"
},
{
"name": "WALLET_CONNECT_BITCOIN_ENABLED",

View file

@ -1714,6 +1714,7 @@
<string name="tangempay_card_details_withdraw_error_title">Withdraw unavailable now</string>
<string name="tangempay_card_details_withdraw_in_progress_description">You can\'t initiate swap or new withdrawal till the current one is finished</string>
<string name="tangempay_card_details_withdraw_in_progress_title">Withdrawal in progress</string>
<string name="tangempay_card_edit_name_placeholder">Card name</string>
<string name="tangempay_card_limit_setup_amount_subtitle">Set a limit from %s</string>
<string name="tangempay_card_limit_setup_error_message">We couldnt set the limit. Please try again</string>
<string name="tangempay_card_page_daily_limit_change">Change</string>

View file

@ -98,13 +98,22 @@ fun BigDecimalFiatFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefere
}
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val formattedAmount = formatter.format(formattingAmount)
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
val currencySymbol = formatterCurrency.getSymbol(locale)
val rawFormatted = formatter.format(formattingAmount)
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
val separatorIndex = decimalSeparator?.let { rawFormatted.indexOf(it).takeIf { i -> i >= 0 } }
?: rawFormatted.length
val wholePart = formattedAmount.take(separatorIndex)
val fractionalPart = formattedAmount.drop(separatorIndex)
val formattedAmount = rawFormatted.replace(currencySymbol, fiatCurrencySymbol)
val offset = fiatCurrencySymbol.length - currencySymbol.length
val adjustedIndex = if (rawFormatted.indexOf(currencySymbol) in 0 until separatorIndex) {
separatorIndex + offset
} else {
separatorIndex
}
val wholePart = formattedAmount.take(adjustedIndex)
val fractionalPart = formattedAmount.drop(adjustedIndex)
combinedReference(
if (formattingAmount.isLessThanThreshold()) stringReference(CAN_BE_LOWER_SIGN) else TextReference.EMPTY,
@ -192,13 +201,22 @@ private fun BigDecimalFiatFormatStyled.price(spanStyleReference: SpanStyleRefere
}
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val formattedAmount = formatter.format(priceAmount)
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
val currencySymbol = formatterCurrency.getSymbol(locale)
val rawFormatted = formatter.format(priceAmount)
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
val separatorIndex = decimalSeparator?.let { rawFormatted.indexOf(it).takeIf { i -> i >= 0 } }
?: rawFormatted.length
val wholePart = formattedAmount.take(separatorIndex)
val fractionalPart = formattedAmount.drop(separatorIndex)
val formattedAmount = rawFormatted.replace(currencySymbol, fiatCurrencySymbol)
val offset = fiatCurrencySymbol.length - currencySymbol.length
val adjustedIndex = if (rawFormatted.indexOf(currencySymbol) in 0 until separatorIndex) {
separatorIndex + offset
} else {
separatorIndex
}
val wholePart = formattedAmount.take(adjustedIndex)
val fractionalPart = formattedAmount.drop(adjustedIndex)
combinedReference(
stringReference(wholePart),

View file

@ -7,6 +7,7 @@ object MainScreenTestTags {
const val TOKEN_LIST_ITEM = "MAIN_SCREEN_TOKEN_LIST_ITEM"
const val WALLET_LIST_ITEM = "MAIN_SCREEN_WALLET_LIST_ITEM"
const val ORGANIZE_TOKENS_BUTTON = "MAIN_SCREEN_ORGANIZE_TOKENS_BUTTON"
const val ADD_AND_MANAGE_BUTTON = "MAIN_SCREEN_ADD_AND_MANAGE_BUTTON"
const val CARD_TITLE = "MAIN_SCREEN_CARD_TITLE"
const val CARD_IMAGE = "MAIN_SCREEN_CARD_IMAGE"
const val DEVICES_COUNT = "MAIN_SCREEN_DEVICES_COUNT"

View file

@ -294,4 +294,52 @@ internal class BigDecimalFiatFormatTest {
Truth.assertThat(formatted)
.isEqualTo("0.00000000000000000000123".addUsdSymbolLeft())
}
@Test
fun `price zero is formatted with default precision and does not crash`() {
val testValue = BigDecimal.ZERO
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).price()
}
Truth.assertThat(formatted)
.isEqualTo("0.00".addUsdSymbolLeft())
}
@Test
fun `price negative integer keeps sign and does not crash`() {
val testValue = BigDecimal("-500")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).price()
}
Truth.assertThat(formatted)
.isEqualTo("-" + "500.00".addUsdSymbolLeft())
}
@Test
fun `price negative fractional keeps sign and does not crash`() {
val testValue = BigDecimal("-0.5")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).price()
}
Truth.assertThat(formatted)
.isEqualTo("-" + "0.50".addUsdSymbolLeft())
}
}

View file

@ -29,6 +29,7 @@ import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
@ -148,6 +149,14 @@ internal interface TangemPayDataModule {
return SetTangemPayCardLimitUseCase(cardDetailsRepository, paymentAccountStatusFetcher)
}
@Provides
fun provideUpdateTangemPayCardNameUseCase(
cardDetailsRepository: TangemPayCardDetailsRepository,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
): UpdateTangemPayCardNameUseCase {
return UpdateTangemPayCardNameUseCase(cardDetailsRepository, paymentAccountStatusFetcher)
}
@Provides
@Singleton
fun provideGetTangemPayCryptoCurrencyStatusUseCase(

View file

@ -1,8 +1,14 @@
package com.tangem.domain.pay.flow
import arrow.core.Either
import com.tangem.domain.core.flow.FlowFetcher
import com.tangem.domain.models.wallet.UserWalletId
interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.Params> {
suspend operator fun invoke(userWalletId: UserWalletId): Either<Throwable, Unit> {
return invoke(Params(userWalletId))
}
data class Params(val userWalletId: UserWalletId)
}

View file

@ -17,9 +17,6 @@ class SetTangemPayCardLimitUseCase(
amount: BigDecimal,
): Either<UniversalError, Unit> {
return cardDetailsRepository.updateCardLimit(cardId, userWalletId, amount.toPlainString())
.onRight {
val params = PaymentAccountStatusFetcher.Params(userWalletId)
paymentAccountStatusFetcher.invoke(params)
}
.onRight { paymentAccountStatusFetcher.invoke(userWalletId) }
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
class UpdateTangemPayCardNameUseCase(
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
) {
suspend operator fun invoke(
cardId: String,
userWalletId: UserWalletId,
displayName: CardDisplayName,
): Either<UniversalError, Unit> {
return cardDetailsRepository.updateCardDisplayName(cardId, userWalletId, displayName)
.onRight { paymentAccountStatusFetcher(userWalletId) }
}
}

View file

@ -217,4 +217,29 @@ sealed class TangemPayAnalyticsEvents(
categoryName = "Visa Onboarding",
event = "Visa Permanent Button Clicked",
)
class CardIconClicked : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Card Icon Clicked",
)
class CardManagementScreenOpened : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Card Management Screen Opened",
)
class AddExtraCardClicked : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Add Extra Card Clicked",
)
class FakeDoorPopupDisplayed : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Fakedoor Popup Displayed",
)
class FakeDoorGotitClicked : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Fakedoor Gotit Clicked",
)
}

View file

@ -17,6 +17,7 @@ interface FeedEntryComponent : ComposableContentComponent {
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
onExpandSheet: () -> Unit,
modifier: Modifier,
)

View file

@ -172,6 +172,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
onExpandSheet: () -> Unit,
modifier: Modifier,
) {
val bsState by bottomSheetState
@ -191,6 +192,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
bottomSheetState = bottomSheetState,
stackState = stackStack,
onHeaderSizeChange = onHeaderSizeChange,
onExpandSheet = onExpandSheet,
isOpenedInBottomSheet = true,
)
}
@ -213,6 +215,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
bottomSheetState = bottomSheetState,
stackState = stack.subscribeAsState(),
onHeaderSizeChange = {},
onExpandSheet = {},
isOpenedInBottomSheet = false,
)
}

View file

@ -91,7 +91,6 @@ internal class MarketsTokenItemConverter(
private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {
val prevPrice = prev?.tokenQuotesShort?.currentPrice
val priceText = tokenQuotesShort.currentPrice.format {
fiat(
fiatCurrencyCode = appCurrency.code,

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@ -9,6 +10,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.ExperimentalDecomposeApi
@ -34,6 +36,7 @@ internal fun EntryContent(
bottomSheetState: State<BottomSheetState>,
stackState: State<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>>,
onHeaderSizeChange: (Dp) -> Unit,
onExpandSheet: () -> Unit,
isOpenedInBottomSheet: Boolean,
) {
if (LocalRedesignEnabled.current) {
@ -41,6 +44,7 @@ internal fun EntryContent(
bottomSheetState = bottomSheetState,
stackState = stackState,
onHeaderSizeChange = onHeaderSizeChange,
onExpandSheet = onExpandSheet,
isOpenedInBottomSheet = isOpenedInBottomSheet,
)
} else {
@ -48,6 +52,7 @@ internal fun EntryContent(
bottomSheetState = bottomSheetState,
stackState = stackState,
onHeaderSizeChange = onHeaderSizeChange,
onExpandSheet = onExpandSheet,
isOpenedInBottomSheet = isOpenedInBottomSheet,
)
}
@ -58,6 +63,7 @@ private fun EntryContentV1(
bottomSheetState: State<BottomSheetState>,
stackState: State<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>>,
onHeaderSizeChange: (Dp) -> Unit,
onExpandSheet: () -> Unit,
isOpenedInBottomSheet: Boolean,
) {
val density = LocalDensity.current
@ -69,7 +75,7 @@ private fun EntryContentV1(
containerColor = background,
contentWindowInsets = WindowInsetsZero,
topBar = {
Children(
Box(
modifier = Modifier
.then(
if (!isOpenedInBottomSheet) {
@ -85,10 +91,17 @@ private fun EntryContentV1(
}
}
},
stack = stackState.value,
animation = stackAnimation,
) { child ->
child.instance.Title(bottomSheetState)
) {
Children(
stack = stackState.value,
animation = stackAnimation,
) { child ->
child.instance.Title(bottomSheetState)
}
CollapsedTitleClickOverlay(
bottomSheetState = bottomSheetState,
onExpandSheet = onExpandSheet,
)
}
},
content = { contentPadding ->
@ -112,6 +125,7 @@ private fun EntryContentV2(
bottomSheetState: State<BottomSheetState>,
stackState: State<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>>,
onHeaderSizeChange: (Dp) -> Unit,
onExpandSheet: () -> Unit,
isOpenedInBottomSheet: Boolean,
) {
val density = LocalDensity.current
@ -145,7 +159,7 @@ private fun EntryContentV2(
bottomSheetState = bottomSheetState,
)
}
AnimatedContent(
Box(
modifier = Modifier
.align(Alignment.TopStart)
.then(
@ -164,14 +178,37 @@ private fun EntryContentV2(
}
}
},
targetState = stackState.value.active,
transitionSpec = animationAppBar,
contentKey = { it.key },
label = "FeedEntryAppBar",
) { state ->
state.instance.Title(bottomSheetState)
) {
AnimatedContent(
targetState = stackState.value.active,
transitionSpec = animationAppBar,
contentKey = { it.key },
label = "FeedEntryAppBar",
) { state ->
state.instance.Title(bottomSheetState)
}
CollapsedTitleClickOverlay(
bottomSheetState = bottomSheetState,
onExpandSheet = onExpandSheet,
)
}
}
}
}
}
@Composable
private fun BoxScope.CollapsedTitleClickOverlay(bottomSheetState: State<BottomSheetState>, onExpandSheet: () -> Unit) {
if (bottomSheetState.value == BottomSheetState.COLLAPSED) {
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = null,
indication = null,
onClick = onExpandSheet,
)
.clearAndSetSemantics {},
)
}
}

View file

@ -225,8 +225,7 @@ private fun TransactionCardLoading(modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
Column(
modifier = Modifier.fillMaxWidth(),
) {
TextShimmer(
@ -278,7 +277,7 @@ private fun TransactionCardLoading(modifier: Modifier = Modifier) {
@Composable
private fun Header(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) {
Row(
Column(
modifier = modifier
.fillMaxWidth()
.padding(
@ -288,8 +287,6 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
end = TangemTheme.dimens.spacing12,
)
.testTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val titleColor = if (type.inputError is TransactionCardType.InputError.Empty) {
TangemTheme.colors.text.tertiary
@ -310,9 +307,7 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
text = balanceText,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
.align(Alignment.CenterVertically)
.testTag(SwapTokenScreenTestTags.BALANCE),
modifier = Modifier.testTag(SwapTokenScreenTestTags.BALANCE),
)
}
} else {

View file

@ -69,6 +69,10 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru
context = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayCardPageComponent.Params(initialStatus = params.initialStatus),
)
TangemPayAccountDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = params,
)
}
private fun onChildBack() {

View file

@ -28,7 +28,7 @@ internal class TangemPayAddToWalletComponent(
params = TangemPayCardDetailsBlockComponent.Params(
card = params.initialStatus.firstCard(),
userWalletId = params.initialStatus.userWalletId,
isDisplayCardNameEnabled = false,
isEditingNameEnabled = false,
),
)

View file

@ -37,7 +37,7 @@ internal class TangemPayCardPageScreenComponent(
params = TangemPayCardDetailsBlockComponent.Params(
card = params.initialStatus.firstCard(),
userWalletId = params.initialStatus.userWalletId,
isDisplayCardNameEnabled = true,
isEditingNameEnabled = true,
),
)

View file

@ -29,7 +29,7 @@ internal class TangemPayEditDisplayNameComponent(
params = TangemPayCardDetailsBlockComponent.Params(
card = params.initialStatus.firstCard(),
userWalletId = params.initialStatus.userWalletId,
isDisplayCardNameEnabled = true,
isEditingNameEnabled = false,
),
)
@ -39,7 +39,7 @@ internal class TangemPayEditDisplayNameComponent(
val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle()
val editingCardDetailsState = cardDetailsState.copy(
displayNameState = DisplayNameState.Editing(
displayName = state.editingValue,
displayName = state.editingValue.text,
editingValue = state.editingValue,
onValueChanged = state.onValueChanged,
onSubmit = state.onDoneClick,

View file

@ -18,6 +18,6 @@ internal interface TangemPayCardDetailsBlockComponent {
data class Params(
val card: TangemPayCard,
val userWalletId: UserWalletId,
val isDisplayCardNameEnabled: Boolean,
val isEditingNameEnabled: Boolean,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.tangempay.entity
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.model.CardDataType
@ -8,21 +9,33 @@ import com.tangem.utils.StringsSigns
internal class TangemPayCardDetailsBlockStateFactory(
private val cardNumberEnd: String,
private val displayNameState: DisplayNameState?,
private val displayName: CardDisplayName?,
private val isEditingNameEnabled: Boolean,
private val onEditNameClick: () -> Unit,
private val onReveal: () -> Unit,
private val onCopy: (String, CardDataType) -> Unit,
) {
fun getInitialState() = TangemPayCardDetailsUM(
number = "",
numberShort = "${StringsSigns.ASTERISK}$cardNumberEnd",
expiry = "",
cvv = "",
buttonText = resourceReference(R.string.tangempay_card_details_reveal_text),
onClick = onReveal,
onCopy = onCopy,
isHidden = true,
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = displayNameState,
)
fun getInitialState(): TangemPayCardDetailsUM {
return TangemPayCardDetailsUM(
number = "",
numberShort = "${StringsSigns.ASTERISK}$cardNumberEnd",
expiry = "",
cvv = "",
buttonText = resourceReference(R.string.tangempay_card_details_reveal_text),
onClick = onReveal,
onCopy = onCopy,
isHidden = true,
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = if (displayName != null) {
DisplayNameState.Display(
displayName = displayName.value,
onClick = onEditNameClick,
isEditingEnabled = isEditingNameEnabled,
)
} else {
null
},
)
}
}

View file

@ -59,6 +59,7 @@ internal class TangemPayDetailsStateFactory(
),
isBalanceHidden = false,
addFundsEnabled = true,
addToWalletBlockState = null,
accountDeactivatedNotificationConfig = NotificationConfig(
title = resourceReference(R.string.tangempay_account_deactivated_message_title),
subtitle = resourceReference(R.string.tangempay_account_deactivated_message_subtitle),

View file

@ -1,5 +1,6 @@
package com.tangem.features.tangempay.entity
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.notifications.NotificationConfig
@ -12,6 +13,7 @@ internal data class TangemPayDetailsUM(
val topBarConfig: TangemPayDetailsTopBarConfig,
val pullToRefreshConfig: PullToRefreshConfig,
val balanceBlockState: TangemPayDetailsBalanceBlockState,
val addToWalletBlockState: AddToWalletBlockState?,
val isBalanceHidden: Boolean,
val addFundsEnabled: Boolean,
val accountDeactivatedNotificationConfig: NotificationConfig?,
@ -39,15 +41,23 @@ internal sealed interface DisplayNameState {
data class Display(
override val displayName: String,
val onClick: () -> Unit,
val isEditingEnabled: Boolean,
) : DisplayNameState
data class Editing(
override val displayName: String,
val editingValue: String,
val onValueChanged: (String) -> Unit,
val editingValue: TextFieldValue,
val onValueChanged: (TextFieldValue) -> Unit,
val onSubmit: () -> Unit,
val onDismiss: () -> Unit,
) : DisplayNameState
fun copySealed(displayName: String): DisplayNameState {
return when (this) {
is Display -> copy(displayName = displayName)
is Editing -> copy(displayName = displayName)
}
}
}
internal sealed class TangemPayDetailsBalanceBlockState {

View file

@ -1,9 +1,11 @@
package com.tangem.features.tangempay.entity
import androidx.compose.ui.text.input.TextFieldValue
internal data class TangemPayEditDisplayNameUM(
val editingValue: String,
val editingValue: TextFieldValue,
val isLoading: Boolean,
val onValueChanged: (String) -> Unit,
val onValueChanged: (TextFieldValue) -> Unit,
val onDoneClick: () -> Unit,
val onDismiss: () -> Unit,
)

View file

@ -117,7 +117,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor(
}
private fun onAmountChange(newValue: String) {
if (newValue.toBigDecimalOrNull() == null) return
if (newValue.isNotEmpty() && newValue.toBigDecimalOrNull() == null) return
uiState.update { state ->
state.copy(
amountFieldModel = state.amountFieldModel.copy(value = newValue),

View file

@ -10,11 +10,16 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.hasCardWithId
import com.tangem.domain.models.account.requireCardWithId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.DisplayNameState
import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.model.listener.CardDetailsEvent
@ -22,10 +27,12 @@ import com.tangem.features.tangempay.model.listener.CardDetailsEventListener
import com.tangem.features.tangempay.model.transformers.DetailsHiddenStateTransformer
import com.tangem.features.tangempay.model.transformers.DetailsRevealProgressStateTransformer
import com.tangem.features.tangempay.model.transformers.DetailsRevealedStateTransformer
import com.tangem.features.tangempay.model.transformers.TangemPayCardDetailsUpdateNameTransformer
import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.transformer.update
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -46,6 +53,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
private val cardDetailsEventListener: CardDetailsEventListener,
private val analytics: AnalyticsEventHandler,
private val router: Router,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
) : Model() {
private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require()
@ -53,12 +61,9 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
private val stateFactory = TangemPayCardDetailsBlockStateFactory(
cardNumberEnd = card.lastDigits,
displayNameState = card.displayName?.takeIf { params.isDisplayCardNameEnabled }?.let { displayName ->
DisplayNameState.Display(
displayName = displayName.value,
onClick = ::startEditingDisplayName,
)
},
displayName = card.displayName,
isEditingNameEnabled = params.isEditingNameEnabled,
onEditNameClick = ::startEditingDisplayName,
onReveal = ::revealCardDetails,
onCopy = ::copyData,
)
@ -70,6 +75,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
private val showCardDetailsTimerJobHolder = JobHolder()
init {
subscribeToCardNameChanges(cardId = card.id, userWalletId = params.userWalletId)
subscribeToCardFrozenState()
modelScope.launch {
cardDetailsEventListener.event.collectLatest { event ->
@ -81,6 +87,23 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
}
}
private fun subscribeToCardNameChanges(cardId: String, userWalletId: UserWalletId) {
paymentAccountStatusSupplier.invoke(userWalletId)
.onEach { state ->
val status = state.value
if (status is PaymentAccountStatusValue.Loaded &&
status.source == StatusSource.ACTUAL &&
status.hasCardWithId(cardId)
) {
val card = status.requireCardWithId(cardId)
val displayName = card.displayName ?: return@onEach
uiState.update(TangemPayCardDetailsUpdateNameTransformer(displayName))
}
}
.launchIn(modelScope)
}
private fun subscribeToCardFrozenState() {
cardDetailsRepository
.cardFrozenState(card.id)

View file

@ -88,6 +88,7 @@ internal class TangemPayCardPageModel @Inject constructor(
// TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed
init {
analytics.send(TangemPayAnalyticsEvents.CardManagementScreenOpened())
fetchAddToWalletBanner()
paymentAccountStatusSupplier.invoke(userWalletId)

View file

@ -38,10 +38,7 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.model.listener.CardDetailsEvent
import com.tangem.features.tangempay.model.listener.CardDetailsEventListener
import com.tangem.features.tangempay.model.transformers.DetailBalanceVisibilityTransformer
import com.tangem.features.tangempay.model.transformers.DetailsBalanceTransformer
import com.tangem.features.tangempay.model.transformers.TangemPayDetailsRefreshTransformer
import com.tangem.features.tangempay.model.transformers.TangemPayFreezeUnfreezeStateTransformer
import com.tangem.features.tangempay.model.transformers.*
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.utils.*
import com.tangem.features.tokendetails.ExpressTransactionsEvent
@ -110,6 +107,7 @@ internal class TangemPayDetailsModel @Inject constructor(
private val refreshStateJobHolder = JobHolder()
private val fetchBalanceJobHolder = JobHolder()
private val addToWalletBannerJobHolder = JobHolder()
private var balance: TangemPayCardBalance? = null
@ -121,6 +119,7 @@ internal class TangemPayDetailsModel @Inject constructor(
fetchBalance()
if (!isTangemPayDeactivated && firstCard != null) {
subscribeToCardFrozenState(firstCard.id)
fetchAddToWalletBanner()
}
}
@ -217,6 +216,24 @@ internal class TangemPayDetailsModel @Inject constructor(
}.saveIn(fetchBalanceJobHolder)
}
private fun fetchAddToWalletBanner() {
modelScope.launch {
val isDone = try {
cardDetailsRepository.isAddToWalletDone(userWalletId).getOrNull() == true
} catch (e: Exception) {
TangemLogger.e("Error", e)
return@launch
}
uiState.update(
transformer = DetailsAddToWalletBannerTransformer(
onClickBanner = ::onClickAddToWalletBlock,
onClickCloseBanner = ::onClickCloseAddToWalletBlock,
isDone = isDone,
),
)
}.saveIn(addToWalletBannerJobHolder)
}
private fun handleBalanceHiding() {
getBalanceHidingSettingsUseCase().onEach {
uiState.update(DetailBalanceVisibilityTransformer(isHidden = it.isBalanceHidden))
@ -247,6 +264,28 @@ internal class TangemPayDetailsModel @Inject constructor(
}.saveIn(refreshStateJobHolder)
}
private fun onClickAddToWalletBlock() {
analytics.send(TangemPayAnalyticsEvents.AddToWalletClicked())
router.push(TangemPayAccountDetailsInnerRoute.AddToWallet)
}
private fun onClickCloseAddToWalletBlock() {
modelScope.launch {
try {
cardDetailsRepository.setAddToWalletAsDone(userWalletId)
} catch (e: Exception) {
TangemLogger.e("Error", e)
}
uiState.update(
transformer = DetailsAddToWalletBannerTransformer(
onClickBanner = ::onClickAddToWalletBlock,
onClickCloseBanner = ::onClickCloseAddToWalletBlock,
isDone = true,
),
)
}.saveIn(addToWalletBannerJobHolder)
}
private fun onOpenMenu() {
analytics.send(TangemPayAnalyticsEvents.CardSettingsClicked())
}
@ -309,11 +348,18 @@ internal class TangemPayDetailsModel @Inject constructor(
}
override fun onCardClick() {
analytics.send(TangemPayAnalyticsEvents.CardIconClicked())
router.push(TangemPayAccountDetailsInnerRoute.CardDetails)
}
override fun onAddCardClick() {
uiMessageSender.send(message = TangemPayMessagesFactory.createFutureFeature())
analytics.send(TangemPayAnalyticsEvents.AddExtraCardClicked())
analytics.send(TangemPayAnalyticsEvents.FakeDoorPopupDisplayed())
uiMessageSender.send(
message = TangemPayMessagesFactory.createFutureFeature(
onGotItClick = { analytics.send(TangemPayAnalyticsEvents.FakeDoorGotitClicked()) },
),
)
}
private fun showBottomSheetError(type: TangemPayDetailsErrorType) {

View file

@ -1,6 +1,8 @@
package com.tangem.features.tangempay.model
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -8,17 +10,21 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.hasCardWithId
import com.tangem.domain.models.account.requireCardWithId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -28,8 +34,9 @@ internal class TangemPayEditDisplayNameModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val updateCardNameUseCase: UpdateTangemPayCardNameUseCase,
private val uiMessageSender: UiMessageSender,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
) : Model() {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
@ -40,7 +47,10 @@ internal class TangemPayEditDisplayNameModel @Inject constructor(
val uiState: StateFlow<TangemPayEditDisplayNameUM>
field = MutableStateFlow(
TangemPayEditDisplayNameUM(
editingValue = originalDisplayName,
editingValue = TextFieldValue(
text = originalDisplayName,
selection = TextRange(originalDisplayName.length),
),
isLoading = false,
onValueChanged = ::onValueChanged,
onDoneClick = ::onDoneClick,
@ -48,23 +58,51 @@ internal class TangemPayEditDisplayNameModel @Inject constructor(
),
)
private fun onValueChanged(value: String) {
if (value.length <= CardDisplayName.MAX_LENGTH) {
init {
subscribeToCardNameChanges(card.id, params.initialStatus.userWalletId)
}
private fun subscribeToCardNameChanges(cardId: String, userWalletId: UserWalletId) {
paymentAccountStatusSupplier.invoke(userWalletId)
.onEach { state ->
val status = state.value
if (status is PaymentAccountStatusValue.Loaded &&
status.source == StatusSource.ACTUAL &&
status.hasCardWithId(cardId)
) {
val card = status.requireCardWithId(cardId)
val displayName = card.displayName ?: return@onEach
uiState.update { uiState ->
uiState.copy(
editingValue = TextFieldValue(
text = displayName.value,
selection = TextRange(displayName.value.length),
),
)
}
}
}
.launchIn(modelScope)
}
private fun onValueChanged(value: TextFieldValue) {
if (value.text.length <= CardDisplayName.MAX_LENGTH) {
uiState.update { it.copy(editingValue = value) }
}
}
private fun onDoneClick() {
val currentValue = uiState.value.editingValue
val currentValue = uiState.value.editingValue.text
if (currentValue.trim() == originalDisplayName.trim()) {
router.pop()
return
}
CardDisplayName(currentValue)
.onRight { cardDisplayName ->
uiState.update { it.copy(isLoading = true) }
modelScope.launch {
cardDetailsRepository.updateCardDisplayName(
uiState.update { it.copy(isLoading = true) }
updateCardNameUseCase(
cardId = card.id,
userWalletId = params.initialStatus.userWalletId,
displayName = cardDisplayName,

View file

@ -0,0 +1,22 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.features.tangempay.entity.AddToWalletBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.utils.transformer.Transformer
internal class DetailsAddToWalletBannerTransformer(
private val onClickBanner: () -> Unit,
private val onClickCloseBanner: () -> Unit,
private val isDone: Boolean,
) : Transformer<TangemPayDetailsUM> {
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
return prevState.copy(
addToWalletBlockState = if (isDone) {
null
} else {
AddToWalletBlockState(onClick = onClickBanner, onClickClose = onClickCloseBanner)
},
)
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.utils.transformer.Transformer
internal class TangemPayCardDetailsUpdateNameTransformer(
private val displayName: CardDisplayName,
) : Transformer<TangemPayCardDetailsUM> {
override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM {
val displayNameState = prevState.displayNameState ?: return prevState
return prevState.copy(displayNameState = displayNameState.copySealed(displayName = displayName.value))
}
}

View file

@ -10,4 +10,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route {
@Serializable
data object CardDetails : TangemPayAccountDetailsInnerRoute()
@Serializable
data object AddToWallet : TangemPayAccountDetailsInnerRoute()
}

View file

@ -1,24 +1,22 @@
package com.tangem.features.tangempay.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -46,6 +44,7 @@ import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -58,7 +57,7 @@ import com.tangem.features.tangempay.entity.DisplayNameState
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.model.CardDataType
private const val ICON_FADE_DURATION_MS = 300
private const val TEXT_WIDTH_PADDING = 2
private val CustomCardBlockColor = Color(0x1F828282)
@Suppress("MagicNumber")
@ -228,80 +227,55 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif
@Composable
private fun CardDisplayName(state: DisplayNameState, modifier: Modifier = Modifier) {
val isDisplayMode = state is DisplayNameState.Display
Row(
modifier = modifier.then(
if (state is DisplayNameState.Display) {
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = state.onClick,
)
} else {
Modifier
},
),
verticalAlignment = Alignment.CenterVertically,
) {
when (state) {
is DisplayNameState.Display -> DisplayOnlyCardDisplayName(state = state)
is DisplayNameState.Editing -> EditingCardDisplayName(state = state)
}
val iconVisibleState = remember {
MutableTransitionState(initialState = !isDisplayMode).apply {
targetState = isDisplayMode
}
}
AnimatedVisibility(
visibleState = iconVisibleState,
enter = fadeIn(animationSpec = tween(durationMillis = ICON_FADE_DURATION_MS)),
exit = fadeOut(animationSpec = tween(durationMillis = ICON_FADE_DURATION_MS)),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
painter = painterResource(id = com.tangem.core.ui.R.drawable.ic_edit_new_12),
contentDescription = null,
modifier = Modifier.size(10.dp),
tint = TangemTheme.colors.text.constantWhite,
)
}
}
when (state) {
is DisplayNameState.Display -> DisplayOnlyCardDisplayName(modifier = modifier, state = state)
is DisplayNameState.Editing -> EditingCardDisplayName(modifier = modifier, state = state)
}
}
@Composable
private fun DisplayOnlyCardDisplayName(state: DisplayNameState.Display, modifier: Modifier = Modifier) {
Text(
text = state.displayName,
style = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite),
maxLines = 1,
modifier = modifier,
)
Row(
modifier = modifier.conditional(
condition = state.isEditingEnabled,
modifier = { clickable(onClick = state.onClick) },
),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = state.displayName,
style = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite),
maxLines = 1,
)
if (state.isEditingEnabled) {
Icon(
painter = painterResource(id = R.drawable.ic_edit_new_12),
contentDescription = null,
modifier = Modifier.size(10.dp),
tint = TangemTheme.colors.text.constantWhite,
)
}
}
}
@Composable
private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
var textFieldValue by remember(state.editingValue) {
mutableStateOf(
TextFieldValue(text = state.editingValue, selection = TextRange(state.editingValue.length)),
)
}
val placeholder = stringResourceSafe(R.string.tangempay_card_edit_name_placeholder)
val textStyle = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite)
val textMeasurer = rememberTextMeasurer()
val measuredText = state.editingValue.text.ifEmpty { placeholder }
val textWidthDp = with(LocalDensity.current) {
textMeasurer.measure(textFieldValue.text, textStyle).size.width.toDp() + 2.dp
textMeasurer.measure(measuredText, textStyle).size.width.toDp() + TEXT_WIDTH_PADDING.dp
}
BasicTextField(
value = textFieldValue,
value = state.editingValue,
onValueChange = { newValue ->
if (newValue.text.length in 0..CardDisplayName.MAX_LENGTH) {
textFieldValue = newValue
state.onValueChanged(newValue.text)
if (newValue.text.length <= CardDisplayName.MAX_LENGTH) {
state.onValueChanged(newValue)
}
},
modifier = modifier
@ -312,6 +286,17 @@ private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Mo
cursorBrush = SolidColor(TangemTheme.colors.text.constantWhite),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { state.onSubmit() }),
decorationBox = { innerTextField ->
Box {
if (state.editingValue.text.isEmpty()) {
Text(
text = placeholder,
style = textStyle.copy(color = TangemTheme.colors.text.tertiary),
)
}
innerTextField()
}
},
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
@ -492,7 +477,13 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide
onCopy = { _, _ -> },
isHidden = true,
cardFrozenState = TangemPayCardFrozenState.Frozen,
displayNameState = DisplayNameState.Display(displayName = "Tangem", onClick = {}),
displayNameState = DisplayNameState.Editing(
displayName = "Tangem",
editingValue = TextFieldValue(text = "movet", selection = TextRange("movet".length)),
onValueChanged = {},
onSubmit = {},
onDismiss = {},
),
),
TangemPayCardDetailsUM(
isLoading = false,
@ -505,7 +496,13 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide
onCopy = { _, _ -> },
isHidden = true,
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}),
displayNameState = DisplayNameState.Editing(
displayName = "Tangem",
editingValue = TextFieldValue(text = ""),
onValueChanged = {},
onSubmit = {},
onDismiss = {},
),
),
TangemPayCardDetailsUM(
isLoading = false,
@ -518,7 +515,11 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide
onCopy = { _, _ -> },
isHidden = true,
cardFrozenState = TangemPayCardFrozenState.Pending,
displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}),
displayNameState = DisplayNameState.Display(
displayName = "Tangem Pay Card",
onClick = {},
isEditingEnabled = true,
),
),
TangemPayCardDetailsUM(
isLoading = false,
@ -531,7 +532,11 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide
expiry = "12/34",
cvv = "123",
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}),
displayNameState = DisplayNameState.Display(
displayName = "Tangem Pay Card",
onClick = {},
isEditingEnabled = false,
),
),
),
)

View file

@ -8,18 +8,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
@ -46,11 +35,7 @@ import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.DisplayNameState
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.entity.TangemPayCardPageSetting
import com.tangem.features.tangempay.entity.TangemPayCardPageUM
import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState
import com.tangem.features.tangempay.entity.*
import kotlinx.collections.immutable.ImmutableList
private const val CONTENT_FADE_DURATION_MS = 300
@ -217,7 +202,11 @@ private fun preview() = TangemThemePreview {
onCopy = { _, _ -> },
onClick = {},
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}),
displayNameState = DisplayNameState.Display(
displayName = "Tangem Pay Card",
onClick = {},
isEditingEnabled = true,
),
),
),
cardDetailsState = TangemPayCardDetailsUM(
@ -228,7 +217,11 @@ private fun preview() = TangemThemePreview {
onCopy = { _, _ -> },
onClick = {},
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}),
displayNameState = DisplayNameState.Display(
displayName = "Tangem Pay Card",
onClick = {},
isEditingEnabled = false,
),
),
)
}

View file

@ -56,10 +56,7 @@ import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTrans
import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent
import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM
import com.tangem.features.tangempay.entity.*
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.utils.StringsSigns.DASH_SIGN
import kotlinx.collections.immutable.persistentListOf
@ -116,6 +113,17 @@ internal fun TangemPayDetailsScreen(
SpacerH12()
},
)
if (state.addToWalletBlockState != null) {
item(
key = AddToWalletBlockState::class.java,
content = {
TangemPayAddToWalletBlock(
state = state.addToWalletBlockState,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
)
},
)
}
if (state.accountDeactivatedNotificationConfig != null) {
item(
key = "DEACTIVATION_MESSAGE",
@ -422,6 +430,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
),
isBalanceHidden = false,
addFundsEnabled = true,
addToWalletBlockState = AddToWalletBlockState(onClick = {}, onClickClose = {}),
accountDeactivatedNotificationConfig = null,
),
TangemPayDetailsUM(
@ -436,6 +445,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
),
isBalanceHidden = false,
addFundsEnabled = true,
addToWalletBlockState = null,
accountDeactivatedNotificationConfig = null,
),
),

View file

@ -75,7 +75,7 @@ internal fun TangemPayEditDisplayNameScreen(
textReference = resourceReference(R.string.common_done),
onClick = state.onDoneClick,
shouldShowProgress = state.isLoading,
isEnabled = !state.isLoading && state.editingValue.isNotBlank(),
isEnabled = !state.isLoading && state.editingValue.text.isNotBlank(),
),
)
}

View file

@ -114,7 +114,7 @@ internal object TangemPayMessagesFactory {
}
}
fun createFutureFeature(): BottomSheetMessage {
fun createFutureFeature(onGotItClick: () -> Unit): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {
icon(R.drawable.ic_credit_card_add_24) {
@ -125,7 +125,10 @@ internal object TangemPayMessagesFactory {
}
primaryButton {
text = resourceReference(R.string.common_got_it)
onClick { closeBs() }
onClick {
onGotItClick()
closeBs()
}
}
}
}

View file

@ -104,6 +104,7 @@ internal class TangemPayCardLimitSetupModelTest {
) {
val model = createModel()
model.uiState.value.amountFieldModel.onValueChange("100")
model.uiState.value.amountFieldModel.onValueChange(amount)
assertThat(model.uiState.value.isSubmitButtonEnabled).isEqualTo(expectedEnabled)
@ -152,7 +153,7 @@ internal class TangemPayCardLimitSetupModelTest {
Arguments.of("100", true),
Arguments.of("-1", false),
Arguments.of("", false),
Arguments.of("abc", false),
Arguments.of("abc", true),
Arguments.of("1001", false),
)
}

View file

@ -0,0 +1,14 @@
package com.tangem.feature.wallet.child.managetokens.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
internal sealed class PortfolioAnalyticsEvent(
event: String,
) : AnalyticsEvent(category = "Portfolio", event = event) {
class ButtonAddManage : PortfolioAnalyticsEvent(event = "Button - Add Manage")
class ButtonAddTokens : PortfolioAnalyticsEvent(event = "Button - Add tokens")
class ButtonOrganizeTokens : PortfolioAnalyticsEvent(event = "Button - Organize Tokens")
}

View file

@ -3,11 +3,13 @@ package com.tangem.feature.wallet.child.managetokens.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.models.account.AccountId
import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent
import com.tangem.feature.wallet.child.managetokens.analytics.PortfolioAnalyticsEvent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
@ -21,6 +23,7 @@ internal class AddAndManageModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val portfolioFetcherFactory: PortfolioFetcher.Factory,
private val analyticsEventHandler: AnalyticsEventHandler,
val portfolioSelectorController: PortfolioSelectorController,
) : Model() {
@ -45,6 +48,7 @@ internal class AddAndManageModel @Inject constructor(
}
fun onAddTokensClick() {
analyticsEventHandler.send(PortfolioAnalyticsEvent.ButtonAddTokens())
modelScope.launch {
val data = portfolioFetcher.data.first()
val isSingleAccount = data.isSingleChoice(params.userWalletId)
@ -65,6 +69,7 @@ internal class AddAndManageModel @Inject constructor(
}
fun onOrganizeTokensClick() {
analyticsEventHandler.send(PortfolioAnalyticsEvent.ButtonOrganizeTokens())
params.onDismiss()
params.onOrganizeTokensClick()
}

View file

@ -260,10 +260,11 @@ internal class WalletComponent @AssistedInject constructor(
WalletScreen2(
state = uiState,
tangemPayComponent = tangemPayMainBlockComponent,
bottomSheetContent = {
bottomSheetContent = { onExpandSheet ->
BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = { headerSize = it },
onExpandSheet = onExpandSheet,
modifier = modifier,
)
},
@ -275,10 +276,11 @@ internal class WalletComponent @AssistedInject constructor(
state = uiState,
promoBannersBlockComponent = promoBannersBlockComponent,
tangemPayComponent = tangemPayMainBlockComponent,
bottomSheetContent = {
bottomSheetContent = { onExpandSheet ->
BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = { headerSize = it },
onExpandSheet = onExpandSheet,
modifier = modifier,
)
},
@ -305,11 +307,13 @@ internal class WalletComponent @AssistedInject constructor(
private fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
onExpandSheet: () -> Unit,
modifier: Modifier = Modifier,
) {
feedEntryComponent.BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = onHeaderSizeChange,
onExpandSheet = onExpandSheet,
modifier = modifier,
)
}

View file

@ -23,6 +23,7 @@ import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplySetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.managetokens.analytics.PortfolioAnalyticsEvent
import com.tangem.feature.wallet.presentation.account.AccountDependencies
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
@ -123,6 +124,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
override fun onOrganizeTokensClick() {
val userWalletId = stateHolder.getSelectedWalletId()
if (walletFeatureToggles.isAddAndManageTokensEnabled) {
analyticsEventHandler.send(PortfolioAnalyticsEvent.ButtonAddManage())
router.openAddAndManageBottomSheet(userWalletId = userWalletId)
} else {
router.openOrganizeTokensScreen(userWalletId = userWalletId)

View file

@ -95,7 +95,7 @@ internal fun WalletScreen(
state: WalletScreenState,
tangemPayComponent: TangemPayMainBlockComponent,
promoBannersBlockComponent: ComposableContentComponent? = null,
bottomSheetContent: @Composable (() -> Unit),
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
) {
@ -139,7 +139,7 @@ private fun WalletContent(
promoBannersBlockComponent: ComposableContentComponent? = null,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (() -> Unit),
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
) {
/*
* Don't pass key to remember, because it will brake scroll animation.
@ -295,7 +295,7 @@ private inline fun BaseScaffoldWithMarkets(
snackbarHostState: SnackbarHostState,
bottomSheetHeaderHeightProvider: () -> Dp,
noinline onBottomSheetStateChange: (BottomSheetState) -> Unit,
crossinline bottomSheetContent: @Composable () -> Unit,
crossinline bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
crossinline content: @Composable (PaddingValues) -> Unit,
) {
val isKeyboardVisible by rememberIsKeyboardVisible()
@ -382,7 +382,7 @@ private inline fun BaseScaffoldWithMarkets(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
// expand bottom sheet when clicked on the header
// expand bottom sheet when clicked on the drag handle
.clickable(
enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded,
indication = null,
@ -400,7 +400,9 @@ private inline fun BaseScaffoldWithMarkets(
isSearchFieldFocused = it.isFocused
},
) {
bottomSheetContent()
bottomSheetContent {
coroutineScope.launch { bottomSheetState.expand() }
}
}
}
},

View file

@ -86,7 +86,7 @@ internal fun WalletScreen2(
state: WalletScreenState,
tangemPayComponent: TangemPayMainBlockComponent,
modifier: Modifier = Modifier,
bottomSheetContent: @Composable (() -> Unit),
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
) {
@ -162,7 +162,7 @@ private fun WalletContent2(
modifier: Modifier = Modifier,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (() -> Unit),
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
) {
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
@ -354,7 +354,7 @@ private inline fun BaseScaffoldWithMarkets(
modifier: Modifier = Modifier,
noinline onBottomSheetStateChange: (BottomSheetState) -> Unit,
crossinline appBarContent: @Composable () -> Unit,
crossinline bottomSheetContent: @Composable () -> Unit,
crossinline bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
crossinline content: @Composable (PaddingValues, TangemSheetState) -> Unit,
) {
val density = LocalDensity.current
@ -395,7 +395,9 @@ private inline fun BaseScaffoldWithMarkets(
isSearchFieldFocused = focusState.isFocused
},
) {
bottomSheetContent()
bottomSheetContent {
coroutineScope.launch { bottomSheetState.expand() }
}
}
},
content = { paddingValues ->
@ -467,7 +469,7 @@ private fun BottomSheet(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
// expand bottom sheet when clicked on the header
// expand bottom sheet when clicked on the drag handle
.clickable(
enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded,
indication = null,

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.buttons.actions.RoundedActionButton
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState
private const val ORGANIZE_BUTTON_CONTENT_TYPE = "OrganizeTokensButton"
@ -24,8 +25,13 @@ internal fun LazyListScope.organizeTokensButton(
modifier: Modifier = Modifier,
) {
item(key = ORGANIZE_BUTTON_CONTENT_TYPE, contentType = ORGANIZE_BUTTON_CONTENT_TYPE) {
val testTag = if (config.textRes == R.string.main_add_and_manage_tokens) {
MainScreenTestTags.ADD_AND_MANAGE_BUTTON
} else {
MainScreenTestTags.ORGANIZE_TOKENS_BUTTON
}
RoundedActionButton(
modifier = modifier.testTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON),
modifier = modifier.testTag(testTag),
config = ActionButtonConfig(
text = resourceReference(id = config.textRes),
iconResId = config.iconRes,

View file

@ -0,0 +1,101 @@
package com.tangem.feature.wallet.child.managetokens.model
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class AddAndManageModelTest {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val portfolioFetcher: PortfolioFetcher = mockk(relaxed = true) {
every { data } returns flowOf(
PortfolioFetcher.Data(
appCurrency = mockk(relaxed = true),
isBalanceHidden = false,
balances = emptyMap(),
),
)
}
private val portfolioFetcherFactory: PortfolioFetcher.Factory = mockk(relaxed = true) {
every { create(any(), any()) } returns portfolioFetcher
}
private val portfolioSelectorController: PortfolioSelectorController = mockk(relaxed = true) {
every { selectedAccount } returns flowOf(null)
}
private val onDismiss: () -> Unit = mockk(relaxed = true)
private val onOrganizeTokensClick: () -> Unit = mockk(relaxed = true)
private val onManageTokensClick: (AccountId) -> Unit = mockk(relaxed = true)
private val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF")
private val params = AddAndManageBottomSheetComponent.Params(
userWalletId = userWalletId,
onDismiss = onDismiss,
onOrganizeTokensClick = onOrganizeTokensClick,
onManageTokensClick = onManageTokensClick,
)
private fun createModel(): AddAndManageModel = AddAndManageModel(
paramsContainer = MutableParamsContainer(params),
dispatchers = TestingCoroutineDispatcherProvider(),
portfolioFetcherFactory = portfolioFetcherFactory,
analyticsEventHandler = analyticsEventHandler,
portfolioSelectorController = portfolioSelectorController,
)
@Test
fun `GIVEN bottom sheet model WHEN onAddTokensClick THEN sends ButtonAddTokens event with correct payload`() =
runTest {
val model = createModel()
val captured = slot<AnalyticsEvent>()
model.onAddTokensClick()
verify(exactly = 1) { analyticsEventHandler.send(capture(captured)) }
assertThat(captured.captured.category).isEqualTo("Portfolio")
assertThat(captured.captured.event).isEqualTo("Button - Add tokens")
assertThat(captured.captured.params).isEmpty()
}
@Test
fun `GIVEN bottom sheet model WHEN onOrganizeTokensClick THEN sends ButtonOrganizeTokens event with correct payload`() =
runTest {
val model = createModel()
val captured = slot<AnalyticsEvent>()
model.onOrganizeTokensClick()
verify(exactly = 1) { analyticsEventHandler.send(capture(captured)) }
assertThat(captured.captured.category).isEqualTo("Portfolio")
assertThat(captured.captured.event).isEqualTo("Button - Organize Tokens")
assertThat(captured.captured.params).isEmpty()
}
@Test
fun `GIVEN bottom sheet model WHEN onOrganizeTokensClick THEN dismisses bottom sheet and forwards to params callback`() =
runTest {
val model = createModel()
model.onOrganizeTokensClick()
verify(exactly = 1) { onDismiss() }
verify(exactly = 1) { onOrganizeTokensClick() }
}
}

View file

@ -0,0 +1,83 @@
package com.tangem.feature.wallet.child.wallet.model.intents
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class WalletContentClickIntentsAnalyticsTest {
private val stateHolder: WalletStateController = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val walletFeatureToggles: WalletFeatureToggles = mockk(relaxed = true)
private val router: InnerWalletRouter = mockk(relaxed = true)
private val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF")
private fun createImplementor(): WalletContentClickIntentsImplementor {
every { stateHolder.getSelectedWalletId() } returns userWalletId
val implementor = WalletContentClickIntentsImplementor(
stateHolder = stateHolder,
currencyActionsClickIntents = mockk(relaxed = true),
onrampStatusFactory = mockk(relaxed = true),
getUserWalletUseCase = mockk(relaxed = true),
singleAccountStatusListSupplier = mockk(relaxed = true),
getCryptoCurrencyActionsUseCase = mockk(relaxed = true),
getExplorerTransactionUrlUseCase = mockk(relaxed = true),
shouldShowMarketsTooltipUseCase = mockk(relaxed = true),
dispatchers = mockk(relaxed = true),
walletEventSender = mockk(relaxed = true),
analyticsEventHandler = analyticsEventHandler,
accountDependencies = mockk(relaxed = true),
yieldSupplySetShouldShowMainPromoUseCase = mockk(relaxed = true),
tokenListAnalyticsSender = mockk(relaxed = true),
uiMessageSender = mockk(relaxed = true),
walletFeatureToggles = walletFeatureToggles,
)
implementor.initialize(router = router, coroutineScope = TestScope())
return implementor
}
@Test
fun `GIVEN add and manage toggle enabled WHEN onOrganizeTokensClick THEN sends ButtonAddManage event and opens bottom sheet`() =
runTest {
every { walletFeatureToggles.isAddAndManageTokensEnabled } returns true
val implementor = createImplementor()
val captured = slot<AnalyticsEvent>()
implementor.onOrganizeTokensClick()
verify(exactly = 1) { analyticsEventHandler.send(capture(captured)) }
assertThat(captured.captured.category).isEqualTo("Portfolio")
assertThat(captured.captured.event).isEqualTo("Button - Add Manage")
assertThat(captured.captured.params).isEmpty()
verify(exactly = 1) { router.openAddAndManageBottomSheet(userWalletId = userWalletId) }
verify(exactly = 0) { router.openOrganizeTokensScreen(any()) }
}
@Test
fun `GIVEN add and manage toggle disabled WHEN onOrganizeTokensClick THEN does not send analytics and opens organize screen`() =
runTest {
every { walletFeatureToggles.isAddAndManageTokensEnabled } returns false
val implementor = createImplementor()
implementor.onOrganizeTokensClick()
verify(exactly = 0) { analyticsEventHandler.send(any<AnalyticsEvent>()) }
verify(exactly = 1) { router.openOrganizeTokensScreen(userWalletId = userWalletId) }
verify(exactly = 0) { router.openAddAndManageBottomSheet(any()) }
}
}