Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-22 11:17:28 +02:00
parent 6031a97ffe
commit 8d0f11d10b
67 changed files with 1527 additions and 41 deletions

View file

@ -26,6 +26,10 @@ interface ExpressTransactionsComponent {
data class Params(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val onRatingRequested: (
(txExternalId: String, providerName: String, txExternalUrl: String, userWalletIdStringValue: String) -> Unit
)? = null,
val onRatingDismiss: (() -> Unit)? = null,
)
interface Factory : ComponentFactory<Params, ExpressTransactionsComponent>

View file

@ -58,6 +58,7 @@ dependencies {
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.common.ui)
implementation(projects.features.rating.api)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)

View file

@ -26,6 +26,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent
import com.tangem.features.rating.RatingComponent
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.tokendetails.TokenDetailsComponent
@ -47,6 +48,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val yieldSupplyWarningComponentFactory: YieldSupplyDepositedWarningComponent.Factory,
yieldSupplyComponentFactory: YieldSupplyComponent.Factory,
private val ratingComponentFactory: RatingComponent.Factory,
) : TokenDetailsComponent, AppComponentContext by appComponentContext {
private val model: TokenDetailsModel = getOrCreateModel(params)
@ -64,6 +66,8 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
params = ExpressTransactionsComponent.Params(
userWalletId = params.userWalletId,
currency = params.currency,
onRatingRequested = model::activateRatingForExpressTx,
onRatingDismiss = { model.ratingSlotNavigation.dismiss() },
),
)
@ -74,6 +78,15 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
childFactory = ::bottomSheetChild,
)
private val ratingSlot = childSlot(
key = RATING_SLOT_KEY,
source = model.ratingSlotNavigation,
serializer = null,
childFactory = { params, ctx ->
ratingComponentFactory.create(childByContext(ctx), params)
},
)
private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()?.let { tokenMarketParams ->
tokenMarketBlockComponentFactory.create(
appComponentContext = child("tokenMarketBlockComponent"),
@ -94,11 +107,13 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val ratingSlotState by ratingSlot.subscribeAsState()
NavigationBar3ButtonsScrim()
if (LocalRedesignEnabled.current) {
val tokenDetailsUM by model.redesignUiState.collectAsStateWithLifecycle()
// TODO [REDACTED_TASK_KEY]: wire ratingSlotState into TokenDetailsScreen when redesign is ready
TokenDetailsScreen(
tokenDetailsUM = tokenDetailsUM,
tokenMarketBlockComponent = tokenMarketBlockComponent,
@ -115,6 +130,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
txHistoryComponent = txHistoryComponent,
yieldSupplyComponent = yieldSupplyComponent,
expressTransactionsComponent = expressTransactionsComponent,
ratingComponent = ratingSlotState.child?.instance,
)
}
@ -178,4 +194,8 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
params: TokenDetailsComponent.Params,
): DefaultTokenDetailsComponent
}
companion object {
private const val RATING_SLOT_KEY = "ratingSlot"
}
}

View file

@ -27,6 +27,7 @@ import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.ExpressStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.ExpressStatusFactory
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.tokendetails.ExpressTransactionsEvent
@ -111,6 +112,16 @@ internal class ExpressTransactionsModel @Inject constructor(
val expressTxState = internalUiState.value.transactionsToDisplay.firstOrNull { it.info.txId == txId }
?: return
internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState)
if (expressTxState is ExchangeUM) {
expressTxState.info.txExternalId?.let { txExternalId ->
params.onRatingRequested?.invoke(
txExternalId,
expressTxState.provider.name,
expressTxState.info.txExternalUrl.orEmpty(),
expressTxState.fromUserWalletId.stringValue,
)
}
}
}
override fun onGoToProviderClick(url: String) {
@ -159,6 +170,7 @@ internal class ExpressTransactionsModel @Inject constructor(
)
}
}
params.onRatingDismiss?.invoke()
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
}
@ -170,6 +182,7 @@ internal class ExpressTransactionsModel @Inject constructor(
}
}
}
params.onRatingDismiss?.invoke()
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
}

View file

