Updated on 2026-08-14
This commit is contained in:
commit
f2b72b3771
66 changed files with 1955 additions and 275 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 couldn’t create contact. Please try again later.</string>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent {
|
|||
isNotificationsEnabled = true,
|
||||
onCheckedNotificationsChanged = {},
|
||||
onNotificationsDescriptionClick = {},
|
||||
isPushNotificationSettingsEnabled = false,
|
||||
onNotificationSettingsClick = {},
|
||||
isNotificationsPermissionGranted = false,
|
||||
onAccessCodeClick = {},
|
||||
onBackupClick = {},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue