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