Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-09 18:50:35 +03:00
parent bbe46b2ab5
commit 4383291669
9 changed files with 310 additions and 0 deletions

View file

@ -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<TxInfo>,
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val onDismiss: () -> Unit,
)
interface Factory : ComponentFactory<Params, TxHistoryDetailsComponent>
}

View file

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

View file

@ -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<TxInfo, TxHistoryDetailsUM> {
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(),
)
}

View file

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

View file

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

View file

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

View file

@ -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<TxHistoryDetailsUM?> = params.txInfo
.map(converter::convert)
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null)
}

View file

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

View file

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