Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-10 15:52:29 +02:00
commit f2b72b3771
66 changed files with 1955 additions and 275 deletions

View file

@ -87,4 +87,53 @@ fun BaseTestCase.openGaslessSendScreenWithHotWallet(
step("Click on 'Send' button in bottom sheet") { step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() } onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
}
/**
* Open an existing hot wallet, select the token to send and choose the swap target token/network
* the shared entry into the gasless send-via-swap flow. Scenario states stay in the test body.
*/
fun BaseTestCase.openSendViaSwapScreenWithHotWallet(
seedPhrase: String,
tokenName: String,
swapTokenName: String,
networkName: String,
networkType: String? = null,
) {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Select '$swapTokenName' as the token to receive via swap") {
selectTokenToSendViaSwap(
swapTokenName = swapTokenName,
networkName = networkName,
networkType = networkType,
)
}
}
/**
* Send-via-swap amount entry: type the amount, advance past the quote-gated 'Next' button (waiting
* until it becomes enabled once the swap quote loads), then fill the recipient and open the
* 'Send confirm' screen. Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in
* extensions on [BaseTestCase].
*/
fun BaseTestCase.enterSwapAmountAndOpenSendConfirm(amount: String, recipientAddress: String) {
step("Type amount '$amount' in input field") {
onSendScreen { amountInputTextField.performTextReplacement(amount) }
}
step("Click on 'Next' button") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSendScreen {
nextButton.assertIsEnabled()
nextButton.performClick()
}
}.isSuccess
}
}
enterRecipientAndOpenSendConfirm(recipientAddress)
} }

View file

@ -0,0 +1,254 @@
package com.tangem.tests.send.sendViaSwap
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.PROVIDERS_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.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.*
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
/**
* Gasless send-via-swap: paying the network fee with the stablecoin while converting it through an
* express swap. Covers the fee-token selection on the swap summary, the stablecoin balance validation
* against the gasless fee, and the full signed swap-and-send on a hot wallet.
*/
@HiltAndroidTest
class GaslessSendViaSwapTest : BaseTestCase() {
private val tokenName = "USDC"
private val currencySymbol = "USDC"
private val nativeTokenName = "Polygon"
private val swapTokenName = "Bitcoin"
private val mainNetwork = "MAIN"
private val providerName = "Changelly"
private val tokenAmount = "1"
private val hotWalletTokensState = "PolygonUSDCHotWallet"
private val quotesState = "PolygonUSDC"
private val assetsScenarioName = "express_api_assets"
private val assetsExchangeEnabledState = "BitcoinExchangeEnabled"
private val providersState = "HotWalletSvS"
@AllureId("5120")
@DisplayName("Gasless Send via Swap: the network fee is selectable and payable with the stablecoin")
@Test
fun checkFeeTokenSelectionForSwapTest() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") {
setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState)
}
step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") {
openSendViaSwapScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
swapTokenName = swapTokenName,
networkName = swapTokenName,
networkType = mainNetwork,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Assert 'Network fee' block with token selection is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeSelectorTitle.assertIsDisplayed()
selectFeeIcon.assertIsDisplayed()
}
}
}
step("Click on 'Network fee' block") {
onSendConfirmScreen { feeSelectorBlock.performClick() }
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' bottom sheet is displayed") {
onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() }
}
step("Assert '$tokenName' is available for the fee payment") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() }
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the network fee is calculated in '$currencySymbol' on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
}
}
}
}
@AllureId("5121")
@DisplayName("Gasless Send via Swap: insufficient stablecoin balance to cover the fee blocks the swap")
@Test
fun checkBalanceValidationForFeeTest() {
val usdcBalanceScenario = "polygon_usdc_balance"
val lowBalanceState = "LowBalance"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(usdcBalanceScenario)
}
).run {
step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") {
setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState)
}
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") {
setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState)
}
step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") {
openSendViaSwapScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
swapTokenName = swapTokenName,
networkName = swapTokenName,
networkType = mainNetwork,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Assert 'Not enough funds' error is displayed in the fee selector") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() }
}
}
step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") {
onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() }
}
}
}
@AllureId("5122")
@DisplayName("Gasless Send via Swap: sign and send a swap paying the fee with the stablecoin")
@Test
fun checkSendViaSwapFinalScreenAndSendTest() {
val exchangeStatusScenario = "exchange_status_provider"
val changellyStatusState = "Changelly"
val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(exchangeStatusScenario)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") {
setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState)
}
step("Set WireMock scenario '$exchangeStatusScenario' to '$changellyStatusState'") {
setWireMockScenarioState(scenarioName = exchangeStatusScenario, state = changellyStatusState)
}
step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") {
openSendViaSwapScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
swapTokenName = swapTokenName,
networkName = swapTokenName,
networkType = mainNetwork,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the sent '$tokenName' amount is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { primaryAmount.assertIsDisplayed() }
}
}
step("Assert the recipient address is displayed") {
onSendConfirmScreen { recipientAddress(BITCOIN_RECIPIENT_ADDRESS).assertIsDisplayed() }
}
step("Assert the amount to receive after the swap is displayed") {
onSendConfirmScreen { secondaryAmount.assertIsDisplayed() }
}
step("Assert the network fee is paid in '$currencySymbol'") {
onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
}
step("Sign, send and open the 'Transaction sent' screen") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
step("Check 'Send via swap' success screen") {
checkSendViaSwapSuccessScreen()
}
step("Click on 'Close' button") {
onSendSuccessScreen { closeButton.performClick() }
}
step("Assert 'Express status' item with title '$expressStatusItemTitle' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() }
}
}
}
}
}

View file

