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") {
|
step("Click on 'Send' button in bottom sheet") {
|
||||||
onTransferBottomSheet { sendButton.clickWithAssertion() }
|
onTransferBottomSheet { sendButton.clickWithAssertion() }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an existing hot wallet, select the token to send and choose the swap target token/network —
|
||||||
|
* the shared entry into the gasless send-via-swap flow. Scenario states stay in the test body.
|
||||||
|
*/
|
||||||
|
fun BaseTestCase.openSendViaSwapScreenWithHotWallet(
|
||||||
|
seedPhrase: String,
|
||||||
|
tokenName: String,
|
||||||
|
swapTokenName: String,
|
||||||
|
networkName: String,
|
||||||
|
networkType: String? = null,
|
||||||
|
) {
|
||||||
|
step("Open 'Main' screen with existing hot wallet") {
|
||||||
|
openMainScreenWithExistingHotWallet(seedPhrase)
|
||||||
|
}
|
||||||
|
step("Click on token with name: '$tokenName'") {
|
||||||
|
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
|
||||||
|
}
|
||||||
|
step("Select '$swapTokenName' as the token to receive via swap") {
|
||||||
|
selectTokenToSendViaSwap(
|
||||||
|
swapTokenName = swapTokenName,
|
||||||
|
networkName = networkName,
|
||||||
|
networkType = networkType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send-via-swap amount entry: type the amount, advance past the quote-gated 'Next' button (waiting
|
||||||
|
* until it becomes enabled once the swap quote loads), then fill the recipient and open the
|
||||||
|
* 'Send confirm' screen. Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in
|
||||||
|
* extensions on [BaseTestCase].
|
||||||
|
*/
|
||||||
|
fun BaseTestCase.enterSwapAmountAndOpenSendConfirm(amount: String, recipientAddress: String) {
|
||||||
|
step("Type amount '$amount' in input field") {
|
||||||
|
onSendScreen { amountInputTextField.performTextReplacement(amount) }
|
||||||
|
}
|
||||||
|
step("Click on 'Next' button") {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
|
runCatching {
|
||||||
|
onSendScreen {
|
||||||
|
nextButton.assertIsEnabled()
|
||||||
|
nextButton.performClick()
|
||||||
|
}
|
||||||
|
}.isSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enterRecipientAndOpenSendConfirm(recipientAddress)
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
|
||||||
)
|
)
|
||||||
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled")
|
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled")
|
||||||
if (isHotWalletOnboardingEnabled) {
|
val afterEmptyRoute: AppRoute = if (isHotWalletOnboardingEnabled) {
|
||||||
val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) {
|
val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) {
|
||||||
appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
|
appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
|
||||||
}
|
}
|
||||||
TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}")
|
TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}")
|
||||||
if (tangemPayHotWalletOnboardingDeepLink != null) {
|
if (tangemPayHotWalletOnboardingDeepLink != null) {
|
||||||
val hotWalletRoute = AppRoute.TangemPayHotWalletOnboarding
|
AppRoute.TangemPayHotWalletOnboarding
|
||||||
val shouldShowTos = !cardRepository.isTangemTOSAccepted()
|
} else {
|
||||||
val route = if (shouldShowTos) "Disclaimer" else "HotWalletOnboarding"
|
getDefaultRoute()
|
||||||
TangemLogger.i("[TangemPay][HWO] TOS accepted=${!shouldShowTos}, navigating to $route")
|
|
||||||
return if (shouldShowTos) {
|
|
||||||
AppRoute.Disclaimer(isTosAccepted = false, nextRoute = hotWalletRoute)
|
|
||||||
} else {
|
|
||||||
hotWalletRoute
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled(
|
|
||||||
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
|
|
||||||
)
|
|
||||||
// Referral users skip the Home stories screen and land directly on the
|
|
||||||
// mobile wallet creation flow.
|
|
||||||
val afterEmptyRoute: AppRoute = if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) {
|
|
||||||
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
|
|
||||||
} else {
|
} else {
|
||||||
AppRoute.Home(launchMode = launchMode)
|
getDefaultRoute()
|
||||||
}
|
}
|
||||||
|
|
||||||
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
|
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
|
||||||
|
|
@ -261,6 +246,19 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun getDefaultRoute(): AppRoute {
|
||||||
|
val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled(
|
||||||
|
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
|
||||||
|
)
|
||||||
|
// Referral users skip the Home stories screen and land directly on the
|
||||||
|
// mobile wallet creation flow.
|
||||||
|
return if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) {
|
||||||
|
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
|
||||||
|
} else {
|
||||||
|
AppRoute.Home(launchMode = launchMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content(modifier: Modifier) {
|
override fun Content(modifier: Modifier) {
|
||||||
RootContent(
|
RootContent(
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import com.tangem.features.onramp.component.*
|
||||||
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
|
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
|
||||||
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacksStub
|
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacksStub
|
||||||
import com.tangem.features.pushnotifications.api.PushNotificationsParams
|
import com.tangem.features.pushnotifications.api.PushNotificationsParams
|
||||||
|
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
|
||||||
import com.tangem.features.send.api.NFTSendComponent
|
import com.tangem.features.send.api.NFTSendComponent
|
||||||
import com.tangem.features.send.api.SendComponent
|
import com.tangem.features.send.api.SendComponent
|
||||||
import com.tangem.features.send.api.SendEntryPointComponent
|
import com.tangem.features.send.api.SendEntryPointComponent
|
||||||
|
|
@ -87,6 +88,7 @@ internal class ChildFactory @Inject constructor(
|
||||||
private val resetCardComponentFactory: ResetCardComponent.Factory,
|
private val resetCardComponentFactory: ResetCardComponent.Factory,
|
||||||
private val referralComponentFactory: ReferralComponent.Factory,
|
private val referralComponentFactory: ReferralComponent.Factory,
|
||||||
private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory,
|
private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory,
|
||||||
|
private val pushNotificationSettingsComponentFactory: PushNotificationSettingsComponent.Factory,
|
||||||
private val walletComponentFactory: WalletEntryComponent.Factory,
|
private val walletComponentFactory: WalletEntryComponent.Factory,
|
||||||
private val sendComponentFactoryV2: SendComponent.Factory,
|
private val sendComponentFactoryV2: SendComponent.Factory,
|
||||||
private val redesignedWalletConnectComponentFactory: WalletConnectEntryComponent.Factory,
|
private val redesignedWalletConnectComponentFactory: WalletConnectEntryComponent.Factory,
|
||||||
|
|
@ -172,6 +174,13 @@ internal class ChildFactory @Inject constructor(
|
||||||
componentFactory = walletSettingsComponentFactory,
|
componentFactory = walletSettingsComponentFactory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is AppRoute.PushNotificationSettings -> {
|
||||||
|
createComponentChild(
|
||||||
|
context = context,
|
||||||
|
params = PushNotificationSettingsComponent.Params(route.userWalletId),
|
||||||
|
componentFactory = pushNotificationSettingsComponentFactory,
|
||||||
|
)
|
||||||
|
}
|
||||||
is AppRoute.WalletBackup -> {
|
is AppRoute.WalletBackup -> {
|
||||||
createComponentChild(
|
createComponentChild(
|
||||||
context = context,
|
context = context,
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,11 @@ sealed class AppRoute(val path: String) : Route {
|
||||||
val userWalletId: UserWalletId,
|
val userWalletId: UserWalletId,
|
||||||
) : AppRoute(path = "/wallet_settings/${userWalletId.stringValue}")
|
) : AppRoute(path = "/wallet_settings/${userWalletId.stringValue}")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PushNotificationSettings(
|
||||||
|
val userWalletId: UserWalletId,
|
||||||
|
) : AppRoute(path = "/push_notification_settings/${userWalletId.stringValue}")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class WalletBackup(
|
data class WalletBackup(
|
||||||
val userWalletId: UserWalletId,
|
val userWalletId: UserWalletId,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ sealed interface PaymentAccountStatusValueDM {
|
||||||
|
|
||||||
@NameLabel("active_account")
|
@NameLabel("active_account")
|
||||||
data class ActiveAccount(
|
data class ActiveAccount(
|
||||||
|
@Json(name = "active_account") val marker: Boolean = true,
|
||||||
@Json(name = "customer_id") val customerId: String,
|
@Json(name = "customer_id") val customerId: String,
|
||||||
@Json(name = "currency_code") val currencyCode: String,
|
@Json(name = "currency_code") val currencyCode: String,
|
||||||
@Json(name = "deposit_address") val depositAddress: String?,
|
@Json(name = "deposit_address") val depositAddress: String?,
|
||||||
|
|
@ -59,9 +60,11 @@ sealed interface PaymentAccountStatusValueDM {
|
||||||
@NameLabel("deactivated_account")
|
@NameLabel("deactivated_account")
|
||||||
data class DeactivatedAccount(
|
data class DeactivatedAccount(
|
||||||
@Json(name = "deactivated_account") val marker: Boolean = true,
|
@Json(name = "deactivated_account") val marker: Boolean = true,
|
||||||
|
@Json(name = "customer_id") val customerId: String,
|
||||||
@Json(name = "fiat_rate") val fiatRate: BigDecimal?,
|
@Json(name = "fiat_rate") val fiatRate: BigDecimal?,
|
||||||
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
|
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
|
||||||
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
|
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
|
||||||
|
@Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal,
|
||||||
) : PaymentAccountStatusValueDM
|
) : PaymentAccountStatusValueDM
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@
|
||||||
<item quantity="one">%d address</item>
|
<item quantity="one">%d address</item>
|
||||||
<item quantity="other">%d addresses</item>
|
<item quantity="other">%d addresses</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="address_book_contact">Contact</string>
|
||||||
<string name="address_book_contact_name">Contact name</string>
|
<string name="address_book_contact_name">Contact name</string>
|
||||||
<string name="address_book_copy_address">Copy address</string>
|
<string name="address_book_copy_address">Copy address</string>
|
||||||
<string name="address_book_creating_error">We couldn’t create contact. Please try again later.</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.IssuingCard -> PaymentAccountStatusValueDM.IssuingCard()
|
||||||
is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveAccount(
|
is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveAccount(
|
||||||
customerId = value.customerId,
|
customerId = value.customerId,
|
||||||
currencyCode = value.currencyCode,
|
currencyCode = value.balance.fiatBalance.currency,
|
||||||
depositAddress = value.depositAddress,
|
depositAddress = value.depositAddress,
|
||||||
fiatBalance = value.fiatBalance.toDM(),
|
fiatBalance = value.balance.fiatBalance.toDM(),
|
||||||
cryptoBalance = value.cryptoBalance.toDM(),
|
cryptoBalance = value.balance.cryptoBalance.toDM(),
|
||||||
availableForWithdrawal = value.availableForWithdrawal,
|
availableForWithdrawal = value.balance.availableForWithdrawal,
|
||||||
fiatRate = value.fiatRate,
|
fiatRate = value.fiatRate,
|
||||||
cards = value.cards.map { card ->
|
cards = value.cards.map { card ->
|
||||||
PaymentAccountStatusValueDM.TangemPayCard(
|
PaymentAccountStatusValueDM.TangemPayCard(
|
||||||
|
|
@ -63,9 +63,11 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
||||||
)
|
)
|
||||||
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
|
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
|
||||||
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
|
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
|
||||||
|
customerId = value.customerId,
|
||||||
fiatRate = value.fiatRate,
|
fiatRate = value.fiatRate,
|
||||||
fiatBalance = value.fiatBalance.toDM(),
|
fiatBalance = value.balance.fiatBalance.toDM(),
|
||||||
cryptoBalance = value.cryptoBalance.toDM(),
|
cryptoBalance = value.balance.cryptoBalance.toDM(),
|
||||||
|
availableForWithdrawal = value.balance.availableForWithdrawal,
|
||||||
)
|
)
|
||||||
// Transient statuses are not persisted
|
// Transient statuses are not persisted
|
||||||
is PaymentAccountStatusValue.Loading,
|
is PaymentAccountStatusValue.Loading,
|
||||||
|
|
@ -90,11 +92,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
||||||
is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded(
|
is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded(
|
||||||
source = StatusSource.CACHE,
|
source = StatusSource.CACHE,
|
||||||
customerId = value.customerId,
|
customerId = value.customerId,
|
||||||
currencyCode = value.currencyCode,
|
|
||||||
depositAddress = value.depositAddress,
|
depositAddress = value.depositAddress,
|
||||||
fiatBalance = value.fiatBalance.toDomain(),
|
balance = PaymentAccountStatusValue.Balance(
|
||||||
cryptoBalance = value.cryptoBalance.toDomain(),
|
fiatBalance = value.fiatBalance.toDomain(),
|
||||||
availableForWithdrawal = value.availableForWithdrawal,
|
cryptoBalance = value.cryptoBalance.toDomain(),
|
||||||
|
availableForWithdrawal = value.availableForWithdrawal,
|
||||||
|
),
|
||||||
cryptoCurrency = cryptoCurrency,
|
cryptoCurrency = cryptoCurrency,
|
||||||
fiatRate = value.fiatRate,
|
fiatRate = value.fiatRate,
|
||||||
cards = value.cards.map { card ->
|
cards = value.cards.map { card ->
|
||||||
|
|
@ -123,8 +126,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
||||||
)
|
)
|
||||||
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
|
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
|
||||||
source = StatusSource.CACHE,
|
source = StatusSource.CACHE,
|
||||||
fiatBalance = value.fiatBalance.toDomain(),
|
customerId = value.customerId,
|
||||||
cryptoBalance = value.cryptoBalance.toDomain(),
|
balance = PaymentAccountStatusValue.Balance(
|
||||||
|
fiatBalance = value.fiatBalance.toDomain(),
|
||||||
|
cryptoBalance = value.cryptoBalance.toDomain(),
|
||||||
|
availableForWithdrawal = value.availableForWithdrawal,
|
||||||
|
),
|
||||||
cryptoCurrency = cryptoCurrency,
|
cryptoCurrency = cryptoCurrency,
|
||||||
fiatRate = value.fiatRate,
|
fiatRate = value.fiatRate,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import com.tangem.domain.pay.repository.TangemPayCloseCardRepository
|
||||||
import com.tangem.security.DeviceSecurityInfoProvider
|
import com.tangem.security.DeviceSecurityInfoProvider
|
||||||
import com.tangem.security.isSecurityExposed
|
import com.tangem.security.isSecurityExposed
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import com.tangem.utils.extensions.orZero
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -287,11 +288,16 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
||||||
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
|
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> {
|
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() &&
|
||||||
|
(isDeactivated || isFormer) -> {
|
||||||
PaymentAccountStatusValue.Deactivated(
|
PaymentAccountStatusValue.Deactivated(
|
||||||
source = StatusSource.ACTUAL,
|
source = StatusSource.ACTUAL,
|
||||||
fiatBalance = fiatBalance,
|
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
|
||||||
cryptoBalance = cryptoBalance,
|
balance = PaymentAccountStatusValue.Balance(
|
||||||
|
fiatBalance = fiatBalance,
|
||||||
|
cryptoBalance = cryptoBalance,
|
||||||
|
availableForWithdrawal = availableForWithdrawal.orZero(),
|
||||||
|
),
|
||||||
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
|
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
|
||||||
fiatRate = quotesData?.fiatRate,
|
fiatRate = quotesData?.fiatRate,
|
||||||
)
|
)
|
||||||
|
|
@ -321,11 +327,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
||||||
return PaymentAccountStatusValue.Loaded(
|
return PaymentAccountStatusValue.Loaded(
|
||||||
source = StatusSource.ACTUAL,
|
source = StatusSource.ACTUAL,
|
||||||
customerId = customerId,
|
customerId = customerId,
|
||||||
currencyCode = cardInfo.currencyCode,
|
|
||||||
depositAddress = cardInfo.depositAddress,
|
depositAddress = cardInfo.depositAddress,
|
||||||
fiatBalance = cardInfo.fiatBalance,
|
balance = PaymentAccountStatusValue.Balance(
|
||||||
cryptoBalance = cardInfo.cryptoBalance,
|
fiatBalance = cardInfo.fiatBalance,
|
||||||
availableForWithdrawal = cardInfo.availableForWithdrawal,
|
cryptoBalance = cardInfo.cryptoBalance,
|
||||||
|
availableForWithdrawal = cardInfo.availableForWithdrawal,
|
||||||
|
),
|
||||||
cryptoCurrency = cryptoCurrency,
|
cryptoCurrency = cryptoCurrency,
|
||||||
fiatRate = fiatRate,
|
fiatRate = fiatRate,
|
||||||
cards = listOf(
|
cards = listOf(
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
|
||||||
state = CustomerInfo.State.fromString(value.state),
|
state = CustomerInfo.State.fromString(value.state),
|
||||||
fiatBalance = fiatBalance?.toDomain(),
|
fiatBalance = fiatBalance?.toDomain(),
|
||||||
cryptoBalance = cryptoBalance?.toDomain(),
|
cryptoBalance = cryptoBalance?.toDomain(),
|
||||||
|
availableForWithdrawal = value.balance?.availableForWithdrawal?.amount,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,15 @@ internal class PaymentAccountStatusValueDMConverterTest {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
val domain = PaymentAccountStatusValue.Deactivated(
|
val domain = PaymentAccountStatusValue.Deactivated(
|
||||||
source = StatusSource.ACTUAL,
|
source = StatusSource.ACTUAL,
|
||||||
fiatBalance = PaymentAccountStatusValue.FiatBalance(
|
customerId = "customer-1",
|
||||||
availableBalance = BigDecimal("100"),
|
balance = PaymentAccountStatusValue.Balance(
|
||||||
currency = "USD",
|
fiatBalance = PaymentAccountStatusValue.FiatBalance(
|
||||||
|
availableBalance = BigDecimal("100"),
|
||||||
|
currency = "USD",
|
||||||
|
),
|
||||||
|
cryptoBalance = cryptoBalance(),
|
||||||
|
availableForWithdrawal = BigDecimal("7"),
|
||||||
),
|
),
|
||||||
cryptoBalance = cryptoBalance(),
|
|
||||||
cryptoCurrency = cryptoCurrency,
|
cryptoCurrency = cryptoCurrency,
|
||||||
fiatRate = BigDecimal("1.05"),
|
fiatRate = BigDecimal("1.05"),
|
||||||
)
|
)
|
||||||
|
|
@ -79,8 +83,10 @@ internal class PaymentAccountStatusValueDMConverterTest {
|
||||||
// THEN
|
// THEN
|
||||||
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.DeactivatedAccount::class.java)
|
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.DeactivatedAccount::class.java)
|
||||||
val dm = result as PaymentAccountStatusValueDM.DeactivatedAccount
|
val dm = result as PaymentAccountStatusValueDM.DeactivatedAccount
|
||||||
|
assertThat(dm.customerId).isEqualTo("customer-1")
|
||||||
assertThat(dm.fiatBalance.availableBalance).isEqualTo(BigDecimal("100"))
|
assertThat(dm.fiatBalance.availableBalance).isEqualTo(BigDecimal("100"))
|
||||||
assertThat(dm.fiatBalance.currency).isEqualTo("USD")
|
assertThat(dm.fiatBalance.currency).isEqualTo("USD")
|
||||||
|
assertThat(dm.availableForWithdrawal).isEqualTo(BigDecimal("7"))
|
||||||
assertThat(dm.fiatRate).isEqualTo(BigDecimal("1.05"))
|
assertThat(dm.fiatRate).isEqualTo(BigDecimal("1.05"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,12 +147,14 @@ internal class PaymentAccountStatusValueDMConverterTest {
|
||||||
fun `GIVEN DM DeactivatedAccount WHEN convertBack THEN returns domain Deactivated with CACHE source`() {
|
fun `GIVEN DM DeactivatedAccount WHEN convertBack THEN returns domain Deactivated with CACHE source`() {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
val dm = PaymentAccountStatusValueDM.DeactivatedAccount(
|
val dm = PaymentAccountStatusValueDM.DeactivatedAccount(
|
||||||
|
customerId = "customer-2",
|
||||||
fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM(
|
fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM(
|
||||||
availableBalance = BigDecimal("200"),
|
availableBalance = BigDecimal("200"),
|
||||||
currency = "EUR",
|
currency = "EUR",
|
||||||
),
|
),
|
||||||
cryptoBalance = cryptoBalanceDM(),
|
cryptoBalance = cryptoBalanceDM(),
|
||||||
fiatRate = BigDecimal("0.92"),
|
fiatRate = BigDecimal("0.92"),
|
||||||
|
availableForWithdrawal = BigDecimal("5"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// WHEN
|
// WHEN
|
||||||
|
|
@ -156,8 +164,10 @@ internal class PaymentAccountStatusValueDMConverterTest {
|
||||||
assertThat(result).isInstanceOf(PaymentAccountStatusValue.Deactivated::class.java)
|
assertThat(result).isInstanceOf(PaymentAccountStatusValue.Deactivated::class.java)
|
||||||
val deactivated = result as PaymentAccountStatusValue.Deactivated
|
val deactivated = result as PaymentAccountStatusValue.Deactivated
|
||||||
assertThat(deactivated.source).isEqualTo(StatusSource.CACHE)
|
assertThat(deactivated.source).isEqualTo(StatusSource.CACHE)
|
||||||
assertThat(deactivated.fiatBalance.availableBalance).isEqualTo(BigDecimal("200"))
|
assertThat(deactivated.customerId).isEqualTo("customer-2")
|
||||||
assertThat(deactivated.fiatBalance.currency).isEqualTo("EUR")
|
assertThat(deactivated.balance.fiatBalance.availableBalance).isEqualTo(BigDecimal("200"))
|
||||||
|
assertThat(deactivated.balance.fiatBalance.currency).isEqualTo("EUR")
|
||||||
|
assertThat(deactivated.balance.availableForWithdrawal).isEqualTo(BigDecimal("5"))
|
||||||
assertThat(deactivated.fiatRate).isEqualTo(BigDecimal("0.92"))
|
assertThat(deactivated.fiatRate).isEqualTo(BigDecimal("0.92"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,11 @@ sealed class PaymentAccountStatusValue {
|
||||||
is Loading -> TotalFiatBalance.Loading
|
is Loading -> TotalFiatBalance.Loading
|
||||||
is Loaded -> {
|
is Loaded -> {
|
||||||
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
|
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
|
||||||
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
|
TotalFiatBalance.Loaded(amount = balance.fiatBalance.availableBalance.multiply(rate), source = source)
|
||||||
}
|
}
|
||||||
is Deactivated -> {
|
is Deactivated -> {
|
||||||
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
|
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
|
||||||
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
|
TotalFiatBalance.Loaded(amount = balance.fiatBalance.availableBalance.multiply(rate), source = source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,8 +104,8 @@ sealed class PaymentAccountStatusValue {
|
||||||
* Represents a state where the account is deactivated.
|
* Represents a state where the account is deactivated.
|
||||||
*
|
*
|
||||||
* @property source The source of the status information.
|
* @property source The source of the status information.
|
||||||
* @property fiatBalance The fiat balance details.
|
* @property customerId The unique identifier of the customer.
|
||||||
* @property cryptoBalance The crypto balance details.
|
* @property balance The balance details (fiat, crypto and amount available for withdrawal).
|
||||||
* @property cryptoCurrency The crypto currency held by the deactivated account.
|
* @property cryptoCurrency The crypto currency held by the deactivated account.
|
||||||
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
|
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
|
||||||
* or `null` if the quote is not yet available. When `null`,
|
* or `null` if the quote is not yet available. When `null`,
|
||||||
|
|
@ -114,18 +114,18 @@ sealed class PaymentAccountStatusValue {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Deactivated(
|
data class Deactivated(
|
||||||
override val source: StatusSource,
|
override val source: StatusSource,
|
||||||
val fiatBalance: FiatBalance,
|
val customerId: String,
|
||||||
val cryptoBalance: CryptoBalance,
|
val balance: Balance,
|
||||||
val cryptoCurrency: CryptoCurrency.Token,
|
val cryptoCurrency: CryptoCurrency.Token,
|
||||||
val fiatRate: SerializedBigDecimal?,
|
val fiatRate: SerializedBigDecimal?,
|
||||||
) : PaymentAccountStatusValue() {
|
) : PaymentAccountStatusValue() {
|
||||||
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
|
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||||
currency = cryptoCurrency,
|
currency = cryptoCurrency,
|
||||||
value = buildCryptoCurrencyStatusValue(
|
value = buildCryptoCurrencyStatusValue(
|
||||||
amount = cryptoBalance.balance,
|
amount = balance.cryptoBalance.balance,
|
||||||
fiatAmount = fiatBalance.availableBalance,
|
fiatAmount = balance.fiatBalance.availableBalance,
|
||||||
fiatRate = fiatRate,
|
fiatRate = fiatRate,
|
||||||
depositAddress = cryptoBalance.depositAddress,
|
depositAddress = balance.cryptoBalance.depositAddress,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -135,11 +135,9 @@ sealed class PaymentAccountStatusValue {
|
||||||
*
|
*
|
||||||
* @property source The source of the status information.
|
* @property source The source of the status information.
|
||||||
* @property customerId The unique identifier of the customer.
|
* @property customerId The unique identifier of the customer.
|
||||||
* @property currencyCode The code of the currency.
|
|
||||||
* @property depositAddress The address for deposits, if available.
|
* @property depositAddress The address for deposits, if available.
|
||||||
* @property fiatBalance The fiat balance details.
|
* @property balance The balance details (fiat, crypto and amount available for withdrawal).
|
||||||
* @property cryptoBalance The crypto balance details.
|
* The fiat currency code is available via [Balance.fiatBalance].
|
||||||
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap (excludes pending/locked funds).
|
|
||||||
* @property cryptoCurrency The crypto currency held by the account.
|
* @property cryptoCurrency The crypto currency held by the account.
|
||||||
* @property cards The list of user's cards.
|
* @property cards The list of user's cards.
|
||||||
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
|
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
|
||||||
|
|
@ -150,11 +148,8 @@ sealed class PaymentAccountStatusValue {
|
||||||
data class Loaded(
|
data class Loaded(
|
||||||
override val source: StatusSource,
|
override val source: StatusSource,
|
||||||
val customerId: String,
|
val customerId: String,
|
||||||
val currencyCode: String,
|
|
||||||
val depositAddress: String?,
|
val depositAddress: String?,
|
||||||
val fiatBalance: FiatBalance,
|
val balance: Balance,
|
||||||
val cryptoBalance: CryptoBalance,
|
|
||||||
val availableForWithdrawal: SerializedBigDecimal,
|
|
||||||
val cryptoCurrency: CryptoCurrency.Token,
|
val cryptoCurrency: CryptoCurrency.Token,
|
||||||
val cards: List<TangemPayCard>,
|
val cards: List<TangemPayCard>,
|
||||||
val fiatRate: SerializedBigDecimal?,
|
val fiatRate: SerializedBigDecimal?,
|
||||||
|
|
@ -162,10 +157,10 @@ sealed class PaymentAccountStatusValue {
|
||||||
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
|
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||||
currency = cryptoCurrency,
|
currency = cryptoCurrency,
|
||||||
value = buildCryptoCurrencyStatusValue(
|
value = buildCryptoCurrencyStatusValue(
|
||||||
amount = availableForWithdrawal,
|
amount = balance.availableForWithdrawal,
|
||||||
fiatAmount = fiatBalance.availableBalance,
|
fiatAmount = balance.fiatBalance.availableBalance,
|
||||||
fiatRate = fiatRate,
|
fiatRate = fiatRate,
|
||||||
depositAddress = cryptoBalance.depositAddress,
|
depositAddress = balance.cryptoBalance.depositAddress,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +197,21 @@ sealed class PaymentAccountStatusValue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates all balance data of a payment account, as returned by the `customer/me` endpoint.
|
||||||
|
*
|
||||||
|
* @property fiatBalance The fiat balance details.
|
||||||
|
* @property cryptoBalance The crypto balance details.
|
||||||
|
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap
|
||||||
|
* (excludes pending/locked funds).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Balance(
|
||||||
|
val fiatBalance: FiatBalance,
|
||||||
|
val cryptoBalance: CryptoBalance,
|
||||||
|
val availableForWithdrawal: SerializedBigDecimal,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the fiat balance of the payment account.
|
* Represents the fiat balance of the payment account.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ data class CustomerInfo(
|
||||||
val state: State,
|
val state: State,
|
||||||
val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
|
val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
|
||||||
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?,
|
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?,
|
||||||
|
val availableForWithdrawal: BigDecimal?,
|
||||||
) {
|
) {
|
||||||
enum class State {
|
enum class State {
|
||||||
NEW,
|
NEW,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ dependencies {
|
||||||
implementation(projects.domain.models)
|
implementation(projects.domain.models)
|
||||||
implementation(projects.domain.addressBook)
|
implementation(projects.domain.addressBook)
|
||||||
|
|
||||||
|
/** Common */
|
||||||
|
implementation(projects.common.ui)
|
||||||
|
|
||||||
/** Core modules */
|
/** Core modules */
|
||||||
implementation(projects.core.configToggles)
|
implementation(projects.core.configToggles)
|
||||||
implementation(projects.core.decompose)
|
implementation(projects.core.decompose)
|
||||||
|
|
@ -41,4 +44,7 @@ dependencies {
|
||||||
|
|
||||||
/** Other */
|
/** Other */
|
||||||
implementation(deps.kotlin.immutable.collections)
|
implementation(deps.kotlin.immutable.collections)
|
||||||
|
|
||||||
|
/** Tests */
|
||||||
|
testImplementation(projects.test.core)
|
||||||
}
|
}
|
||||||
|
|
@ -7,4 +7,12 @@ internal sealed class AddressBookRoute {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object List : AddressBookRoute()
|
data object List : AddressBookRoute()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if [contactId] is not null we should fetch existing contact
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EditContact(
|
||||||
|
val contactId: String? = null,
|
||||||
|
) : AddressBookRoute()
|
||||||
}
|
}
|
||||||
|
|
@ -9,11 +9,15 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
|
||||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||||
import com.arkivanov.decompose.router.stack.StackNavigation
|
import com.arkivanov.decompose.router.stack.StackNavigation
|
||||||
import com.arkivanov.decompose.router.stack.childStack
|
import com.arkivanov.decompose.router.stack.childStack
|
||||||
|
import com.arkivanov.decompose.router.stack.pop
|
||||||
|
import com.arkivanov.decompose.router.stack.pushNew
|
||||||
import com.tangem.core.decompose.context.AppComponentContext
|
import com.tangem.core.decompose.context.AppComponentContext
|
||||||
import com.tangem.core.decompose.context.childByContext
|
import com.tangem.core.decompose.context.childByContext
|
||||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
|
import com.tangem.domain.addressbook.model.ContactId
|
||||||
import com.tangem.features.addressbook.AddressBookComponent
|
import com.tangem.features.addressbook.AddressBookComponent
|
||||||
import com.tangem.features.addressbook.list.AddressBookListComponent
|
import com.tangem.features.addressbook.list.AddressBookListComponent
|
||||||
|
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
@ -22,6 +26,7 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||||
@Assisted context: AppComponentContext,
|
@Assisted context: AppComponentContext,
|
||||||
@Assisted private val params: AddressBookComponent.Params,
|
@Assisted private val params: AddressBookComponent.Params,
|
||||||
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
|
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
|
||||||
|
private val editContactComponentFactory: EditContactComponent.Factory,
|
||||||
) : AddressBookComponent, AppComponentContext by context {
|
) : AddressBookComponent, AppComponentContext by context {
|
||||||
|
|
||||||
private val navigation = StackNavigation<AddressBookRoute>()
|
private val navigation = StackNavigation<AddressBookRoute>()
|
||||||
|
|
@ -51,11 +56,16 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||||
context = childByContext(componentContext),
|
context = childByContext(componentContext),
|
||||||
params = AddressBookListComponent.Params(
|
params = AddressBookListComponent.Params(
|
||||||
onContactClick = { contactId ->
|
onContactClick = { contactId ->
|
||||||
// TODO [REDACTED_TASK_KEY] router.push(EditContact(contactId))
|
navigation.pushNew(AddressBookRoute.EditContact(contactId))
|
||||||
},
|
|
||||||
onAddContactClick = {
|
|
||||||
// TODO [REDACTED_TASK_KEY] router.push(AddContact)
|
|
||||||
},
|
},
|
||||||
|
onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
is AddressBookRoute.EditContact -> editContactComponentFactory.create(
|
||||||
|
context = childByContext(componentContext),
|
||||||
|
params = EditContactComponent.Params(
|
||||||
|
contactId = config.contactId?.let(::ContactId),
|
||||||
|
onBackClick = { navigation.pop() },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import com.tangem.features.addressbook.AddressBookComponent
|
||||||
import com.tangem.features.addressbook.component.DefaultAddressBookComponent
|
import com.tangem.features.addressbook.component.DefaultAddressBookComponent
|
||||||
import com.tangem.features.addressbook.list.AddressBookListComponent
|
import com.tangem.features.addressbook.list.AddressBookListComponent
|
||||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||||
|
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
|
||||||
|
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -23,4 +25,8 @@ internal interface AddressBookComponentModule {
|
||||||
fun bindAddressBookListComponentFactory(
|
fun bindAddressBookListComponentFactory(
|
||||||
factory: DefaultAddressBookListComponent.Factory,
|
factory: DefaultAddressBookListComponent.Factory,
|
||||||
): AddressBookListComponent.Factory
|
): AddressBookListComponent.Factory
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package com.tangem.features.addressbook.di
|
||||||
import com.tangem.core.decompose.di.ModelComponent
|
import com.tangem.core.decompose.di.ModelComponent
|
||||||
import com.tangem.core.decompose.model.Model
|
import com.tangem.core.decompose.model.Model
|
||||||
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
||||||
|
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -17,4 +18,9 @@ internal interface AddressBookModelModule {
|
||||||
@IntoMap
|
@IntoMap
|
||||||
@ClassKey(AddressBookListModel::class)
|
@ClassKey(AddressBookListModel::class)
|
||||||
fun bindAddressBookModel(model: AddressBookListModel): Model
|
fun bindAddressBookModel(model: AddressBookListModel): Model
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@ClassKey(EditContactModel::class)
|
||||||
|
fun bindEditContactModel(model: EditContactModel): Model
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
@Composable
|
||||||
override fun Content(modifier: Modifier) {
|
override fun Content(modifier: Modifier) {
|
||||||
val state by model.state.collectAsStateWithLifecycle()
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
when (state) {
|
when (val addressBookListUM = state) {
|
||||||
AddressBookListUM.Empty -> AddressBookEmptyScreen(
|
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
|
||||||
onAddContactClick = params.onAddContactClick,
|
tangemButtonUM = addressBookListUM.tangemButtonUM,
|
||||||
onBackClick = router::pop,
|
onBackClick = router::pop,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
package com.tangem.features.addressbook.list.contract
|
package com.tangem.features.addressbook.list.contract
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||||
import com.tangem.domain.addressbook.model.Contact
|
import com.tangem.domain.addressbook.model.Contact
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
internal sealed class AddressBookListUM {
|
internal sealed class AddressBookListUM {
|
||||||
|
|
||||||
data object Empty : AddressBookListUM()
|
data class Empty(
|
||||||
|
val tangemButtonUM: TangemButtonUM,
|
||||||
|
) : AddressBookListUM()
|
||||||
data class AddressList(val contacts: ImmutableList<Contact>) : AddressBookListUM()
|
data class AddressList(val contacts: ImmutableList<Contact>) : AddressBookListUM()
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,16 @@ package com.tangem.features.addressbook.list.model
|
||||||
|
|
||||||
import com.tangem.core.decompose.di.ModelScoped
|
import com.tangem.core.decompose.di.ModelScoped
|
||||||
import com.tangem.core.decompose.model.Model
|
import com.tangem.core.decompose.model.Model
|
||||||
|
import com.tangem.core.decompose.model.ParamsContainer
|
||||||
|
import com.tangem.core.ui.R
|
||||||
|
import com.tangem.core.ui.R.drawable.ic_plus_24
|
||||||
|
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
|
||||||
|
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||||
|
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||||
|
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||||
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
|
import com.tangem.features.addressbook.list.AddressBookListComponent
|
||||||
import com.tangem.features.addressbook.list.contract.AddressBookListUM
|
import com.tangem.features.addressbook.list.contract.AddressBookListUM
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -10,10 +20,24 @@ import javax.inject.Inject
|
||||||
|
|
||||||
@ModelScoped
|
@ModelScoped
|
||||||
internal class AddressBookListModel @Inject constructor(
|
internal class AddressBookListModel @Inject constructor(
|
||||||
|
paramsContainer: ParamsContainer,
|
||||||
override val dispatchers: CoroutineDispatcherProvider,
|
override val dispatchers: CoroutineDispatcherProvider,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
|
private val params = paramsContainer.require<AddressBookListComponent.Params>()
|
||||||
|
|
||||||
val state: StateFlow<AddressBookListUM> = MutableStateFlow(
|
val state: StateFlow<AddressBookListUM> = MutableStateFlow(
|
||||||
AddressBookListUM.Empty,
|
AddressBookListUM.Empty(
|
||||||
|
tangemButtonUM = TangemButtonUM(
|
||||||
|
text = TextReference.Res(R.string.address_book_new_contact),
|
||||||
|
tangemIconUM = TangemIconUM.Icon(
|
||||||
|
iconRes = ic_plus_24,
|
||||||
|
tintReference = { TangemTheme.colors3.text.inverse.primary },
|
||||||
|
),
|
||||||
|
iconPosition = TangemButtonIconPosition.End,
|
||||||
|
type = TangemButtonType.Primary,
|
||||||
|
onClick = params.onAddContactClick,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -14,17 +14,21 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tangem.core.ui.R
|
import com.tangem.core.ui.R
|
||||||
import com.tangem.core.ui.components.PrimaryButtonIconEnd
|
import com.tangem.core.ui.ds.button.PrimaryTangemButton
|
||||||
|
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
|
||||||
|
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||||
|
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||||
import com.tangem.core.ui.ds2.button.TangemButton
|
import com.tangem.core.ui.ds2.button.TangemButton
|
||||||
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddressBookEmptyScreen(
|
internal fun AddressBookEmptyScreen(
|
||||||
onAddContactClick: () -> Unit,
|
tangemButtonUM: TangemButtonUM,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -40,19 +44,17 @@ internal fun AddressBookEmptyScreen(
|
||||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
|
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
|
||||||
onClick = onBackClick,
|
onClick = onBackClick,
|
||||||
size = TangemButton.Size.X11,
|
size = TangemButton.Size.X11,
|
||||||
variant = TangemButton.Variant.Secondary,
|
variant = TangemButton.Variant.Material,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
NoContactInfo()
|
NoContactInfo()
|
||||||
PrimaryButtonIconEnd(
|
PrimaryTangemButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||||
text = stringResourceSafe(R.string.address_book_add_contact),
|
buttonUM = tangemButtonUM,
|
||||||
iconResId = R.drawable.ic_plus_24,
|
|
||||||
onClick = onAddContactClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,5 +106,14 @@ private fun ContactImage() {
|
||||||
@Preview(showBackground = true, widthDp = 360)
|
@Preview(showBackground = true, widthDp = 360)
|
||||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
private fun Preview_AddressBookEmptyScreen() {
|
private fun Preview_AddressBookEmptyScreen() {
|
||||||
AddressBookEmptyScreen(onAddContactClick = {}, onBackClick = {})
|
AddressBookEmptyScreen(
|
||||||
|
tangemButtonUM = TangemButtonUM(
|
||||||
|
text = TextReference.Res(R.string.address_book_new_contact),
|
||||||
|
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24),
|
||||||
|
iconPosition = TangemButtonIconPosition.End,
|
||||||
|
type = TangemButtonType.Secondary,
|
||||||
|
onClick = {},
|
||||||
|
),
|
||||||
|
onBackClick = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
implementation(projects.domain.pushNotificationPreferences)
|
||||||
|
|
||||||
/* AndroidX */
|
/* AndroidX */
|
||||||
|
implementation(deps.androidx.activity.compose)
|
||||||
implementation(deps.lifecycle.compose)
|
implementation(deps.lifecycle.compose)
|
||||||
|
|
||||||
/* Compose */
|
/* Compose */
|
||||||
|
|
@ -50,7 +51,7 @@ dependencies {
|
||||||
implementation(deps.kotlin.immutable.collections)
|
implementation(deps.kotlin.immutable.collections)
|
||||||
|
|
||||||
/* Tests */
|
/* Tests */
|
||||||
testImplementation(deps.test.junit)
|
testImplementation(deps.test.junit5)
|
||||||
testImplementation(deps.test.mockk)
|
testImplementation(deps.test.mockk)
|
||||||
testImplementation(deps.test.coroutine)
|
testImplementation(deps.test.coroutine)
|
||||||
testImplementation(deps.test.truth)
|
testImplementation(deps.test.truth)
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.tangem.features.pushnotificationsettings.impl.entity
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.tangem.core.ui.event.StateEvent
|
|
||||||
import kotlinx.collections.immutable.PersistentList
|
import kotlinx.collections.immutable.PersistentList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
@ -12,7 +11,6 @@ internal sealed interface PushNotificationSettingsUM {
|
||||||
data class Content(
|
data class Content(
|
||||||
val banner: AllowPushNotificationsBannerUM?,
|
val banner: AllowPushNotificationsBannerUM?,
|
||||||
val toggles: PersistentList<ToggleUM>,
|
val toggles: PersistentList<ToggleUM>,
|
||||||
val requestPermissionEvent: StateEvent<Unit>,
|
|
||||||
val onMoreInfoClick: () -> Unit,
|
val onMoreInfoClick: () -> Unit,
|
||||||
) : PushNotificationSettingsUM
|
) : PushNotificationSettingsUM
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ import com.tangem.core.decompose.model.ParamsContainer
|
||||||
import com.tangem.core.decompose.ui.UiMessageSender
|
import com.tangem.core.decompose.ui.UiMessageSender
|
||||||
import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider
|
import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider
|
||||||
import com.tangem.core.navigation.settings.SettingsManager
|
import com.tangem.core.navigation.settings.SettingsManager
|
||||||
import com.tangem.core.ui.event.StateEvent
|
|
||||||
import com.tangem.core.ui.event.consumedEvent
|
|
||||||
import com.tangem.core.ui.event.triggeredEvent
|
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.core.ui.message.DialogMessage
|
import com.tangem.core.ui.message.DialogMessage
|
||||||
|
|
@ -38,6 +35,8 @@ import com.tangem.utils.coroutines.saveIn
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.collections.immutable.PersistentList
|
import kotlinx.collections.immutable.PersistentList
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -45,6 +44,7 @@ import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -69,7 +69,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
|
||||||
|
|
||||||
private val loadState = MutableStateFlow<LoadState>(LoadState.Loading)
|
private val loadState = MutableStateFlow<LoadState>(LoadState.Loading)
|
||||||
private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled())
|
private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled())
|
||||||
private val pendingRequest = MutableStateFlow<StateEvent<Unit>>(consumedEvent())
|
|
||||||
|
|
||||||
private var pendingPermissionToggle: ToggleSpec? = null
|
private var pendingPermissionToggle: ToggleSpec? = null
|
||||||
private val preferencesJobHolder = JobHolder()
|
private val preferencesJobHolder = JobHolder()
|
||||||
|
|
@ -77,15 +76,19 @@ internal class PushNotificationSettingsModel @Inject constructor(
|
||||||
private val cachedPrefs: WalletPushNotificationPreferences?
|
private val cachedPrefs: WalletPushNotificationPreferences?
|
||||||
get() = (loadState.value as? LoadState.Content)?.prefs
|
get() = (loadState.value as? LoadState.Content)?.prefs
|
||||||
|
|
||||||
|
private val requestPushPermissionChannel = Channel<Unit>(Channel.BUFFERED)
|
||||||
|
|
||||||
|
/** One-shot requests to launch the system push permission prompt, consumed by the component. */
|
||||||
|
val requestPushPermission: Flow<Unit> = requestPushPermissionChannel.receiveAsFlow()
|
||||||
|
|
||||||
val uiState: StateFlow<PushNotificationSettingsUM> = combine(
|
val uiState: StateFlow<PushNotificationSettingsUM> = combine(
|
||||||
loadState,
|
loadState,
|
||||||
osNotificationsEnabled,
|
osNotificationsEnabled,
|
||||||
pendingRequest,
|
) { load, osEnabled ->
|
||||||
) { load, osEnabled, request ->
|
|
||||||
when (load) {
|
when (load) {
|
||||||
is LoadState.Failed -> PushNotificationSettingsUM.Error(onRetryClick = ::onRetry)
|
is LoadState.Failed -> PushNotificationSettingsUM.Error(onRetryClick = ::onRetry)
|
||||||
is LoadState.Loading -> PushNotificationSettingsUM.Loading
|
is LoadState.Loading -> PushNotificationSettingsUM.Loading
|
||||||
is LoadState.Content -> buildContent(prefs = load.prefs, osEnabled = osEnabled, request = request)
|
is LoadState.Content -> buildContent(prefs = load.prefs, osEnabled = osEnabled)
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = modelScope,
|
scope = modelScope,
|
||||||
|
|
@ -117,7 +120,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPermissionResult(isGranted: Boolean) {
|
fun onPermissionResult(isGranted: Boolean) {
|
||||||
pendingRequest.value = consumedEvent()
|
|
||||||
val tapped = pendingPermissionToggle
|
val tapped = pendingPermissionToggle
|
||||||
pendingPermissionToggle = null
|
pendingPermissionToggle = null
|
||||||
modelScope.launch {
|
modelScope.launch {
|
||||||
|
|
@ -145,12 +147,10 @@ internal class PushNotificationSettingsModel @Inject constructor(
|
||||||
private fun buildContent(
|
private fun buildContent(
|
||||||
prefs: WalletPushNotificationPreferences,
|
prefs: WalletPushNotificationPreferences,
|
||||||
osEnabled: Boolean,
|
osEnabled: Boolean,
|
||||||
request: StateEvent<Unit>,
|
|
||||||
): PushNotificationSettingsUM.Content {
|
): PushNotificationSettingsUM.Content {
|
||||||
return PushNotificationSettingsUM.Content(
|
return PushNotificationSettingsUM.Content(
|
||||||
banner = buildBanner(prefs = prefs, osEnabled = osEnabled),
|
banner = buildBanner(prefs = prefs, osEnabled = osEnabled),
|
||||||
toggles = buildToggles(prefs),
|
toggles = buildToggles(prefs),
|
||||||
requestPermissionEvent = request,
|
|
||||||
onMoreInfoClick = ::onMoreInfoClick,
|
onMoreInfoClick = ::onMoreInfoClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +190,7 @@ internal class PushNotificationSettingsModel @Inject constructor(
|
||||||
|
|
||||||
private fun requestPermission(tapped: ToggleSpec? = null) {
|
private fun requestPermission(tapped: ToggleSpec? = null) {
|
||||||
pendingPermissionToggle = tapped
|
pendingPermissionToggle = tapped
|
||||||
pendingRequest.value = triggeredEvent(data = Unit, onConsume = ::onPermissionEventConsumed)
|
requestPushPermissionChannel.trySend(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onBannerCtaClick() {
|
private fun onBannerCtaClick() {
|
||||||
|
|
@ -219,10 +219,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
|
||||||
applyOptimisticToggle(spec, newValue)
|
applyOptimisticToggle(spec, newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPermissionEventConsumed() {
|
|
||||||
pendingRequest.value = consumedEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyOptimisticToggle(spec: ToggleSpec, newValue: Boolean) {
|
private fun applyOptimisticToggle(spec: ToggleSpec, newValue: Boolean) {
|
||||||
val current = cachedPrefs ?: return
|
val current = cachedPrefs ?: return
|
||||||
loadState.value = LoadState.Content(current.withCategory(spec.category, newValue))
|
loadState.value = LoadState.Content(current.withCategory(spec.category, newValue))
|
||||||
|
|
|
||||||
|
|
@ -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.flow.flow
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
class PushNotificationSettingsModelTest {
|
class PushNotificationSettingsModelTest {
|
||||||
|
|
@ -204,13 +204,15 @@ class PushNotificationSettingsModelTest {
|
||||||
val model = model(osEnabled = false, preferencesFlow = flow)
|
val model = model(osEnabled = false, preferencesFlow = flow)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
|
model.requestPushPermission.test {
|
||||||
.toggles.first { it.id == ToggleId.OffersUpdates }
|
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
|
||||||
offers.onCheckedChange(true)
|
.toggles.first { it.id == ToggleId.OffersUpdates }
|
||||||
advanceUntilIdle()
|
offers.onCheckedChange(true)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
val content = model.uiState.value as PushNotificationSettingsUM.Content
|
awaitItem()
|
||||||
assertThat(content.requestPermissionEvent.javaClass.simpleName).isEqualTo("Triggered")
|
expectNoEvents()
|
||||||
|
}
|
||||||
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
|
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,12 +226,14 @@ class PushNotificationSettingsModelTest {
|
||||||
val banner = requireNotNull(
|
val banner = requireNotNull(
|
||||||
(model.uiState.value as PushNotificationSettingsUM.Content).banner,
|
(model.uiState.value as PushNotificationSettingsUM.Content).banner,
|
||||||
)
|
)
|
||||||
banner.onOpenSettingsClick()
|
model.requestPushPermission.test {
|
||||||
advanceUntilIdle()
|
banner.onOpenSettingsClick()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// The banner CTA opens system settings directly and never asks for the permission.
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
verify(exactly = 1) { settingsManager.openAppNotificationSettings() }
|
verify(exactly = 1) { settingsManager.openAppNotificationSettings() }
|
||||||
val refreshed = model.uiState.value as PushNotificationSettingsUM.Content
|
|
||||||
assertThat(refreshed.requestPermissionEvent.javaClass.simpleName).isEqualTo("Consumed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -271,7 +271,7 @@ internal class SendDestinationModel @Inject constructor(
|
||||||
private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? {
|
private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? {
|
||||||
val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null
|
val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null
|
||||||
val (paymentAccountAddress, currency) = when (val status = this.value) {
|
val (paymentAccountAddress, currency) = when (val status = this.value) {
|
||||||
is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress to status.cryptoCurrency
|
is PaymentAccountStatusValue.Loaded -> status.balance.cryptoBalance.depositAddress to status.cryptoCurrency
|
||||||
else -> return null
|
else -> return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor(
|
||||||
?.takeIf { it.period == TangemPayCardLimitPeriod.DAY }
|
?.takeIf { it.period == TangemPayCardLimitPeriod.DAY }
|
||||||
?.amount
|
?.amount
|
||||||
|
|
||||||
val currency = getJavaCurrencyByCode(status.currencyCode)
|
val currency = getJavaCurrencyByCode(status.balance.fiatBalance.currency)
|
||||||
uiState.update { state ->
|
uiState.update { state ->
|
||||||
val amount = if (index == 0) {
|
val amount = if (index == 0) {
|
||||||
currentLimit?.stripTrailingZeros()?.toPlainString().orEmpty()
|
currentLimit?.stripTrailingZeros()?.toPlainString().orEmpty()
|
||||||
|
|
|
||||||
|
|
@ -118,8 +118,9 @@ internal class TangemPayCardPageModel @Inject constructor(
|
||||||
val dailyLimitState = if (limit != null) {
|
val dailyLimitState = if (limit != null) {
|
||||||
TangemPayDailyLimitBlockState.Content(
|
TangemPayDailyLimitBlockState.Content(
|
||||||
limit = limit.amount.format {
|
limit = limit.amount.format {
|
||||||
val symbol = getJavaCurrencyByCode(status.currencyCode).symbol
|
val currencyCode = status.balance.fiatBalance.currency
|
||||||
fiat(status.currencyCode, symbol).optionalDecimals()
|
val symbol = getJavaCurrencyByCode(currencyCode).symbol
|
||||||
|
fiat(currencyCode, symbol).optionalDecimals()
|
||||||
},
|
},
|
||||||
onChangeClick = ::onClickLimitChange,
|
onChangeClick = ::onClickLimitChange,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||||
import com.tangem.domain.models.pay.TangemPayCardState
|
import com.tangem.domain.models.pay.TangemPayCardState
|
||||||
|
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||||
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
||||||
import com.tangem.domain.pay.model.TangemPayCardBalance
|
|
||||||
import com.tangem.domain.pay.model.TangemPayTopUpData
|
import com.tangem.domain.pay.model.TangemPayTopUpData
|
||||||
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
|
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
|
||||||
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
|
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
|
||||||
|
|
@ -40,8 +40,6 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType
|
||||||
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
|
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
|
||||||
import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
|
import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
|
||||||
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
|
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
|
||||||
import com.tangem.features.tangempay.model.listener.CardDetailsEvent
|
|
||||||
import com.tangem.features.tangempay.model.listener.CardDetailsEventListener
|
|
||||||
import com.tangem.features.tangempay.model.transformers.*
|
import com.tangem.features.tangempay.model.transformers.*
|
||||||
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
|
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
|
||||||
import com.tangem.features.tangempay.utils.*
|
import com.tangem.features.tangempay.utils.*
|
||||||
|
|
@ -52,7 +50,6 @@ import com.tangem.utils.coroutines.JobHolder
|
||||||
import com.tangem.utils.coroutines.saveIn
|
import com.tangem.utils.coroutines.saveIn
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import com.tangem.utils.transformer.update
|
import com.tangem.utils.transformer.update
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -70,33 +67,36 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
private val cardDetailsRepository: TangemPayCardDetailsRepository,
|
private val cardDetailsRepository: TangemPayCardDetailsRepository,
|
||||||
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
|
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
|
||||||
private val uiMessageSender: UiMessageSender,
|
private val uiMessageSender: UiMessageSender,
|
||||||
private val cardDetailsEventListener: CardDetailsEventListener,
|
|
||||||
private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener,
|
private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener,
|
||||||
private val tangemPayWithdrawRepository: TangemPayWithdrawRepository,
|
private val tangemPayWithdrawRepository: TangemPayWithdrawRepository,
|
||||||
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
|
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
|
||||||
private val expressTransactionsEventListener: ExpressTransactionsEventListener,
|
private val expressTransactionsEventListener: ExpressTransactionsEventListener,
|
||||||
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
|
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
|
||||||
|
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
|
||||||
) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener {
|
) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener {
|
||||||
|
|
||||||
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
|
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
|
||||||
|
|
||||||
private val userWalletId = params.initialStatus.userWalletId
|
|
||||||
private val isTangemPayDeactivated = params.initialStatus.isDeactivated
|
private val isTangemPayDeactivated = params.initialStatus.isDeactivated
|
||||||
private val loaded: PaymentAccountStatusValue.Loaded? =
|
|
||||||
params.initialStatus.value as? PaymentAccountStatusValue.Loaded
|
|
||||||
private val firstCard = loaded?.cards?.firstOrNull()
|
|
||||||
val cryptoCurrency: CryptoCurrency = params.initialStatus.cryptoCurrency
|
|
||||||
|
|
||||||
private val initialCardFrozenState: TangemPayCardFrozenState = when {
|
private val initialCard = params.initialStatus.ifLoadedOrNull { it.cards.firstOrNull() }
|
||||||
firstCard == null -> TangemPayCardFrozenState.Unfrozen
|
|
||||||
else -> firstCard.frozenState
|
private val currentStatus = MutableStateFlow(params.initialStatus)
|
||||||
}
|
|
||||||
|
private val userWalletId
|
||||||
|
get() = currentStatus.value.userWalletId
|
||||||
|
|
||||||
|
val cryptoCurrency
|
||||||
|
get() = currentStatus.value.cryptoCurrency
|
||||||
|
|
||||||
private val stateFactory = TangemPayDetailsStateFactory(
|
private val stateFactory = TangemPayDetailsStateFactory(
|
||||||
onBack = router::pop,
|
onBack = router::pop,
|
||||||
onOpenMenu = ::onOpenMenu,
|
onOpenMenu = ::onOpenMenu,
|
||||||
intents = this,
|
intents = this,
|
||||||
cardFrozenState = initialCardFrozenState,
|
cardFrozenState = when {
|
||||||
|
initialCard == null -> TangemPayCardFrozenState.Unfrozen
|
||||||
|
else -> initialCard.frozenState
|
||||||
|
},
|
||||||
isRedesignEnabled = isRedesignEnabled(),
|
isRedesignEnabled = isRedesignEnabled(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -104,39 +104,49 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
field = MutableStateFlow(
|
field = MutableStateFlow(
|
||||||
stateFactory.getInitialState(
|
stateFactory.getInitialState(
|
||||||
isTangemPayDeactivated = isTangemPayDeactivated,
|
isTangemPayDeactivated = isTangemPayDeactivated,
|
||||||
cardNumberEnd = firstCard?.lastDigits.orEmpty(),
|
cardNumberEnd = initialCard?.lastDigits.orEmpty(),
|
||||||
isReissuing = firstCard == null || firstCard.state != TangemPayCardState.Active,
|
isReissuing = initialCard == null || initialCard.state != TangemPayCardState.Active,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val refreshStateJobHolder = JobHolder()
|
private val refreshStateJobHolder = JobHolder()
|
||||||
private val fetchBalanceJobHolder = JobHolder()
|
|
||||||
private val addToWalletBannerJobHolder = JobHolder()
|
private val addToWalletBannerJobHolder = JobHolder()
|
||||||
|
|
||||||
private var balance: TangemPayCardBalance? = null
|
|
||||||
|
|
||||||
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
|
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
analytics.send(TangemPayAnalyticsEvents.MainScreenOpened())
|
analytics.send(TangemPayAnalyticsEvents.MainScreenOpened())
|
||||||
handleBalanceHiding()
|
handleBalanceHiding()
|
||||||
fetchBalance()
|
|
||||||
if (!isTangemPayDeactivated && firstCard != null) {
|
|
||||||
subscribeToCardFrozenState(firstCard.id)
|
|
||||||
fetchAddToWalletBanner()
|
|
||||||
|
|
||||||
paymentAccountStatusSupplier.invoke(userWalletId)
|
val statusFlow = paymentAccountStatusSupplier.invoke(userWalletId)
|
||||||
.map { it.value }
|
.onEach { status -> currentStatus.update { status } }
|
||||||
|
.map { it.value }
|
||||||
|
|
||||||
|
if (isTangemPayDeactivated) {
|
||||||
|
statusFlow
|
||||||
|
.filterIsInstance<PaymentAccountStatusValue.Deactivated>()
|
||||||
|
.onEach { state ->
|
||||||
|
uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance))
|
||||||
|
}
|
||||||
|
.launchIn(modelScope)
|
||||||
|
} else {
|
||||||
|
if (initialCard != null) {
|
||||||
|
subscribeToCardFrozenState(initialCard.id)
|
||||||
|
}
|
||||||
|
fetchAddToWalletBanner()
|
||||||
|
statusFlow
|
||||||
.filterIsInstance<PaymentAccountStatusValue.Loaded>()
|
.filterIsInstance<PaymentAccountStatusValue.Loaded>()
|
||||||
.filter { it.source == StatusSource.ACTUAL }
|
.filter { it.source == StatusSource.ACTUAL }
|
||||||
.onEach { state ->
|
.onEach { state ->
|
||||||
val card = state.cards.firstOrNull() ?: return@onEach
|
uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance))
|
||||||
uiState.update(
|
state.cards.firstOrNull()?.let { card ->
|
||||||
TangemPayCardDataTransformer(
|
uiState.update(
|
||||||
card = card,
|
TangemPayCardDataTransformer(
|
||||||
onCardClick = { onCardClick() },
|
card = card,
|
||||||
),
|
onCardClick = { onCardClick() },
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.launchIn(modelScope)
|
.launchIn(modelScope)
|
||||||
}
|
}
|
||||||
|
|
@ -165,17 +175,16 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
|
|
||||||
override fun onClickAddFunds() {
|
override fun onClickAddFunds() {
|
||||||
analytics.send(TangemPayAnalyticsEvents.AddFundsClicked())
|
analytics.send(TangemPayAnalyticsEvents.AddFundsClicked())
|
||||||
val currentBalance = balance
|
val balance = currentStatus.value.balanceOrNull()
|
||||||
val depositAddress = currentBalance?.depositAddress
|
if (balance == null) {
|
||||||
if (currentBalance == null || depositAddress == null) {
|
|
||||||
showBottomSheetError(TangemPayDetailsErrorType.Receive)
|
showBottomSheetError(TangemPayDetailsErrorType.Receive)
|
||||||
} else {
|
} else {
|
||||||
bottomSheetNavigation.activate(
|
bottomSheetNavigation.activate(
|
||||||
TangemPayDetailsNavigation.AddFunds(
|
TangemPayDetailsNavigation.AddFunds(
|
||||||
walletId = userWalletId,
|
walletId = userWalletId,
|
||||||
fiatBalance = currentBalance.availableForWithdrawal,
|
fiatBalance = balance.availableForWithdrawal,
|
||||||
cryptoBalance = currentBalance.availableForWithdrawal,
|
cryptoBalance = balance.availableForWithdrawal,
|
||||||
depositAddress = depositAddress,
|
depositAddress = balance.cryptoBalance.depositAddress,
|
||||||
cryptoCurrency = cryptoCurrency,
|
cryptoCurrency = cryptoCurrency,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -184,12 +193,6 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
|
|
||||||
override fun onClickWithdraw() {
|
override fun onClickWithdraw() {
|
||||||
analytics.send(TangemPayAnalyticsEvents.WithdrawClicked())
|
analytics.send(TangemPayAnalyticsEvents.WithdrawClicked())
|
||||||
val currentBalance = balance
|
|
||||||
val depositAddress = currentBalance?.depositAddress
|
|
||||||
if (currentBalance == null || depositAddress == null) {
|
|
||||||
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modelScope.launch {
|
modelScope.launch {
|
||||||
val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWalletId)
|
val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWalletId)
|
||||||
if (hasActiveWithdrawal) {
|
if (hasActiveWithdrawal) {
|
||||||
|
|
@ -197,18 +200,19 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
} else {
|
} else {
|
||||||
uiMessageSender.send(
|
uiMessageSender.send(
|
||||||
message = TangemPayMessagesFactory.createWithdrawWarning(
|
message = TangemPayMessagesFactory.createWithdrawWarning(
|
||||||
onGotItClick = { onConfirmWithdrawal(cryptoCurrency, currentBalance, depositAddress) },
|
onGotItClick = { onConfirmWithdrawal(cryptoCurrency) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onConfirmWithdrawal(
|
private fun onConfirmWithdrawal(currency: CryptoCurrency) {
|
||||||
currency: CryptoCurrency,
|
val balance = currentStatus.value.balanceOrNull()
|
||||||
currentBalance: TangemPayCardBalance,
|
if (balance == null) {
|
||||||
depositAddress: String,
|
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
|
||||||
) {
|
return
|
||||||
|
}
|
||||||
router.push(
|
router.push(
|
||||||
AppRoute.Swap(
|
AppRoute.Swap(
|
||||||
cryptoCurrency = currency,
|
cryptoCurrency = currency,
|
||||||
|
|
@ -216,27 +220,15 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
|
||||||
currencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
|
currencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
|
||||||
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
tangemPayInput = AppRoute.Swap.TangemPayInput(
|
||||||
cryptoAmount = currentBalance.availableForWithdrawal,
|
cryptoAmount = balance.availableForWithdrawal,
|
||||||
fiatAmount = currentBalance.availableForWithdrawal,
|
fiatAmount = balance.availableForWithdrawal,
|
||||||
depositAddress = depositAddress,
|
depositAddress = balance.cryptoBalance.depositAddress,
|
||||||
isWithdrawal = true,
|
isWithdrawal = true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchBalance(): Job {
|
|
||||||
return modelScope.launch {
|
|
||||||
val result = try {
|
|
||||||
cardDetailsRepository.getCardBalance(userWalletId).onRight { balance = it }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TangemLogger.e("Error", e)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
uiState.update(transformer = DetailsBalanceTransformer(balance = result))
|
|
||||||
}.saveIn(fetchBalanceJobHolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchAddToWalletBanner() {
|
private fun fetchAddToWalletBanner() {
|
||||||
modelScope.launch {
|
modelScope.launch {
|
||||||
val isDone = try {
|
val isDone = try {
|
||||||
|
|
@ -263,7 +255,7 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
|
|
||||||
override fun onContactSupportClicked() {
|
override fun onContactSupportClicked() {
|
||||||
analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay))
|
analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay))
|
||||||
val customerId = loaded?.customerId ?: return
|
val customerId = currentStatus.value.ifLoadedOrNull { it.customerId } ?: return
|
||||||
modelScope.launch {
|
modelScope.launch {
|
||||||
sendFeedbackEmailUseCase.invoke(
|
sendFeedbackEmailUseCase.invoke(
|
||||||
type = FeedbackEmailType.Visa.FeatureIsBeta(
|
type = FeedbackEmailType.Visa.FeatureIsBeta(
|
||||||
|
|
@ -277,10 +269,9 @@ internal class TangemPayDetailsModel @Inject constructor(
|
||||||
override fun onRefreshSwipe(refreshState: ShowRefreshState) {
|
override fun onRefreshSwipe(refreshState: ShowRefreshState) {
|
||||||
modelScope.launch {
|
modelScope.launch {
|
||||||
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value))
|
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value))
|
||||||
cardDetailsEventListener.send(CardDetailsEvent.Hide)
|
paymentAccountStatusFetcher.invoke(userWalletId)
|
||||||
expressTransactionsEventListener.send(ExpressTransactionsEvent.Update)
|
expressTransactionsEventListener.send(ExpressTransactionsEvent.Update)
|
||||||
txHistoryUpdateListener.triggerUpdate()
|
txHistoryUpdateListener.triggerUpdate()
|
||||||
fetchBalance().join()
|
|
||||||
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false))
|
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false))
|
||||||
}.saveIn(refreshStateJobHolder)
|
}.saveIn(refreshStateJobHolder)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,32 @@
|
||||||
package com.tangem.features.tangempay.model.transformers
|
package com.tangem.features.tangempay.model.transformers
|
||||||
|
|
||||||
import arrow.core.Either
|
|
||||||
import com.tangem.core.error.UniversalError
|
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||||
import com.tangem.core.ui.format.bigdecimal.formatStyled
|
import com.tangem.core.ui.format.bigdecimal.formatStyled
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.domain.pay.model.TangemPayCardBalance
|
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||||
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
|
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
|
||||||
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
|
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
|
||||||
import com.tangem.utils.transformer.Transformer
|
import com.tangem.utils.transformer.Transformer
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import java.util.Currency
|
import java.util.Currency
|
||||||
|
|
||||||
internal class DetailsBalanceTransformer(
|
internal class DetailsBalanceTransformer(
|
||||||
private val balance: Either<UniversalError, TangemPayCardBalance>,
|
private val fiatBalance: PaymentAccountStatusValue.FiatBalance,
|
||||||
) : Transformer<TangemPayDetailsUM> {
|
) : Transformer<TangemPayDetailsUM> {
|
||||||
|
|
||||||
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
|
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
|
||||||
val balance = when (balance) {
|
val balance = TangemPayDetailsBalanceBlockState.Content(
|
||||||
is Either.Left<UniversalError> -> {
|
isBalanceFlickering = false,
|
||||||
TangemPayDetailsBalanceBlockState.Error(
|
fiatBalance = getFiatBalanceText(fiatBalance),
|
||||||
actionButtons = persistentListOf(),
|
actionButtons = prevState.balanceBlockState.actionButtons,
|
||||||
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
|
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
is Either.Right<TangemPayCardBalance> -> {
|
|
||||||
TangemPayDetailsBalanceBlockState.Content(
|
|
||||||
isBalanceFlickering = false,
|
|
||||||
fiatBalance = getFiatBalanceText(balance.value),
|
|
||||||
actionButtons = prevState.balanceBlockState.actionButtons,
|
|
||||||
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prevState.copy(balanceBlockState = balance)
|
return prevState.copy(balanceBlockState = balance)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFiatBalanceText(balance: TangemPayCardBalance): TextReference {
|
private fun getFiatBalanceText(fiatBalance: PaymentAccountStatusValue.FiatBalance): TextReference {
|
||||||
val currency = Currency.getInstance(balance.currencyCode)
|
val currency = Currency.getInstance(fiatBalance.currency)
|
||||||
return balance.fiatBalance.formatStyled {
|
return fiatBalance.availableBalance.formatStyled {
|
||||||
fiat(
|
fiat(
|
||||||
fiatCurrencyCode = currency.currencyCode,
|
fiatCurrencyCode = currency.currencyCode,
|
||||||
fiatCurrencySymbol = currency.symbol,
|
fiatCurrencySymbol = currency.symbol,
|
||||||
|
|
|
||||||
|
|
@ -108,14 +108,11 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M
|
||||||
@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries")
|
@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries")
|
||||||
@Composable
|
@Composable
|
||||||
private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) {
|
private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) {
|
||||||
val isRenaming = state.displayNameState is DisplayNameState.Editing
|
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
TangemPayCardBackground(
|
TangemPayCardBackground(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.zIndex(0f),
|
.zIndex(0f),
|
||||||
isRenaming = isRenaming,
|
|
||||||
cardFrozenState = state.cardFrozenState,
|
cardFrozenState = state.cardFrozenState,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -147,6 +144,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CardNumberBlock(
|
CardNumberBlock(
|
||||||
|
isRenaming = state.displayNameState is DisplayNameState.Editing,
|
||||||
numberShort = state.numberShort,
|
numberShort = state.numberShort,
|
||||||
cardNumberRef = cardNumberRef,
|
cardNumberRef = cardNumberRef,
|
||||||
)
|
)
|
||||||
|
|
@ -203,11 +201,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TangemPayCardBackground(
|
private fun TangemPayCardBackground(cardFrozenState: TangemPayCardFrozenState, modifier: Modifier = Modifier) {
|
||||||
isRenaming: Boolean,
|
|
||||||
cardFrozenState: TangemPayCardFrozenState,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val isFrozen = cardFrozenState == TangemPayCardFrozenState.Frozen
|
val isFrozen = cardFrozenState == TangemPayCardFrozenState.Frozen
|
||||||
val freezeProgress by animateFloatAsState(
|
val freezeProgress by animateFloatAsState(
|
||||||
targetValue = if (isFrozen) 1f else 0f,
|
targetValue = if (isFrozen) 1f else 0f,
|
||||||
|
|
@ -225,14 +219,6 @@ private fun TangemPayCardBackground(
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isRenaming && LocalVisaRedesignEnabled.current) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(CardBackgroundColor.copy(alpha = 0.8f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFrozen || freezeProgress > 0f) {
|
if (isFrozen || freezeProgress > 0f) {
|
||||||
Image(
|
Image(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -344,6 +330,7 @@ private fun CardTopBlock(modifier: Modifier = Modifier) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConstraintLayoutScope.CardNumberBlock(
|
private fun ConstraintLayoutScope.CardNumberBlock(
|
||||||
|
isRenaming: Boolean,
|
||||||
numberShort: String,
|
numberShort: String,
|
||||||
cardNumberRef: ConstrainedLayoutReference,
|
cardNumberRef: ConstrainedLayoutReference,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -352,7 +339,11 @@ private fun ConstraintLayoutScope.CardNumberBlock(
|
||||||
Text(
|
Text(
|
||||||
text = numberShort,
|
text = numberShort,
|
||||||
style = TangemTheme.typography3.body.medium,
|
style = TangemTheme.typography3.body.medium,
|
||||||
color = TangemTheme.colors3.text.staticDark.primary,
|
color = if (isRenaming) {
|
||||||
|
TangemTheme.colors3.text.staticDark.secondary
|
||||||
|
} else {
|
||||||
|
TangemTheme.colors3.text.staticDark.primary
|
||||||
|
},
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.constrainAs(cardNumberRef) {
|
.constrainAs(cardNumberRef) {
|
||||||
start.linkTo(parent.start)
|
start.linkTo(parent.start)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import com.tangem.core.ui.extensions.resolveReference
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreview
|
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||||
import com.tangem.core.ui.res.generated.icons.Icons
|
import com.tangem.core.ui.res.generated.icons.Icons
|
||||||
import com.tangem.core.ui.res.generated.icons.ic_cross_20
|
import com.tangem.core.ui.res.generated.icons.ic_cross_20
|
||||||
import com.tangem.core.ui.test.TangemPayTestTags
|
import com.tangem.core.ui.test.TangemPayTestTags
|
||||||
|
|
@ -54,7 +54,7 @@ internal fun TangemPayChangePinScreenV2(
|
||||||
.statusBarsPadding(),
|
.statusBarsPadding(),
|
||||||
) {
|
) {
|
||||||
TangemTopBar(
|
TangemTopBar(
|
||||||
title = resourceReference(R.string.tangempay_set_pin_title),
|
title = resourceReference(R.string.visa_onboarding_pin_code_title),
|
||||||
endContent = {
|
endContent = {
|
||||||
TangemButton(
|
TangemButton(
|
||||||
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_cross_20),
|
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_cross_20),
|
||||||
|
|
@ -73,7 +73,7 @@ internal fun TangemPayChangePinScreenV2(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResourceSafe(R.string.tangempay_set_pin_header),
|
text = stringResourceSafe(R.string.visa_onboarding_pin_code_description),
|
||||||
style = TangemTheme.typography3.body.medium,
|
style = TangemTheme.typography3.body.medium,
|
||||||
color = TangemTheme.colors3.text.secondary,
|
color = TangemTheme.colors3.text.secondary,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
|
@ -94,6 +94,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
|
||||||
PinCode(
|
PinCode(
|
||||||
|
isError = state.error != null,
|
||||||
value = state.pinCode,
|
value = state.pinCode,
|
||||||
onValueChange = state.onPinCodeChange,
|
onValueChange = state.onPinCodeChange,
|
||||||
focusRequester = focusRequester,
|
focusRequester = focusRequester,
|
||||||
|
|
@ -109,7 +110,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod
|
||||||
Text(
|
Text(
|
||||||
text = error.resolveReference(),
|
text = error.resolveReference(),
|
||||||
style = TangemTheme.typography3.caption.medium,
|
style = TangemTheme.typography3.caption.medium,
|
||||||
color = TangemTheme.colors3.text.status.warning,
|
color = TangemTheme.colors3.text.status.error,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.testTag(TangemPayTestTags.PIN_ERROR_MESSAGE),
|
modifier = Modifier.testTag(TangemPayTestTags.PIN_ERROR_MESSAGE),
|
||||||
)
|
)
|
||||||
|
|
@ -125,6 +126,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PinCode(
|
private fun PinCode(
|
||||||
|
isError: Boolean,
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -180,10 +182,10 @@ private fun PinCode(
|
||||||
),
|
),
|
||||||
digit = digit,
|
digit = digit,
|
||||||
backgroundColor = TangemTheme.colors3.bg.opaque.primary,
|
backgroundColor = TangemTheme.colors3.bg.opaque.primary,
|
||||||
borderColor = if (isActive) {
|
borderColor = when {
|
||||||
TangemTheme.colors3.border.status.info
|
isError -> TangemTheme.colors3.border.status.error
|
||||||
} else {
|
isActive -> TangemTheme.colors3.border.status.info
|
||||||
TangemTheme.colors3.border.secondary
|
else -> TangemTheme.colors3.border.secondary
|
||||||
},
|
},
|
||||||
textColor = TangemTheme.colors3.text.primary,
|
textColor = TangemTheme.colors3.text.primary,
|
||||||
textStyle = TangemTheme.typography3.heading.medium,
|
textStyle = TangemTheme.typography3.heading.medium,
|
||||||
|
|
@ -201,7 +203,7 @@ private fun PinCode(
|
||||||
private fun TangemPayChangePinScreenV2Preview(
|
private fun TangemPayChangePinScreenV2Preview(
|
||||||
@PreviewParameter(TangemPayChangePinUMPreviewProvider::class) state: TangemPayChangePinUM,
|
@PreviewParameter(TangemPayChangePinUMPreviewProvider::class) state: TangemPayChangePinUM,
|
||||||
) {
|
) {
|
||||||
TangemThemePreview {
|
TangemThemePreviewRedesign {
|
||||||
TangemPayChangePinScreenV2(
|
TangemPayChangePinScreenV2(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ internal fun TangemPayEditDisplayNameScreenV2(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4)
|
.padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4)
|
||||||
.imePadding(),
|
.imePadding(),
|
||||||
text = resourceReference(R.string.common_save),
|
text = resourceReference(R.string.common_done),
|
||||||
onClick = state.onDoneClick,
|
onClick = state.onDoneClick,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
isEnabled = !state.isLoading && state.isDoneEnabled,
|
isEnabled = !state.isLoading && state.isDoneEnabled,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ internal inline fun <T> AccountStatus.Payment.ifLoadedOrNull(call: (PaymentAccou
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Balance? = when (val v = value) {
|
||||||
|
is PaymentAccountStatusValue.Loaded -> v.balance
|
||||||
|
is PaymentAccountStatusValue.Deactivated -> v.balance
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
internal fun AccountStatus.Payment.findCard(
|
internal fun AccountStatus.Payment.findCard(
|
||||||
initialCardId: String,
|
initialCardId: String,
|
||||||
initialStatus: AccountStatus.Payment,
|
initialStatus: AccountStatus.Payment,
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,11 @@ internal class TangemPayCardLimitSetupModelTest {
|
||||||
val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) {
|
val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) {
|
||||||
every { source } returns StatusSource.ACTUAL
|
every { source } returns StatusSource.ACTUAL
|
||||||
every { cards } returns listOf(cardWithLimit)
|
every { cards } returns listOf(cardWithLimit)
|
||||||
every { currencyCode } returns "USD"
|
every { balance } returns mockk(relaxed = true) {
|
||||||
|
every { fiatBalance } returns mockk(relaxed = true) {
|
||||||
|
every { currency } returns "USD"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val paymentStatusWithLimit: AccountStatus.Payment = mockk(relaxed = true) {
|
val paymentStatusWithLimit: AccountStatus.Payment = mockk(relaxed = true) {
|
||||||
every { value } returns statusWithLimit
|
every { value } returns statusWithLimit
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import com.tangem.core.decompose.di.ModelScoped
|
||||||
import com.tangem.core.decompose.model.Model
|
import com.tangem.core.decompose.model.Model
|
||||||
import com.tangem.core.decompose.navigation.Router
|
import com.tangem.core.decompose.navigation.Router
|
||||||
import com.tangem.core.decompose.ui.UiMessageSender
|
import com.tangem.core.decompose.ui.UiMessageSender
|
||||||
import com.tangem.core.navigation.url.UrlOpener
|
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.core.ui.message.DialogMessage
|
import com.tangem.core.ui.message.DialogMessage
|
||||||
import com.tangem.core.ui.message.dialog.Dialogs
|
import com.tangem.core.ui.message.dialog.Dialogs
|
||||||
|
|
@ -16,7 +15,6 @@ import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
||||||
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
|
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
|
||||||
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
|
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
|
||||||
import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
|
import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
|
||||||
import com.tangem.features.tangempay.TangemPayConstants
|
|
||||||
import com.tangem.features.tangempay.onboarding.api.R
|
import com.tangem.features.tangempay.onboarding.api.R
|
||||||
import com.tangem.hot.sdk.model.HotAuth
|
import com.tangem.hot.sdk.model.HotAuth
|
||||||
import com.tangem.hot.sdk.model.MnemonicType
|
import com.tangem.hot.sdk.model.MnemonicType
|
||||||
|
|
@ -38,7 +36,6 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor(
|
||||||
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
|
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
|
||||||
private val router: Router,
|
private val router: Router,
|
||||||
private val uiMessageSender: UiMessageSender,
|
private val uiMessageSender: UiMessageSender,
|
||||||
private val urlOpener: UrlOpener,
|
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
val uiState: StateFlow<TangemPayHotWalletOnboardingUM>
|
val uiState: StateFlow<TangemPayHotWalletOnboardingUM>
|
||||||
|
|
@ -51,7 +48,7 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor(
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun onTermsClick() {
|
private fun onTermsClick() {
|
||||||
urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK)
|
router.push(AppRoute.Disclaimer(isTosAccepted = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onGetCardClick() {
|
private fun onGetCardClick() {
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,16 @@ import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tangem.common.ui.navigationButtons.NavigationButton
|
import com.tangem.common.ui.navigationButtons.NavigationButton
|
||||||
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
|
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
|
||||||
import com.tangem.core.ui.R
|
import com.tangem.core.ui.R
|
||||||
import com.tangem.core.ui.components.TextButton
|
import com.tangem.core.ui.components.SpacerH16
|
||||||
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
|
import com.tangem.core.ui.extensions.*
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
|
||||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreview
|
import com.tangem.core.ui.res.TangemThemePreview
|
||||||
import com.tangem.core.ui.utils.WindowInsetsZero
|
import com.tangem.core.ui.utils.WindowInsetsZero
|
||||||
|
|
@ -80,10 +79,11 @@ private fun Content(state: TangemPayHotWalletOnboardingUM, modifier: Modifier =
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 40.dp),
|
.padding(horizontal = 40.dp),
|
||||||
)
|
)
|
||||||
|
SpacerH16()
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
NavigationPrimaryButton(
|
NavigationPrimaryButton(
|
||||||
primaryButton = NavigationButton(
|
primaryButton = NavigationButton(
|
||||||
|
|
@ -94,18 +94,36 @@ private fun Content(state: TangemPayHotWalletOnboardingUM, modifier: Modifier =
|
||||||
onClick = state.onGetCardClick,
|
onClick = state.onGetCardClick,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
TextButton(
|
TosText(onClick = state.onTermsClick)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
text = stringResourceSafe(R.string.tangem_pay_terms_fees_limits),
|
|
||||||
onClick = state.onTermsClick,
|
|
||||||
colors = TangemButtonsDefaults.defaultTextButtonColors.copy(
|
|
||||||
contentColor = TangemTheme.colors.text.primary1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TosText(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
val termsTemplate = stringResourceSafe(R.string.onboarding_create_wallet_term_of_conditions_text)
|
||||||
|
val termsLinkText = stringResourceSafe(R.string.disclaimer_title)
|
||||||
|
val termsLinkColor = TangemTheme.colors.text.accent
|
||||||
|
Text(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
text = buildAnnotatedString {
|
||||||
|
appendWithStyledPlaceholder(template = termsTemplate) {
|
||||||
|
withLink(
|
||||||
|
LinkAnnotation.Clickable(
|
||||||
|
tag = "tos_link",
|
||||||
|
styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.None)),
|
||||||
|
) { onClick() },
|
||||||
|
) {
|
||||||
|
appendColored(text = termsLinkText, color = termsLinkColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = TangemTheme.typography.caption2,
|
||||||
|
color = TangemTheme.colors.text.tertiary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Features(modifier: Modifier = Modifier) {
|
private fun Features(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import com.tangem.common.routing.AppRoute
|
import com.tangem.common.routing.AppRoute
|
||||||
import com.tangem.core.decompose.navigation.Router
|
import com.tangem.core.decompose.navigation.Router
|
||||||
import com.tangem.core.decompose.ui.UiMessageSender
|
import com.tangem.core.decompose.ui.UiMessageSender
|
||||||
import com.tangem.core.navigation.url.UrlOpener
|
|
||||||
import com.tangem.core.ui.message.DialogMessage
|
import com.tangem.core.ui.message.DialogMessage
|
||||||
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
|
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
|
||||||
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
||||||
|
|
@ -14,7 +13,6 @@ import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
|
import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
|
||||||
import com.tangem.features.tangempay.TangemPayConstants
|
|
||||||
import com.tangem.hot.sdk.model.HotAuth
|
import com.tangem.hot.sdk.model.HotAuth
|
||||||
import com.tangem.hot.sdk.model.MnemonicType
|
import com.tangem.hot.sdk.model.MnemonicType
|
||||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||||
|
|
@ -32,7 +30,6 @@ internal class TangemPayHotWalletOnboardingModelTest {
|
||||||
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk()
|
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk()
|
||||||
private val router: Router = mockk(relaxed = true)
|
private val router: Router = mockk(relaxed = true)
|
||||||
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
|
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
|
||||||
private val urlOpener: UrlOpener = mockk(relaxed = true)
|
|
||||||
|
|
||||||
private val testUserWalletId = UserWalletId("1234567890ABCDEF")
|
private val testUserWalletId = UserWalletId("1234567890ABCDEF")
|
||||||
private val testUserWallet: UserWallet.Hot = mockk(relaxed = true) {
|
private val testUserWallet: UserWallet.Hot = mockk(relaxed = true) {
|
||||||
|
|
@ -43,12 +40,12 @@ internal class TangemPayHotWalletOnboardingModelTest {
|
||||||
inner class OnTermsClick {
|
inner class OnTermsClick {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `WHEN onTermsClick THEN urlOpener called with terms link`() = runTest {
|
fun `WHEN onTermsClick THEN navigate to Disclaimer`() = runTest {
|
||||||
val model = createModel()
|
val model = createModel()
|
||||||
|
|
||||||
model.uiState.value.onTermsClick.invoke()
|
model.uiState.value.onTermsClick.invoke()
|
||||||
|
|
||||||
verify { urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) }
|
verify { router.push(AppRoute.Disclaimer(isTosAccepted = true)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +115,6 @@ internal class TangemPayHotWalletOnboardingModelTest {
|
||||||
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
|
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
|
||||||
router = router,
|
router = router,
|
||||||
uiMessageSender = uiMessageSender,
|
uiMessageSender = uiMessageSender,
|
||||||
urlOpener = urlOpener,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +109,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.Q
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
|
||||||
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetYieldSupplyBalanceTransformer
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateActionButtonsTransformer
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateActionButtonsTransformer
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateAddFundsTransformer
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateAddFundsTransformer
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTransferTransformer
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTransferTransformer
|
||||||
|
|
@ -470,6 +471,7 @@ internal class TokenDetailsModel @Inject constructor(
|
||||||
yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value)
|
yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value)
|
||||||
.onEach { formatted ->
|
.onEach { formatted ->
|
||||||
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
|
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
|
||||||
|
redesignStateController.update(SetYieldSupplyBalanceTransformer(formatted))
|
||||||
}
|
}
|
||||||
.flowOn(dispatchers.main)
|
.flowOn(dispatchers.main)
|
||||||
.launchIn(modelScope)
|
.launchIn(modelScope)
|
||||||
|
|
@ -479,6 +481,7 @@ internal class TokenDetailsModel @Inject constructor(
|
||||||
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
|
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
|
||||||
YieldSupplyRewardBalance.empty(),
|
YieldSupplyRewardBalance.empty(),
|
||||||
)
|
)
|
||||||
|
redesignStateController.update(SetYieldSupplyBalanceTransformer(YieldSupplyRewardBalance.empty()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ internal sealed class TokenDetailsBalanceBlockUM {
|
||||||
val displayFiatBalanceAvailable: TextReference?,
|
val displayFiatBalanceAvailable: TextReference?,
|
||||||
val isBalanceFlickering: Boolean,
|
val isBalanceFlickering: Boolean,
|
||||||
val isBalanceZero: Boolean,
|
val isBalanceZero: Boolean,
|
||||||
|
val displayYieldSupplyFiatBalance: String? = null,
|
||||||
|
val displayYieldSupplyCryptoBalance: String? = null,
|
||||||
) : TokenDetailsBalanceBlockUM() {
|
) : TokenDetailsBalanceBlockUM() {
|
||||||
|
|
||||||
val displayCryptoBalance: TextReference
|
val displayCryptoBalance: TextReference
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ internal class SetBalanceTransformer(
|
||||||
|
|
||||||
val totalCryptoAmount = computeTotal(status.value.amount, stakingCryptoAmount)
|
val totalCryptoAmount = computeTotal(status.value.amount, stakingCryptoAmount)
|
||||||
|
|
||||||
|
val isYieldSupplyActive = status.value.yieldSupplyStatus?.isActive == true
|
||||||
|
val prevContent = prev as? TokenDetailsBalanceBlockUM.Content
|
||||||
|
|
||||||
return TokenDetailsBalanceBlockUM.Content(
|
return TokenDetailsBalanceBlockUM.Content(
|
||||||
addFundsButton = prev.addFundsButton,
|
addFundsButton = prev.addFundsButton,
|
||||||
swapButton = prev.swapButton,
|
swapButton = prev.swapButton,
|
||||||
|
|
@ -108,6 +111,17 @@ internal class SetBalanceTransformer(
|
||||||
},
|
},
|
||||||
isBalanceFlickering = status.value.sources.total == StatusSource.CACHE,
|
isBalanceFlickering = status.value.sources.total == StatusSource.CACHE,
|
||||||
isBalanceZero = totalCryptoAmount.isNullOrZero(),
|
isBalanceZero = totalCryptoAmount.isNullOrZero(),
|
||||||
|
// Preserve the ticking yield balance produced by SetYieldSupplyBalanceTransformer across status updates
|
||||||
|
displayYieldSupplyFiatBalance = if (isYieldSupplyActive) {
|
||||||
|
prevContent?.displayYieldSupplyFiatBalance
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
displayYieldSupplyCryptoBalance = if (isYieldSupplyActive) {
|
||||||
|
prevContent?.displayYieldSupplyCryptoBalance
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
@ -23,10 +25,12 @@ import com.tangem.core.ui.components.SpacerH
|
||||||
import com.tangem.core.ui.components.TextShimmer
|
import com.tangem.core.ui.components.TextShimmer
|
||||||
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
|
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
|
||||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||||
|
import com.tangem.core.ui.components.text.TextAnimatedCounter
|
||||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||||
import com.tangem.core.ui.ds.button.action.ActionButtons
|
import com.tangem.core.ui.ds.button.action.ActionButtons
|
||||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||||
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.core.ui.extensions.orMaskWithStars
|
import com.tangem.core.ui.extensions.orMaskWithStars
|
||||||
import com.tangem.core.ui.extensions.resolveAnnotatedReference
|
import com.tangem.core.ui.extensions.resolveAnnotatedReference
|
||||||
import com.tangem.core.ui.extensions.resolveReference
|
import com.tangem.core.ui.extensions.resolveReference
|
||||||
|
|
@ -42,6 +46,9 @@ import kotlinx.collections.immutable.persistentListOf
|
||||||
private val CurrencyIconSize: Dp = 70.dp
|
private val CurrencyIconSize: Dp = 70.dp
|
||||||
private val NetworkBadgeSize: Dp = 24.dp
|
private val NetworkBadgeSize: Dp = 24.dp
|
||||||
|
|
||||||
|
/** OpenType "tabular figures" feature — makes every digit the same width to prevent horizontal jitter. */
|
||||||
|
private const val TABULAR_FIGURES_FEATURE = "tnum"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TokenDetailsBalanceBlock(
|
internal fun TokenDetailsBalanceBlock(
|
||||||
balanceBlockUM: TokenDetailsBalanceBlockUM,
|
balanceBlockUM: TokenDetailsBalanceBlockUM,
|
||||||
|
|
@ -124,20 +131,60 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SpacerH(TangemTheme.dimens2.x2)
|
SpacerH(TangemTheme.dimens2.x2)
|
||||||
Text(
|
AnimatedBalance(
|
||||||
modifier = Modifier.testTag(TokenDetailsScreenTestTags.BALANCE_FIAT),
|
modifier = Modifier.testTag(TokenDetailsScreenTestTags.BALANCE_FIAT),
|
||||||
text = state.displayFiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
|
yieldBalance = state.displayYieldSupplyFiatBalance,
|
||||||
|
fallbackBalance = state.displayFiatBalance,
|
||||||
style = TangemTheme.typography2.titleRegular44,
|
style = TangemTheme.typography2.titleRegular44,
|
||||||
color = TangemTheme.colors2.text.neutral.primary,
|
color = TangemTheme.colors2.text.neutral.primary,
|
||||||
|
isBalanceHidden = isBalanceHidden,
|
||||||
)
|
)
|
||||||
SpacerH(TangemTheme.dimens2.x2_5)
|
SpacerH(TangemTheme.dimens2.x2_5)
|
||||||
Text(
|
AnimatedBalance(
|
||||||
text = state.displayCryptoBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
|
yieldBalance = state.displayYieldSupplyCryptoBalance,
|
||||||
|
fallbackBalance = state.displayCryptoBalance,
|
||||||
style = TangemTheme.typography2.bodySemibold16,
|
style = TangemTheme.typography2.bodySemibold16,
|
||||||
color = TangemTheme.colors2.text.neutral.secondary,
|
color = TangemTheme.colors2.text.neutral.secondary,
|
||||||
|
isBalanceHidden = isBalanceHidden,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a balance that animates digit-by-digit ([TextAnimatedCounter]) while a ticking yield supply
|
||||||
|
* value is present, and falls back to a plain [Text] otherwise (or when the balance is hidden).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AnimatedBalance(
|
||||||
|
yieldBalance: String?,
|
||||||
|
fallbackBalance: TextReference,
|
||||||
|
style: TextStyle,
|
||||||
|
color: Color,
|
||||||
|
isBalanceHidden: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = TangemTheme.dimens2.x6),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (yieldBalance != null && !isBalanceHidden) {
|
||||||
|
TextAnimatedCounter(
|
||||||
|
text = yieldBalance,
|
||||||
|
// Tabular figures keep every digit the same width, so the centered balance doesn't
|
||||||
|
// jitter horizontally as digits roll during the increment animation.
|
||||||
|
style = style.copy(color = color, fontFeatureSettings = TABULAR_FIGURES_FEATURE),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = fallbackBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
|
||||||
|
style = style,
|
||||||
|
color = color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadingBody() {
|
private fun LoadingBody() {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.models.staking.StakingBalance
|
import com.tangem.domain.models.staking.StakingBalance
|
||||||
|
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
|
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
|
||||||
|
|
@ -434,6 +435,59 @@ class SetBalanceTransformerTest {
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
// region Yield supply balance
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN yield active AND prev has yield balances WHEN transform THEN yield balances preserved`() {
|
||||||
|
// GIVEN
|
||||||
|
val status = createStatus(loadedValue(yieldSupplyStatus = activeYieldSupplyStatus()))
|
||||||
|
val transformer = createTransformer(status)
|
||||||
|
val prev = contentWithYieldBalances(fiat = "$21,000.12", crypto = "10.500001 ETH")
|
||||||
|
val state = initialState().copy(balanceBlockUM = prev)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = transformer.transform(state)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
|
||||||
|
assertThat(content.displayYieldSupplyFiatBalance).isEqualTo("$21,000.12")
|
||||||
|
assertThat(content.displayYieldSupplyCryptoBalance).isEqualTo("10.500001 ETH")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN yield inactive AND prev has yield balances WHEN transform THEN yield balances are cleared`() {
|
||||||
|
// GIVEN
|
||||||
|
val status = createStatus(loadedValue(yieldSupplyStatus = null))
|
||||||
|
val transformer = createTransformer(status)
|
||||||
|
val prev = contentWithYieldBalances(fiat = "$21,000.12", crypto = "10.500001 ETH")
|
||||||
|
val state = initialState().copy(balanceBlockUM = prev)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = transformer.transform(state)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
|
||||||
|
assertThat(content.displayYieldSupplyFiatBalance).isNull()
|
||||||
|
assertThat(content.displayYieldSupplyCryptoBalance).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN yield active AND prev is not Content WHEN transform THEN yield balances are null`() {
|
||||||
|
// GIVEN — prev is the default Loading block, so there are no yield balances to preserve yet
|
||||||
|
val status = createStatus(loadedValue(yieldSupplyStatus = activeYieldSupplyStatus()))
|
||||||
|
val transformer = createTransformer(status)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = transformer.transform(initialState())
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
|
||||||
|
assertThat(content.displayYieldSupplyFiatBalance).isNull()
|
||||||
|
assertThat(content.displayYieldSupplyCryptoBalance).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
// region Unrelated fields preserved
|
// region Unrelated fields preserved
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -473,6 +527,7 @@ class SetBalanceTransformerTest {
|
||||||
fiatAmount: BigDecimal = BigDecimal("21000"),
|
fiatAmount: BigDecimal = BigDecimal("21000"),
|
||||||
fiatRate: BigDecimal = BigDecimal("2000"),
|
fiatRate: BigDecimal = BigDecimal("2000"),
|
||||||
stakingBalance: StakingBalance? = null,
|
stakingBalance: StakingBalance? = null,
|
||||||
|
yieldSupplyStatus: YieldSupplyStatus? = null,
|
||||||
sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(),
|
sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(),
|
||||||
): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded(
|
): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded(
|
||||||
amount = amount,
|
amount = amount,
|
||||||
|
|
@ -480,13 +535,39 @@ class SetBalanceTransformerTest {
|
||||||
fiatRate = fiatRate,
|
fiatRate = fiatRate,
|
||||||
priceChange = BigDecimal("2.5"),
|
priceChange = BigDecimal("2.5"),
|
||||||
stakingBalance = stakingBalance,
|
stakingBalance = stakingBalance,
|
||||||
yieldSupplyStatus = null,
|
yieldSupplyStatus = yieldSupplyStatus,
|
||||||
hasCurrentNetworkTransactions = false,
|
hasCurrentNetworkTransactions = false,
|
||||||
pendingTransactions = emptySet(),
|
pendingTransactions = emptySet(),
|
||||||
networkAddress = mockk(relaxed = true),
|
networkAddress = mockk(relaxed = true),
|
||||||
sources = sources,
|
sources = sources,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun activeYieldSupplyStatus(): YieldSupplyStatus = YieldSupplyStatus(
|
||||||
|
isActive = true,
|
||||||
|
isInitialized = true,
|
||||||
|
isAllowedToSpend = true,
|
||||||
|
effectiveProtocolBalance = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun contentWithYieldBalances(
|
||||||
|
fiat: String?,
|
||||||
|
crypto: String?,
|
||||||
|
): TokenDetailsBalanceBlockUM.Content = TokenDetailsBalanceBlockUM.Content(
|
||||||
|
addFundsButton = placeholderButton(),
|
||||||
|
swapButton = placeholderButton(),
|
||||||
|
transferButton = placeholderButton(),
|
||||||
|
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
|
||||||
|
currencyIconState = CurrencyIconState.Loading,
|
||||||
|
displayCryptoBalanceAll = stringReference(""),
|
||||||
|
displayFiatBalanceAll = stringReference(""),
|
||||||
|
displayCryptoBalanceAvailable = null,
|
||||||
|
displayFiatBalanceAvailable = null,
|
||||||
|
isBalanceFlickering = false,
|
||||||
|
isBalanceZero = false,
|
||||||
|
displayYieldSupplyFiatBalance = fiat,
|
||||||
|
displayYieldSupplyCryptoBalance = crypto,
|
||||||
|
)
|
||||||
|
|
||||||
private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote(
|
private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote(
|
||||||
amount = BigDecimal("5.0"),
|
amount = BigDecimal("5.0"),
|
||||||
stakingBalance = null,
|
stakingBalance = null,
|
||||||
|
|
|
||||||
|
|
@ -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.nft.api)
|
||||||
implementation(projects.features.onboardingV2.api)
|
implementation(projects.features.onboardingV2.api)
|
||||||
implementation(projects.features.pushNotifications.api)
|
implementation(projects.features.pushNotifications.api)
|
||||||
|
implementation(projects.features.pushNotificationSettings.api)
|
||||||
implementation(projects.features.hotWallet.api)
|
implementation(projects.features.hotWallet.api)
|
||||||
implementation(projects.features.wallet.api)
|
implementation(projects.features.wallet.api)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent {
|
||||||
isNotificationsEnabled = true,
|
isNotificationsEnabled = true,
|
||||||
onCheckedNotificationsChanged = {},
|
onCheckedNotificationsChanged = {},
|
||||||
onNotificationsDescriptionClick = {},
|
onNotificationsDescriptionClick = {},
|
||||||
|
isPushNotificationSettingsEnabled = false,
|
||||||
|
onNotificationSettingsClick = {},
|
||||||
isNotificationsPermissionGranted = false,
|
isNotificationsPermissionGranted = false,
|
||||||
onAccessCodeClick = {},
|
onAccessCodeClick = {},
|
||||||
onBackupClick = {},
|
onBackupClick = {},
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ import com.tangem.feature.walletsettings.utils.ItemsBuilder
|
||||||
import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate
|
import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate
|
||||||
import com.tangem.features.hotwallet.HotWalletFeatureToggles
|
import com.tangem.features.hotwallet.HotWalletFeatureToggles
|
||||||
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
|
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
|
||||||
|
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
|
||||||
import com.tangem.hot.sdk.model.HotWalletId
|
import com.tangem.hot.sdk.model.HotWalletId
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import kotlinx.collections.immutable.PersistentList
|
import kotlinx.collections.immutable.PersistentList
|
||||||
|
|
@ -92,6 +93,7 @@ internal class WalletSettingsModel @Inject constructor(
|
||||||
private val accountListSortingSaver: AccountListSortingSaver,
|
private val accountListSortingSaver: AccountListSortingSaver,
|
||||||
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase,
|
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase,
|
||||||
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
|
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
|
||||||
|
private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
val params: WalletSettingsComponent.Params = paramsContainer.require()
|
val params: WalletSettingsComponent.Params = paramsContainer.require()
|
||||||
|
|
@ -203,6 +205,8 @@ internal class WalletSettingsModel @Inject constructor(
|
||||||
is UserWallet.Cold -> userWallet.isMultiCurrency
|
is UserWallet.Cold -> userWallet.isMultiCurrency
|
||||||
is UserWallet.Hot -> true
|
is UserWallet.Hot -> true
|
||||||
}
|
}
|
||||||
|
val isPushNotificationSettingsEnabled =
|
||||||
|
pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled
|
||||||
return itemsBuilder.buildItems(
|
return itemsBuilder.buildItems(
|
||||||
userWallet = userWallet,
|
userWallet = userWallet,
|
||||||
cardItem = cardItem,
|
cardItem = cardItem,
|
||||||
|
|
@ -245,6 +249,8 @@ internal class WalletSettingsModel @Inject constructor(
|
||||||
isNotificationsPermissionGranted = isNotificationsPermissionGranted,
|
isNotificationsPermissionGranted = isNotificationsPermissionGranted,
|
||||||
onCheckedNotificationsChanged = ::onCheckedNotificationsChange,
|
onCheckedNotificationsChanged = ::onCheckedNotificationsChange,
|
||||||
onNotificationsDescriptionClick = ::onNotificationsDescriptionClick,
|
onNotificationsDescriptionClick = ::onNotificationsDescriptionClick,
|
||||||
|
isPushNotificationSettingsEnabled = isPushNotificationSettingsEnabled,
|
||||||
|
onNotificationSettingsClick = ::onNotificationSettingsClick,
|
||||||
onAccessCodeClick = { onAccessCodeClick(userWallet) },
|
onAccessCodeClick = { onAccessCodeClick(userWallet) },
|
||||||
onBackupClick = ::onBackupClick,
|
onBackupClick = ::onBackupClick,
|
||||||
onCardSettingsClick = ::onCardSettingsClick,
|
onCardSettingsClick = ::onCardSettingsClick,
|
||||||
|
|
@ -252,6 +258,10 @@ internal class WalletSettingsModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onNotificationSettingsClick() {
|
||||||
|
router.push(AppRoute.PushNotificationSettings(userWalletId = params.userWalletId))
|
||||||
|
}
|
||||||
|
|
||||||
private fun forgetWallet() = modelScope.launch {
|
private fun forgetWallet() = modelScope.launch {
|
||||||
val userWallet = getUserWalletUseCase(params.userWalletId)
|
val userWallet = getUserWalletUseCase(params.userWalletId)
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ internal class ItemsBuilder @Inject constructor() {
|
||||||
isNotificationsPermissionGranted: Boolean,
|
isNotificationsPermissionGranted: Boolean,
|
||||||
onCheckedNotificationsChanged: (Boolean) -> Unit,
|
onCheckedNotificationsChanged: (Boolean) -> Unit,
|
||||||
onNotificationsDescriptionClick: () -> Unit,
|
onNotificationsDescriptionClick: () -> Unit,
|
||||||
|
isPushNotificationSettingsEnabled: Boolean,
|
||||||
|
onNotificationSettingsClick: () -> Unit,
|
||||||
forgetWallet: () -> Unit,
|
forgetWallet: () -> Unit,
|
||||||
onLinkMoreCardsClick: () -> Unit,
|
onLinkMoreCardsClick: () -> Unit,
|
||||||
onReferralClick: () -> Unit,
|
onReferralClick: () -> Unit,
|
||||||
|
|
@ -50,20 +52,26 @@ internal class ItemsBuilder @Inject constructor() {
|
||||||
isLinkMoreCardsAvailable = isLinkMoreCardsAvailable,
|
isLinkMoreCardsAvailable = isLinkMoreCardsAvailable,
|
||||||
isReferralAvailable = isReferralAvailable,
|
isReferralAvailable = isReferralAvailable,
|
||||||
isManageTokensAvailable = isManageTokensAvailable,
|
isManageTokensAvailable = isManageTokensAvailable,
|
||||||
|
isPushNotificationSettingsEnabled = isPushNotificationSettingsEnabled,
|
||||||
onLinkMoreCardsClick = onLinkMoreCardsClick,
|
onLinkMoreCardsClick = onLinkMoreCardsClick,
|
||||||
onReferralClick = onReferralClick,
|
onReferralClick = onReferralClick,
|
||||||
onManageTokensClick = onManageTokensClick,
|
onManageTokensClick = onManageTokensClick,
|
||||||
onBackupClick = onBackupClick,
|
onBackupClick = onBackupClick,
|
||||||
onCardSettingsClick = onCardSettingsClick,
|
onCardSettingsClick = onCardSettingsClick,
|
||||||
|
onNotificationSettingsClick = onNotificationSettingsClick,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.addAll(
|
.addAll(
|
||||||
buildNotificationItems(
|
if (isPushNotificationSettingsEnabled) {
|
||||||
isNotificationsPermissionGranted = isNotificationsPermissionGranted,
|
emptyList()
|
||||||
isNotificationsEnabled = isNotificationsEnabled,
|
} else {
|
||||||
onCheckedNotificationsChanged = onCheckedNotificationsChanged,
|
buildNotificationItems(
|
||||||
onNotificationsDescriptionClick = onNotificationsDescriptionClick,
|
isNotificationsPermissionGranted = isNotificationsPermissionGranted,
|
||||||
),
|
isNotificationsEnabled = isNotificationsEnabled,
|
||||||
|
onCheckedNotificationsChanged = onCheckedNotificationsChanged,
|
||||||
|
onNotificationsDescriptionClick = onNotificationsDescriptionClick,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.addAll(
|
.addAll(
|
||||||
buildNFTItems(
|
buildNFTItems(
|
||||||
|
|
@ -137,11 +145,13 @@ internal class ItemsBuilder @Inject constructor() {
|
||||||
isLinkMoreCardsAvailable: Boolean,
|
isLinkMoreCardsAvailable: Boolean,
|
||||||
isReferralAvailable: Boolean,
|
isReferralAvailable: Boolean,
|
||||||
isManageTokensAvailable: Boolean,
|
isManageTokensAvailable: Boolean,
|
||||||
|
isPushNotificationSettingsEnabled: Boolean,
|
||||||
onLinkMoreCardsClick: () -> Unit,
|
onLinkMoreCardsClick: () -> Unit,
|
||||||
onReferralClick: () -> Unit,
|
onReferralClick: () -> Unit,
|
||||||
onManageTokensClick: () -> Unit,
|
onManageTokensClick: () -> Unit,
|
||||||
onBackupClick: () -> Unit,
|
onBackupClick: () -> Unit,
|
||||||
onCardSettingsClick: () -> Unit,
|
onCardSettingsClick: () -> Unit,
|
||||||
|
onNotificationSettingsClick: () -> Unit,
|
||||||
) = WalletSettingsItemUM.WithItems(
|
) = WalletSettingsItemUM.WithItems(
|
||||||
id = "card",
|
id = "card",
|
||||||
description = null,
|
description = null,
|
||||||
|
|
@ -207,6 +217,16 @@ internal class ItemsBuilder @Inject constructor() {
|
||||||
|
|
||||||
add(referralBlock)
|
add(referralBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPushNotificationSettingsEnabled) {
|
||||||
|
val notificationSettingsBlock = BlockUM(
|
||||||
|
text = resourceReference(R.string.push_notification_settings_title),
|
||||||
|
iconRes = R.drawable.ic_push_notification_settings_24,
|
||||||
|
onClick = onNotificationSettingsClick,
|
||||||
|
)
|
||||||
|
|
||||||
|
add(notificationSettingsBlock)
|
||||||
|
}
|
||||||
}.toImmutableList(),
|
}.toImmutableList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ internal class TangemPayMainBlockConverter(
|
||||||
subtitle = TextReference.Res(R.string.tangempay_status_deactivated),
|
subtitle = TextReference.Res(R.string.tangempay_status_deactivated),
|
||||||
isBalanceFlickering = statusValue.source == StatusSource.CACHE,
|
isBalanceFlickering = statusValue.source == StatusSource.CACHE,
|
||||||
balance = getBalanceText(
|
balance = getBalanceText(
|
||||||
currencyCode = statusValue.fiatBalance.currency,
|
currencyCode = statusValue.balance.fiatBalance.currency,
|
||||||
balance = statusValue.fiatBalance.availableBalance,
|
balance = statusValue.balance.fiatBalance.availableBalance,
|
||||||
),
|
),
|
||||||
balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol),
|
balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol),
|
||||||
shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE,
|
shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE,
|
||||||
|
|
@ -75,8 +75,8 @@ internal class TangemPayMainBlockConverter(
|
||||||
},
|
},
|
||||||
isBalanceFlickering = statusValue.source == StatusSource.CACHE,
|
isBalanceFlickering = statusValue.source == StatusSource.CACHE,
|
||||||
balance = getBalanceText(
|
balance = getBalanceText(
|
||||||
currencyCode = statusValue.currencyCode,
|
currencyCode = statusValue.balance.fiatBalance.currency,
|
||||||
balance = statusValue.fiatBalance.availableBalance,
|
balance = statusValue.balance.fiatBalance.availableBalance,
|
||||||
),
|
),
|
||||||
balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol),
|
balanceSubtitle = stringReference(statusValue.cryptoCurrency.symbol),
|
||||||
shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE,
|
shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue