diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt new file mode 100644 index 0000000000..eafab54206 --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.txhistory.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +interface TxHistoryDetailsComponent : ComposableBottomSheetComponent { + + data class Params( + val txInfo: Flow, + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + val onDismiss: () -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryDetailsComponent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryDetailsComponent.kt new file mode 100644 index 0000000000..12db42a758 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryDetailsComponent.kt @@ -0,0 +1,55 @@ +package com.tangem.features.txhistory.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import com.tangem.features.txhistory.model.TxHistoryDetailsModel +import com.tangem.features.txhistory.ui.TxHistoryDetailsContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultTxHistoryDetailsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: TxHistoryDetailsComponent.Params, +) : TxHistoryDetailsComponent, AppComponentContext by context { + + private val model: TxHistoryDetailsModel = getOrCreateModel(params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = state != null, + onDismissRequest = ::dismiss, + content = state ?: TangemBottomSheetConfigContent.Empty, + ), + title = { + TangemModalBottomSheetTitle(endIconRes = R.drawable.ic_close_24, onEndClick = ::dismiss) + }, + content = { um -> TxHistoryDetailsContent(state = um) }, + ) + } + + @AssistedFactory + interface Factory : TxHistoryDetailsComponent.Factory { + override fun create( + context: AppComponentContext, + params: TxHistoryDetailsComponent.Params, + ): DefaultTxHistoryDetailsComponent + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt new file mode 100644 index 0000000000..9b19315abb --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt @@ -0,0 +1,30 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.network.TxInfo.TransactionType +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import com.tangem.utils.converter.Converter + +/** + * Converts a [TxInfo] to a [TxHistoryDetailsUM] for the in-app transaction details card. + * + * Single dispatch on [TxInfo.type] picks the layout family — mirroring the same `when(type)` used by + * [TxHistoryItemToTransactionItemUMConverter]: + * - [TransactionType.Swap] (and onramp once it lands in `TxInfo`) -> [TxHistoryDetailsUM.TwoAssets] + * - everything else -> [TxHistoryDetailsUM.SingleAsset] + */ +internal class TxInfoToTxHistoryDetailsUMConverter : Converter { + + override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) { + is TransactionType.Swap -> twoAssets(value) + else -> singleAsset(value) + } + + private fun singleAsset(tx: TxInfo): TxHistoryDetailsUM.SingleAsset = TxHistoryDetailsUM.SingleAsset( + title = tx.type.toString(), + ) + + private fun twoAssets(tx: TxInfo): TxHistoryDetailsUM.TwoAssets = TxHistoryDetailsUM.TwoAssets( + title = tx.type.toString(), + ) +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryDetailsModelModule.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryDetailsModelModule.kt new file mode 100644 index 0000000000..1163ae3648 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryDetailsModelModule.kt @@ -0,0 +1,19 @@ +package com.tangem.features.txhistory.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.txhistory.model.TxHistoryDetailsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface TxHistoryDetailsModelModule { + @Binds + @IntoMap + @ClassKey(TxHistoryDetailsModel::class) + fun bindModel(model: TxHistoryDetailsModel): Model +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt index c3d8ea1955..d2b0c4dcb4 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/di/TxHistoryFeatureModule.kt @@ -1,7 +1,9 @@ package com.tangem.features.txhistory.di import com.tangem.features.txhistory.component.DefaultTxHistoryComponent +import com.tangem.features.txhistory.component.DefaultTxHistoryDetailsComponent import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.component.TxHistoryDetailsComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -15,4 +17,10 @@ internal interface TxHistoryFeatureModule { @Binds @Singleton fun bindComponentFactory(factory: DefaultTxHistoryComponent.Factory): TxHistoryComponent.Factory + + @Binds + @Singleton + fun bindTxHistoryDetailsComponentFactory( + factory: DefaultTxHistoryDetailsComponent.Factory, + ): TxHistoryDetailsComponent.Factory } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt new file mode 100644 index 0000000000..27754f168f --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -0,0 +1,29 @@ +package com.tangem.features.txhistory.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +/** + * UI model for the in-app transaction details ("Operation") card. + * + * One model for all transaction types; the layout family is chosen from the transaction type by + * `TxInfoToTxHistoryDetailsUMConverter`: + * - [SingleAsset] — Receive / Send / Transfer + * - [TwoAssets] — Swap / Onramp + */ +@Immutable +internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { + + /** Operation title, status-driven color is resolved at render time. */ + val title: String + + /** Single-asset layout: Receive / Send / Transfer */ + data class SingleAsset( + override val title: String, + ) : TxHistoryDetailsUM + + /** Two-asset layout: Swap / Onramp */ + data class TwoAssets( + override val title: String, + ) : TxHistoryDetailsUM +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt new file mode 100644 index 0000000000..1fa4172f63 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt @@ -0,0 +1,33 @@ +package com.tangem.features.txhistory.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.txhistory.component.TxHistoryDetailsComponent +import com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TxHistoryDetailsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: TxHistoryDetailsComponent.Params = paramsContainer.require() + + private val converter = TxInfoToTxHistoryDetailsUMConverter() + + val uiState: StateFlow = params.txInfo + .map(converter::convert) + .flowOn(dispatchers.default) + .stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null) +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt new file mode 100644 index 0000000000..85a775bf7b --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.txhistory.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM + +@Composable +internal fun TxHistoryDetailsContent(state: TxHistoryDetailsUM, modifier: Modifier = Modifier) { + // Placeholder:card showing only the operation title, to verify tap -> sheet navigation + Box( + modifier = modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level2) + .heightIn(min = 240.dp) + .padding(TangemTheme.dimens2.x6), + contentAlignment = Alignment.Center, + ) { + Text( + text = state.title, + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.headingSemibold28, + textAlign = TextAlign.Center, + ) + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt new file mode 100644 index 0000000000..d005da5c94 --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt @@ -0,0 +1,81 @@ +package com.tangem.features.txhistory.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.network.TxInfo.TransactionType +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TxInfoToTxHistoryDetailsUMConverterTest { + + private val converter = TxInfoToTxHistoryDetailsUMConverter() + + @Test + fun `GIVEN Swap WHEN convert THEN TwoAssets`() { + // Arrange + val tx = txInfo(type = TransactionType.Swap) + + // Act + val result = converter.convert(tx) + + // Assert + assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java) + } + + @Test + fun `GIVEN non-Swap TransactionType WHEN convert THEN SingleAsset`() { + val nonSwapTypes = listOf( + TransactionType.Transfer, + TransactionType.Approve, + TransactionType.Operation(name = "Mint NFT"), + TransactionType.UnknownOperation, + TransactionType.GaslessFee, + TransactionType.Staking.Stake, + TransactionType.Staking.ClaimRewards, + TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS), + TransactionType.YieldSupply.Topup, + TransactionType.YieldSupply.Enter(address = USER_ADDRESS), + ) + + nonSwapTypes.forEach { type -> + val result = converter.convert(txInfo(type = type)) + + assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java) + } + } + + @Test + fun `GIVEN tx WHEN convert THEN title is the transaction type`() { + // Arrange + val type = TransactionType.Transfer + val tx = txInfo(type = type) + + // Act + val result = converter.convert(tx) + + // Assert + assertThat(result.title).isEqualTo(type.toString()) + } + + private fun txInfo(type: TransactionType): TxInfo = TxInfo( + txHash = TX_HASH, + timestampInMillis = TIMESTAMP, + isOutgoing = false, + destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)), + sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS), + interactionAddressType = null, + status = TxInfo.TransactionStatus.Confirmed, + type = type, + amount = BigDecimal.ONE, + ) + + private companion object { + const val TX_HASH = "0xtxhash" + const val TIMESTAMP = 1_700_000_000_000L + const val USER_ADDRESS = "0x1234567890abcdef1234" + const val VALIDATOR_ADDRESS = "0xvalidator" + } +} \ No newline at end of file