Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-02 12:58:08 +03:00
parent 889f47acdb
commit 489726e264
7 changed files with 307 additions and 3 deletions

View file

@ -1804,6 +1804,8 @@
<string name="tangem_pay_transaction_details_mcc">MCC</string> <string name="tangem_pay_transaction_details_mcc">MCC</string>
<string name="tangem_pay_transaction_fee_notification_text">A fee is charged due to the service tariffs</string> <string name="tangem_pay_transaction_fee_notification_text">A fee is charged due to the service tariffs</string>
<string name="tangem_pay_transaction_reversed_notification_text">The transaction was partially or fully reversed by the merchant</string> <string name="tangem_pay_transaction_reversed_notification_text">The transaction was partially or fully reversed by the merchant</string>
<string name="tangem_pay_unavailable_region_description">We are rolling it out gradually and let you know when Tangem Pay will be available here</string>
<string name="tangem_pay_unavailable_region_title">Tangem Pay is unavailable in your region</string>
<string name="tangem_pay_unfreeze_card_alert_body">Keep using your money. You can freeze anytime.</string> <string name="tangem_pay_unfreeze_card_alert_body">Keep using your money. You can freeze anytime.</string>
<string name="tangem_pay_unfreeze_card_alert_title">Unfreeze your card?</string> <string name="tangem_pay_unfreeze_card_alert_title">Unfreeze your card?</string>
<string name="tangem_pay_unfreeze_card_failed">Failed to unfreeze the card. Try again later.</string> <string name="tangem_pay_unfreeze_card_failed">Failed to unfreeze the card. Try again later.</string>

View file

@ -143,6 +143,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
private fun TangemPayEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) { private fun TangemPayEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) {
TangemPayEntryPoint.BANNER -> TangemPayEligibilityType.BANNER TangemPayEntryPoint.BANNER -> TangemPayEligibilityType.BANNER
TangemPayEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS TangemPayEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS
TangemPayEntryPoint.DEEPLINK -> TangemPayEligibilityType.DEEPLINK
} }
private data class UserWalletData( private data class UserWalletData(

View file

@ -3,4 +3,5 @@ package com.tangem.domain.pay.model
enum class TangemPayEntryPoint { enum class TangemPayEntryPoint {
BANNER, BANNER,
DETAILS, DETAILS,
DEEPLINK,
} }

View file

@ -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.TangemPayOnboardingNavigation
import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -64,7 +65,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
when (params) { when (params) {
is TangemPayOnboardingComponent.Params.Deeplink -> { is TangemPayOnboardingComponent.Params.Deeplink -> {
repository.validateDeeplink(params.deeplink) repository.validateDeeplink(params.deeplink)
.onRight { isValid -> if (isValid) showOnboarding() else back() } .onRight { isValid -> if (isValid) checkEligibilityAndShow() else back() }
.onLeft { back() } .onLeft { back() }
} }
is TangemPayOnboardingComponent.Params.ContinueOnboarding -> { 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) { private fun checkCustomerInfo(userWalletId: UserWalletId) {
modelScope.launch { modelScope.launch {
uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = true)) uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = true))

View file

@ -2,19 +2,24 @@ package com.tangem.features.tangempay.ui
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview 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 androidx.compose.ui.unit.dp
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.AppBarWithBackButton 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.TextReference
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.ic_heart_broken_32
import com.tangem.core.ui.utils.WindowInsetsZero 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 @Composable
internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) { 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( Scaffold(
modifier = modifier.systemBarsPadding(), modifier = modifier.systemBarsPadding(),
topBar = { topBar = {
@ -52,11 +78,81 @@ internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, mo
state = state, state = state,
modifier = contentModifier, 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 @Composable
private fun TangemPayOnboardingLoading(modifier: Modifier = Modifier) { private fun TangemPayOnboardingLoading(modifier: Modifier = Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
@ -155,7 +251,7 @@ private fun TandemPayOnboardingScreenPreview(
@PreviewParameter(TangemPayOnboardingScreenStateProvider::class) @PreviewParameter(TangemPayOnboardingScreenStateProvider::class)
state: TangemPayOnboardingScreenState, state: TangemPayOnboardingScreenState,
) { ) {
TangemThemePreview { TangemThemePreviewRedesign {
TandemPayOnboardingScreen(state = state, modifier = Modifier.fillMaxSize()) TandemPayOnboardingScreen(state = state, modifier = Modifier.fillMaxSize())
} }
} }
@ -174,5 +270,6 @@ private class TangemPayOnboardingScreenStateProvider :
onTermsClick = {}, onTermsClick = {},
buttonConfig = TangemPayOnboardingScreenState.Content.ButtonConfig(isLoading = true, onClick = {}), buttonConfig = TangemPayOnboardingScreenState.Content.ButtonConfig(isLoading = true, onClick = {}),
), ),
TangemPayOnboardingScreenState.NotAvailable(onBack = {}),
), ),
) )

View file

@ -14,4 +14,9 @@ internal sealed class TangemPayOnboardingScreenState {
) : TangemPayOnboardingScreenState() { ) : TangemPayOnboardingScreenState() {
data class ButtonConfig(val isLoading: Boolean, val onClick: () -> Unit) 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()
} }

View file

@ -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<UniversalError, Boolean>,
) = 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<out TangemPayOnboardingScreenState>,
)
private fun provideAvailabilityCases() = listOf(
AvailabilityCase(
isAvailable = true,
expectedState = TangemPayOnboardingScreenState.Content::class.java,
),
AvailabilityCase(
isAvailable = false,
expectedState = TangemPayOnboardingScreenState.NotAvailable::class.java,
),
)
private fun provideRejectedDeeplinkCases(): List<Either<UniversalError, Boolean>> = listOf(
false.right(),
DEEPLINK_ERROR.left(),
)
private companion object {
val DEEPLINK_ERROR = object : UniversalError {
override val errorCode: Int = 0
}
}
}