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") {
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,
)
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled")
if (isHotWalletOnboardingEnabled) {
val afterEmptyRoute: AppRoute = if (isHotWalletOnboardingEnabled) {
val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) {
appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
}
TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}")
if (tangemPayHotWalletOnboardingDeepLink != null) {
val hotWalletRoute = AppRoute.TangemPayHotWalletOnboarding
val shouldShowTos = !cardRepository.isTangemTOSAccepted()
val route = if (shouldShowTos) "Disclaimer" else "HotWalletOnboarding"
TangemLogger.i("[TangemPay][HWO] TOS accepted=${!shouldShowTos}, navigating to $route")
return if (shouldShowTos) {
AppRoute.Disclaimer(isTosAccepted = false, nextRoute = hotWalletRoute)
} else {
hotWalletRoute
}
AppRoute.TangemPayHotWalletOnboarding
} else {
getDefaultRoute()
}
}
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 {
AppRoute.Home(launchMode = launchMode)
getDefaultRoute()
}
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
override fun Content(modifier: Modifier) {
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.PushNotificationsModelCallbacksStub
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.SendComponent
import com.tangem.features.send.api.SendEntryPointComponent
@ -87,6 +88,7 @@ internal class ChildFactory @Inject constructor(
private val resetCardComponentFactory: ResetCardComponent.Factory,
private val referralComponentFactory: ReferralComponent.Factory,
private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory,
private val pushNotificationSettingsComponentFactory: PushNotificationSettingsComponent.Factory,
private val walletComponentFactory: WalletEntryComponent.Factory,
private val sendComponentFactoryV2: SendComponent.Factory,
private val redesignedWalletConnectComponentFactory: WalletConnectEntryComponent.Factory,
@ -172,6 +174,13 @@ internal class ChildFactory @Inject constructor(
componentFactory = walletSettingsComponentFactory,
)
}
is AppRoute.PushNotificationSettings -> {
createComponentChild(
context = context,
params = PushNotificationSettingsComponent.Params(route.userWalletId),
componentFactory = pushNotificationSettingsComponentFactory,
)
}
is AppRoute.WalletBackup -> {
createComponentChild(
context = context,

View file

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

View file

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

View file

@ -102,6 +102,7 @@
<item quantity="one">%d address</item>
<item quantity="other">%d addresses</item>
</plurals>
<string name="address_book_contact">Contact</string>
<string name="address_book_contact_name">Contact name</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>

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.Loaded -> PaymentAccountStatusValueDM.ActiveAccount(
customerId = value.customerId,
currencyCode = value.currencyCode,
currencyCode = value.balance.fiatBalance.currency,
depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
availableForWithdrawal = value.availableForWithdrawal,
fiatBalance = value.balance.fiatBalance.toDM(),
cryptoBalance = value.balance.cryptoBalance.toDM(),
availableForWithdrawal = value.balance.availableForWithdrawal,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
PaymentAccountStatusValueDM.TangemPayCard(
@ -63,9 +63,11 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
customerId = value.customerId,
fiatRate = value.fiatRate,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
fiatBalance = value.balance.fiatBalance.toDM(),
cryptoBalance = value.balance.cryptoBalance.toDM(),
availableForWithdrawal = value.balance.availableForWithdrawal,
)
// Transient statuses are not persisted
is PaymentAccountStatusValue.Loading,
@ -90,11 +92,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded(
source = StatusSource.CACHE,
customerId = value.customerId,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
@ -123,8 +126,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
source = StatusSource.CACHE,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
customerId = value.customerId,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency,
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.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
@ -287,11 +288,16 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
}
fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> {
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() &&
(isDeactivated || isFormer) -> {
PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
availableForWithdrawal = availableForWithdrawal.orZero(),
),
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = quotesData?.fiatRate,
)
@ -321,11 +327,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
customerId = customerId,
currencyCode = cardInfo.currencyCode,
depositAddress = cardInfo.depositAddress,
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
availableForWithdrawal = cardInfo.availableForWithdrawal,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
availableForWithdrawal = cardInfo.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency,
fiatRate = fiatRate,
cards = listOf(

View file

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

View file

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

View file

@ -33,11 +33,11 @@ sealed class PaymentAccountStatusValue {
is Loading -> TotalFiatBalance.Loading
is Loaded -> {
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 -> {
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.
*
* @property source The source of the status information.
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
* @property customerId The unique identifier of the customer.
* @property balance The balance details (fiat, crypto and amount available for withdrawal).
* @property cryptoCurrency The crypto currency held by the deactivated account.
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
* or `null` if the quote is not yet available. When `null`,
@ -114,18 +114,18 @@ sealed class PaymentAccountStatusValue {
@Serializable
data class Deactivated(
override val source: StatusSource,
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
val customerId: String,
val balance: Balance,
val cryptoCurrency: CryptoCurrency.Token,
val fiatRate: SerializedBigDecimal?,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = buildCryptoCurrencyStatusValue(
amount = cryptoBalance.balance,
fiatAmount = fiatBalance.availableBalance,
amount = balance.cryptoBalance.balance,
fiatAmount = balance.fiatBalance.availableBalance,
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 customerId The unique identifier of the customer.
* @property currencyCode The code of the currency.
* @property depositAddress The address for deposits, if available.
* @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).
* @property balance The balance details (fiat, crypto and amount available for withdrawal).
* The fiat currency code is available via [Balance.fiatBalance].
* @property cryptoCurrency The crypto currency held by the account.
* @property cards The list of user's cards.
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
@ -150,11 +148,8 @@ sealed class PaymentAccountStatusValue {
data class Loaded(
override val source: StatusSource,
val customerId: String,
val currencyCode: String,
val depositAddress: String?,
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
val availableForWithdrawal: SerializedBigDecimal,
val balance: Balance,
val cryptoCurrency: CryptoCurrency.Token,
val cards: List<TangemPayCard>,
val fiatRate: SerializedBigDecimal?,
@ -162,10 +157,10 @@ sealed class PaymentAccountStatusValue {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = buildCryptoCurrencyStatusValue(
amount = availableForWithdrawal,
fiatAmount = fiatBalance.availableBalance,
amount = balance.availableForWithdrawal,
fiatAmount = balance.fiatBalance.availableBalance,
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.
*

View file

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

View file

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

View file

@ -7,4 +7,12 @@ internal sealed class AddressBookRoute {
@Serializable
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.router.stack.StackNavigation
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.childByContext
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.list.AddressBookListComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -22,6 +26,7 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddressBookComponent.Params,
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
private val editContactComponentFactory: EditContactComponent.Factory,
) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>()
@ -51,11 +56,16 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
context = childByContext(componentContext),
params = AddressBookListComponent.Params(
onContactClick = { contactId ->
// TODO [REDACTED_TASK_KEY] router.push(EditContact(contactId))
},
onAddContactClick = {
// TODO [REDACTED_TASK_KEY] router.push(AddContact)
navigation.pushNew(AddressBookRoute.EditContact(contactId))
},
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.list.AddressBookListComponent
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.Module
import dagger.hilt.InstallIn
@ -23,4 +25,8 @@ internal interface AddressBookComponentModule {
fun bindAddressBookListComponentFactory(
factory: DefaultAddressBookListComponent.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.model.Model
import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.editcontact.model.EditContactModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -17,4 +18,9 @@ internal interface AddressBookModelModule {
@IntoMap
@ClassKey(AddressBookListModel::class)
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
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
when (state) {
AddressBookListUM.Empty -> AddressBookEmptyScreen(
onAddContactClick = params.onAddContactClick,
when (val addressBookListUM = state) {
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
tangemButtonUM = addressBookListUM.tangemButtonUM,
onBackClick = router::pop,
modifier = modifier,
)

View file

@ -1,12 +1,15 @@
package com.tangem.features.addressbook.list.contract
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.domain.addressbook.model.Contact
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class AddressBookListUM {
data object Empty : AddressBookListUM()
data class Empty(
val tangemButtonUM: TangemButtonUM,
) : 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.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.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
@ -10,10 +20,24 @@ import javax.inject.Inject
@ModelScoped
internal class AddressBookListModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<AddressBookListComponent.Params>()
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.unit.dp
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.topbar.TangemTopBar
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.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun AddressBookEmptyScreen(
onAddContactClick: () -> Unit,
tangemButtonUM: TangemButtonUM,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -40,19 +44,17 @@ internal fun AddressBookEmptyScreen(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
onClick = onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Secondary,
variant = TangemButton.Variant.Material,
)
},
)
NoContactInfo()
PrimaryButtonIconEnd(
PrimaryTangemButton(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
text = stringResourceSafe(R.string.address_book_add_contact),
iconResId = R.drawable.ic_plus_24,
onClick = onAddContactClick,
buttonUM = tangemButtonUM,
)
}
}
@ -104,5 +106,14 @@ private fun ContactImage() {
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
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)
/* AndroidX */
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
/* Compose */
@ -50,7 +51,7 @@ dependencies {
implementation(deps.kotlin.immutable.collections)
/* Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.coroutine)
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
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.event.StateEvent
import kotlinx.collections.immutable.PersistentList
@Immutable
@ -12,7 +11,6 @@ internal sealed interface PushNotificationSettingsUM {
data class Content(
val banner: AllowPushNotificationsBannerUM?,
val toggles: PersistentList<ToggleUM>,
val requestPermissionEvent: StateEvent<Unit>,
val onMoreInfoClick: () -> Unit,
) : 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.navigation.notifications.SystemNotificationsStateProvider
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.resourceReference
import com.tangem.core.ui.message.DialogMessage
@ -38,6 +35,8 @@ import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -45,6 +44,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -69,7 +69,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
private val loadState = MutableStateFlow<LoadState>(LoadState.Loading)
private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled())
private val pendingRequest = MutableStateFlow<StateEvent<Unit>>(consumedEvent())
private var pendingPermissionToggle: ToggleSpec? = null
private val preferencesJobHolder = JobHolder()
@ -77,15 +76,19 @@ internal class PushNotificationSettingsModel @Inject constructor(
private val cachedPrefs: WalletPushNotificationPreferences?
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(
loadState,
osNotificationsEnabled,
pendingRequest,
) { load, osEnabled, request ->
) { load, osEnabled ->
when (load) {
is LoadState.Failed -> PushNotificationSettingsUM.Error(onRetryClick = ::onRetry)
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(
scope = modelScope,
@ -117,7 +120,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
}
fun onPermissionResult(isGranted: Boolean) {
pendingRequest.value = consumedEvent()
val tapped = pendingPermissionToggle
pendingPermissionToggle = null
modelScope.launch {
@ -145,12 +147,10 @@ internal class PushNotificationSettingsModel @Inject constructor(
private fun buildContent(
prefs: WalletPushNotificationPreferences,
osEnabled: Boolean,
request: StateEvent<Unit>,
): PushNotificationSettingsUM.Content {
return PushNotificationSettingsUM.Content(
banner = buildBanner(prefs = prefs, osEnabled = osEnabled),
toggles = buildToggles(prefs),
requestPermissionEvent = request,
onMoreInfoClick = ::onMoreInfoClick,
)
}
@ -190,7 +190,7 @@ internal class PushNotificationSettingsModel @Inject constructor(
private fun requestPermission(tapped: ToggleSpec? = null) {
pendingPermissionToggle = tapped
pendingRequest.value = triggeredEvent(data = Unit, onConsume = ::onPermissionEventConsumed)
requestPushPermissionChannel.trySend(Unit)
}
private fun onBannerCtaClick() {
@ -219,10 +219,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
applyOptimisticToggle(spec, newValue)
}
private fun onPermissionEventConsumed() {
pendingRequest.value = consumedEvent()
}
private fun applyOptimisticToggle(spec: ToggleSpec, newValue: Boolean) {
val current = cachedPrefs ?: return
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.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
@Suppress("LongParameterList")
class PushNotificationSettingsModelTest {
@ -204,13 +204,15 @@ class PushNotificationSettingsModelTest {
val model = model(osEnabled = false, preferencesFlow = flow)
advanceUntilIdle()
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
offers.onCheckedChange(true)
advanceUntilIdle()
model.requestPushPermission.test {
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
offers.onCheckedChange(true)
advanceUntilIdle()
val content = model.uiState.value as PushNotificationSettingsUM.Content
assertThat(content.requestPermissionEvent.javaClass.simpleName).isEqualTo("Triggered")
awaitItem()
expectNoEvents()
}
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
}
@ -224,12 +226,14 @@ class PushNotificationSettingsModelTest {
val banner = requireNotNull(
(model.uiState.value as PushNotificationSettingsUM.Content).banner,
)
banner.onOpenSettingsClick()
advanceUntilIdle()
model.requestPushPermission.test {
banner.onOpenSettingsClick()
advanceUntilIdle()
// The banner CTA opens system settings directly and never asks for the permission.
expectNoEvents()
}
verify(exactly = 1) { settingsManager.openAppNotificationSettings() }
val refreshed = model.uiState.value as PushNotificationSettingsUM.Content
assertThat(refreshed.requestPermissionEvent.javaClass.simpleName).isEqualTo("Consumed")
}
@Test

View file

@ -271,7 +271,7 @@ internal class SendDestinationModel @Inject constructor(
private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? {
val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null
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
}

View file

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

View file

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

View file

@ -1,45 +1,32 @@
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.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.formatStyled
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.TangemPayDetailsUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import java.util.Currency
internal class DetailsBalanceTransformer(
private val balance: Either<UniversalError, TangemPayCardBalance>,
private val fiatBalance: PaymentAccountStatusValue.FiatBalance,
) : Transformer<TangemPayDetailsUM> {
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
val balance = when (balance) {
is Either.Left<UniversalError> -> {
TangemPayDetailsBalanceBlockState.Error(
actionButtons = persistentListOf(),
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
)
}
is Either.Right<TangemPayCardBalance> -> {
TangemPayDetailsBalanceBlockState.Content(
isBalanceFlickering = false,
fiatBalance = getFiatBalanceText(balance.value),
actionButtons = prevState.balanceBlockState.actionButtons,
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
)
}
}
val balance = TangemPayDetailsBalanceBlockState.Content(
isBalanceFlickering = false,
fiatBalance = getFiatBalanceText(fiatBalance),
actionButtons = prevState.balanceBlockState.actionButtons,
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
)
return prevState.copy(balanceBlockState = balance)
}
private fun getFiatBalanceText(balance: TangemPayCardBalance): TextReference {
val currency = Currency.getInstance(balance.currencyCode)
return balance.fiatBalance.formatStyled {
private fun getFiatBalanceText(fiatBalance: PaymentAccountStatusValue.FiatBalance): TextReference {
val currency = Currency.getInstance(fiatBalance.currency)
return fiatBalance.availableBalance.formatStyled {
fiat(
fiatCurrencyCode = currency.currencyCode,
fiatCurrencySymbol = currency.symbol,

View file

@ -108,14 +108,11 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M
@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries")
@Composable
private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) {
val isRenaming = state.displayNameState is DisplayNameState.Editing
Box(modifier = modifier.fillMaxSize()) {
TangemPayCardBackground(
modifier = Modifier
.fillMaxSize()
.zIndex(0f),
isRenaming = isRenaming,
cardFrozenState = state.cardFrozenState,
)
@ -147,6 +144,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif
)
}
CardNumberBlock(
isRenaming = state.displayNameState is DisplayNameState.Editing,
numberShort = state.numberShort,
cardNumberRef = cardNumberRef,
)
@ -203,11 +201,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif
}
@Composable
private fun TangemPayCardBackground(
isRenaming: Boolean,
cardFrozenState: TangemPayCardFrozenState,
modifier: Modifier = Modifier,
) {
private fun TangemPayCardBackground(cardFrozenState: TangemPayCardFrozenState, modifier: Modifier = Modifier) {
val isFrozen = cardFrozenState == TangemPayCardFrozenState.Frozen
val freezeProgress by animateFloatAsState(
targetValue = if (isFrozen) 1f else 0f,
@ -225,14 +219,6 @@ private fun TangemPayCardBackground(
contentDescription = null,
)
if (isRenaming && LocalVisaRedesignEnabled.current) {
Box(
modifier = Modifier
.fillMaxSize()
.background(CardBackgroundColor.copy(alpha = 0.8f)),
)
}
if (isFrozen || freezeProgress > 0f) {
Image(
modifier = Modifier
@ -344,6 +330,7 @@ private fun CardTopBlock(modifier: Modifier = Modifier) {
@Composable
private fun ConstraintLayoutScope.CardNumberBlock(
isRenaming: Boolean,
numberShort: String,
cardNumberRef: ConstrainedLayoutReference,
modifier: Modifier = Modifier,
@ -352,7 +339,11 @@ private fun ConstraintLayoutScope.CardNumberBlock(
Text(
text = numberShort,
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
.constrainAs(cardNumberRef) {
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.stringResourceSafe
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.ic_cross_20
import com.tangem.core.ui.test.TangemPayTestTags
@ -54,7 +54,7 @@ internal fun TangemPayChangePinScreenV2(
.statusBarsPadding(),
) {
TangemTopBar(
title = resourceReference(R.string.tangempay_set_pin_title),
title = resourceReference(R.string.visa_onboarding_pin_code_title),
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_cross_20),
@ -73,7 +73,7 @@ internal fun TangemPayChangePinScreenV2(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.tangempay_set_pin_header),
text = stringResourceSafe(R.string.visa_onboarding_pin_code_description),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
@ -94,6 +94,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod
val focusRequester = remember { FocusRequester() }
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
PinCode(
isError = state.error != null,
value = state.pinCode,
onValueChange = state.onPinCodeChange,
focusRequester = focusRequester,
@ -109,7 +110,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod
Text(
text = error.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.status.warning,
color = TangemTheme.colors3.text.status.error,
textAlign = TextAlign.Center,
modifier = Modifier.testTag(TangemPayTestTags.PIN_ERROR_MESSAGE),
)
@ -125,6 +126,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod
@Composable
private fun PinCode(
isError: Boolean,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
@ -180,10 +182,10 @@ private fun PinCode(
),
digit = digit,
backgroundColor = TangemTheme.colors3.bg.opaque.primary,
borderColor = if (isActive) {
TangemTheme.colors3.border.status.info
} else {
TangemTheme.colors3.border.secondary
borderColor = when {
isError -> TangemTheme.colors3.border.status.error
isActive -> TangemTheme.colors3.border.status.info
else -> TangemTheme.colors3.border.secondary
},
textColor = TangemTheme.colors3.text.primary,
textStyle = TangemTheme.typography3.heading.medium,
@ -201,7 +203,7 @@ private fun PinCode(
private fun TangemPayChangePinScreenV2Preview(
@PreviewParameter(TangemPayChangePinUMPreviewProvider::class) state: TangemPayChangePinUM,
) {
TangemThemePreview {
TangemThemePreviewRedesign {
TangemPayChangePinScreenV2(
state = state,
onBackClick = {},

View file

@ -144,7 +144,7 @@ internal fun TangemPayEditDisplayNameScreenV2(
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4)
.imePadding(),
text = resourceReference(R.string.common_save),
text = resourceReference(R.string.common_done),
onClick = state.onDoneClick,
isLoading = state.isLoading,
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(
initialCardId: String,
initialStatus: AccountStatus.Payment,

View file

@ -85,7 +85,11 @@ internal class TangemPayCardLimitSetupModelTest {
val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) {
every { source } returns StatusSource.ACTUAL
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) {
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.navigation.Router
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.message.DialogMessage
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.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.features.tangempay.onboarding.api.R
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType
@ -38,7 +36,6 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor(
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
private val router: Router,
private val uiMessageSender: UiMessageSender,
private val urlOpener: UrlOpener,
) : Model() {
val uiState: StateFlow<TangemPayHotWalletOnboardingUM>
@ -51,7 +48,7 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor(
)
private fun onTermsClick() {
urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK)
router.push(AppRoute.Disclaimer(isTosAccepted = true))
}
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
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.unit.dp
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.R
import com.tangem.core.ui.components.TextButton
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
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.components.SpacerH16
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
@ -80,10 +79,11 @@ private fun Content(state: TangemPayHotWalletOnboardingUM, modifier: Modifier =
.fillMaxWidth()
.padding(horizontal = 40.dp),
)
SpacerH16()
Spacer(Modifier.weight(1f))
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
NavigationPrimaryButton(
primaryButton = NavigationButton(
@ -94,18 +94,36 @@ private fun Content(state: TangemPayHotWalletOnboardingUM, modifier: Modifier =
onClick = state.onGetCardClick,
),
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.tangem_pay_terms_fees_limits),
onClick = state.onTermsClick,
colors = TangemButtonsDefaults.defaultTextButtonColors.copy(
contentColor = TangemTheme.colors.text.primary1,
),
)
TosText(onClick = state.onTermsClick)
}
}
}
@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
private fun Features(modifier: Modifier = Modifier) {
Column(

View file

@ -6,7 +6,6 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
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.UserWalletId
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.MnemonicType
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -32,7 +30,6 @@ internal class TangemPayHotWalletOnboardingModelTest {
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk()
private val router: Router = mockk(relaxed = true)
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val testUserWalletId = UserWalletId("1234567890ABCDEF")
private val testUserWallet: UserWallet.Hot = mockk(relaxed = true) {
@ -43,12 +40,12 @@ internal class TangemPayHotWalletOnboardingModelTest {
inner class OnTermsClick {
@Test
fun `WHEN onTermsClick THEN urlOpener called with terms link`() = runTest {
fun `WHEN onTermsClick THEN navigate to Disclaimer`() = runTest {
val model = createModel()
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,
router = router,
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.transformer.InitializeWithCryptoCurrencyTransformer
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.UpdateAddFundsTransformer
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)
.onEach { formatted ->
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
redesignStateController.update(SetYieldSupplyBalanceTransformer(formatted))
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
@ -479,6 +481,7 @@ internal class TokenDetailsModel @Inject constructor(
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
YieldSupplyRewardBalance.empty(),
)
redesignStateController.update(SetYieldSupplyBalanceTransformer(YieldSupplyRewardBalance.empty()))
}
}

View file

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

View file

@ -86,6 +86,9 @@ internal class SetBalanceTransformer(
val totalCryptoAmount = computeTotal(status.value.amount, stakingCryptoAmount)
val isYieldSupplyActive = status.value.yieldSupplyStatus?.isActive == true
val prevContent = prev as? TokenDetailsBalanceBlockUM.Content
return TokenDetailsBalanceBlockUM.Content(
addFundsButton = prev.addFundsButton,
swapButton = prev.swapButton,
@ -108,6 +111,17 @@ internal class SetBalanceTransformer(
},
isBalanceFlickering = status.value.sources.total == StatusSource.CACHE,
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
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.currency.icon.CurrencyIcon
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.TangemButtonUM
import com.tangem.core.ui.ds.button.action.ActionButtons
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.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
@ -42,6 +46,9 @@ import kotlinx.collections.immutable.persistentListOf
private val CurrencyIconSize: Dp = 70.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
internal fun TokenDetailsBalanceBlock(
balanceBlockUM: TokenDetailsBalanceBlockUM,
@ -124,20 +131,60 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd
}
}
SpacerH(TangemTheme.dimens2.x2)
Text(
AnimatedBalance(
modifier = Modifier.testTag(TokenDetailsScreenTestTags.BALANCE_FIAT),
text = state.displayFiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
yieldBalance = state.displayYieldSupplyFiatBalance,
fallbackBalance = state.displayFiatBalance,
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
isBalanceHidden = isBalanceHidden,
)
SpacerH(TangemTheme.dimens2.x2_5)
Text(
text = state.displayCryptoBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
AnimatedBalance(
yieldBalance = state.displayYieldSupplyCryptoBalance,
fallbackBalance = state.displayCryptoBalance,
style = TangemTheme.typography2.bodySemibold16,
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
private fun LoadingBody() {
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.network.Network
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.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
@ -434,6 +435,59 @@ class SetBalanceTransformerTest {
// 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
@Test
@ -473,6 +527,7 @@ class SetBalanceTransformerTest {
fiatAmount: BigDecimal = BigDecimal("21000"),
fiatRate: BigDecimal = BigDecimal("2000"),
stakingBalance: StakingBalance? = null,
yieldSupplyStatus: YieldSupplyStatus? = null,
sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(),
): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded(
amount = amount,
@ -480,13 +535,39 @@ class SetBalanceTransformerTest {
fiatRate = fiatRate,
priceChange = BigDecimal("2.5"),
stakingBalance = stakingBalance,
yieldSupplyStatus = null,
yieldSupplyStatus = yieldSupplyStatus,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
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(
amount = BigDecimal("5.0"),
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.onboardingV2.api)
implementation(projects.features.pushNotifications.api)
implementation(projects.features.pushNotificationSettings.api)
implementation(projects.features.hotWallet.api)
implementation(projects.features.wallet.api)

View file

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

View file

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

View file

@ -33,6 +33,8 @@ internal class ItemsBuilder @Inject constructor() {
isNotificationsPermissionGranted: Boolean,
onCheckedNotificationsChanged: (Boolean) -> Unit,
onNotificationsDescriptionClick: () -> Unit,
isPushNotificationSettingsEnabled: Boolean,
onNotificationSettingsClick: () -> Unit,
forgetWallet: () -> Unit,
onLinkMoreCardsClick: () -> Unit,
onReferralClick: () -> Unit,
@ -50,20 +52,26 @@ internal class ItemsBuilder @Inject constructor() {
isLinkMoreCardsAvailable = isLinkMoreCardsAvailable,
isReferralAvailable = isReferralAvailable,
isManageTokensAvailable = isManageTokensAvailable,
isPushNotificationSettingsEnabled = isPushNotificationSettingsEnabled,
onLinkMoreCardsClick = onLinkMoreCardsClick,
onReferralClick = onReferralClick,
onManageTokensClick = onManageTokensClick,
onBackupClick = onBackupClick,
onCardSettingsClick = onCardSettingsClick,
onNotificationSettingsClick = onNotificationSettingsClick,
),
)
.addAll(
buildNotificationItems(
isNotificationsPermissionGranted = isNotificationsPermissionGranted,
isNotificationsEnabled = isNotificationsEnabled,
onCheckedNotificationsChanged = onCheckedNotificationsChanged,
onNotificationsDescriptionClick = onNotificationsDescriptionClick,
),
if (isPushNotificationSettingsEnabled) {
emptyList()
} else {
buildNotificationItems(
isNotificationsPermissionGranted = isNotificationsPermissionGranted,
isNotificationsEnabled = isNotificationsEnabled,
onCheckedNotificationsChanged = onCheckedNotificationsChanged,
onNotificationsDescriptionClick = onNotificationsDescriptionClick,
)
},
)
.addAll(
buildNFTItems(
@ -137,11 +145,13 @@ internal class ItemsBuilder @Inject constructor() {
isLinkMoreCardsAvailable: Boolean,
isReferralAvailable: Boolean,
isManageTokensAvailable: Boolean,
isPushNotificationSettingsEnabled: Boolean,
onLinkMoreCardsClick: () -> Unit,
onReferralClick: () -> Unit,
onManageTokensClick: () -> Unit,
onBackupClick: () -> Unit,
onCardSettingsClick: () -> Unit,
onNotificationSettingsClick: () -> Unit,
) = WalletSettingsItemUM.WithItems(
id = "card",
description = null,
@ -207,6 +217,16 @@ internal class ItemsBuilder @Inject constructor() {
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(),
)

View file

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