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

@ -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))

View file

@ -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 = {}),
),
)

View file

@ -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()
}

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
}
}
}