diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 7775e7abac..5809f451ba 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1804,6 +1804,8 @@ MCC A fee is charged due to the service tariffs The transaction was partially or fully reversed by the merchant + We are rolling it out gradually and let you know when Tangem Pay will be available here + Tangem Pay is unavailable in your region Keep using your money. You can freeze anytime. Unfreeze your card? Failed to unfreeze the card. Try again later. diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index 6f439a26bd..0934b71734 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -143,6 +143,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( private fun TangemPayEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) { TangemPayEntryPoint.BANNER -> TangemPayEligibilityType.BANNER TangemPayEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS + TangemPayEntryPoint.DEEPLINK -> TangemPayEligibilityType.DEEPLINK } private data class UserWalletData( diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayEntryPoint.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayEntryPoint.kt index 72a1de7e96..cd596dd5f5 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayEntryPoint.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayEntryPoint.kt @@ -3,4 +3,5 @@ package com.tangem.domain.pay.model enum class TangemPayEntryPoint { BANNER, DETAILS, + DEEPLINK, } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt index 93b9db5768..869fc11d95 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -25,6 +25,7 @@ import com.tangem.features.tangempay.model.transformers.TangemPayOnboardingButto import com.tangem.features.tangempay.ui.TangemPayOnboardingNavigation import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -64,7 +65,7 @@ internal class TangemPayOnboardingModel @Inject constructor( when (params) { is TangemPayOnboardingComponent.Params.Deeplink -> { repository.validateDeeplink(params.deeplink) - .onRight { isValid -> if (isValid) showOnboarding() else back() } + .onRight { isValid -> if (isValid) checkEligibilityAndShow() else back() } .onLeft { back() } } is TangemPayOnboardingComponent.Params.ContinueOnboarding -> { @@ -93,6 +94,21 @@ internal class TangemPayOnboardingModel @Inject constructor( } } + private suspend fun checkEligibilityAndShow() { + runSuspendCatching { + eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) + } + .onSuccess { isAvailable -> if (isAvailable) showOnboarding() else showNotAvailable() } + .onFailure { error -> + TangemLogger.e(messageString = "TangemPayOnboarding: eligibility check failed", throwable = error) + back() + } + } + + private fun showNotAvailable() { + uiState.update { state -> TangemPayOnboardingScreenState.NotAvailable(onBack = state.onBack) } + } + private fun checkCustomerInfo(userWalletId: UserWalletId) { modelScope.launch { uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = true)) diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt index 346fea774a..9650260c81 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt @@ -2,19 +2,24 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold 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.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -23,14 +28,35 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_heart_broken_32 import com.tangem.core.ui.utils.WindowInsetsZero +// Warm top glow of the "unavailable" screen: a translucent gold (sampled from Figma) drawn over +// bg.primary, so one value matches both light and dark themes. Fades to transparent by mid-screen. +private const val GLOW_COLOR = 0xFFFCB92A +private const val GLOW_ALPHA = 0.49f +private const val GLOW_FADE_STOP = 0.5f + @Composable internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) { + when (state) { + is TangemPayOnboardingScreenState.NotAvailable -> + TangemPayOnboardingUnavailable(onBack = state.onBack, modifier = modifier) + is TangemPayOnboardingScreenState.Loading, + is TangemPayOnboardingScreenState.Content, + -> TangemPayOnboardingScaffold(state = state, modifier = modifier) + } +} + +@Composable +private fun TangemPayOnboardingScaffold(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) { Scaffold( modifier = modifier.systemBarsPadding(), topBar = { @@ -52,11 +78,81 @@ internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, mo state = state, modifier = contentModifier, ) + is TangemPayOnboardingScreenState.NotAvailable -> Unit } }, ) } +@Composable +private fun TangemPayOnboardingUnavailable(onBack: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors3.bg.primary), + ) { + UnavailableGlow(modifier = Modifier.matchParentSize()) + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + ) { + Column( + modifier = Modifier.padding(top = 72.dp, start = 24.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + modifier = Modifier.size(32.dp), + imageVector = Icons.ic_heart_broken_32, + contentDescription = null, + tint = TangemTheme.colors3.icon.primary, + ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResourceSafe(R.string.tangem_pay_unavailable_region_title), + style = TangemTheme.typography3.heading.medium, + color = TangemTheme.colors3.text.primary, + ) + Text( + text = stringResourceSafe(R.string.tangem_pay_unavailable_region_description), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + text = resourceReference(R.string.common_got_it), + onClick = onBack, + ) + } + } +} + +@Composable +private fun UnavailableGlow(modifier: Modifier = Modifier) { + Box( + modifier = modifier.drawBehind { + drawRect( + brush = Brush.radialGradient( + colorStops = arrayOf( + 0f to Color(GLOW_COLOR).copy(alpha = GLOW_ALPHA), + GLOW_FADE_STOP to Color.Transparent, + 1f to Color.Transparent, + ), + center = Offset(x = size.width / 2f, y = 0f), + radius = size.height, + ), + ) + }, + ) +} + @Composable private fun TangemPayOnboardingLoading(modifier: Modifier = Modifier) { Box(modifier = modifier, contentAlignment = Alignment.Center) { @@ -155,7 +251,7 @@ private fun TandemPayOnboardingScreenPreview( @PreviewParameter(TangemPayOnboardingScreenStateProvider::class) state: TangemPayOnboardingScreenState, ) { - TangemThemePreview { + TangemThemePreviewRedesign { TandemPayOnboardingScreen(state = state, modifier = Modifier.fillMaxSize()) } } @@ -174,5 +270,6 @@ private class TangemPayOnboardingScreenStateProvider : onTermsClick = {}, buttonConfig = TangemPayOnboardingScreenState.Content.ButtonConfig(isLoading = true, onClick = {}), ), + TangemPayOnboardingScreenState.NotAvailable(onBack = {}), ), ) \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt index af92226019..f5e6506ad0 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayOnboardingScreenState.kt @@ -14,4 +14,9 @@ internal sealed class TangemPayOnboardingScreenState { ) : TangemPayOnboardingScreenState() { data class ButtonConfig(val isLoading: Boolean, val onClick: () -> Unit) } + + /** + * Shown when the user is not eligible for Tangem Pay. [onBack] also backs the "Got it" button. + */ + data class NotAvailable(override val onBack: () -> Unit) : TangemPayOnboardingScreenState() } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt new file mode 100644 index 0000000000..ca82d57966 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt @@ -0,0 +1,182 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.tangem.features.tangempay.model + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.error.UniversalError +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.pay.TangemPayEligibilityManager +import com.tangem.domain.pay.model.TangemPayEntryPoint +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase +import com.tangem.features.tangempay.components.TangemPayOnboardingComponent +import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayOnboardingModelTest { + + private val router: Router = mockk(relaxed = true) + private val repository: OnboardingRepository = mockk(relaxed = true) + private val eligibilityManager: TangemPayEligibilityManager = mockk() + private val analytics: AnalyticsEventHandler = mockk(relaxed = true) + private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase = mockk(relaxed = true) + private val urlOpener: UrlOpener = mockk(relaxed = true) + + private val deeplink = "tangem://onboard-visa" + + @BeforeEach + fun resetMocks() { + clearMocks(router, repository, eligibilityManager, analytics, produceInitialDataUseCase, urlOpener) + } + + @ParameterizedTest + @MethodSource("provideAvailabilityCases") + fun `GIVEN valid deeplink WHEN model created THEN state reflects tangem pay availability`( + case: AvailabilityCase, + ) = runTest { + // Arrange + coEvery { repository.validateDeeplink(deeplink) } returns true.right() + coEvery { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) } returns case.isAvailable + + // Act + val model = createModel(TangemPayOnboardingComponent.Params.Deeplink(deeplink)) + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value).isInstanceOf(case.expectedState) + coVerify(exactly = 1) { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) } + model.onDestroy() + } + + @ParameterizedTest + @MethodSource("provideRejectedDeeplinkCases") + fun `GIVEN deeplink rejected WHEN model created THEN screen closed AND availability not checked`( + validationResult: Either, + ) = runTest { + // Arrange + coEvery { repository.validateDeeplink(deeplink) } returns validationResult + + // Act + val model = createModel(TangemPayOnboardingComponent.Params.Deeplink(deeplink)) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { router.pop() } + coVerify(exactly = 0) { eligibilityManager.getTangemPayAvailability(any()) } + model.onDestroy() + } + + @Test + fun `GIVEN availability check fails WHEN model created THEN screen closed`() = runTest { + // Arrange + coEvery { repository.validateDeeplink(deeplink) } returns true.right() + coEvery { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) } throws + RuntimeException("network error") + + // Act + val model = createModel(TangemPayOnboardingComponent.Params.Deeplink(deeplink)) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) } + verify(exactly = 1) { router.pop() } + model.onDestroy() + } + + @Test + fun `GIVEN unavailable shown WHEN got it clicked THEN screen closed`() = runTest { + // Arrange + coEvery { repository.validateDeeplink(deeplink) } returns true.right() + coEvery { eligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DEEPLINK) } returns false + val model = createModel(TangemPayOnboardingComponent.Params.Deeplink(deeplink)) + advanceUntilIdle() + + // Act + model.uiState.value.onBack.invoke() + + // Assert + verify(exactly = 1) { router.pop() } + model.onDestroy() + } + + @Test + fun `GIVEN from banner entry WHEN model created THEN content shown without availability check`() = runTest { + // Act + val model = createModel(TangemPayOnboardingComponent.Params.FromBannerOnMain) + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value).isInstanceOf(TangemPayOnboardingScreenState.Content::class.java) + coVerify(exactly = 0) { eligibilityManager.getTangemPayAvailability(any()) } + model.onDestroy() + } + + private fun TestScope.createModel(params: TangemPayOnboardingComponent.Params): TangemPayOnboardingModel { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TangemPayOnboardingModel( + paramsContainer = MutableParamsContainer(params), + dispatchers = TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ), + analytics = analytics, + router = router, + repository = repository, + produceInitialDataUseCase = produceInitialDataUseCase, + urlOpener = urlOpener, + eligibilityManager = eligibilityManager, + ) + } + + internal data class AvailabilityCase( + val isAvailable: Boolean, + val expectedState: Class, + ) + + private fun provideAvailabilityCases() = listOf( + AvailabilityCase( + isAvailable = true, + expectedState = TangemPayOnboardingScreenState.Content::class.java, + ), + AvailabilityCase( + isAvailable = false, + expectedState = TangemPayOnboardingScreenState.NotAvailable::class.java, + ), + ) + + private fun provideRejectedDeeplinkCases(): List> = listOf( + false.right(), + DEEPLINK_ERROR.left(), + ) + + private companion object { + val DEEPLINK_ERROR = object : UniversalError { + override val errorCode: Int = 0 + } + } +} \ No newline at end of file