diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index 022e5924b1..215563541d 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -1905,6 +1905,7 @@
Change PIN-code
Come back to the app if you forget it.
Card
+ Error loading
Change plan
Card related
Plan related
diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTxHistoryDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTxHistoryDetailsUM.kt
index 3cc10af2ab..bc07eaf7a6 100644
--- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTxHistoryDetailsUM.kt
+++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayTxHistoryDetailsUM.kt
@@ -44,7 +44,7 @@ internal data class TangemPayTxHistoryDetailsUMV2(
val subtitle: TextReference,
val iconState: TangemIconUM,
val transactionTitle: TextReference,
- val card: TextReference?,
+ val detail: TransactionDetailUM?,
val transactionCategory: TextReference,
val mcc: TextReference?,
val transactionAmount: String,
@@ -68,6 +68,15 @@ internal enum class TransactionStateType {
Reversed,
}
+@Immutable
+internal sealed interface TransactionDetailUM {
+ data object Loading : TransactionDetailUM
+ data class Content(val card: TextReference) : TransactionDetailUM
+ data class Error(val onRefreshClick: () -> Unit) : TransactionDetailUM
+}
+
+internal enum class TransactionLoadState { Loading, Loaded, Error }
+
@Immutable
internal sealed interface TangemPayTxHistoryDetailsUiState {
data class Legacy(val state: TangemPayTxHistoryDetailsUM) : TangemPayTxHistoryDetailsUiState
diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt
index 1a36dcd1c4..7034bfdc3f 100644
--- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt
+++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt
@@ -17,9 +17,11 @@ import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.features.tangempay.components.TangemPayTransactionBottomSheetComponent
import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUiStates
+import com.tangem.features.tangempay.entity.TransactionLoadState
import com.tangem.features.tangempay.model.transformers.TangemPayTxHistoryDetailsConverter
import com.tangem.features.tangempay.model.transformers.TangemPayTxHistoryDetailsConverterV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -41,16 +43,23 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor(
private val params = paramsContainer.require()
private val transaction = MutableStateFlow(params.transaction)
+ private val transactionLoadState = MutableStateFlow(TransactionLoadState.Loading)
+ private var loadTransactionJob: Job? = null
val uiState: StateFlow = combine(
balanceHidingSettings.isBalanceHidden(),
transaction,
- ) { isBalanceHidden, transaction ->
- buildUiStates(isBalanceHidden, transaction)
+ transactionLoadState,
+ ) { isBalanceHidden, transaction, transactionLoadState ->
+ buildUiStates(isBalanceHidden, transaction, transactionLoadState)
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
- initialValue = buildUiStates(isBalanceHidden = params.isBalanceHidden, transaction = params.transaction),
+ initialValue = buildUiStates(
+ isBalanceHidden = params.isBalanceHidden,
+ transaction = params.transaction,
+ transactionLoadState = transactionLoadState.value,
+ ),
)
init {
@@ -64,17 +73,25 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor(
private fun loadTransaction() {
val current = params.transaction
if (current !is TangemPayTxHistoryItem.Spend) return
- modelScope.launch {
+ if (loadTransactionJob?.isActive == true) return
+ loadTransactionJob = modelScope.launch {
+ transactionLoadState.value = TransactionLoadState.Loading
tangemPayTxHistoryRepository.getTransaction(
userWalletId = params.userWalletId,
transactionId = current.id,
- ).onRight { loaded -> if (loaded != null) transaction.value = loaded }
+ ).onRight { loaded ->
+ if (loaded != null) transaction.value = loaded
+ transactionLoadState.value = TransactionLoadState.Loaded
+ }.onLeft {
+ transactionLoadState.value = TransactionLoadState.Error
+ }
}
}
private fun buildUiStates(
isBalanceHidden: Boolean,
transaction: TangemPayTxHistoryItem,
+ transactionLoadState: TransactionLoadState,
): TangemPayTxHistoryDetailsUiStates {
val converterInput = TangemPayTxHistoryDetailsConverter.Input(
item = transaction,
@@ -89,8 +106,10 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor(
value = TangemPayTxHistoryDetailsConverterV2.Input(
item = converterInput.item,
isBalanceHidden = converterInput.isBalanceHidden,
+ transactionLoadState = transactionLoadState,
onExplorerClick = converterInput.onExplorerClick,
onDisputeClick = converterInput.onDisputeClick,
+ onCardRefreshClick = ::loadTransaction,
onDismiss = converterInput.onDismiss,
),
),
diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryDetailsConverterV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryDetailsConverterV2.kt
index 717983e7eb..248b7fbcb6 100644
--- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryDetailsConverterV2.kt
+++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryDetailsConverterV2.kt
@@ -15,7 +15,9 @@ import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.ButtonState
import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUMV2
+import com.tangem.features.tangempay.entity.TransactionDetailUM
import com.tangem.features.tangempay.entity.TransactionLabelUM
+import com.tangem.features.tangempay.entity.TransactionLoadState
import com.tangem.features.tangempay.entity.TransactionStateType
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
@@ -34,7 +36,7 @@ internal object TangemPayTxHistoryDetailsConverterV2 :
subtitle = transaction.extractDate(),
iconState = transaction.extractIcon(),
transactionTitle = transaction.extractTransactionTitle(),
- card = transaction.extractCard(),
+ detail = value.extractDetail(),
transactionCategory = transaction.extractTransactionCategory(),
mcc = transaction.extractMcc(),
transactionAmount = transaction.extractAmount(),
@@ -290,8 +292,16 @@ internal object TangemPayTxHistoryDetailsConverterV2 :
}
}
- private fun TangemPayTxHistoryItem.extractCard(): TextReference? {
- if (this !is TangemPayTxHistoryItem.Spend) return null
+ private fun Input.extractDetail(): TransactionDetailUM? {
+ val spend = item as? TangemPayTxHistoryItem.Spend ?: return null
+ return when (transactionLoadState) {
+ TransactionLoadState.Loading -> TransactionDetailUM.Loading
+ TransactionLoadState.Error -> TransactionDetailUM.Error(onRefreshClick = onCardRefreshClick)
+ TransactionLoadState.Loaded -> spend.extractCardValue()?.let(TransactionDetailUM::Content)
+ }
+ }
+
+ private fun TangemPayTxHistoryItem.Spend.extractCardValue(): TextReference? {
val name = cardName?.takeIf { it.isNotEmpty() }
val last4 = cardNumberLast4?.takeIf { it.isNotEmpty() }
return when {
@@ -312,8 +322,10 @@ internal object TangemPayTxHistoryDetailsConverterV2 :
data class Input(
val item: TangemPayTxHistoryItem,
val isBalanceHidden: Boolean,
+ val transactionLoadState: TransactionLoadState,
val onExplorerClick: (String?) -> Unit,
val onDisputeClick: () -> Unit,
+ val onCardRefreshClick: () -> Unit,
val onDismiss: () -> Unit,
)
}
\ No newline at end of file
diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangempayTxDetailsUiV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangempayTxDetailsUiV2.kt
index a3eff7a77c..21269f238b 100644
--- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangempayTxDetailsUiV2.kt
+++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangempayTxDetailsUiV2.kt
@@ -2,12 +2,14 @@ package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -30,23 +32,16 @@ import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.button.TangemButton
-import com.tangem.core.ui.ds2.row.TangemRow
-import com.tangem.core.ui.ds2.row.TangemRowContentLead
-import com.tangem.core.ui.ds2.row.TangemRowText
-import com.tangem.core.ui.ds2.row.TangemRowTextRole
-import com.tangem.core.ui.extensions.orMaskWithStars
-import com.tangem.core.ui.extensions.resolveReference
-import com.tangem.core.ui.extensions.resourceReference
-import com.tangem.core.ui.extensions.stringReference
+import com.tangem.core.ui.ds2.row.*
+import com.tangem.core.ui.ds2.shimmers.TangemShimmer
+import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
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_arrow_down_24
+import com.tangem.core.ui.res.generated.icons.ic_arrow_refresh_20
import com.tangem.features.tangempay.details.impl.R
-import com.tangem.features.tangempay.entity.ButtonState
-import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUMV2
-import com.tangem.features.tangempay.entity.TransactionLabelUM
-import com.tangem.features.tangempay.entity.TransactionStateType
+import com.tangem.features.tangempay.entity.*
@Suppress("LongMethod")
@Composable
@@ -233,23 +228,11 @@ internal fun TransactionLabel(label: TransactionLabelUM, modifier: Modifier = Mo
@Composable
private fun TransactionDetailsBlock(state: TangemPayTxHistoryDetailsUMV2, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
- if (state.card != null) {
- TangemRow(
- divider = true,
- contentLead = TangemRowContentLead.Start,
- titleSlot = {
- TangemRowText(
- text = resourceReference(R.string.tangempay_common_card),
- role = TangemRowTextRole.Title,
- )
- },
- valueSlot = {
- TangemRowText(
- text = state.card,
- role = TangemRowTextRole.Value,
- )
- },
- )
+ when (val detail = state.detail) {
+ null -> Unit
+ TransactionDetailUM.Loading -> CardRowShimmer()
+ is TransactionDetailUM.Content -> CardRow(value = detail.card)
+ is TransactionDetailUM.Error -> CardRowError(onRefreshClick = detail.onRefreshClick)
}
TangemRow(
divider = state.mcc != null,
@@ -287,6 +270,79 @@ private fun TransactionDetailsBlock(state: TangemPayTxHistoryDetailsUMV2, modifi
}
}
+@Composable
+private fun CardRow(value: TextReference, modifier: Modifier = Modifier) {
+ TangemRow(
+ modifier = modifier,
+ divider = true,
+ contentLead = TangemRowContentLead.Start,
+ titleSlot = {
+ TangemRowText(
+ text = resourceReference(R.string.tangempay_common_card),
+ role = TangemRowTextRole.Title,
+ )
+ },
+ valueSlot = {
+ TangemRowText(
+ text = value,
+ role = TangemRowTextRole.Value,
+ )
+ },
+ )
+}
+
+@Composable
+private fun CardRowShimmer(modifier: Modifier = Modifier) {
+ TangemRow(
+ modifier = modifier,
+ divider = true,
+ contentLead = TangemRowContentLead.Start,
+ titleSlot = {
+ TangemShimmer(style = TangemTheme.typography3.body.medium)
+ },
+ )
+}
+
+@Composable
+private fun CardRowError(onRefreshClick: () -> Unit, modifier: Modifier = Modifier) {
+ TangemRow(
+ modifier = modifier,
+ divider = true,
+ contentLead = TangemRowContentLead.Start,
+ verticalAlignment = TangemRowVerticalAlignment.Center,
+ titleSlot = {
+ TangemRowText(
+ text = resourceReference(R.string.tangempay_common_card),
+ role = TangemRowTextRole.Title,
+ )
+ },
+ valueSlot = {
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(4.dp))
+ .clickable(onClick = onRefreshClick)
+ .padding(2.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = resourceReference(R.string.tangempay_common_error_loading).resolveReference(),
+ style = TangemTheme.typography3.body.medium,
+ color = TangemTheme.colors3.text.status.error,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = Icons.ic_arrow_refresh_20,
+ contentDescription = null,
+ tint = TangemTheme.colors3.icon.status.error,
+ )
+ }
+ },
+ )
+}
+
@Preview(device = Devices.PIXEL_7_PRO)
@Preview(device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@@ -307,7 +363,7 @@ private class TangemPayTxHistoryDetailsUMProviderV2 :
subtitle = stringReference("12 June 2026, 12:40"),
iconState = TangemIconUM.Icon(iconRes = R.drawable.ic_category_24),
transactionTitle = stringReference("Starbucks"),
- card = stringReference("Basic card *9092"),
+ detail = TransactionDetailUM.Content(stringReference("Basic card *9092")),
transactionCategory = stringReference("Food and drinks"),
mcc = stringReference("5814"),
transactionAmount = "-$5.86",
@@ -329,7 +385,7 @@ private class TangemPayTxHistoryDetailsUMProviderV2 :
subtitle = stringReference("12 June 2026, 12:40"),
iconState = TangemIconUM.Icon(iconRes = R.drawable.ic_category_24),
transactionTitle = stringReference("NuCaloric"),
- card = stringReference("Basic card *9092"),
+ detail = TransactionDetailUM.Content(stringReference("Basic card *9092")),
transactionCategory = stringReference("Groceries"),
mcc = stringReference("0000"),
transactionAmount = "-$820.52",
@@ -352,7 +408,7 @@ private class TangemPayTxHistoryDetailsUMProviderV2 :
subtitle = stringReference("12 June 2026, 12:40"),
iconState = TangemIconUM.Icon(iconRes = R.drawable.ic_category_24),
transactionTitle = stringReference("Starbucks"),
- card = stringReference("Basic card *9092"),
+ detail = TransactionDetailUM.Content(stringReference("Basic card *9092")),
transactionCategory = stringReference("Food and drinks"),
mcc = null,
transactionAmount = "-$5.86",
@@ -374,7 +430,7 @@ private class TangemPayTxHistoryDetailsUMProviderV2 :
subtitle = stringReference("12 June 2026, 12:40"),
iconState = TangemIconUM.Icon(iconRes = R.drawable.ic_percent_24),
transactionTitle = stringReference("Service fees"),
- card = null,
+ detail = null,
transactionCategory = stringReference("Service fees"),
mcc = null,
transactionAmount = "-$5.86",
@@ -397,7 +453,7 @@ private class TangemPayTxHistoryDetailsUMProviderV2 :
subtitle = stringReference("12 June 2026, 12:40"),
iconState = TangemIconUM.Icon(imageVector = Icons.ic_arrow_down_24),
transactionTitle = resourceReference(R.string.common_transfer),
- card = null,
+ detail = null,
transactionCategory = resourceReference(R.string.common_transfer),
mcc = null,
transactionAmount = "+$20",
@@ -409,5 +465,49 @@ private class TangemPayTxHistoryDetailsUMProviderV2 :
),
dismiss = {},
),
+ TangemPayTxHistoryDetailsUMV2(
+ isBalanceHidden = false,
+ title = resourceReference(R.string.tangem_pay_purchase),
+ subtitle = stringReference("12 June 2026, 12:40"),
+ iconState = TangemIconUM.Icon(iconRes = R.drawable.ic_category_24),
+ transactionTitle = stringReference("Starbucks"),
+ detail = TransactionDetailUM.Loading,
+ transactionCategory = stringReference("Food and drinks"),
+ mcc = stringReference("5814"),
+ transactionAmount = "-$5.86",
+ localTransactionText = null,
+ label = TransactionLabelUM(
+ transactionStateType = TransactionStateType.Completed,
+ icon = TangemIconUM.Icon(iconRes = R.drawable.ic_token_info_24),
+ title = resourceReference(R.string.tangem_pay_status_completed),
+ ),
+ buttonState = ButtonState(
+ text = resourceReference(R.string.tangem_pay_get_help),
+ onClick = {},
+ ),
+ dismiss = {},
+ ),
+ TangemPayTxHistoryDetailsUMV2(
+ isBalanceHidden = false,
+ title = resourceReference(R.string.tangem_pay_purchase),
+ subtitle = stringReference("12 June 2026, 12:40"),
+ iconState = TangemIconUM.Icon(iconRes = R.drawable.ic_category_24),
+ transactionTitle = stringReference("Starbucks"),
+ detail = TransactionDetailUM.Error(onRefreshClick = {}),
+ transactionCategory = stringReference("Food and drinks"),
+ mcc = stringReference("5814"),
+ transactionAmount = "-$5.86",
+ localTransactionText = null,
+ label = TransactionLabelUM(
+ transactionStateType = TransactionStateType.Completed,
+ icon = TangemIconUM.Icon(iconRes = R.drawable.ic_token_info_24),
+ title = resourceReference(R.string.tangem_pay_status_completed),
+ ),
+ buttonState = ButtonState(
+ text = resourceReference(R.string.tangem_pay_get_help),
+ onClick = {},
+ ),
+ dismiss = {},
+ ),
),
)
\ No newline at end of file
diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt
index 2a67c729c8..1c7e5f6f68 100644
--- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt
+++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt
@@ -3,6 +3,46 @@ package com.tangem.features.tangempay
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
+import com.tangem.domain.visa.model.TangemPayTxHistoryItem
+import org.joda.time.DateTime
+import java.math.BigDecimal
+import java.util.Currency
+
+internal fun spendTransaction(
+ id: String = "tx_1",
+ cardName: String? = "Basic card",
+ cardNumberLast4: String? = "9092",
+ status: TangemPayTxHistoryItem.Status = TangemPayTxHistoryItem.Status.COMPLETED,
+): TangemPayTxHistoryItem.Spend = TangemPayTxHistoryItem.Spend(
+ id = id,
+ jsonRepresentation = "{}",
+ date = DateTime(0L),
+ amount = BigDecimal.TEN,
+ currency = Currency.getInstance("USD"),
+ authorizedAmount = BigDecimal.TEN,
+ localAmount = null,
+ localCurrency = null,
+ enrichedMerchantName = null,
+ merchantName = "Merchant",
+ enrichedMerchantCategory = null,
+ merchantCategoryCode = null,
+ merchantCategory = null,
+ status = status,
+ enrichedMerchantIconUrl = null,
+ declinedReason = null,
+ cardName = cardName,
+ cardNumberLast4 = cardNumberLast4,
+)
+
+internal fun paymentTransaction(id: String = "tx_payment_1"): TangemPayTxHistoryItem.Payment =
+ TangemPayTxHistoryItem.Payment(
+ id = id,
+ jsonRepresentation = "{}",
+ date = DateTime(0L),
+ amount = BigDecimal.TEN,
+ currency = Currency.getInstance("USD"),
+ transactionHash = null,
+ )
internal fun tangemPayCard(
id: String = "card_1",
diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModelTest.kt
new file mode 100644
index 0000000000..95f151b1f1
--- /dev/null
+++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModelTest.kt
@@ -0,0 +1,195 @@
+package com.tangem.features.tangempay.model
+
+import android.text.format.DateFormat
+import arrow.core.Either
+import arrow.core.left
+import arrow.core.right
+import com.google.common.truth.Truth.assertThat
+import com.tangem.core.decompose.model.MutableParamsContainer
+import com.tangem.core.ui.extensions.stringReference
+import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
+import com.tangem.domain.models.wallet.UserWalletId
+import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
+import com.tangem.domain.visa.error.VisaApiError
+import com.tangem.domain.visa.model.TangemPayTxHistoryItem
+import com.tangem.features.tangempay.components.TangemPayTransactionBottomSheetComponent
+import com.tangem.features.tangempay.entity.TransactionDetailUM
+import com.tangem.features.tangempay.paymentTransaction
+import com.tangem.features.tangempay.spendTransaction
+import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
+import io.mockk.clearMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.flowOf
+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.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+internal class TangemPayTxHistoryDetailsModelTest {
+
+ private val userWalletId = UserWalletId("123")
+ private val repository: TangemPayTxHistoryRepository = mockk()
+ private val balanceHidingSettings: GetBalanceHidingSettingsUseCase = mockk()
+
+ @BeforeEach
+ fun setup() {
+ clearMocks(repository, balanceHidingSettings)
+ every { balanceHidingSettings.isBalanceHidden() } returns flowOf(false)
+ mockkStatic(DateFormat::class)
+ every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
+ }
+
+ @AfterEach
+ fun tearDown() {
+ unmockkStatic(DateFormat::class)
+ }
+
+ @Test
+ fun `GIVEN spend transaction WHEN detail fetch in flight THEN card is Loading`() = runTest {
+ // Arrange
+ coEvery { repository.getTransaction(any(), any()) } coAnswers { awaitCancellation() }
+ val model = createModel(testScope = this, transaction = spendTransaction())
+
+ // Act
+ advanceUntilIdle()
+
+ // Assert
+ assertThat(model.uiState.value.redesign.detail).isEqualTo(TransactionDetailUM.Loading)
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN spend transaction WHEN detail fetch succeeds THEN card shows loaded name and last4`() = runTest {
+ // Arrange — the sheet opens with no card info, the detail fetch back-fills it
+ val loaded = spendTransaction(cardName = "Loaded card", cardNumberLast4 = "4321")
+ coEvery { repository.getTransaction(any(), any()) } returns loaded.right()
+ val model = createModel(
+ testScope = this,
+ transaction = spendTransaction(cardName = null, cardNumberLast4 = null),
+ )
+
+ // Act
+ advanceUntilIdle()
+
+ // Assert
+ assertThat(model.uiState.value.redesign.detail)
+ .isEqualTo(TransactionDetailUM.Content(stringReference("Loaded card *4321")))
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN spend transaction WHEN detail fetch succeeds without card info THEN card row is hidden`() = runTest {
+ // Arrange
+ coEvery { repository.getTransaction(any(), any()) } returns
+ spendTransaction(cardName = null, cardNumberLast4 = null).right()
+ val model = createModel(testScope = this, transaction = spendTransaction())
+
+ // Act
+ advanceUntilIdle()
+
+ // Assert
+ assertThat(model.uiState.value.redesign.detail).isNull()
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN spend transaction WHEN detail fetch fails THEN card is Error`() = runTest {
+ // Arrange
+ coEvery { repository.getTransaction(any(), any()) } returns VisaApiError.Unspecified.left()
+ val model = createModel(testScope = this, transaction = spendTransaction())
+
+ // Act
+ advanceUntilIdle()
+
+ // Assert
+ assertThat(model.uiState.value.redesign.detail).isInstanceOf(TransactionDetailUM.Error::class.java)
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN card error WHEN refresh clicked and fetch succeeds THEN card shows value`() = runTest {
+ // Arrange — first fetch fails, retry succeeds
+ val loaded = spendTransaction(cardName = "Loaded card", cardNumberLast4 = "4321")
+ coEvery {
+ repository.getTransaction(any(), any())
+ } returnsMany listOf>(
+ VisaApiError.Unspecified.left(),
+ loaded.right(),
+ )
+ val model = createModel(testScope = this, transaction = spendTransaction())
+ advanceUntilIdle()
+ val errorState = model.uiState.value.redesign.detail
+ assertThat(errorState).isInstanceOf(TransactionDetailUM.Error::class.java)
+
+ // Act — tap refresh
+ (errorState as TransactionDetailUM.Error).onRefreshClick()
+ advanceUntilIdle()
+
+ // Assert
+ assertThat(model.uiState.value.redesign.detail)
+ .isEqualTo(TransactionDetailUM.Content(stringReference("Loaded card *4321")))
+ coVerify(exactly = 2) { repository.getTransaction(any(), any()) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN non-spend transaction WHEN model created THEN card is hidden and detail not fetched`() = runTest {
+ // Arrange
+ val model = createModel(testScope = this, transaction = paymentTransaction())
+
+ // Act
+ advanceUntilIdle()
+
+ // Assert
+ assertThat(model.uiState.value.redesign.detail).isNull()
+ coVerify(exactly = 0) { repository.getTransaction(any(), any()) }
+ model.onDestroy()
+ }
+
+ private fun createModel(
+ testScope: TestScope,
+ transaction: TangemPayTxHistoryItem,
+ ): TangemPayTxHistoryDetailsModel {
+ val params = TangemPayTransactionBottomSheetComponent.Params(
+ isBalanceHidden = false,
+ transaction = transaction,
+ userWalletId = userWalletId,
+ customerId = "customer_1",
+ onDismiss = {},
+ )
+ return TangemPayTxHistoryDetailsModel(
+ dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
+ getWalletMetaInfoUseCase = mockk(relaxed = true),
+ sendFeedbackEmailUseCase = mockk(relaxed = true),
+ urlOpener = mockk(relaxed = true),
+ balanceHidingSettings = balanceHidingSettings,
+ tangemPayTxHistoryRepository = repository,
+ analytics = mockk(relaxed = true),
+ paramsContainer = MutableParamsContainer(params),
+ )
+ }
+
+ private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
+ val testDispatcher = StandardTestDispatcher(testScheduler)
+ return TestingCoroutineDispatcherProvider(
+ main = testDispatcher,
+ mainImmediate = testDispatcher,
+ io = testDispatcher,
+ default = testDispatcher,
+ single = testDispatcher,
+ )
+ }
+}
\ No newline at end of file