@ -214,33 +214,18 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING, FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
) )
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled") TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled")
if (isHotWalletOnboardingEnabled) { val afterEmptyRoute: AppRoute = if (isHotWalletOnboardingEnabled) {
val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) { val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) {
appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
} }
TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}") TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}")
if (tangemPayHotWalletOnboardingDeepLink != null) { if (tangemPayHotWalletOnboardingDeepLink != null) {
val hotWalletRoute = AppRoute.TangemPayHotWalletOnboarding AppRoute.TangemPayHotWalletOnboarding
val shouldShowTos = !cardRepository.isTangemTOSAccepted() } else {
val route = if (shouldShowTos) "Disclaimer" else "HotWalletOnboarding" getDefaultRoute()
TangemLogger.i("[TangemPay][HWO] TOS accepted=${!shouldShowTos}, navigating to $route")
return if (shouldShowTos) {
AppRoute.Disclaimer(isTosAccepted = false, nextRoute = hotWalletRoute)
} else {
hotWalletRoute
}
} }
}
val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
)
// Referral users skip the Home stories screen and land directly on the
// mobile wallet creation flow.
val afterEmptyRoute: AppRoute = if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) {
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
} else { } else {
AppRoute.Home(launchMode = launchMode) getDefaultRoute()
} }
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull() val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
@ -261,6 +246,19 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
} }
} }
private suspend fun getDefaultRoute(): AppRoute {
val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
)
// Referral users skip the Home stories screen and land directly on the
// mobile wallet creation flow.
return if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) {
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
} else {
AppRoute.Home(launchMode = launchMode)
}
}
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
RootContent( RootContent(

View file

@ -32,6 +32,7 @@ import com.tangem.features.onramp.component.*
import com.tangem.features.pushnotifications.api.PushNotificationsComponent import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacksStub import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacksStub
import com.tangem.features.pushnotifications.api.PushNotificationsParams import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.NFTSendComponent
import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.SendEntryPointComponent import com.tangem.features.send.api.SendEntryPointComponent
@ -87,6 +88,7 @@ internal class ChildFactory @Inject constructor(
private val resetCardComponentFactory: ResetCardComponent.Factory, private val resetCardComponentFactory: ResetCardComponent.Factory,
private val referralComponentFactory: ReferralComponent.Factory, private val referralComponentFactory: ReferralComponent.Factory,
private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory,
private val pushNotificationSettingsComponentFactory: PushNotificationSettingsComponent.Factory,
private val walletComponentFactory: WalletEntryComponent.Factory, private val walletComponentFactory: WalletEntryComponent.Factory,
private val sendComponentFactoryV2: SendComponent.Factory, private val sendComponentFactoryV2: SendComponent.Factory,
private val redesignedWalletConnectComponentFactory: WalletConnectEntryComponent.Factory, private val redesignedWalletConnectComponentFactory: WalletConnectEntryComponent.Factory,
@ -172,6 +174,13 @@ internal class ChildFactory @Inject constructor(
componentFactory = walletSettingsComponentFactory, componentFactory = walletSettingsComponentFactory,
) )
} }
is AppRoute.PushNotificationSettings -> {
createComponentChild(
context = context,
params = PushNotificationSettingsComponent.Params(route.userWalletId),
componentFactory = pushNotificationSettingsComponentFactory,
)
}
is AppRoute.WalletBackup -> { is AppRoute.WalletBackup -> {
createComponentChild( createComponentChild(
context = context, context = context,

View file

@ -257,6 +257,11 @@ sealed class AppRoute(val path: String) : Route {
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
) : AppRoute(path = "/wallet_settings/${userWalletId.stringValue}") ) : AppRoute(path = "/wallet_settings/${userWalletId.stringValue}")
@Serializable
data class PushNotificationSettings(
val userWalletId: UserWalletId,
) : AppRoute(path = "/push_notification_settings/${userWalletId.stringValue}")
@Serializable @Serializable
data class WalletBackup( data class WalletBackup(
val userWalletId: UserWalletId, val userWalletId: UserWalletId,

View file

@ -40,6 +40,7 @@ sealed interface PaymentAccountStatusValueDM {
@NameLabel("active_account") @NameLabel("active_account")
data class ActiveAccount( data class ActiveAccount(
@Json(name = "active_account") val marker: Boolean = true,
@Json(name = "customer_id") val customerId: String, @Json(name = "customer_id") val customerId: String,
@Json(name = "currency_code") val currencyCode: String, @Json(name = "currency_code") val currencyCode: String,
@Json(name = "deposit_address") val depositAddress: String?, @Json(name = "deposit_address") val depositAddress: String?,
@ -59,9 +60,11 @@ sealed interface PaymentAccountStatusValueDM {
@NameLabel("deactivated_account") @NameLabel("deactivated_account")
data class DeactivatedAccount( data class DeactivatedAccount(
@Json(name = "deactivated_account") val marker: Boolean = true, @Json(name = "deactivated_account") val marker: Boolean = true,
@Json(name = "customer_id") val customerId: String,
@Json(name = "fiat_rate") val fiatRate: BigDecimal?, @Json(name = "fiat_rate") val fiatRate: BigDecimal?,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
@Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal,
) : PaymentAccountStatusValueDM ) : PaymentAccountStatusValueDM
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)

View file

@ -102,6 +102,7 @@
<item quantity="one">%d address</item> <item quantity="one">%d address</item>
<item quantity="other">%d addresses</item> <item quantity="other">%d addresses</item>
</plurals> </plurals>
<string name="address_book_contact">Contact</string>
<string name="address_book_contact_name">Contact name</string> <string name="address_book_contact_name">Contact name</string>
<string name="address_book_copy_address">Copy address</string> <string name="address_book_copy_address">Copy address</string>
<string name="address_book_creating_error">We couldnt create contact. Please try again later.</string> <string name="address_book_creating_error">We couldnt create contact. Please try again later.</string>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14.852,19.604C14.462,20.806 13.333,21.676 12,21.676C10.667,21.676 9.538,20.806 9.147,19.604H14.852ZM12.424,2.009C16.132,2.197 19.08,5.263 19.08,9.018V15.053L21.08,17.135V18.106H3.045V17.135L5.045,15.053V9.018C5.045,5.142 8.187,2 12.063,2L12.424,2.009ZM12.063,4C9.292,4 7.045,6.247 7.045,9.018V15.858L6.807,16.106H17.319L17.08,15.857V9.018C17.08,6.247 14.833,4 12.063,4Z"
android:fillColor="#656565"
android:fillType="evenOdd"/>
</vector>

View file

@ -39,11 +39,11 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
is PaymentAccountStatusValue.IssuingCard -> PaymentAccountStatusValueDM.IssuingCard() is PaymentAccountStatusValue.IssuingCard -> PaymentAccountStatusValueDM.IssuingCard()
is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveAccount( is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveAccount(
customerId = value.customerId, customerId = value.customerId,
currencyCode = value.currencyCode, currencyCode = value.balance.fiatBalance.currency,
depositAddress = value.depositAddress, depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDM(), fiatBalance = value.balance.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(), cryptoBalance = value.balance.cryptoBalance.toDM(),
availableForWithdrawal = value.availableForWithdrawal, availableForWithdrawal = value.balance.availableForWithdrawal,
fiatRate = value.fiatRate, fiatRate = value.fiatRate,
cards = value.cards.map { card -> cards = value.cards.map { card ->
PaymentAccountStatusValueDM.TangemPayCard( PaymentAccountStatusValueDM.TangemPayCard(
@ -63,9 +63,11 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
) )
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty() is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount( is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
customerId = value.customerId,
fiatRate = value.fiatRate, fiatRate = value.fiatRate,
fiatBalance = value.fiatBalance.toDM(), fiatBalance = value.balance.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(), cryptoBalance = value.balance.cryptoBalance.toDM(),
availableForWithdrawal = value.balance.availableForWithdrawal,
) )
// Transient statuses are not persisted // Transient statuses are not persisted
is PaymentAccountStatusValue.Loading, is PaymentAccountStatusValue.Loading,
@ -90,11 +92,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded( is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded(
source = StatusSource.CACHE, source = StatusSource.CACHE,
customerId = value.customerId, customerId = value.customerId,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress, depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDomain(), balance = PaymentAccountStatusValue.Balance(
cryptoBalance = value.cryptoBalance.toDomain(), fiatBalance = value.fiatBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal, cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate, fiatRate = value.fiatRate,
cards = value.cards.map { card -> cards = value.cards.map { card ->
@ -123,8 +126,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
) )
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated( is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
source = StatusSource.CACHE, source = StatusSource.CACHE,
fiatBalance = value.fiatBalance.toDomain(), customerId = value.customerId,
cryptoBalance = value.cryptoBalance.toDomain(), balance = PaymentAccountStatusValue.Balance(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate, fiatRate = value.fiatRate,
) )

View file

@ -33,6 +33,7 @@ import com.tangem.domain.pay.repository.TangemPayCloseCardRepository
import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -287,11 +288,16 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
customerId = requireNotNull(customerId) { "CustomerId must not be null" }, customerId = requireNotNull(customerId) { "CustomerId must not be null" },
) )
} }
fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> { fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() &&
(isDeactivated || isFormer) -> {
PaymentAccountStatusValue.Deactivated( PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
fiatBalance = fiatBalance, customerId = requireNotNull(customerId) { "CustomerId must not be null" },
cryptoBalance = cryptoBalance, balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
availableForWithdrawal = availableForWithdrawal.orZero(),
),
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = quotesData?.fiatRate, fiatRate = quotesData?.fiatRate,
) )
@ -321,11 +327,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return PaymentAccountStatusValue.Loaded( return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
customerId = customerId, customerId = customerId,
currencyCode = cardInfo.currencyCode,
depositAddress = cardInfo.depositAddress, depositAddress = cardInfo.depositAddress,
fiatBalance = cardInfo.fiatBalance, balance = PaymentAccountStatusValue.Balance(
cryptoBalance = cardInfo.cryptoBalance, fiatBalance = cardInfo.fiatBalance,
availableForWithdrawal = cardInfo.availableForWithdrawal, cryptoBalance = cardInfo.cryptoBalance,
availableForWithdrawal = cardInfo.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatRate = fiatRate, fiatRate = fiatRate,
cards = listOf( cards = listOf(

View file

@ -65,6 +65,7 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
state = CustomerInfo.State.fromString(value.state), state = CustomerInfo.State.fromString(value.state),
fiatBalance = fiatBalance?.toDomain(), fiatBalance = fiatBalance?.toDomain(),
cryptoBalance = cryptoBalance?.toDomain(), cryptoBalance = cryptoBalance?.toDomain(),
availableForWithdrawal = value.balance?.availableForWithdrawal?.amount,
) )
} }

View file

@ -64,11 +64,15 @@ internal class PaymentAccountStatusValueDMConverterTest {
// GIVEN // GIVEN
val domain = PaymentAccountStatusValue.Deactivated( val domain = PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
fiatBalance = PaymentAccountStatusValue.FiatBalance( customerId = "customer-1",
availableBalance = BigDecimal("100"), balance = PaymentAccountStatusValue.Balance(
currency = "USD", fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("100"),
currency = "USD",
),
cryptoBalance = cryptoBalance(),
availableForWithdrawal = BigDecimal("7"),
), ),
cryptoBalance = cryptoBalance(),
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatRate = BigDecimal("1.05"), fiatRate = BigDecimal("1.05"),
) )
@ -79,8 +83,10 @@ internal class PaymentAccountStatusValueDMConverterTest {
// THEN // THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.DeactivatedAccount::class.java) assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.DeactivatedAccount::class.java)
val dm = result as PaymentAccountStatusValueDM.DeactivatedAccount val dm = result as PaymentAccountStatusValueDM.DeactivatedAccount
assertThat(dm.customerId).isEqualTo("customer-1")
assertThat(dm.fiatBalance.availableBalance).isEqualTo(BigDecimal("100")) assertThat(dm.fiatBalance.availableBalance).isEqualTo(BigDecimal("100"))
assertThat(dm.fiatBalance.currency).isEqualTo("USD") assertThat(dm.fiatBalance.currency).isEqualTo("USD")
assertThat(dm.availableForWithdrawal).isEqualTo(BigDecimal("7"))
assertThat(dm.fiatRate).isEqualTo(BigDecimal("1.05")) assertThat(dm.fiatRate).isEqualTo(BigDecimal("1.05"))
} }
@ -141,12 +147,14 @@ internal class PaymentAccountStatusValueDMConverterTest {
fun `GIVEN DM DeactivatedAccount WHEN convertBack THEN returns domain Deactivated with CACHE source`() { fun `GIVEN DM DeactivatedAccount WHEN convertBack THEN returns domain Deactivated with CACHE source`() {
// GIVEN // GIVEN
val dm = PaymentAccountStatusValueDM.DeactivatedAccount( val dm = PaymentAccountStatusValueDM.DeactivatedAccount(
customerId = "customer-2",
fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM( fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM(
availableBalance = BigDecimal("200"), availableBalance = BigDecimal("200"),
currency = "EUR", currency = "EUR",
), ),
cryptoBalance = cryptoBalanceDM(), cryptoBalance = cryptoBalanceDM(),
fiatRate = BigDecimal("0.92"), fiatRate = BigDecimal("0.92"),
availableForWithdrawal = BigDecimal("5"),
) )
// WHEN // WHEN
@ -156,8 +164,10 @@ internal class PaymentAccountStatusValueDMConverterTest {
assertThat(result).isInstanceOf(PaymentAccountStatusValue.Deactivated::class.java) assertThat(result).isInstanceOf(PaymentAccountStatusValue.Deactivated::class.java)
val deactivated = result as PaymentAccountStatusValue.Deactivated val deactivated = result as PaymentAccountStatusValue.Deactivated
assertThat(deactivated.source).isEqualTo(StatusSource.CACHE) assertThat(deactivated.source).isEqualTo(StatusSource.CACHE)
assertThat(deactivated.fiatBalance.availableBalance).isEqualTo(BigDecimal("200")) assertThat(deactivated.customerId).isEqualTo("customer-2")
assertThat(deactivated.fiatBalance.currency).isEqualTo("EUR") assertThat(deactivated.balance.fiatBalance.availableBalance).isEqualTo(BigDecimal("200"))
assertThat(deactivated.balance.fiatBalance.currency).isEqualTo("EUR")
assertThat(deactivated.balance.availableForWithdrawal).isEqualTo(BigDecimal("5"))
assertThat(deactivated.fiatRate).isEqualTo(BigDecimal("0.92")) assertThat(deactivated.fiatRate).isEqualTo(BigDecimal("0.92"))
} }

View file

@ -33,11 +33,11 @@ sealed class PaymentAccountStatusValue {
is Loading -> TotalFiatBalance.Loading is Loading -> TotalFiatBalance.Loading
is Loaded -> { is Loaded -> {
val rate = this.fiatRate ?: return TotalFiatBalance.Failed val rate = this.fiatRate ?: return TotalFiatBalance.Failed
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source) TotalFiatBalance.Loaded(amount = balance.fiatBalance.availableBalance.multiply(rate), source = source)
} }
is Deactivated -> { is Deactivated -> {
val rate = this.fiatRate ?: return TotalFiatBalance.Failed val rate = this.fiatRate ?: return TotalFiatBalance.Failed
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source) TotalFiatBalance.Loaded(amount = balance.fiatBalance.availableBalance.multiply(rate), source = source)
} }
} }
@ -104,8 +104,8 @@ sealed class PaymentAccountStatusValue {
* Represents a state where the account is deactivated. * Represents a state where the account is deactivated.
* *
* @property source The source of the status information. * @property source The source of the status information.
* @property fiatBalance The fiat balance details. * @property customerId The unique identifier of the customer.
* @property cryptoBalance The crypto balance details. * @property balance The balance details (fiat, crypto and amount available for withdrawal).
* @property cryptoCurrency The crypto currency held by the deactivated account. * @property cryptoCurrency The crypto currency held by the deactivated account.
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency, * @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
* or `null` if the quote is not yet available. When `null`, * or `null` if the quote is not yet available. When `null`,
@ -114,18 +114,18 @@ sealed class PaymentAccountStatusValue {
@Serializable @Serializable
data class Deactivated( data class Deactivated(
override val source: StatusSource, override val source: StatusSource,
val fiatBalance: FiatBalance, val customerId: String,
val cryptoBalance: CryptoBalance, val balance: Balance,
val cryptoCurrency: CryptoCurrency.Token, val cryptoCurrency: CryptoCurrency.Token,
val fiatRate: SerializedBigDecimal?, val fiatRate: SerializedBigDecimal?,
) : PaymentAccountStatusValue() { ) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency, currency = cryptoCurrency,
value = buildCryptoCurrencyStatusValue( value = buildCryptoCurrencyStatusValue(
amount = cryptoBalance.balance, amount = balance.cryptoBalance.balance,
fiatAmount = fiatBalance.availableBalance, fiatAmount = balance.fiatBalance.availableBalance,
fiatRate = fiatRate, fiatRate = fiatRate,
depositAddress = cryptoBalance.depositAddress, depositAddress = balance.cryptoBalance.depositAddress,
), ),
) )
} }
@ -135,11 +135,9 @@ sealed class PaymentAccountStatusValue {
* *
* @property source The source of the status information. * @property source The source of the status information.
* @property customerId The unique identifier of the customer. * @property customerId The unique identifier of the customer.
* @property currencyCode The code of the currency.
* @property depositAddress The address for deposits, if available. * @property depositAddress The address for deposits, if available.
* @property fiatBalance The fiat balance details. * @property balance The balance details (fiat, crypto and amount available for withdrawal).
* @property cryptoBalance The crypto balance details. * The fiat currency code is available via [Balance.fiatBalance].
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap (excludes pending/locked funds).
* @property cryptoCurrency The crypto currency held by the account. * @property cryptoCurrency The crypto currency held by the account.
* @property cards The list of user's cards. * @property cards The list of user's cards.
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency, * @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
@ -150,11 +148,8 @@ sealed class PaymentAccountStatusValue {
data class Loaded( data class Loaded(
override val source: StatusSource, override val source: StatusSource,
val customerId: String, val customerId: String,
val currencyCode: String,
val depositAddress: String?, val depositAddress: String?,
val fiatBalance: FiatBalance, val balance: Balance,
val cryptoBalance: CryptoBalance,
val availableForWithdrawal: SerializedBigDecimal,
val cryptoCurrency: CryptoCurrency.Token, val cryptoCurrency: CryptoCurrency.Token,
val cards: List<TangemPayCard>, val cards: List<TangemPayCard>,
val fiatRate: SerializedBigDecimal?, val fiatRate: SerializedBigDecimal?,
@ -162,10 +157,10 @@ sealed class PaymentAccountStatusValue {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency, currency = cryptoCurrency,
value = buildCryptoCurrencyStatusValue( value = buildCryptoCurrencyStatusValue(
amount = availableForWithdrawal, amount = balance.availableForWithdrawal,
fiatAmount = fiatBalance.availableBalance, fiatAmount = balance.fiatBalance.availableBalance,
fiatRate = fiatRate, fiatRate = fiatRate,
depositAddress = cryptoBalance.depositAddress, depositAddress = balance.cryptoBalance.depositAddress,
), ),
) )
} }
@ -202,6 +197,21 @@ sealed class PaymentAccountStatusValue {
} }
} }
/**
* Aggregates all balance data of a payment account, as returned by the `customer/me` endpoint.
*
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap
* (excludes pending/locked funds).
*/
@Serializable
data class Balance(
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
val availableForWithdrawal: SerializedBigDecimal,
)
/** /**
* Represents the fiat balance of the payment account. * Represents the fiat balance of the payment account.
* *

View file

@ -28,6 +28,7 @@ data class CustomerInfo(
val state: State, val state: State,
val fiatBalance: PaymentAccountStatusValue.FiatBalance?, val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?, val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?,
val availableForWithdrawal: BigDecimal?,
) { ) {
enum class State { enum class State {
NEW, NEW,

View file

@ -19,6 +19,9 @@ dependencies {
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.addressBook) implementation(projects.domain.addressBook)
/** Common */
implementation(projects.common.ui)
/** Core modules */ /** Core modules */
implementation(projects.core.configToggles) implementation(projects.core.configToggles)
implementation(projects.core.decompose) implementation(projects.core.decompose)
@ -41,4 +44,7 @@ dependencies {
/** Other */ /** Other */
implementation(deps.kotlin.immutable.collections) implementation(deps.kotlin.immutable.collections)
/** Tests */
testImplementation(projects.test.core)
} }

View file

@ -7,4 +7,12 @@ internal sealed class AddressBookRoute {
@Serializable @Serializable
data object List : AddressBookRoute() data object List : AddressBookRoute()
/**
* if [contactId] is not null we should fetch existing contact
*/
@Serializable
data class EditContact(
val contactId: String? = null,
) : AddressBookRoute()
} }

View file

@ -9,11 +9,15 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.AddressBookComponent import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.list.AddressBookListComponent import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -22,6 +26,7 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted context: AppComponentContext, @Assisted context: AppComponentContext,
@Assisted private val params: AddressBookComponent.Params, @Assisted private val params: AddressBookComponent.Params,
private val addressBookListComponentFactory: AddressBookListComponent.Factory, private val addressBookListComponentFactory: AddressBookListComponent.Factory,
private val editContactComponentFactory: EditContactComponent.Factory,
) : AddressBookComponent, AppComponentContext by context { ) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>() private val navigation = StackNavigation<AddressBookRoute>()
@ -51,11 +56,16 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
context = childByContext(componentContext), context = childByContext(componentContext),
params = AddressBookListComponent.Params( params = AddressBookListComponent.Params(
onContactClick = { contactId -> onContactClick = { contactId ->
// TODO [REDACTED_TASK_KEY] router.push(EditContact(contactId)) navigation.pushNew(AddressBookRoute.EditContact(contactId))
},
onAddContactClick = {
// TODO [REDACTED_TASK_KEY] router.push(AddContact)
}, },
onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) },
),
)
is AddressBookRoute.EditContact -> editContactComponentFactory.create(
context = childByContext(componentContext),
params = EditContactComponent.Params(
contactId = config.contactId?.let(::ContactId),
onBackClick = { navigation.pop() },
), ),
) )
} }