@ -10,11 +10,18 @@ import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.address.AddressType
import com.tangem.domain.dynamicaddresses.IsDynamicAddressesAvailableUseCase
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.receive.AddressModel
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.features.rating.RatingComponent
import com.tangem.feature.swap.domain.SwapFeedbackUseCase
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
@ -183,6 +190,8 @@ internal class TokenDetailsModel @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val designFeatureToggles: DesignFeatureToggles,
private val redesignStateController: TokenDetailsStateController,
private val swapFeedbackUseCase: SwapFeedbackUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
) : Model(),
TokenDetailsClickIntents,
YieldSupplyDepositedWarningComponent.ModelCallback {
@ -209,6 +218,7 @@ internal class TokenDetailsModel @Inject constructor(
private var isBalanceLoadedEventSent = false
val bottomSheetNavigation: SlotNavigation<TokenDetailsBottomSheetConfig> = SlotNavigation()
val ratingSlotNavigation = SlotNavigation<RatingComponent.Params>()
private val stateFactory = TokenDetailsStateFactory(
currentStateProvider = Provider { uiState.value },
@ -907,6 +917,7 @@ internal class TokenDetailsModel @Inject constructor(
state.copy(pullToRefreshConfig = state.pullToRefreshConfig.copy(isRefreshing = false))
}
}.saveIn(refreshStateJobHolder)
ratingSlotNavigation.dismiss()
}
override fun onCloseRentInfoNotification() {
@ -1079,6 +1090,37 @@ internal class TokenDetailsModel @Inject constructor(
uiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config)
}
fun activateRatingForExpressTx(
txExternalId: String,
providerName: String,
txExternalUrl: String,
userWalletIdStringValue: String,
) {
if (!swapFeatureToggles.isSwapRateExperienceEnabled) return
ratingSlotNavigation.activate(
RatingComponent.Params(
onLoadRating = {
swapFeedbackUseCase.getExistingRating(txExternalId)
.fold(ifLeft = { null }, ifRight = { it?.rating })
},
onSubmitRating = { rating, feedback ->
swapFeedbackUseCase.submit(
SwapFeedbackParams(
userWalletIdHash = userWalletIdStringValue.hexToBytes()
.calculateSha256()
.toHexString(),
providerName = providerName,
txUrl = txExternalUrl,
txExternalId = txExternalId,
rating = rating,
feedback = feedback,
),
).onLeft { TangemLogger.e("Failed to submit swap feedback: $it") }
},
),
)
}
override fun onYieldInfoClick() {
analyticsEventsHandler.send(
YieldSupplyAnalytics.EarnedFundsInfo(

View file

@ -207,9 +207,12 @@ internal class ExpressStatusFactory @AssistedInject constructor(
}
private fun TangemBottomSheetConfig.toBottomSheetSlot(): BottomSheetSlot {
val contentLambda: @Composable () -> Unit = {
val contentLambda: @Composable ((@Composable () -> Unit)?) -> Unit = { extraContent ->
when (this.content) {
is ExpressStatusBottomSheetConfig -> ExpressStatusBottomSheet(config = this)
is ExpressStatusBottomSheetConfig -> ExpressStatusBottomSheet(
config = this,
extraContent = extraContent,
)
}
}
return BottomSheetSlot(config = this, content = contentLambda)

View file

@ -126,7 +126,7 @@ internal fun TokenDetailsScreen(
)
}
expressState.bottomSheetSlot?.content()
expressState.bottomSheetSlot?.content(null)
}
}

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState
import com.tangem.features.rating.RatingComponent
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.components.marketprice.MarketPriceBlock
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
@ -43,7 +44,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
// TODO: Split to blocks [REDACTED_JIRA]
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList")
@Composable
internal fun TokenDetailsScreenLegacy(
state: TokenDetailsState,
@ -51,6 +52,7 @@ internal fun TokenDetailsScreenLegacy(
txHistoryComponent: TxHistoryComponent,
yieldSupplyComponent: YieldSupplyComponent,
expressTransactionsComponent: ExpressTransactionsComponent,
ratingComponent: RatingComponent?,
) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
@ -164,7 +166,9 @@ internal fun TokenDetailsScreenLegacy(
}
}
expressState.bottomSheetSlot?.content()
expressState.bottomSheetSlot?.content(
ratingComponent?.let { comp -> { comp.Content(modifier = Modifier.fillMaxWidth()) } },
)
}
}
@ -198,6 +202,7 @@ private fun TokenDetailsScreenPreview(
}
},
expressTransactionsComponent = PreviewExpressTransactionsComponent,
ratingComponent = null,
)
}
}

View file

@ -15,14 +15,17 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.E
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.exchange.ExchangeStatusBottomSheetContent
@Composable
internal fun ExpressStatusBottomSheet(config: TangemBottomSheetConfig) {
internal fun ExpressStatusBottomSheet(
config: TangemBottomSheetConfig,
extraContent: (@Composable () -> Unit)? = null,
) {
TangemBottomSheet(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
) { content: ExpressStatusBottomSheetConfig ->
when (val state = content.value) {
is ExpressTransactionStateUM.OnrampUM -> OnrampStatusBottomSheetContent(state)
is ExchangeUM -> ExchangeStatusBottomSheetContent(state)
is ExchangeUM -> ExchangeStatusBottomSheetContent(state = state, extraContent = extraContent)
}
}
}

View file

@ -28,7 +28,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.component
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
@Composable
internal fun ExchangeStatusBottomSheetContent(state: ExchangeUM) {
internal fun ExchangeStatusBottomSheetContent(state: ExchangeUM, extraContent: (@Composable () -> Unit)? = null) {
Column(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
@ -70,6 +70,10 @@ internal fun ExchangeStatusBottomSheetContent(state: ExchangeUM) {
imageUrl = state.provider.imageLarge,
)
SpacerH12()
if (extraContent != null) {
extraContent()
SpacerH12()
}
ExchangeStatusBlock(
statuses = state.statuses,
showLink = state.showProviderLink,