View file

@ -4,6 +4,8 @@ import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.component.DefaultAddressBookComponent import com.tangem.features.addressbook.component.DefaultAddressBookComponent
import com.tangem.features.addressbook.list.AddressBookListComponent import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -23,4 +25,8 @@ internal interface AddressBookComponentModule {
fun bindAddressBookListComponentFactory( fun bindAddressBookListComponentFactory(
factory: DefaultAddressBookListComponent.Factory, factory: DefaultAddressBookListComponent.Factory,
): AddressBookListComponent.Factory ): AddressBookListComponent.Factory
@Binds
@Singleton
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
} }

View file

@ -3,6 +3,7 @@ package com.tangem.features.addressbook.di
import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.editcontact.model.EditContactModel
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -17,4 +18,9 @@ internal interface AddressBookModelModule {
@IntoMap @IntoMap
@ClassKey(AddressBookListModel::class) @ClassKey(AddressBookListModel::class)
fun bindAddressBookModel(model: AddressBookListModel): Model fun bindAddressBookModel(model: AddressBookListModel): Model
@Binds
@IntoMap
@ClassKey(EditContactModel::class)
fun bindEditContactModel(model: EditContactModel): Model
} }

View file

@ -0,0 +1,40 @@
package com.tangem.features.addressbook.editcontact
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.addressbook.editcontact.model.EditContactModel
import com.tangem.features.addressbook.editcontact.ui.EditContactContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultEditContactComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: EditContactComponent.Params,
) : EditContactComponent, AppComponentContext by context {
private val model: EditContactModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
EditContactContent(
state = state,
modifier = modifier,
)
BackHandler(onBack = state.onCloseClick)
}
@AssistedFactory
interface Factory : EditContactComponent.Factory {
override fun create(
context: AppComponentContext,
params: EditContactComponent.Params,
): DefaultEditContactComponent
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.addressbook.editcontact
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
internal interface EditContactComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, EditContactComponent>
data class Params(
val contactId: ContactId?,
val onBackClick: () -> Unit,
)
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.addressbook.editcontact.contract
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class EditContactUM(
val title: TextReference,
val name: String,
val namePlaceholder: TextReference,
val portfolioIcon: AccountIconUM.CryptoPortfolio,
val colors: Colors,
val onNameChange: (String) -> Unit,
val onCloseClick: () -> Unit,
) {
@Immutable
data class Colors(
val selected: CryptoPortfolioIcon.Color,
val list: ImmutableList<CryptoPortfolioIcon.Color>,
val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.addressbook.editcontact.model
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class EditContactModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params: EditContactComponent.Params = paramsContainer.require()
val state: StateFlow<EditContactUM>
field = MutableStateFlow(getInitialState())
private fun onNameChange(name: String) {
state.update { it.copy(name = name) }
}
private fun onColorSelect(color: CryptoPortfolioIcon.Color) {
state.update { oldState ->
oldState.copy(
colors = oldState.colors.copy(selected = color),
portfolioIcon = oldState.portfolioIcon.copy(color = color),
)
}
}
private fun getInitialState(): EditContactUM {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val selectedColor = colors.first()
val titleResId = if (params.contactId == null) {
R.string.address_book_new_contact
} else {
R.string.address_book_contact
}
return EditContactUM(
title = resourceReference(titleResId),
name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = selectedColor,
),
colors = EditContactUM.Colors(
selected = selectedColor,
list = colors,
onColorSelect = ::onColorSelect,
),
onNameChange = ::onNameChange,
onCloseClick = params.onBackClick,
)
}
}

View file

@ -0,0 +1,185 @@
package com.tangem.features.addressbook.editcontact.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.getUiColor
import com.tangem.core.ui.R
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.fields.AutoSizeTextField
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = state.title,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = state.onCloseClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
ContactSummary(state = state)
ContactColor(colors = state.colors)
}
}
}
@Composable
private fun ContactSummary(state: EditContactUM) {
val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() }
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
AccountIcon(
name = stringReference(avatarName),
icon = state.portfolioIcon,
size = AccountIconSize.Large,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResourceSafe(R.string.address_book_contact_name),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
)
Spacer(modifier = Modifier.height(2.dp))
AutoSizeTextField(
value = state.name,
onValueChange = state.onNameChange,
centered = true,
singleLine = true,
placeholder = state.namePlaceholder,
textStyle = TangemTheme.typography3.heading.medium,
color = TangemTheme.colors3.text.primary,
placeholderColor = TangemTheme.colors3.text.tertiary,
)
Spacer(modifier = Modifier.height(20.dp))
}
}
@OptIn(ExperimentalLayoutApi::class)
@Suppress("MagicNumber")
@Composable
private fun ContactColor(colors: EditContactUM.Colors) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
FlowRow(
maxItemsInEachRow = 6,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
) {
colors.list.fastForEach { color ->
val isSelected = color == colors.selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = { colors.onColorSelect(color) })
.size(48.dp),
) {
if (isSelected) {
Box(
modifier = Modifier
.size(47.dp)
.border(2.dp, color.getUiColor(), shape = CircleShape),
)
Box(
modifier = Modifier
.size(36.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
} else {
Box(
modifier = Modifier
.size(40.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
}
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_EditContactContent() {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
TangemThemePreview {
EditContactContent(
state = EditContactUM(
title = stringReference("New contact"),
name = "",
namePlaceholder = stringReference("New contact"),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = colors.first(),
),
colors = EditContactUM.Colors(
selected = colors.first(),
list = colors,
onColorSelect = {},
),
onNameChange = {},
onCloseClick = {},
),
)
}
}

View file

@ -23,9 +23,9 @@ internal class DefaultAddressBookListComponent @AssistedInject constructor(
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
when (state) { when (val addressBookListUM = state) {
AddressBookListUM.Empty -> AddressBookEmptyScreen( is AddressBookListUM.Empty -> AddressBookEmptyScreen(
onAddContactClick = params.onAddContactClick, tangemButtonUM = addressBookListUM.tangemButtonUM,
onBackClick = router::pop, onBackClick = router::pop,
modifier = modifier, modifier = modifier,
) )

View file

@ -1,12 +1,15 @@
package com.tangem.features.addressbook.list.contract package com.tangem.features.addressbook.list.contract
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.Contact
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
internal sealed class AddressBookListUM { internal sealed class AddressBookListUM {
data object Empty : AddressBookListUM() data class Empty(
val tangemButtonUM: TangemButtonUM,
) : AddressBookListUM()
data class AddressList(val contacts: ImmutableList<Contact>) : AddressBookListUM() data class AddressList(val contacts: ImmutableList<Contact>) : AddressBookListUM()
} }

View file

@ -2,6 +2,16 @@ package com.tangem.features.addressbook.list.model
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.R.drawable.ic_plus_24
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.list.contract.AddressBookListUM import com.tangem.features.addressbook.list.contract.AddressBookListUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -10,10 +20,24 @@ import javax.inject.Inject
@ModelScoped @ModelScoped
internal class AddressBookListModel @Inject constructor( internal class AddressBookListModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
) : Model() { ) : Model() {
private val params = paramsContainer.require<AddressBookListComponent.Params>()
val state: StateFlow<AddressBookListUM> = MutableStateFlow( val state: StateFlow<AddressBookListUM> = MutableStateFlow(
AddressBookListUM.Empty, AddressBookListUM.Empty(
tangemButtonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_new_contact),
tangemIconUM = TangemIconUM.Icon(
iconRes = ic_plus_24,
tintReference = { TangemTheme.colors3.text.inverse.primary },
),
iconPosition = TangemButtonIconPosition.End,
type = TangemButtonType.Primary,
onClick = params.onAddContactClick,
),
),
) )
} }

View file

@ -14,17 +14,21 @@ 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 com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButtonIconEnd import com.tangem.core.ui.ds.button.PrimaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
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.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
@Composable @Composable
internal fun AddressBookEmptyScreen( internal fun AddressBookEmptyScreen(
onAddContactClick: () -> Unit, tangemButtonUM: TangemButtonUM,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -40,19 +44,17 @@ internal fun AddressBookEmptyScreen(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24), iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
onClick = onBackClick, onClick = onBackClick,
size = TangemButton.Size.X11, size = TangemButton.Size.X11,
variant = TangemButton.Variant.Secondary, variant = TangemButton.Variant.Material,
) )
}, },
) )
NoContactInfo() NoContactInfo()
PrimaryButtonIconEnd( PrimaryTangemButton(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.navigationBarsPadding() .navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), .padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
text = stringResourceSafe(R.string.address_book_add_contact), buttonUM = tangemButtonUM,
iconResId = R.drawable.ic_plus_24,
onClick = onAddContactClick,
) )
} }
} }
@ -104,5 +106,14 @@ private fun ContactImage() {
@Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_AddressBookEmptyScreen() { private fun Preview_AddressBookEmptyScreen() {
AddressBookEmptyScreen(onAddContactClick = {}, onBackClick = {}) AddressBookEmptyScreen(
tangemButtonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_new_contact),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24),
iconPosition = TangemButtonIconPosition.End,
type = TangemButtonType.Secondary,
onClick = {},
),
onBackClick = {},
)
} }

View file

@ -0,0 +1,113 @@
package com.tangem.features.addressbook.editcontact.model
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class EditContactModelTest {
@Test
fun `WHEN model created THEN initial state is correct`() = runTest {
val expectedColors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val expectedSelectedColor = expectedColors.first()
val model = createModel(testScope = this)
val state = model.state.value
val expected = EditContactUM(
title = resourceReference(R.string.address_book_new_contact),
name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = expectedSelectedColor,
),
colors = EditContactUM.Colors(
selected = expectedSelectedColor,
list = expectedColors,
onColorSelect = state.colors.onColorSelect,
),
onNameChange = state.onNameChange,
onCloseClick = state.onCloseClick,
)
assertThat(state).isEqualTo(expected)
}
@Test
fun `GIVEN existing contactId WHEN model created THEN title is contact`() = runTest {
// Arrange
val params = EditContactComponent.Params(
contactId = ContactId(value = "contact-id"),
onBackClick = {},
)
// Act
val model = createModel(testScope = this, params = params)
val state = model.state.value
// Assert
assertThat(state.title).isEqualTo(resourceReference(R.string.address_book_contact))
}
@Test
fun `GIVEN initial state WHEN onNameChange THEN name updated`() = runTest {
val model = createModel(testScope = this)
val newName = "Satoshi"
model.state.value.onNameChange(newName)
assertThat(model.state.value.name).isEqualTo(newName)
}
@Test
fun `GIVEN initial state WHEN onColorSelect THEN selected color and portfolio icon updated`() = runTest {
val model = createModel(testScope = this)
val newColor = CryptoPortfolioIcon.Color.entries.last()
model.state.value.colors.onColorSelect(newColor)
val state = model.state.value
assertThat(state.colors.selected).isEqualTo(newColor)
assertThat(state.portfolioIcon.color).isEqualTo(newColor)
}
private fun createModel(
testScope: TestScope,
params: EditContactComponent.Params = EditContactComponent.Params(
contactId = null,
onBackClick = {},
),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
): EditContactModel {
return EditContactModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -31,6 +31,7 @@ dependencies {
implementation(projects.domain.pushNotificationPreferences) implementation(projects.domain.pushNotificationPreferences)
/* AndroidX */ /* AndroidX */
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose) implementation(deps.lifecycle.compose)
/* Compose */ /* Compose */
@ -50,7 +51,7 @@ dependencies {
implementation(deps.kotlin.immutable.collections) implementation(deps.kotlin.immutable.collections)
/* Tests */ /* Tests */
testImplementation(deps.test.junit) testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk) testImplementation(deps.test.mockk)
testImplementation(deps.test.coroutine) testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth) testImplementation(deps.test.truth)

View file

@ -0,0 +1,96 @@
package com.tangem.features.pushnotificationsettings.impl.component
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.doOnResume
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.feature.walletsettings.component.NetworksAvailableForNotificationsComponent
import com.tangem.features.pushnotifications.api.utils.getPushPermissionOrNull
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
import com.tangem.features.pushnotificationsettings.impl.entity.NetworksAvailableForNotificationBSConfig
import com.tangem.features.pushnotificationsettings.impl.model.PushNotificationSettingsModel
import com.tangem.features.pushnotificationsettings.impl.ui.PushNotificationSettingsScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultPushNotificationSettingsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: PushNotificationSettingsComponent.Params,
private val networksAvailableForNotificationsComponentFactory: NetworksAvailableForNotificationsComponent.Factory,
) : PushNotificationSettingsComponent, AppComponentContext by context {
private val model: PushNotificationSettingsModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
key = "moreInfoBottomSheet",
childFactory = ::bottomSheetChild,
)
init {
lifecycle.doOnResume { model.onResume() }
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = model::onPermissionResult,
)
LaunchedEffect(Unit) {
model.requestPushPermission.collect {
val permission = getPushPermissionOrNull()
if (permission != null) {
permissionLauncher.launch(permission)
} else {
model.onPermissionResult(isGranted = false)
}
}
}
PushNotificationSettingsScreen(
modifier = modifier,
state = state,
onBackClick = router::pop,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
@Suppress("UNUSED_PARAMETER") config: NetworksAvailableForNotificationBSConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = networksAvailableForNotificationsComponentFactory.create(
context = childByContext(componentContext),
params = NetworksAvailableForNotificationsComponent.Params(
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
@AssistedFactory
interface Factory : PushNotificationSettingsComponent.Factory {
override fun create(
context: AppComponentContext,
params: PushNotificationSettingsComponent.Params,
): DefaultPushNotificationSettingsComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.pushnotificationsettings.impl.di
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
import com.tangem.features.pushnotificationsettings.impl.component.DefaultPushNotificationSettingsComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface PushNotificationSettingsComponentModule {
@Binds
@Singleton
fun bindPushNotificationSettingsComponentFactory(
factory: DefaultPushNotificationSettingsComponent.Factory,
): PushNotificationSettingsComponent.Factory
}

View file

@ -1,7 +1,6 @@
package com.tangem.features.pushnotificationsettings.impl.entity package com.tangem.features.pushnotificationsettings.impl.entity
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.core.ui.event.StateEvent
import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentList
@Immutable @Immutable
@ -12,7 +11,6 @@ internal sealed interface PushNotificationSettingsUM {
data class Content( data class Content(
val banner: AllowPushNotificationsBannerUM?, val banner: AllowPushNotificationsBannerUM?,
val toggles: PersistentList<ToggleUM>, val toggles: PersistentList<ToggleUM>,
val requestPermissionEvent: StateEvent<Unit>,
val onMoreInfoClick: () -> Unit, val onMoreInfoClick: () -> Unit,
) : PushNotificationSettingsUM ) : PushNotificationSettingsUM

View file

@ -9,9 +9,6 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider
import com.tangem.core.navigation.settings.SettingsManager import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.DialogMessage
@ -38,6 +35,8 @@ import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -45,6 +44,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -69,7 +69,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
private val loadState = MutableStateFlow<LoadState>(LoadState.Loading) private val loadState = MutableStateFlow<LoadState>(LoadState.Loading)
private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled()) private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled())
private val pendingRequest = MutableStateFlow<StateEvent<Unit>>(consumedEvent())
private var pendingPermissionToggle: ToggleSpec? = null private var pendingPermissionToggle: ToggleSpec? = null
private val preferencesJobHolder = JobHolder() private val preferencesJobHolder = JobHolder()
@ -77,15 +76,19 @@ internal class PushNotificationSettingsModel @Inject constructor(
private val cachedPrefs: WalletPushNotificationPreferences? private val cachedPrefs: WalletPushNotificationPreferences?
get() = (loadState.value as? LoadState.Content)?.prefs get() = (loadState.value as? LoadState.Content)?.prefs
private val requestPushPermissionChannel = Channel<Unit>(Channel.BUFFERED)
/** One-shot requests to launch the system push permission prompt, consumed by the component. */
val requestPushPermission: Flow<Unit> = requestPushPermissionChannel.receiveAsFlow()
val uiState: StateFlow<PushNotificationSettingsUM> = combine( val uiState: StateFlow<PushNotificationSettingsUM> = combine(
loadState, loadState,
osNotificationsEnabled, osNotificationsEnabled,
pendingRequest, ) { load, osEnabled ->
) { load, osEnabled, request ->
when (load) { when (load) {
is LoadState.Failed -> PushNotificationSettingsUM.Error(onRetryClick = ::onRetry) is LoadState.Failed -> PushNotificationSettingsUM.Error(onRetryClick = ::onRetry)
is LoadState.Loading -> PushNotificationSettingsUM.Loading is LoadState.Loading -> PushNotificationSettingsUM.Loading
is LoadState.Content -> buildContent(prefs = load.prefs, osEnabled = osEnabled, request = request) is LoadState.Content -> buildContent(prefs = load.prefs, osEnabled = osEnabled)
} }
}.stateIn( }.stateIn(
scope = modelScope, scope = modelScope,
@ -117,7 +120,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
} }
fun onPermissionResult(isGranted: Boolean) { fun onPermissionResult(isGranted: Boolean) {
pendingRequest.value = consumedEvent()
val tapped = pendingPermissionToggle val tapped = pendingPermissionToggle
pendingPermissionToggle = null pendingPermissionToggle = null
modelScope.launch { modelScope.launch {
@ -145,12 +147,10 @@ internal class PushNotificationSettingsModel @Inject constructor(
private fun buildContent( private fun buildContent(
prefs: WalletPushNotificationPreferences, prefs: WalletPushNotificationPreferences,
osEnabled: Boolean, osEnabled: Boolean,
request: StateEvent<Unit>,
): PushNotificationSettingsUM.Content { ): PushNotificationSettingsUM.Content {
return PushNotificationSettingsUM.Content( return PushNotificationSettingsUM.Content(
banner = buildBanner(prefs = prefs, osEnabled = osEnabled), banner = buildBanner(prefs = prefs, osEnabled = osEnabled),
toggles = buildToggles(prefs), toggles = buildToggles(prefs),
requestPermissionEvent = request,
onMoreInfoClick = ::onMoreInfoClick, onMoreInfoClick = ::onMoreInfoClick,
) )
} }
@ -190,7 +190,7 @@ internal class PushNotificationSettingsModel @Inject constructor(
private fun requestPermission(tapped: ToggleSpec? = null) { private fun requestPermission(tapped: ToggleSpec? = null) {
pendingPermissionToggle = tapped pendingPermissionToggle = tapped
pendingRequest.value = triggeredEvent(data = Unit, onConsume = ::onPermissionEventConsumed) requestPushPermissionChannel.trySend(Unit)
} }
private fun onBannerCtaClick() { private fun onBannerCtaClick() {
@ -219,10 +219,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
applyOptimisticToggle(spec, newValue) applyOptimisticToggle(spec, newValue)
} }
private fun onPermissionEventConsumed() {
pendingRequest.value = consumedEvent()
}
private fun applyOptimisticToggle(spec: ToggleSpec, newValue: Boolean) { private fun applyOptimisticToggle(spec: ToggleSpec, newValue: Boolean) {
val current = cachedPrefs ?: return val current = cachedPrefs ?: return
loadState.value = LoadState.Content(current.withCategory(spec.category, newValue)) loadState.value = LoadState.Content(current.withCategory(spec.category, newValue))

View file

@ -0,0 +1,28 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.R as CoreUiR
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.pushnotificationsettings.impl.R
import com.tangem.features.pushnotificationsettings.impl.entity.AllowPushNotificationsBannerUM
@Composable
internal fun AllowPushNotificationsBanner(state: AllowPushNotificationsBannerUM, modifier: Modifier = Modifier) {
Notification(
modifier = modifier.fillMaxWidth(),
config = NotificationConfig(
title = resourceReference(R.string.push_notification_settings_banner_title),
subtitle = resourceReference(R.string.push_notification_settings_banner_description),
iconResId = CoreUiR.drawable.ic_alert_circle_24,
iconTint = NotificationConfig.IconTint.Warning,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_open_settings_button_title),
onClick = state.onOpenSettingsClick,
),
),
)
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.styledResourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.pushnotificationsettings.impl.R
import com.tangem.features.pushnotificationsettings.impl.entity.ToggleUM
@Composable
internal fun NotificationSettingRow(
toggle: ToggleUM,
showInlineMoreInfoLink: Boolean,
onMoreInfoClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
BlockCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Text(
modifier = Modifier.weight(1f),
text = stringResourceSafe(id = toggle.titleRes),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
TangemSwitch(
checked = toggle.isOn,
onCheckedChange = toggle.onCheckedChange,
)
}
}
SubtitleText(
base = toggle.subtitle,
showMoreInfo = showInlineMoreInfoLink,
onMoreInfoClick = onMoreInfoClick,
)
}
}
@Composable
private fun SubtitleText(base: TextReference, showMoreInfo: Boolean, onMoreInfoClick: () -> Unit) {
val reference: TextReference = if (showMoreInfo) {
combinedReference(
base,
stringReference(" "),
styledResourceReference(
id = R.string.push_notifications_more_info,
spanStyleReference = {
TangemTheme.typography.caption2
.copy(color = TangemTheme.colors.text.accent)
.toSpanStyle()
},
onClick = onMoreInfoClick,
),
)
} else {
base
}
Text(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
text = reference.resolveAnnotatedReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.pushnotificationsettings.impl.entity.AllowPushNotificationsBannerUM
import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM
import com.tangem.features.pushnotificationsettings.impl.entity.ToggleId
@Composable
internal fun PushNotificationSettingsContent(
state: PushNotificationSettingsUM.Content,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
item(key = "banner") {
AnimatedBannerSlot(banner = state.banner)
}
items(items = state.toggles, key = { it.id.name }) { toggle ->
NotificationSettingRow(
modifier = Modifier.animateItem(),
toggle = toggle,
showInlineMoreInfoLink = toggle.id == ToggleId.TransactionAlerts,
onMoreInfoClick = state.onMoreInfoClick,
)
}
}
}
@Composable
private fun AnimatedBannerSlot(banner: AllowPushNotificationsBannerUM?) {
var lastVisible by remember { mutableStateOf(banner) }
if (banner != null) {
lastVisible = banner
}
AnimatedVisibility(
visible = banner != null,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
lastVisible?.let { AllowPushNotificationsBanner(state = it) }
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM
@Composable
internal fun PushNotificationSettingsError(state: PushNotificationSettingsUM.Error, modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = state.onRetryClick)
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun PushNotificationSettingsLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
repeat(SHIMMER_ROW_COUNT) {
ShimmerRow()
}
}
}
@Composable
private fun ShimmerRow(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
BlockCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
RectangleShimmer(
modifier = Modifier
.width(SHIMMER_TITLE_WIDTH)
.height(SHIMMER_TITLE_HEIGHT),
)
RectangleShimmer(
modifier = Modifier
.width(SHIMMER_SWITCH_WIDTH)
.height(SHIMMER_SWITCH_HEIGHT),
radius = TangemTheme.dimens.radius12,
)
}
}
RectangleShimmer(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.width(SHIMMER_SUBTITLE_WIDTH)
.height(SHIMMER_SUBTITLE_HEIGHT),
)
}
}
private const val SHIMMER_ROW_COUNT = 3
private val SHIMMER_TITLE_WIDTH = 160.dp
private val SHIMMER_TITLE_HEIGHT = 18.dp
private val SHIMMER_SWITCH_WIDTH = 40.dp
private val SHIMMER_SWITCH_HEIGHT = 22.dp
private val SHIMMER_SUBTITLE_WIDTH = 220.dp
private val SHIMMER_SUBTITLE_HEIGHT = 14.dp

View file

@ -0,0 +1,46 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.pushnotificationsettings.impl.R
import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM
@Composable
internal fun PushNotificationSettingsScreen(
state: PushNotificationSettingsUM,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.push_notification_settings_title),
startButton = TopAppBarButtonUM.Back(onBackClicked = onBackClick),
)
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when (state) {
is PushNotificationSettingsUM.Loading -> PushNotificationSettingsLoading()
is PushNotificationSettingsUM.Content -> PushNotificationSettingsContent(state = state)
is PushNotificationSettingsUM.Error -> PushNotificationSettingsError(state = state)
}
}
}
}

View file

@ -29,7 +29,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.jupiter.api.Test
@Suppress("LongParameterList") @Suppress("LongParameterList")
class PushNotificationSettingsModelTest { class PushNotificationSettingsModelTest {
@ -204,13 +204,15 @@ class PushNotificationSettingsModelTest {
val model = model(osEnabled = false, preferencesFlow = flow) val model = model(osEnabled = false, preferencesFlow = flow)
advanceUntilIdle() advanceUntilIdle()
val offers = (model.uiState.value as PushNotificationSettingsUM.Content) model.requestPushPermission.test {
.toggles.first { it.id == ToggleId.OffersUpdates } val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
offers.onCheckedChange(true) .toggles.first { it.id == ToggleId.OffersUpdates }
advanceUntilIdle() offers.onCheckedChange(true)
advanceUntilIdle()
val content = model.uiState.value as PushNotificationSettingsUM.Content awaitItem()
assertThat(content.requestPermissionEvent.javaClass.simpleName).isEqualTo("Triggered") expectNoEvents()
}
coVerify(exactly = 0) { updatePreference(any(), any(), any()) } coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
} }
@ -224,12 +226,14 @@ class PushNotificationSettingsModelTest {
val banner = requireNotNull( val banner = requireNotNull(
(model.uiState.value as PushNotificationSettingsUM.Content).banner, (model.uiState.value as PushNotificationSettingsUM.Content).banner,
) )
banner.onOpenSettingsClick() model.requestPushPermission.test {
advanceUntilIdle() banner.onOpenSettingsClick()
advanceUntilIdle()
// The banner CTA opens system settings directly and never asks for the permission.
expectNoEvents()
}
verify(exactly = 1) { settingsManager.openAppNotificationSettings() } verify(exactly = 1) { settingsManager.openAppNotificationSettings() }
val refreshed = model.uiState.value as PushNotificationSettingsUM.Content
assertThat(refreshed.requestPermissionEvent.javaClass.simpleName).isEqualTo("Consumed")
} }
@Test @Test

View file

@ -271,7 +271,7 @@ internal class SendDestinationModel @Inject constructor(
private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? { private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? {
val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null
val (paymentAccountAddress, currency) = when (val status = this.value) { val (paymentAccountAddress, currency) = when (val status = this.value) {
is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress to status.cryptoCurrency is PaymentAccountStatusValue.Loaded -> status.balance.cryptoBalance.depositAddress to status.cryptoCurrency
else -> return null else -> return null
} }

View file

@ -97,7 +97,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor(
?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } ?.takeIf { it.period == TangemPayCardLimitPeriod.DAY }
?.amount ?.amount
val currency = getJavaCurrencyByCode(status.currencyCode) val currency = getJavaCurrencyByCode(status.balance.fiatBalance.currency)
uiState.update { state -> uiState.update { state ->
val amount = if (index == 0) { val amount = if (index == 0) {
currentLimit?.stripTrailingZeros()?.toPlainString().orEmpty() currentLimit?.stripTrailingZeros()?.toPlainString().orEmpty()

View file

@ -118,8 +118,9 @@ internal class TangemPayCardPageModel @Inject constructor(
val dailyLimitState = if (limit != null) { val dailyLimitState = if (limit != null) {
TangemPayDailyLimitBlockState.Content( TangemPayDailyLimitBlockState.Content(
limit = limit.amount.format { limit = limit.amount.format {
val symbol = getJavaCurrencyByCode(status.currencyCode).symbol val currencyCode = status.balance.fiatBalance.currency
fiat(status.currencyCode, symbol).optionalDecimals() val symbol = getJavaCurrencyByCode(currencyCode).symbol
fiat(currencyCode, symbol).optionalDecimals()
}, },
onChangeClick = ::onClickLimitChange, onChangeClick = ::onClickLimitChange,
) )

View file

@ -25,8 +25,8 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
@ -40,8 +40,6 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
import com.tangem.features.tangempay.entity.TangemPayDetailsUM 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.* import com.tangem.features.tangempay.model.transformers.*
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.utils.* import com.tangem.features.tangempay.utils.*
@ -52,7 +50,6 @@ import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.transformer.update import com.tangem.utils.transformer.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -70,33 +67,36 @@ internal class TangemPayDetailsModel @Inject constructor(
private val cardDetailsRepository: TangemPayCardDetailsRepository, private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val uiMessageSender: UiMessageSender, private val uiMessageSender: UiMessageSender,
private val cardDetailsEventListener: CardDetailsEventListener,
private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener, private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener,
private val tangemPayWithdrawRepository: TangemPayWithdrawRepository, private val tangemPayWithdrawRepository: TangemPayWithdrawRepository,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val expressTransactionsEventListener: ExpressTransactionsEventListener, private val expressTransactionsEventListener: ExpressTransactionsEventListener,
private val tangemPayFeatureToggles: TangemPayFeatureToggles, private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener { ) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
private val userWalletId = params.initialStatus.userWalletId
private val isTangemPayDeactivated = params.initialStatus.isDeactivated private val isTangemPayDeactivated = params.initialStatus.isDeactivated
private val loaded: PaymentAccountStatusValue.Loaded? =
params.initialStatus.value as? PaymentAccountStatusValue.Loaded
private val firstCard = loaded?.cards?.firstOrNull()
val cryptoCurrency: CryptoCurrency = params.initialStatus.cryptoCurrency
private val initialCardFrozenState: TangemPayCardFrozenState = when { private val initialCard = params.initialStatus.ifLoadedOrNull { it.cards.firstOrNull() }
firstCard == null -> TangemPayCardFrozenState.Unfrozen
else -> firstCard.frozenState private val currentStatus = MutableStateFlow(params.initialStatus)
}
private val userWalletId
get() = currentStatus.value.userWalletId
val cryptoCurrency
get() = currentStatus.value.cryptoCurrency
private val stateFactory = TangemPayDetailsStateFactory( private val stateFactory = TangemPayDetailsStateFactory(
onBack = router::pop, onBack = router::pop,
onOpenMenu = ::onOpenMenu, onOpenMenu = ::onOpenMenu,
intents = this, intents = this,
cardFrozenState = initialCardFrozenState, cardFrozenState = when {
initialCard == null -> TangemPayCardFrozenState.Unfrozen
else -> initialCard.frozenState
},
isRedesignEnabled = isRedesignEnabled(), isRedesignEnabled = isRedesignEnabled(),
) )
@ -104,39 +104,49 @@ internal class TangemPayDetailsModel @Inject constructor(
field = MutableStateFlow( field = MutableStateFlow(
stateFactory.getInitialState( stateFactory.getInitialState(
isTangemPayDeactivated = isTangemPayDeactivated, isTangemPayDeactivated = isTangemPayDeactivated,
cardNumberEnd = firstCard?.lastDigits.orEmpty(), cardNumberEnd = initialCard?.lastDigits.orEmpty(),
isReissuing = firstCard == null || firstCard.state != TangemPayCardState.Active, isReissuing = initialCard == null || initialCard.state != TangemPayCardState.Active,
), ),
) )
private val refreshStateJobHolder = JobHolder() private val refreshStateJobHolder = JobHolder()
private val fetchBalanceJobHolder = JobHolder()
private val addToWalletBannerJobHolder = JobHolder() private val addToWalletBannerJobHolder = JobHolder()
private var balance: TangemPayCardBalance? = null
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation() val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
init { init {
analytics.send(TangemPayAnalyticsEvents.MainScreenOpened()) analytics.send(TangemPayAnalyticsEvents.MainScreenOpened())
handleBalanceHiding() handleBalanceHiding()
fetchBalance()
if (!isTangemPayDeactivated && firstCard != null) {
subscribeToCardFrozenState(firstCard.id)
fetchAddToWalletBanner()
paymentAccountStatusSupplier.invoke(userWalletId) val statusFlow = paymentAccountStatusSupplier.invoke(userWalletId)
.map { it.value } .onEach { status -> currentStatus.update { status } }
.map { it.value }
if (isTangemPayDeactivated) {
statusFlow
.filterIsInstance<PaymentAccountStatusValue.Deactivated>()
.onEach { state ->
uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance))
}
.launchIn(modelScope)
} else {
if (initialCard != null) {
subscribeToCardFrozenState(initialCard.id)
}
fetchAddToWalletBanner()
statusFlow
.filterIsInstance<PaymentAccountStatusValue.Loaded>() .filterIsInstance<PaymentAccountStatusValue.Loaded>()
.filter { it.source == StatusSource.ACTUAL } .filter { it.source == StatusSource.ACTUAL }
.onEach { state -> .onEach { state ->
val card = state.cards.firstOrNull() ?: return@onEach uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance))
uiState.update( state.cards.firstOrNull()?.let { card ->
TangemPayCardDataTransformer( uiState.update(
card = card, TangemPayCardDataTransformer(
onCardClick = { onCardClick() }, card = card,
), onCardClick = { onCardClick() },
) ),
)
}
} }
.launchIn(modelScope) .launchIn(modelScope)
} }
@ -165,17 +175,16 @@ internal class TangemPayDetailsModel @Inject constructor(
override fun onClickAddFunds() { override fun onClickAddFunds() {
analytics.send(TangemPayAnalyticsEvents.AddFundsClicked()) analytics.send(TangemPayAnalyticsEvents.AddFundsClicked())
val currentBalance = balance val balance = currentStatus.value.balanceOrNull()
val depositAddress = currentBalance?.depositAddress if (balance == null) {
if (currentBalance == null || depositAddress == null) {
showBottomSheetError(TangemPayDetailsErrorType.Receive) showBottomSheetError(TangemPayDetailsErrorType.Receive)
} else { } else {
bottomSheetNavigation.activate( bottomSheetNavigation.activate(
TangemPayDetailsNavigation.AddFunds( TangemPayDetailsNavigation.AddFunds(
walletId = userWalletId, walletId = userWalletId,
fiatBalance = currentBalance.availableForWithdrawal, fiatBalance = balance.availableForWithdrawal,
cryptoBalance = currentBalance.availableForWithdrawal, cryptoBalance = balance.availableForWithdrawal,
depositAddress = depositAddress, depositAddress = balance.cryptoBalance.depositAddress,
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
), ),
) )
@ -184,12 +193,6 @@ internal class TangemPayDetailsModel @Inject constructor(
override fun onClickWithdraw() { override fun onClickWithdraw() {
analytics.send(TangemPayAnalyticsEvents.WithdrawClicked()) analytics.send(TangemPayAnalyticsEvents.WithdrawClicked())
val currentBalance = balance
val depositAddress = currentBalance?.depositAddress
if (currentBalance == null || depositAddress == null) {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
return
}
modelScope.launch { modelScope.launch {
val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWalletId) val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWalletId)
if (hasActiveWithdrawal) { if (hasActiveWithdrawal) {
@ -197,18 +200,19 @@ internal class TangemPayDetailsModel @Inject constructor(
} else { } else {
uiMessageSender.send( uiMessageSender.send(
message = TangemPayMessagesFactory.createWithdrawWarning( message = TangemPayMessagesFactory.createWithdrawWarning(
onGotItClick = { onConfirmWithdrawal(cryptoCurrency, currentBalance, depositAddress) }, onGotItClick = { onConfirmWithdrawal(cryptoCurrency) },
), ),
) )
} }
} }
} }
private fun onConfirmWithdrawal( private fun onConfirmWithdrawal(currency: CryptoCurrency) {
currency: CryptoCurrency, val balance = currentStatus.value.balanceOrNull()
currentBalance: TangemPayCardBalance, if (balance == null) {
depositAddress: String, showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
) { return
}
router.push( router.push(
AppRoute.Swap( AppRoute.Swap(
cryptoCurrency = currency, cryptoCurrency = currency,
@ -216,27 +220,15 @@ internal class TangemPayDetailsModel @Inject constructor(
screenSource = AnalyticsParam.ScreensSources.TangemPay.value, screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
currencyPosition = AppRoute.Swap.CurrencyPosition.FROM, currencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
tangemPayInput = AppRoute.Swap.TangemPayInput( tangemPayInput = AppRoute.Swap.TangemPayInput(
cryptoAmount = currentBalance.availableForWithdrawal, cryptoAmount = balance.availableForWithdrawal,
fiatAmount = currentBalance.availableForWithdrawal, fiatAmount = balance.availableForWithdrawal,
depositAddress = depositAddress, depositAddress = balance.cryptoBalance.depositAddress,
isWithdrawal = true, isWithdrawal = true,
), ),
), ),
) )
} }
private fun fetchBalance(): Job {
return modelScope.launch {
val result = try {
cardDetailsRepository.getCardBalance(userWalletId).onRight { balance = it }
} catch (e: Exception) {
TangemLogger.e("Error", e)
return@launch
}
uiState.update(transformer = DetailsBalanceTransformer(balance = result))
}.saveIn(fetchBalanceJobHolder)
}
private fun fetchAddToWalletBanner() { private fun fetchAddToWalletBanner() {
modelScope.launch { modelScope.launch {
val isDone = try { val isDone = try {
@ -263,7 +255,7 @@ internal class TangemPayDetailsModel @Inject constructor(
override fun onContactSupportClicked() { override fun onContactSupportClicked() {
analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay)) analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay))
val customerId = loaded?.customerId ?: return val customerId = currentStatus.value.ifLoadedOrNull { it.customerId } ?: return
modelScope.launch { modelScope.launch {
sendFeedbackEmailUseCase.invoke( sendFeedbackEmailUseCase.invoke(
type = FeedbackEmailType.Visa.FeatureIsBeta( type = FeedbackEmailType.Visa.FeatureIsBeta(
@ -277,10 +269,9 @@ internal class TangemPayDetailsModel @Inject constructor(
override fun onRefreshSwipe(refreshState: ShowRefreshState) { override fun onRefreshSwipe(refreshState: ShowRefreshState) {
modelScope.launch { modelScope.launch {
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value)) uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value))
cardDetailsEventListener.send(CardDetailsEvent.Hide) paymentAccountStatusFetcher.invoke(userWalletId)
expressTransactionsEventListener.send(ExpressTransactionsEvent.Update) expressTransactionsEventListener.send(ExpressTransactionsEvent.Update)
txHistoryUpdateListener.triggerUpdate() txHistoryUpdateListener.triggerUpdate()
fetchBalance().join()
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false)) uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false))
}.saveIn(refreshStateJobHolder) }.saveIn(refreshStateJobHolder)
} }

View file

@ -1,45 +1,32 @@
package com.tangem.features.tangempay.model.transformers package com.tangem.features.tangempay.model.transformers
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.formatStyled import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import java.util.Currency import java.util.Currency
internal class DetailsBalanceTransformer( internal class DetailsBalanceTransformer(
private val balance: Either<UniversalError, TangemPayCardBalance>, private val fiatBalance: PaymentAccountStatusValue.FiatBalance,
) : Transformer<TangemPayDetailsUM> { ) : Transformer<TangemPayDetailsUM> {
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
val balance = when (balance) { val balance = TangemPayDetailsBalanceBlockState.Content(
is Either.Left<UniversalError> -> { isBalanceFlickering = false,
TangemPayDetailsBalanceBlockState.Error( fiatBalance = getFiatBalanceText(fiatBalance),
actionButtons = persistentListOf(), actionButtons = prevState.balanceBlockState.actionButtons,
cardsBlockState = prevState.balanceBlockState.cardsBlockState, cardsBlockState = prevState.balanceBlockState.cardsBlockState,
) )
}
is Either.Right<TangemPayCardBalance> -> {
TangemPayDetailsBalanceBlockState.Content(
isBalanceFlickering = false,
fiatBalance = getFiatBalanceText(balance.value),
actionButtons = prevState.balanceBlockState.actionButtons,
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
)
}
}
return prevState.copy(balanceBlockState = balance) return prevState.copy(balanceBlockState = balance)
} }
private fun getFiatBalanceText(balance: TangemPayCardBalance): TextReference { private fun getFiatBalanceText(fiatBalance: PaymentAccountStatusValue.FiatBalance): TextReference {
val currency = Currency.getInstance(balance.currencyCode) val currency = Currency.getInstance(fiatBalance.currency)
return balance.fiatBalance.formatStyled { return fiatBalance.availableBalance.formatStyled {
fiat( fiat(
fiatCurrencyCode = currency.currencyCode, fiatCurrencyCode = currency.currencyCode,
fiatCurrencySymbol = currency.symbol, fiatCurrencySymbol = currency.symbol,

View file

@ -108,14 +108,11 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M
@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries") @Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries")
@Composable @Composable
private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) {
val isRenaming = state.displayNameState is DisplayNameState.Editing
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
TangemPayCardBackground( TangemPayCardBackground(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.zIndex(0f), .zIndex(0f),
isRenaming = isRenaming,
cardFrozenState = state.cardFrozenState, cardFrozenState = state.cardFrozenState,
) )
@ -147,6 +144,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif
) )
} }
CardNumberBlock( CardNumberBlock(
isRenaming = state.displayNameState is DisplayNameState.Editing,
numberShort = state.numberShort, numberShort = state.numberShort,
cardNumberRef = cardNumberRef, cardNumberRef = cardNumberRef,
) )
@ -203,11 +201,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif
} }
@Composable @Composable
private fun TangemPayCardBackground( private fun TangemPayCardBackground(cardFrozenState: TangemPayCardFrozenState, modifier: Modifier = Modifier) {
isRenaming: Boolean,
cardFrozenState: TangemPayCardFrozenState,
modifier: Modifier = Modifier,
) {
val isFrozen = cardFrozenState == TangemPayCardFrozenState.Frozen val isFrozen = cardFrozenState == TangemPayCardFrozenState.Frozen
val freezeProgress by animateFloatAsState( val freezeProgress by animateFloatAsState(
targetValue = if (isFrozen) 1f else 0f, targetValue = if (isFrozen) 1f else 0f,
@ -225,14 +219,6 @@ private fun TangemPayCardBackground(
contentDescription = null, contentDescription = null,
) )
if (isRenaming && LocalVisaRedesignEnabled.current) {
Box(
modifier = Modifier
.fillMaxSize()
.background(CardBackgroundColor.copy(alpha = 0.8f)),
)
}
if (isFrozen || freezeProgress > 0f) { if (isFrozen || freezeProgress > 0f) {
Image( Image(
modifier = Modifier modifier = Modifier
@ -344,6 +330,7 @@ private fun CardTopBlock(modifier: Modifier = Modifier) {
@Composable @Composable
private fun ConstraintLayoutScope.CardNumberBlock( private fun ConstraintLayoutScope.CardNumberBlock(
isRenaming: Boolean,
numberShort: String, numberShort: String,
cardNumberRef: ConstrainedLayoutReference, cardNumberRef: ConstrainedLayoutReference,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -352,7 +339,11 @@ private fun ConstraintLayoutScope.CardNumberBlock(
Text( Text(
text = numberShort, text = numberShort,
style = TangemTheme.typography3.body.medium, style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.staticDark.primary, color = if (isRenaming) {
TangemTheme.colors3.text.staticDark.secondary
} else {
TangemTheme.colors3.text.staticDark.primary
},
modifier = modifier modifier = modifier
.constrainAs(cardNumberRef) { .constrainAs(cardNumberRef) {
start.linkTo(parent.start) start.linkTo(parent.start)

View file

@ -34,7 +34,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
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.TangemThemePreviewRedesign
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_cross_20 import com.tangem.core.ui.res.generated.icons.ic_cross_20
import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.core.ui.test.TangemPayTestTags
@ -54,7 +54,7 @@ internal fun TangemPayChangePinScreenV2(
.statusBarsPadding(), .statusBarsPadding(),
) { ) {
TangemTopBar( TangemTopBar(
title = resourceReference(R.string.tangempay_set_pin_title), title = resourceReference(R.string.visa_onboarding_pin_code_title),
endContent = { endContent = {
TangemButton( TangemButton(
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_cross_20), iconStart = TangemIconUM.Icon(imageVector = Icons.ic_cross_20),
@ -73,7 +73,7 @@ internal fun TangemPayChangePinScreenV2(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = stringResourceSafe(R.string.tangempay_set_pin_header), text = stringResourceSafe(R.string.visa_onboarding_pin_code_description),
style = TangemTheme.typography3.body.medium, style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary, color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -94,6 +94,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
PinCode( PinCode(
isError = state.error != null,
value = state.pinCode, value = state.pinCode,
onValueChange = state.onPinCodeChange, onValueChange = state.onPinCodeChange,
focusRequester = focusRequester, focusRequester = focusRequester,
@ -109,7 +110,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod
Text( Text(
text = error.resolveReference(), text = error.resolveReference(),
style = TangemTheme.typography3.caption.medium, style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.status.warning, color = TangemTheme.colors3.text.status.error,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.testTag(TangemPayTestTags.PIN_ERROR_MESSAGE), modifier = Modifier.testTag(TangemPayTestTags.PIN_ERROR_MESSAGE),
) )
@ -125,6 +126,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod
@Composable @Composable
private fun PinCode( private fun PinCode(
isError: Boolean,
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -180,10 +182,10 @@ private fun PinCode(
), ),
digit = digit, digit = digit,
backgroundColor = TangemTheme.colors3.bg.opaque.primary, backgroundColor = TangemTheme.colors3.bg.opaque.primary,
borderColor = if (isActive) { borderColor = when {
TangemTheme.colors3.border.status.info isError -> TangemTheme.colors3.border.status.error
} else { isActive -> TangemTheme.colors3.border.status.info
TangemTheme.colors3.border.secondary else -> TangemTheme.colors3.border.secondary
}, },
textColor = TangemTheme.colors3.text.primary, textColor = TangemTheme.colors3.text.primary,
textStyle = TangemTheme.typography3.heading.medium, textStyle = TangemTheme.typography3.heading.medium,
@ -201,7 +203,7 @@ private fun PinCode(
private fun TangemPayChangePinScreenV2Preview( private fun TangemPayChangePinScreenV2Preview(
@PreviewParameter(TangemPayChangePinUMPreviewProvider::class) state: TangemPayChangePinUM, @PreviewParameter(TangemPayChangePinUMPreviewProvider::class) state: TangemPayChangePinUM,
) { ) {
TangemThemePreview { TangemThemePreviewRedesign {
TangemPayChangePinScreenV2( TangemPayChangePinScreenV2(
state = state, state = state,
onBackClick = {}, onBackClick = {},

View file

@ -144,7 +144,7 @@ internal fun TangemPayEditDisplayNameScreenV2(
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4) .padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4)
.imePadding(), .imePadding(),
text = resourceReference(R.string.common_save), text = resourceReference(R.string.common_done),
onClick = state.onDoneClick, onClick = state.onDoneClick,
isLoading = state.isLoading, isLoading = state.isLoading,
isEnabled = !state.isLoading && state.isDoneEnabled, isEnabled = !state.isLoading && state.isDoneEnabled,

View file

@ -36,6 +36,12 @@ internal inline fun <T> AccountStatus.Payment.ifLoadedOrNull(call: (PaymentAccou
} }
} }
internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Balance? = when (val v = value) {
is PaymentAccountStatusValue.Loaded -> v.balance
is PaymentAccountStatusValue.Deactivated -> v.balance
else -> null
}
internal fun AccountStatus.Payment.findCard( internal fun AccountStatus.Payment.findCard(
initialCardId: String, initialCardId: String,
initialStatus: AccountStatus.Payment, initialStatus: AccountStatus.Payment,

View file

@ -85,7 +85,11 @@ internal class TangemPayCardLimitSetupModelTest {
val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) {
every { source } returns StatusSource.ACTUAL every { source } returns StatusSource.ACTUAL
every { cards } returns listOf(cardWithLimit) every { cards } returns listOf(cardWithLimit)
every { currencyCode } returns "USD" every { balance } returns mockk(relaxed = true) {
every { fiatBalance } returns mockk(relaxed = true) {
every { currency } returns "USD"
}
}
} }
val paymentStatusWithLimit: AccountStatus.Payment = mockk(relaxed = true) { val paymentStatusWithLimit: AccountStatus.Payment = mockk(relaxed = true) {
every { value } returns statusWithLimit every { value } returns statusWithLimit

View file

@ -7,7 +7,6 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.dialog.Dialogs import com.tangem.core.ui.message.dialog.Dialogs
@ -16,7 +15,6 @@ import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.features.tangempay.onboarding.api.R import com.tangem.features.tangempay.onboarding.api.R
import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType import com.tangem.hot.sdk.model.MnemonicType
@ -38,7 +36,6 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor(
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase, private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
private val router: Router, private val router: Router,
private val uiMessageSender: UiMessageSender, private val uiMessageSender: UiMessageSender,
private val urlOpener: UrlOpener,
) : Model() { ) : Model() {
val uiState: StateFlow<TangemPayHotWalletOnboardingUM> val uiState: StateFlow<TangemPayHotWalletOnboardingUM>
@ -51,7 +48,7 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor(
) )
private fun onTermsClick() { private fun onTermsClick() {
urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) router.push(AppRoute.Disclaimer(isTosAccepted = true))
} }
private fun onGetCardClick() { private fun onGetCardClick() {

View file

@ -14,17 +14,16 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
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.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.TextButton import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
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.utils.WindowInsetsZero import com.tangem.core.ui.utils.WindowInsetsZero
@ -80,10 +79,11 @@ private fun Content(state: TangemPayHotWalletOnboardingUM, modifier: Modifier =
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 40.dp), .padding(horizontal = 40.dp),
) )
SpacerH16()
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
NavigationPrimaryButton( NavigationPrimaryButton(
primaryButton = NavigationButton( primaryButton = NavigationButton(
@ -94,18 +94,36 @@ private fun Content(state: TangemPayHotWalletOnboardingUM, modifier: Modifier =
onClick = state.onGetCardClick, onClick = state.onGetCardClick,
), ),
) )
TextButton( TosText(onClick = state.onTermsClick)
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.tangem_pay_terms_fees_limits),
onClick = state.onTermsClick,
colors = TangemButtonsDefaults.defaultTextButtonColors.copy(
contentColor = TangemTheme.colors.text.primary1,
),
)
} }
} }
} }
@Composable
private fun TosText(onClick: () -> Unit, modifier: Modifier = Modifier) {
val termsTemplate = stringResourceSafe(R.string.onboarding_create_wallet_term_of_conditions_text)
val termsLinkText = stringResourceSafe(R.string.disclaimer_title)
val termsLinkColor = TangemTheme.colors.text.accent
Text(
modifier = modifier.fillMaxWidth(),
text = buildAnnotatedString {
appendWithStyledPlaceholder(template = termsTemplate) {
withLink(
LinkAnnotation.Clickable(
tag = "tos_link",
styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.None)),
) { onClick() },
) {
appendColored(text = termsLinkText, color = termsLinkColor)
}
}
},
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
@Composable @Composable
private fun Features(modifier: Modifier = Modifier) { private fun Features(modifier: Modifier = Modifier) {
Column( Column(

View file

@ -6,7 +6,6 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
@ -14,7 +13,6 @@ import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
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.usecase.CreateHotWalletUseCase import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType import com.tangem.hot.sdk.model.MnemonicType
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -32,7 +30,6 @@ internal class TangemPayHotWalletOnboardingModelTest {
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk() private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk()
private val router: Router = mockk(relaxed = true) private val router: Router = mockk(relaxed = true)
private val uiMessageSender: UiMessageSender = mockk(relaxed = true) private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val testUserWalletId = UserWalletId("1234567890ABCDEF") private val testUserWalletId = UserWalletId("1234567890ABCDEF")
private val testUserWallet: UserWallet.Hot = mockk(relaxed = true) { private val testUserWallet: UserWallet.Hot = mockk(relaxed = true) {
@ -43,12 +40,12 @@ internal class TangemPayHotWalletOnboardingModelTest {
inner class OnTermsClick { inner class OnTermsClick {
@Test @Test
fun `WHEN onTermsClick THEN urlOpener called with terms link`() = runTest { fun `WHEN onTermsClick THEN navigate to Disclaimer`() = runTest {
val model = createModel() val model = createModel()
model.uiState.value.onTermsClick.invoke() model.uiState.value.onTermsClick.invoke()
verify { urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) } verify { router.push(AppRoute.Disclaimer(isTosAccepted = true)) }
} }
} }
@ -118,7 +115,6 @@ internal class TangemPayHotWalletOnboardingModelTest {
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase, clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
router = router, router = router,
uiMessageSender = uiMessageSender, uiMessageSender = uiMessageSender,
urlOpener = urlOpener,
) )
} }
} }

View file

@ -109,6 +109,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.Q
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetYieldSupplyBalanceTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateActionButtonsTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateActionButtonsTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateAddFundsTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateAddFundsTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTransferTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTransferTransformer
@ -470,6 +471,7 @@ internal class TokenDetailsModel @Inject constructor(
yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value) yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value)
.onEach { formatted -> .onEach { formatted ->
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted) uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
redesignStateController.update(SetYieldSupplyBalanceTransformer(formatted))
} }
.flowOn(dispatchers.main) .flowOn(dispatchers.main)
.launchIn(modelScope) .launchIn(modelScope)
@ -479,6 +481,7 @@ internal class TokenDetailsModel @Inject constructor(
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance( uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
YieldSupplyRewardBalance.empty(), YieldSupplyRewardBalance.empty(),
) )
redesignStateController.update(SetYieldSupplyBalanceTransformer(YieldSupplyRewardBalance.empty()))
} }
} }

View file

@ -37,6 +37,8 @@ internal sealed class TokenDetailsBalanceBlockUM {
val displayFiatBalanceAvailable: TextReference?, val displayFiatBalanceAvailable: TextReference?,
val isBalanceFlickering: Boolean, val isBalanceFlickering: Boolean,
val isBalanceZero: Boolean, val isBalanceZero: Boolean,
val displayYieldSupplyFiatBalance: String? = null,
val displayYieldSupplyCryptoBalance: String? = null,
) : TokenDetailsBalanceBlockUM() { ) : TokenDetailsBalanceBlockUM() {
val displayCryptoBalance: TextReference val displayCryptoBalance: TextReference

View file

@ -86,6 +86,9 @@ internal class SetBalanceTransformer(
val totalCryptoAmount = computeTotal(status.value.amount, stakingCryptoAmount) val totalCryptoAmount = computeTotal(status.value.amount, stakingCryptoAmount)
val isYieldSupplyActive = status.value.yieldSupplyStatus?.isActive == true
val prevContent = prev as? TokenDetailsBalanceBlockUM.Content
return TokenDetailsBalanceBlockUM.Content( return TokenDetailsBalanceBlockUM.Content(
addFundsButton = prev.addFundsButton, addFundsButton = prev.addFundsButton,
swapButton = prev.swapButton, swapButton = prev.swapButton,
@ -108,6 +111,17 @@ internal class SetBalanceTransformer(
}, },
isBalanceFlickering = status.value.sources.total == StatusSource.CACHE, isBalanceFlickering = status.value.sources.total == StatusSource.CACHE,
isBalanceZero = totalCryptoAmount.isNullOrZero(), isBalanceZero = totalCryptoAmount.isNullOrZero(),
// Preserve the ticking yield balance produced by SetYieldSupplyBalanceTransformer across status updates
displayYieldSupplyFiatBalance = if (isYieldSupplyActive) {
prevContent?.displayYieldSupplyFiatBalance
} else {
null
},
displayYieldSupplyCryptoBalance = if (isYieldSupplyActive) {
prevContent?.displayYieldSupplyCryptoBalance
} else {
null
},
) )
} }

View file

@ -0,0 +1,27 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
/**
* Pushes the ticking yield supply balance (emitted every tick by [YieldSupplyGetRewardsBalanceUseCase])
* into the redesign [TokenDetailsBalanceBlockUM.Content], so the balance increments in real time.
*/
internal class SetYieldSupplyBalanceTransformer(
private val yieldSupplyRewardBalance: YieldSupplyRewardBalance,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val balanceBlockUM = prevState.balanceBlockUM
if (balanceBlockUM !is TokenDetailsBalanceBlockUM.Content) return prevState
return prevState.copy(
balanceBlockUM = balanceBlockUM.copy(
displayYieldSupplyFiatBalance = yieldSupplyRewardBalance.fiatBalance,
displayYieldSupplyCryptoBalance = yieldSupplyRewardBalance.cryptoBalance,
),
)
}
}

View file

@ -11,7 +11,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember 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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -23,10 +25,12 @@ import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.text.TextAnimatedCounter
import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.button.action.ActionButtons import com.tangem.core.ui.ds.button.action.ActionButtons
import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resolveReference
@ -42,6 +46,9 @@ import kotlinx.collections.immutable.persistentListOf
private val CurrencyIconSize: Dp = 70.dp private val CurrencyIconSize: Dp = 70.dp
private val NetworkBadgeSize: Dp = 24.dp private val NetworkBadgeSize: Dp = 24.dp
/** OpenType "tabular figures" feature — makes every digit the same width to prevent horizontal jitter. */
private const val TABULAR_FIGURES_FEATURE = "tnum"
@Composable @Composable
internal fun TokenDetailsBalanceBlock( internal fun TokenDetailsBalanceBlock(
balanceBlockUM: TokenDetailsBalanceBlockUM, balanceBlockUM: TokenDetailsBalanceBlockUM,
@ -124,20 +131,60 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd
} }
} }
SpacerH(TangemTheme.dimens2.x2) SpacerH(TangemTheme.dimens2.x2)
Text( AnimatedBalance(
modifier = Modifier.testTag(TokenDetailsScreenTestTags.BALANCE_FIAT), modifier = Modifier.testTag(TokenDetailsScreenTestTags.BALANCE_FIAT),
text = state.displayFiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), yieldBalance = state.displayYieldSupplyFiatBalance,
fallbackBalance = state.displayFiatBalance,
style = TangemTheme.typography2.titleRegular44, style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary, color = TangemTheme.colors2.text.neutral.primary,
isBalanceHidden = isBalanceHidden,
) )
SpacerH(TangemTheme.dimens2.x2_5) SpacerH(TangemTheme.dimens2.x2_5)
Text( AnimatedBalance(
text = state.displayCryptoBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), yieldBalance = state.displayYieldSupplyCryptoBalance,
fallbackBalance = state.displayCryptoBalance,
style = TangemTheme.typography2.bodySemibold16, style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.secondary, color = TangemTheme.colors2.text.neutral.secondary,
isBalanceHidden = isBalanceHidden,
) )
} }
/**
* Renders a balance that animates digit-by-digit ([TextAnimatedCounter]) while a ticking yield supply
* value is present, and falls back to a plain [Text] otherwise (or when the balance is hidden).
*/
@Composable
private fun AnimatedBalance(
yieldBalance: String?,
fallbackBalance: TextReference,
style: TextStyle,
color: Color,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x6),
contentAlignment = Alignment.Center,
) {
if (yieldBalance != null && !isBalanceHidden) {
TextAnimatedCounter(
text = yieldBalance,
// Tabular figures keep every digit the same width, so the centered balance doesn't
// jitter horizontally as digits roll during the increment animation.
style = style.copy(color = color, fontFeatureSettings = TABULAR_FIGURES_FEATURE),
)
} else {
Text(
text = fallbackBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
style = style,
color = color,
)
}
}
}
@Composable @Composable
private fun LoadingBody() { private fun LoadingBody() {
Text( Text(

View file

@ -14,6 +14,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
@ -434,6 +435,59 @@ class SetBalanceTransformerTest {
// endregion // endregion
// region Yield supply balance
@Test
fun `GIVEN yield active AND prev has yield balances WHEN transform THEN yield balances preserved`() {
// GIVEN
val status = createStatus(loadedValue(yieldSupplyStatus = activeYieldSupplyStatus()))
val transformer = createTransformer(status)
val prev = contentWithYieldBalances(fiat = "$21,000.12", crypto = "10.500001 ETH")
val state = initialState().copy(balanceBlockUM = prev)
// WHEN
val result = transformer.transform(state)
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isEqualTo("$21,000.12")
assertThat(content.displayYieldSupplyCryptoBalance).isEqualTo("10.500001 ETH")
}
@Test
fun `GIVEN yield inactive AND prev has yield balances WHEN transform THEN yield balances are cleared`() {
// GIVEN
val status = createStatus(loadedValue(yieldSupplyStatus = null))
val transformer = createTransformer(status)
val prev = contentWithYieldBalances(fiat = "$21,000.12", crypto = "10.500001 ETH")
val state = initialState().copy(balanceBlockUM = prev)
// WHEN
val result = transformer.transform(state)
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isNull()
assertThat(content.displayYieldSupplyCryptoBalance).isNull()
}
@Test
fun `GIVEN yield active AND prev is not Content WHEN transform THEN yield balances are null`() {
// GIVEN — prev is the default Loading block, so there are no yield balances to preserve yet
val status = createStatus(loadedValue(yieldSupplyStatus = activeYieldSupplyStatus()))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isNull()
assertThat(content.displayYieldSupplyCryptoBalance).isNull()
}
// endregion
// region Unrelated fields preserved // region Unrelated fields preserved
@Test @Test
@ -473,6 +527,7 @@ class SetBalanceTransformerTest {
fiatAmount: BigDecimal = BigDecimal("21000"), fiatAmount: BigDecimal = BigDecimal("21000"),
fiatRate: BigDecimal = BigDecimal("2000"), fiatRate: BigDecimal = BigDecimal("2000"),
stakingBalance: StakingBalance? = null, stakingBalance: StakingBalance? = null,
yieldSupplyStatus: YieldSupplyStatus? = null,
sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(), sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(),
): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded( ): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded(
amount = amount, amount = amount,
@ -480,13 +535,39 @@ class SetBalanceTransformerTest {
fiatRate = fiatRate, fiatRate = fiatRate,
priceChange = BigDecimal("2.5"), priceChange = BigDecimal("2.5"),
stakingBalance = stakingBalance, stakingBalance = stakingBalance,
yieldSupplyStatus = null, yieldSupplyStatus = yieldSupplyStatus,
hasCurrentNetworkTransactions = false, hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(), pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true), networkAddress = mockk(relaxed = true),
sources = sources, sources = sources,
) )
private fun activeYieldSupplyStatus(): YieldSupplyStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = null,
)
private fun contentWithYieldBalances(
fiat: String?,
crypto: String?,
): TokenDetailsBalanceBlockUM.Content = TokenDetailsBalanceBlockUM.Content(
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference(""),
displayFiatBalanceAll = stringReference(""),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
isBalanceZero = false,
displayYieldSupplyFiatBalance = fiat,
displayYieldSupplyCryptoBalance = crypto,
)
private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote( private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote(
amount = BigDecimal("5.0"), amount = BigDecimal("5.0"),
stakingBalance = null, stakingBalance = null,

View file

@ -0,0 +1,146 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class SetYieldSupplyBalanceTransformerTest {
@Test
fun `GIVEN Content state WHEN transform THEN yield balances are set from reward balance`() {
// GIVEN
val state = stateWith(contentBlock())
val transformer = SetYieldSupplyBalanceTransformer(
YieldSupplyRewardBalance(fiatBalance = "$21,000.12", cryptoBalance = "10.500001 ETH"),
)
// WHEN
val result = transformer.transform(state)
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isEqualTo("$21,000.12")
assertThat(content.displayYieldSupplyCryptoBalance).isEqualTo("10.500001 ETH")
}
@Test
fun `GIVEN empty reward balance WHEN transform on Content THEN yield balances are nulled`() {
// GIVEN
val state = stateWith(
contentBlock().copy(
displayYieldSupplyFiatBalance = "$21,000.12",
displayYieldSupplyCryptoBalance = "10.500001 ETH",
),
)
val transformer = SetYieldSupplyBalanceTransformer(YieldSupplyRewardBalance.empty())
// WHEN
val result = transformer.transform(state)
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayYieldSupplyFiatBalance).isNull()
assertThat(content.displayYieldSupplyCryptoBalance).isNull()
}
@Test
fun `GIVEN Loading block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = stateWith(loadingBlock())
val transformer = SetYieldSupplyBalanceTransformer(
YieldSupplyRewardBalance(fiatBalance = "$1", cryptoBalance = "1 ETH"),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
@Test
fun `GIVEN Error block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = stateWith(errorBlock())
val transformer = SetYieldSupplyBalanceTransformer(
YieldSupplyRewardBalance(fiatBalance = "$1", cryptoBalance = "1 ETH"),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
private fun contentBlock(): TokenDetailsBalanceBlockUM.Content = TokenDetailsBalanceBlockUM.Content(
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("10.5 ETH"),
displayFiatBalanceAll = stringReference("$21,000.00"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
isBalanceZero = false,
)
private fun loadingBlock(): TokenDetailsBalanceBlockUM.Loading = TokenDetailsBalanceBlockUM.Loading(
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
)
private fun errorBlock(): TokenDetailsBalanceBlockUM.Error = TokenDetailsBalanceBlockUM.Error(
addFundsButton = placeholderButton(),
swapButton = placeholderButton(),
transferButton = placeholderButton(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
)
private fun stateWith(balanceBlockUM: TokenDetailsBalanceBlockUM): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = balanceBlockUM,
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,
zeroBalanceActionsUM = ZeroBalanceActionsUM.Loading,
)
private fun placeholderButton(): TangemButtonUM = TangemButtonUM(
text = stringReference(""),
type = TangemButtonType.Secondary,
onClick = {},
)
}

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.features.nft.api) implementation(projects.features.nft.api)
implementation(projects.features.onboardingV2.api) implementation(projects.features.onboardingV2.api)
implementation(projects.features.pushNotifications.api) implementation(projects.features.pushNotifications.api)
implementation(projects.features.pushNotificationSettings.api)
implementation(projects.features.hotWallet.api) implementation(projects.features.hotWallet.api)
implementation(projects.features.wallet.api) implementation(projects.features.wallet.api)

View file

@ -63,6 +63,8 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent {
isNotificationsEnabled = true, isNotificationsEnabled = true,
onCheckedNotificationsChanged = {}, onCheckedNotificationsChanged = {},
onNotificationsDescriptionClick = {}, onNotificationsDescriptionClick = {},
isPushNotificationSettingsEnabled = false,
onNotificationSettingsClick = {},
isNotificationsPermissionGranted = false, isNotificationsPermissionGranted = false,
onAccessCodeClick = {}, onAccessCodeClick = {},
onBackupClick = {}, onBackupClick = {},

View file

@ -51,6 +51,7 @@ import com.tangem.feature.walletsettings.utils.ItemsBuilder
import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate
import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentList
@ -92,6 +93,7 @@ internal class WalletSettingsModel @Inject constructor(
private val accountListSortingSaver: AccountListSortingSaver, private val accountListSortingSaver: AccountListSortingSaver,
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles, private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles,
) : Model() { ) : Model() {
val params: WalletSettingsComponent.Params = paramsContainer.require() val params: WalletSettingsComponent.Params = paramsContainer.require()
@ -203,6 +205,8 @@ internal class WalletSettingsModel @Inject constructor(
is UserWallet.Cold -> userWallet.isMultiCurrency is UserWallet.Cold -> userWallet.isMultiCurrency
is UserWallet.Hot -> true is UserWallet.Hot -> true
} }
val isPushNotificationSettingsEnabled =
pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled
return itemsBuilder.buildItems( return itemsBuilder.buildItems(
userWallet = userWallet, userWallet = userWallet,
cardItem = cardItem, cardItem = cardItem,
@ -245,6 +249,8 @@ internal class WalletSettingsModel @Inject constructor(
isNotificationsPermissionGranted = isNotificationsPermissionGranted, isNotificationsPermissionGranted = isNotificationsPermissionGranted,
onCheckedNotificationsChanged = ::onCheckedNotificationsChange, onCheckedNotificationsChanged = ::onCheckedNotificationsChange,
onNotificationsDescriptionClick = ::onNotificationsDescriptionClick, onNotificationsDescriptionClick = ::onNotificationsDescriptionClick,
isPushNotificationSettingsEnabled = isPushNotificationSettingsEnabled,
onNotificationSettingsClick = ::onNotificationSettingsClick,
onAccessCodeClick = { onAccessCodeClick(userWallet) }, onAccessCodeClick = { onAccessCodeClick(userWallet) },
onBackupClick = ::onBackupClick, onBackupClick = ::onBackupClick,
onCardSettingsClick = ::onCardSettingsClick, onCardSettingsClick = ::onCardSettingsClick,
@ -252,6 +258,10 @@ internal class WalletSettingsModel @Inject constructor(
) )
} }
private fun onNotificationSettingsClick() {
router.push(AppRoute.PushNotificationSettings(userWalletId = params.userWalletId))
}
private fun forgetWallet() = modelScope.launch { private fun forgetWallet() = modelScope.launch {
val userWallet = getUserWalletUseCase(params.userWalletId) val userWallet = getUserWalletUseCase(params.userWalletId)
.getOrNull() .getOrNull()

View file

@ -33,6 +33,8 @@ internal class ItemsBuilder @Inject constructor() {
isNotificationsPermissionGranted: Boolean, isNotificationsPermissionGranted: Boolean,
onCheckedNotificationsChanged: (Boolean) -> Unit, onCheckedNotificationsChanged: (Boolean) -> Unit,
onNotificationsDescriptionClick: () -> Unit, onNotificationsDescriptionClick: () -> Unit,
isPushNotificationSettingsEnabled: Boolean,
onNotificationSettingsClick: () -> Unit,
forgetWallet: () -> Unit, forgetWallet: () -> Unit,
onLinkMoreCardsClick: () -> Unit, onLinkMoreCardsClick: () -> Unit,
onReferralClick: () -> Unit, onReferralClick: () -> Unit,
@ -50,20 +52,26 @@ internal class ItemsBuilder @Inject constructor() {
isLinkMoreCardsAvailable = isLinkMoreCardsAvailable, isLinkMoreCardsAvailable = isLinkMoreCardsAvailable,
isReferralAvailable = isReferralAvailable, isReferralAvailable = isReferralAvailable,
isManageTokensAvailable = isManageTokensAvailable, isManageTokensAvailable = isManageTokensAvailable,
isPushNotificationSettingsEnabled = isPushNotificationSettingsEnabled,
onLinkMoreCardsClick = onLinkMoreCardsClick, onLinkMoreCardsClick = onLinkMoreCardsClick,
onReferralClick = onReferralClick, onReferralClick = onReferralClick,
onManageTokensClick = onManageTokensClick, onManageTokensClick = onManageTokensClick,
onBackupClick = onBackupClick, onBackupClick = onBackupClick,
onCardSettingsClick = onCardSettingsClick, onCardSettingsClick = onCardSettingsClick,
onNotificationSettingsClick = onNotificationSettingsClick,
), ),
) )
.addAll( .addAll(
buildNotificationItems( if (isPushNotificationSettingsEnabled) {
isNotificationsPermissionGranted = isNotificationsPermissionGranted, emptyList()
isNotificationsEnabled = isNotificationsEnabled, } else {
onCheckedNotificationsChanged = onCheckedNotificationsChanged, buildNotificationItems(
onNotificationsDescriptionClick = onNotificationsDescriptionClick, isNotificationsPermissionGranted = isNotificationsPermissionGranted,
), isNotificationsEnabled = isNotificationsEnabled,
onCheckedNotificationsChanged = onCheckedNotificationsChanged,
onNotificationsDescriptionClick = onNotificationsDescriptionClick,
)
},
) )
.addAll( .addAll(
buildNFTItems( buildNFTItems(
@ -137,11 +145,13 @@ internal class ItemsBuilder @Inject constructor() {
isLinkMoreCardsAvailable: Boolean, isLinkMoreCardsAvailable: Boolean,
isReferralAvailable: Boolean, isReferralAvailable: Boolean,
isManageTokensAvailable: Boolean, isManageTokensAvailable: Boolean,
isPushNotificationSettingsEnabled: Boolean,
onLinkMoreCardsClick: () -> Unit, onLinkMoreCardsClick: () -> Unit,
onReferralClick: () -> Unit, onReferralClick: () -> Unit,
onManageTokensClick: () -> Unit, onManageTokensClick: () -> Unit,
onBackupClick: () -> Unit, onBackupClick: () -> Unit,
onCardSettingsClick: () -> Unit, onCardSettingsClick: () -> Unit,
onNotificationSettingsClick: () -> Unit,
) = WalletSettingsItemUM.WithItems( ) = WalletSettingsItemUM.WithItems(
id = "card", id = "card",
description = null, description = null,
@ -207,6 +217,16 @@ internal class ItemsBuilder @Inject constructor() {
add(referralBlock) add(referralBlock)
} }
if (isPushNotificationSettingsEnabled) {
val notificationSettingsBlock = BlockUM(
text = resourceReference(R.string.push_notification_settings_title),
iconRes = R.drawable.ic_push_notification_settings_24,
onClick = onNotificationSettingsClick,
)
add(notificationSettingsBlock)
}
}.toImmutableList(), }.toImmutableList(),
) )

View file

@ -58,8 +58,8 @@ internal class TangemPayMainBlockConverter(
subtitle = TextReference.Res(R.string.tangempay_status_deactivated), subtitle = TextReference.Res(R.string.tangempay_status_deactivated),
isBalanceFlickering = statusValue.source == StatusSource.CACHE, isBalanceFlickering = statusValue.source == StatusSource.CACHE,
balance = getBalanceText( balance = getBalanceText(
currencyCode = statusValue.fiatBalance.currency, currencyCode = statusValue.balance.fiatBalance.currency,
balance = statusValue.fiatBalance.availableBalance, balance = statusValue.balance.fiatBalance.availableBalance,
), ),
balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol), balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol),
shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE,
@ -75,8 +75,8 @@ internal class TangemPayMainBlockConverter(
}, },
isBalanceFlickering = statusValue.source == StatusSource.CACHE, isBalanceFlickering = statusValue.source == StatusSource.CACHE,
balance = getBalanceText( balance = getBalanceText(
currencyCode = statusValue.currencyCode, currencyCode = statusValue.balance.fiatBalance.currency,
balance = statusValue.fiatBalance.availableBalance, balance = statusValue.balance.fiatBalance.availableBalance,
), ),
balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol), balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol),
shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE,