From af8544e6d7295a3f6eba6622ff3923357a441515 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Apr 2026 11:50:14 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/common/ui/earn/EarnBlock.kt | 315 ++++++++++++++++++ .../com/tangem/common/ui/earn/EarnBlockUM.kt | 71 ++++ .../tangem/core/ui/ds/button/TangemButton.kt | 2 +- .../model/TokenDetailsDialogFactory.kt | 16 + .../tokendetails/model/TokenDetailsModel.kt | 116 ++++--- .../state/TokenDetailsStateController.kt | 3 +- .../tokendetails/state/TokenDetailsUM.kt | 5 +- .../UpdateNotificationsTransformer.kt | 225 +++++++++++++ .../UpdateStakingNotificationTransformer.kt | 298 +++++++++++++++++ .../tokendetails/ui/TokenDetailsScreen.kt | 46 ++- .../ui/TokenDetailsScreenLegacy.kt | 4 +- ...ingBlock.kt => TokenStakingBlockLegacy.kt} | 12 +- 12 files changed, 1048 insertions(+), 65 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt create mode 100644 common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt rename features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/{TokenStakingBlock.kt => TokenStakingBlockLegacy.kt} (95%) diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt new file mode 100644 index 0000000000..916fe0e737 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt @@ -0,0 +1,315 @@ +package com.tangem.common.ui.earn + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.innerShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.ds.button.TangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.resolveAnnotatedReference +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.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.res.R as CoreResR + +private const val TINTED_BACKGROUND_ALPHA = 0.1f +private const val TINTED_BORDER_ALPHA = 0.1f +private const val TINTED_INNER_SHADOW_ALPHA = 0.3f +private val BorderWidth = 1.dp +private val InnerShadowBlur = 20.dp +private val ShimmerSubtitleWidth = 78.dp + +@Composable +fun EarnBlock(state: EarnBlockUM, modifier: Modifier = Modifier) { + when (state) { + is EarnBlockUM.Loading -> EarnBlockLoading(modifier) + is EarnBlockUM.Content -> EarnBlockContent(state, modifier) + } +} + +@Composable +private fun EarnBlockLoading(modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(TangemTheme.dimens2.x4) + TangemRowContainer( + modifier = modifier + .clip(shape) + .background(TangemTheme.colors2.surface.level3) + .border(width = BorderWidth, color = TangemTheme.colors2.border.neutral.primary, shape = shape), + contentPadding = PaddingValues(all = TangemTheme.dimens2.x3), + content = { + CircleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3) + .size(TangemTheme.dimens2.x10), + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .size(width = TangemTheme.dimens2.x16, height = TangemTheme.dimens2.x5), + radius = TangemTheme.dimens2.x2, + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .size(width = ShimmerSubtitleWidth, height = TangemTheme.dimens2.x4), + radius = TangemTheme.dimens2.x2, + ) + }, + ) +} + +@Composable +private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(TangemTheme.dimens2.x4) + + val backgroundModifier = resolveBackgroundModifier(state.backgroundUM, shape) + + val clickModifier = when (val trailing = state.trailingUM) { + is EarnBlockUM.TrailingUM.Balance -> Modifier.clickable(onClick = trailing.onClick) + else -> Modifier + } + + TangemRowContainer( + modifier = modifier + .clip(shape) + .then(backgroundModifier) + .then(clickModifier), + contentPadding = PaddingValues(all = TangemTheme.dimens2.x3), + content = { + // Icon (HEAD) + EarnBlockIcon( + iconUM = state.iconUM, + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3), + ) + + // Title (START_TOP) + Text( + text = state.titleUM.text.resolveReference(), + style = when (state.titleUM.style) { + EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 + }, + color = state.titleUM.color(), + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .padding(end = TangemTheme.dimens2.x2), + ) + + // Subtitle (START_BOTTOM) + val subtitle = state.subtitleUM + if (subtitle is EarnBlockUM.SubtitleUM.Text) { + Text( + text = subtitle.text.resolveReference(), + style = when (subtitle.style) { + EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 + }, + color = subtitle.color(), + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .padding(end = TangemTheme.dimens2.x2), + ) + } + + // Trailing + EarnBlockTrailing(trailingUM = state.trailingUM) + }, + ) +} + +@Composable +private fun resolveBackgroundModifier(backgroundUM: EarnBlockUM.BackgroundUM, shape: RoundedCornerShape): Modifier { + return when (backgroundUM) { + is EarnBlockUM.BackgroundUM.Surface -> Modifier + .background(TangemTheme.colors2.surface.level3) + .border(width = BorderWidth, color = TangemTheme.colors2.border.neutral.primary, shape = shape) + is EarnBlockUM.BackgroundUM.Tinted -> { + val tintColor = backgroundUM.color() + Modifier + .background(tintColor.copy(alpha = TINTED_BACKGROUND_ALPHA)) + .border(width = BorderWidth, color = tintColor.copy(alpha = TINTED_BORDER_ALPHA), shape = shape) + .innerShadow( + shape = shape, + shadow = Shadow( + radius = InnerShadowBlur, + color = tintColor.copy(alpha = TINTED_INNER_SHADOW_ALPHA), + offset = DpOffset.Zero, + ), + ) + } + } +} + +@Composable +private fun EarnBlockTrailing(trailingUM: EarnBlockUM.TrailingUM?) { + when (trailingUM) { + is EarnBlockUM.TrailingUM.Button -> { + TangemButton( + buttonUM = trailingUM.buttonUM, + modifier = Modifier.layoutId(TangemRowLayoutId.TAIL), + ) + } + is EarnBlockUM.TrailingUM.Balance -> { + if (!trailingUM.isBalanceHidden) { + Text( + text = trailingUM.fiatValue.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), + ) + Text( + text = trailingUM.cryptoValue.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), + ) + } + } + null -> Unit + } +} + +@Composable +private fun EarnBlockIcon(iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(TangemTheme.dimens2.x10), + ) { + if (iconUM is EarnBlockUM.IconUM.Glowing) { + val glowShape = RoundedCornerShape(percent = 50) + Box( + modifier = Modifier + .size(TangemTheme.dimens2.x6) + .blur(radius = TangemTheme.dimens2.x4, edgeTreatment = BlurredEdgeTreatment.Unbounded) + .background(color = iconUM.glowColor().copy(alpha = 0.7f), shape = glowShape), + ) + } + val iconRes = when (iconUM) { + is EarnBlockUM.IconUM.Glowing -> iconUM.iconRes + is EarnBlockUM.IconUM.Plain -> iconUM.iconRes + } + TangemIcon( + tangemIconUM = TangemIconUM.Image(imageRes = iconRes), + modifier = Modifier.size(TangemTheme.dimens2.x10), + ) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EarnBlock_Preview(@PreviewParameter(EarnBlockPreviewProvider::class) state: EarnBlockUM) { + TangemThemePreviewRedesign { + EarnBlock( + state = state, + modifier = Modifier.padding(TangemTheme.dimens2.x4), + ) + } +} + +private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider( + collection = listOf( + EarnBlockUM.Loading, + EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_staking_disable_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.staking_native), + style = EarnBlockUM.TitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.tertiary }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.staking_notification_network_error_text), + style = EarnBlockUM.SubtitleUM.Style.Small, + color = { TangemTheme.colors2.text.neutral.tertiary }, + ), + trailingUM = null, + ), + EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Tinted { TangemTheme.colors2.border.status.accent }, + iconUM = EarnBlockUM.IconUM.Glowing( + iconRes = R.drawable.ic_staking_40, + glowColor = { TangemTheme.colors2.border.status.accent }, + ), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.token_details_staking_block_title), + style = EarnBlockUM.TitleUM.Style.Small, + color = { TangemTheme.colors2.text.status.accent }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stringReference("Average APR 5.24%"), + style = EarnBlockUM.SubtitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.primary }, + ), + trailingUM = EarnBlockUM.TrailingUM.Button( + buttonUM = TangemButtonUM( + text = stringReference("Stake"), + type = TangemButtonType.Accent, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + onClick = {}, + ), + ), + ), + EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing( + iconRes = R.drawable.ic_staking_40, + glowColor = { TangemTheme.colors2.text.status.accent }, + ), + titleUM = EarnBlockUM.TitleUM( + text = stringReference("Native staking"), + style = EarnBlockUM.TitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.primary }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stringReference("$ 12.34 rewards"), + style = EarnBlockUM.SubtitleUM.Style.Small, + color = { TangemTheme.colors2.text.status.accent }, + ), + trailingUM = EarnBlockUM.TrailingUM.Balance( + fiatValue = stringReference("$ 500.17"), + cryptoValue = stringReference("500.00 SOL"), + isBalanceHidden = false, + onClick = {}, + ), + ), + ), +) +// endregion \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt new file mode 100644 index 0000000000..adfd69841f --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt @@ -0,0 +1,71 @@ +package com.tangem.common.ui.earn + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.ColorReference2 +import com.tangem.core.ui.extensions.TextReference + +@Immutable +sealed interface EarnBlockUM { + + data object Loading : EarnBlockUM + + data class Content( + val backgroundUM: BackgroundUM, + val iconUM: IconUM, + val titleUM: TitleUM, + val subtitleUM: SubtitleUM?, + val trailingUM: TrailingUM?, + ) : EarnBlockUM + + @Immutable + sealed interface BackgroundUM { + data object Surface : BackgroundUM + data class Tinted(val color: ColorReference2) : BackgroundUM + } + + @Immutable + sealed interface IconUM { + data class Glowing( + @DrawableRes val iconRes: Int, + val glowColor: ColorReference2, + ) : IconUM + + data class Plain( + @DrawableRes val iconRes: Int, + ) : IconUM + } + + @Immutable + data class TitleUM( + val text: TextReference, + val style: Style, + val color: ColorReference2, + ) { + enum class Style { Large, Small } + } + + @Immutable + sealed interface SubtitleUM { + data class Text( + val text: TextReference, + val style: Style, + val color: ColorReference2, + ) : SubtitleUM + + enum class Style { Large, Small } + } + + @Immutable + sealed interface TrailingUM { + data class Button(val buttonUM: TangemButtonUM) : TrailingUM + + data class Balance( + val fiatValue: TextReference, + val cryptoValue: TextReference, + val isBalanceHidden: Boolean, + val onClick: () -> Unit, + ) : TrailingUM + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt index 60ac1aebea..7c51476e5b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt @@ -47,7 +47,7 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { iconPosition = buttonUM.iconPosition, isEnabled = buttonUM.isEnabled, isLoading = buttonUM.isLoading, - type = TangemButtonType.Positive, + type = TangemButtonType.Accent, size = buttonUM.size, shape = buttonUM.shape, ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt index df70f292b1..7a986acecf 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt @@ -70,4 +70,20 @@ internal class TokenDetailsDialogFactory @Inject constructor( fun showError(text: TextReference) { uiMessageSender.send(DialogMessage(message = text)) } + + fun showConfirmHideExpressStatus(onConfirm: () -> Unit) { + uiMessageSender.send( + DialogMessage( + title = resourceReference(R.string.express_status_hide_dialog_title), + message = resourceReference(R.string.express_status_hide_dialog_text), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_hide), + onClick = onConfirm, + ) + }, + secondActionBuilder = { cancelAction() }, + ), + ) + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index d7f1bb3ec4..11185ac5f1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -42,8 +42,6 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager -import com.tangem.core.ui.message.DialogMessage -import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase @@ -110,6 +108,8 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.transform import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateStakingNotificationTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateNotificationsTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R @@ -128,6 +128,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @Suppress("LongParameterList", "LargeClass", "TooManyFunctions", "PropertyUsedBeforeDeclaration") +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Stable @ModelScoped internal class TokenDetailsModel @Inject constructor( @@ -204,6 +205,7 @@ internal class TokenDetailsModel @Inject constructor( private val yieldSupplyBalanceJobHolder = JobHolder() private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() private val redesignBalanceJobHolder = JobHolder() + private val redesignEarnJobHolder = JobHolder() private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null private var account: Account.CryptoPortfolio? = null @@ -386,6 +388,13 @@ internal class TokenDetailsModel @Inject constructor( val updatedState = stateFactory.getStateWithNotifications(warnings) notificationsAnalyticsSender.send(uiState.value, updatedState.notifications) uiState.value = updatedState + + redesignStateController.update( + UpdateNotificationsTransformer( + warnings = warnings, + clickIntents = this@TokenDetailsModel, + ), + ) } .launchIn(modelScope) .saveIn(warningsJobHolder) @@ -797,9 +806,9 @@ internal class TokenDetailsModel @Inject constructor( ) if (canHide) { - showConfirmHideTokenDialog(cryptoCurrency) + dialogFactory.showConfirmHideToken(currency = cryptoCurrency, onConfirm = ::onHideConfirmed) } else { - showLinkedTokensDialog(cryptoCurrency) + dialogFactory.showLinkedTokens(currency = cryptoCurrency) } } } @@ -1021,7 +1030,7 @@ internal class TokenDetailsModel @Inject constructor( } } if (message != null) { - showErrorDialog(stringReference(message)) + dialogFactory.showError(text = stringReference(message)) TangemLogger.e(message) } }, @@ -1065,7 +1074,7 @@ internal class TokenDetailsModel @Inject constructor( } if (message != null) { - showErrorDialog(message) + dialogFactory.showError(text = message) } }, ifRight = { uiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() }, @@ -1080,7 +1089,9 @@ internal class TokenDetailsModel @Inject constructor( blockchain = cryptoCurrency.network.name, ), ) - showDismissIncompleteTransactionConfirmDialog() + dialogFactory.showDismissIncompleteTransactionConfirm( + onConfirm = ::onConfirmDismissIncompleteTransactionClick, + ) } override fun onConfirmDismissIncompleteTransactionClick() { @@ -1090,7 +1101,7 @@ internal class TokenDetailsModel @Inject constructor( currency = cryptoCurrency, ).fold( ifLeft = { e -> - showErrorDialog(stringReference(e.message.orEmpty())) + dialogFactory.showError(text = stringReference(e.message.orEmpty())) TangemLogger.e("Error: $e") }, ifRight = { @@ -1115,7 +1126,8 @@ internal class TokenDetailsModel @Inject constructor( ifLeft = { e -> when (e) { is AssociateAssetError.NotEnoughBalance -> { - showErrorDialog( + dialogFactory.showError( + text = resourceReference( id = R.string.warning_hedera_token_association_not_enough_hbar_message, formatArgs = wrappedList(e.feeCurrency.symbol), @@ -1123,7 +1135,7 @@ internal class TokenDetailsModel @Inject constructor( ) } is AssociateAssetError.DataError -> { - showErrorDialog(stringReference(e.message.orEmpty())) + dialogFactory.showError(text = stringReference(e.message.orEmpty())) TangemLogger.e("Error: $e") } } @@ -1138,7 +1150,7 @@ internal class TokenDetailsModel @Inject constructor( } override fun onConfirmDisposeExpressStatus() { - showConfirmHideExpressStatusDialog() + dialogFactory.showConfirmHideExpressStatus(onConfirm = ::onDisposeExpressStatus) } override fun onDisposeExpressStatus() { @@ -1172,7 +1184,7 @@ internal class TokenDetailsModel @Inject constructor( private fun handleUnavailabilityReason(unavailabilityReason: ScenarioUnavailabilityReason): Boolean { if (unavailabilityReason == ScenarioUnavailabilityReason.None) return false - showErrorDialog(unavailabilityReason.getUnavailabilityReasonText()) + dialogFactory.showError(text = unavailabilityReason.getUnavailabilityReasonText()) return true } @@ -1201,42 +1213,6 @@ internal class TokenDetailsModel @Inject constructor( uiMessageSender.send(SnackbarMessage(resourceReference(R.string.staking_error_no_validators_title))) } - private fun showConfirmHideTokenDialog(currency: CryptoCurrency) { - dialogFactory.showConfirmHideToken(currency = currency, onConfirm = ::onHideConfirmed) - } - - private fun showLinkedTokensDialog(currency: CryptoCurrency) { - dialogFactory.showLinkedTokens(currency = currency) - } - - private fun showDismissIncompleteTransactionConfirmDialog() { - dialogFactory.showDismissIncompleteTransactionConfirm( - onConfirm = ::onConfirmDismissIncompleteTransactionClick, - ) - } - - private fun showConfirmHideExpressStatusDialog() { - uiMessageSender.send( - DialogMessage( - title = resourceReference(R.string.express_status_hide_dialog_title), - message = resourceReference(R.string.express_status_hide_dialog_text), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.common_hide), - onClick = { - onDisposeExpressStatus() - }, - ) - }, - secondActionBuilder = { cancelAction() }, - ), - ) - } - - private fun showErrorDialog(text: TextReference) { - dialogFactory.showError(text = text) - } - private fun checkForActionUpdates() { combine( tokenDetailsDeepLinkActionListener.tokenDetailsActionFlow, @@ -1424,6 +1400,50 @@ internal class TokenDetailsModel @Inject constructor( observeRedesignBalance() updateRedesignTopBarMenu() observeRedesignTopBarTitle() + observeRedesignStakingNotification() + } + + private fun observeRedesignStakingNotification() { + val statusFlow = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .map { it.status } + .distinctUntilChanged() + + val availabilityFlow = getStakingAvailabilityUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + .map { it.getOrElse { StakingAvailability.Unavailable } } + .distinctUntilChanged() + + val entryInfoFlow = availabilityFlow.mapLatest { availability -> + (availability as? StakingAvailability.Available)?.let { available -> + getStakingEntryInfoUseCase( + cryptoCurrencyId = cryptoCurrency.id, + symbol = cryptoCurrency.symbol, + stakingOption = available.option, + ).getOrNull() + } + } + + combine( + flow = statusFlow, + flow2 = availabilityFlow, + flow3 = entryInfoFlow, + flow4 = selectedAppCurrencyFlow, + ) { status, availability, entryInfo, appCurrency -> + redesignStateController.update( + UpdateStakingNotificationTransformer( + cryptoCurrencyStatus = status, + stakingAvailability = availability, + stakingEntryInfo = entryInfo, + appCurrency = appCurrency, + clickIntents = this, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + .saveIn(redesignEarnJobHolder) } private fun initRedesignState() { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt index 7fcf3fadd1..6175a1fdd6 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt @@ -61,8 +61,9 @@ internal class TokenDetailsStateController @Inject constructor() { tokenBalanceTypeUM = TokenBalanceTypeUM.Single, currencyIconState = CurrencyIconState.Loading, ), + notifications = persistentListOf(), + earnBlockState = null, marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = ""), - stakingBlocksState = null, pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, onRefresh = {}, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt index 91ee9dc832..f67bd6b402 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt @@ -5,7 +5,9 @@ import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.common.ui.earn.EarnBlockUM import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.message.TangemMessageUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -13,8 +15,9 @@ import kotlinx.collections.immutable.ImmutableList internal data class TokenDetailsUM( val topAppBarUM: TokenDetailsTopAppBarUM, val balanceBlockUM: TokenDetailsBalanceBlockUM, + val notifications: ImmutableList, + val earnBlockState: EarnBlockUM?, val marketPriceBlockState: MarketPriceBlockState, - val stakingBlocksState: StakingBlockUM?, val pullToRefreshConfig: PullToRefreshConfig, val isBalanceHidden: Boolean, val isMarketPriceAvailable: Boolean, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt new file mode 100644 index 0000000000..a8ecb28437 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt @@ -0,0 +1,225 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.message.TangemMessageButtonUM +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.message.TangemMessageUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.tokens.model.warnings.HederaWarnings +import com.tangem.domain.tokens.model.warnings.KaspaWarnings +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import com.tangem.core.res.R as CoreResR + +internal class UpdateNotificationsTransformer( + private val warnings: Set, + private val clickIntents: TokenDetailsClickIntents, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val notifications = warnings.mapNotNull(::mapWarning).toImmutableList() + return prevState.copy(notifications = notifications) + } + + @Suppress("LongMethod") + private fun mapWarning(warning: CryptoCurrencyWarning): TangemMessageUM? { + return when (warning) { + is CryptoCurrencyWarning.SomeNetworksUnreachable -> TangemMessageUM( + id = "networks_unreachable", + title = resourceReference(CoreResR.string.warning_network_unreachable_title), + subtitle = resourceReference(CoreResR.string.warning_network_unreachable_message), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + ) + is CryptoCurrencyWarning.BalanceNotEnoughForFee -> createFeeWarning( + FeeWarningParams( + id = "balance_not_enough_for_fee", + currency = warning.tokenCurrency, + networkName = warning.coinCurrency.network.name, + feeCurrencyName = warning.coinCurrency.name, + feeCurrencySymbol = warning.coinCurrency.symbol, + buyCurrency = warning.coinCurrency, + ), + ) + is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> createFeeWarning( + FeeWarningParams( + id = "custom_token_not_enough_for_fee", + currency = warning.currency, + networkName = warning.feeCurrency?.network?.name ?: warning.networkName, + feeCurrencyName = warning.feeCurrencyName, + feeCurrencySymbol = warning.feeCurrencySymbol, + buyCurrency = warning.feeCurrency, + ), + ) + is CryptoCurrencyWarning.BeaconChainShutdown -> TangemMessageUM( + id = "beacon_chain_shutdown", + title = resourceReference(CoreResR.string.warning_beacon_chain_retirement_title), + subtitle = resourceReference(CoreResR.string.warning_beacon_chain_retirement_content), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + ) + is HederaWarnings.AssociateWarning -> TangemMessageUM( + id = "hedera_associate", + title = resourceReference(CoreResR.string.warning_hedera_missing_token_association_title), + subtitle = resourceReference(CoreResR.string.warning_hedera_missing_token_association_message_brief), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_hedera_missing_token_association_button_title), + type = TangemButtonType.Primary, + onClick = clickIntents::onAssociateClick, + ), + ), + ) + is HederaWarnings.AssociateWarningWithFee -> TangemMessageUM( + id = "hedera_associate_fee", + title = resourceReference(CoreResR.string.warning_hedera_missing_token_association_title), + subtitle = resourceReference( + CoreResR.string.warning_hedera_missing_token_association_message, + wrappedList( + warning.fee.format { crypto(symbol = "", decimals = warning.feeCurrencyDecimals) }, + warning.feeCurrencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_hedera_missing_token_association_button_title), + type = TangemButtonType.Primary, + onClick = clickIntents::onAssociateClick, + ), + ), + ) + is CryptoCurrencyWarning.RequiredTrustline -> TangemMessageUM( + id = "required_trustline", + title = resourceReference(CoreResR.string.warning_token_trustline_title), + subtitle = resourceReference( + CoreResR.string.warning_token_trustline_subtitle, + wrappedList( + warning.requiredAmount.format { crypto(symbol = "", warning.currencyDecimals) }.trim(), + warning.currencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_token_trustline_button_title), + type = TangemButtonType.Primary, + onClick = clickIntents::onOpenTrustlineClick, + ), + ), + ) + is KaspaWarnings.IncompleteTransaction -> TangemMessageUM( + id = "kaspa_incomplete", + title = resourceReference(CoreResR.string.warning_kaspa_unfinished_token_transaction_title), + subtitle = resourceReference( + CoreResR.string.warning_kaspa_unfinished_token_transaction_message, + wrappedList( + warning.amount.format { crypto(symbol = "", decimals = warning.currencyDecimals) }, + warning.currencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.alert_button_try_again), + type = TangemButtonType.Primary, + onClick = clickIntents::onRetryIncompleteTransactionClick, + ), + ), + onCloseClick = clickIntents::onDismissIncompleteTransactionClick, + ) + is CryptoCurrencyWarning.MigrationMaticToPol -> TangemMessageUM( + id = "migration_matic_pol", + title = resourceReference(CoreResR.string.warning_matic_migration_title), + subtitle = resourceReference(CoreResR.string.warning_matic_migration_message), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + ) + is CryptoCurrencyWarning.MigrationClore -> TangemMessageUM( + id = "migration_clore", + title = resourceReference(CoreResR.string.warning_clore_migration_title), + subtitle = resourceReference(CoreResR.string.warning_clore_migration_description), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_clore_migration_button), + type = TangemButtonType.Primary, + onClick = clickIntents::onCloreMigrationClick, + ), + ), + ) + // Non-warning types — skip for redesign + is CryptoCurrencyWarning.ExistentialDeposit, + is CryptoCurrencyWarning.Rent, + is CryptoCurrencyWarning.SomeNetworksNoAccount, + is CryptoCurrencyWarning.TopUpWithoutReserve, + is CryptoCurrencyWarning.SwapPromo, + is CryptoCurrencyWarning.FeeResourceInfo, + is CryptoCurrencyWarning.UsedOutdatedDataWarning, + -> null + } + } + + private fun createFeeWarning(params: FeeWarningParams): TangemMessageUM { + val buttons = if (params.buyCurrency != null) { + persistentListOf( + TangemMessageButtonUM( + text = resourceReference( + CoreResR.string.common_buy_currency, + wrappedList(params.feeCurrencySymbol), + ), + type = TangemButtonType.Primary, + onClick = { clickIntents.onBuyCoinClick(params.buyCurrency) }, + ), + ) + } else { + persistentListOf() + } + + return TangemMessageUM( + id = params.id, + title = resourceReference( + CoreResR.string.warning_send_blocked_funds_for_fee_title, + wrappedList(params.feeCurrencyName), + ), + subtitle = resourceReference( + CoreResR.string.warning_send_blocked_funds_for_fee_message, + wrappedList( + params.currency.name, + params.networkName, + params.currency.name, + params.feeCurrencyName, + params.feeCurrencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = buttons, + ) + } + + private data class FeeWarningParams( + val id: String, + val currency: CryptoCurrency, + val networkName: String, + val feeCurrencyName: String, + val feeCurrencySymbol: String, + val buyCurrency: CryptoCurrency?, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt new file mode 100644 index 0000000000..abb2d6d3d3 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt @@ -0,0 +1,298 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import androidx.compose.ui.text.SpanStyle +import com.tangem.common.getRewardStakingBalance +import com.tangem.common.getTotalStakingBalance +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.defaultAmount +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.RewardBlockType +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.StakingOption +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.features.tokendetails.impl.R +import com.tangem.lib.crypto.BlockchainUtils.isStakingRewardUnavailable +import com.tangem.utils.isNullOrZero +import com.tangem.utils.transformer.Transformer +import java.math.BigDecimal +import com.tangem.core.res.R as CoreResR +import com.tangem.core.ui.R as CoreUiR + +internal class UpdateStakingNotificationTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val stakingAvailability: StakingAvailability, + private val stakingEntryInfo: StakingEntryInfo?, + private val appCurrency: AppCurrency, + private val clickIntents: TokenDetailsClickIntents, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + return prevState.copy(earnBlockState = buildEarnBlock(prevState.isBalanceHidden)) + } + + private fun buildEarnBlock(isBalanceHidden: Boolean): EarnBlockUM? { + return when (val availability = stakingAvailability) { + StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable() + StakingAvailability.Unavailable -> null + is StakingAvailability.Available -> getStakingInfoBlock(availability, isBalanceHidden) + } + } + + private fun buildTemporaryUnavailable(): EarnBlockUM.Content { + return EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.staking_native), + style = EarnBlockUM.TitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.tertiary }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.staking_notification_network_error_text), + style = EarnBlockUM.SubtitleUM.Style.Small, + color = { TangemTheme.colors2.text.neutral.tertiary }, + ), + trailingUM = null, + ) + } + + private fun getStakingInfoBlock( + availability: StakingAvailability.Available, + isBalanceHidden: Boolean, + ): EarnBlockUM? { + val status = cryptoCurrencyStatus + val stakingBalance = status.value.stakingBalance as? StakingBalance.Data + val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId) + + return when { + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo != null -> { + val hasPendingBalances = stakingBalance.hasPendingBalances() + if (!hasPendingBalances) { + buildStakeAvailable( + availability = availability, + isEnabled = isStakingButtonEnabled(status), + ) + } else { + buildActiveBlock( + stakingAmount = stakingBalance.getPendingAmount(), + rewardAmount = null, + isBalanceHidden = isBalanceHidden, + ) + } + } + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo == null -> null + else -> buildActiveBlock( + stakingAmount = stakingCryptoAmount, + rewardAmount = stakingBalance.getRewardAmount(), + isBalanceHidden = isBalanceHidden, + ) + } + } + + private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean { + return status.value is CryptoCurrencyStatus.Loaded || + status.value is CryptoCurrencyStatus.NoQuote || + status.value is CryptoCurrencyStatus.Custom + } + + private fun buildStakeAvailable( + availability: StakingAvailability.Available, + isEnabled: Boolean, + ): EarnBlockUM.Content { + return EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Tinted { TangemTheme.colors2.markers.backgroundTintedBlue }, + iconUM = EarnBlockUM.IconUM.Glowing( + iconRes = CoreUiR.drawable.ic_staking_40, + glowColor = { TangemTheme.colors2.border.status.accent }, + ), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(id = R.string.token_details_staking_block_title), + style = EarnBlockUM.TitleUM.Style.Small, + color = { TangemTheme.colors2.text.status.accent }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stakeAvailableSubtitle(availability.option.displayApy), + style = EarnBlockUM.SubtitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.primary }, + ), + trailingUM = EarnBlockUM.TrailingUM.Button( + buttonUM = TangemButtonUM( + text = resourceReference(R.string.common_stake), + type = TangemButtonType.Accent, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + isEnabled = isEnabled, + onClick = clickIntents::onStakeBannerClick, + ), + ), + ) + } + + private fun stakeAvailableSubtitle(apy: BigDecimal?): TextReference { + return if (apy != null) { + resourceReference( + CoreResR.string.token_details_earn_staking_subtitle, + wrappedList(apy.format { percent() }), + ) + } else { + resourceReference(CoreResR.string.staking_notification_earn_rewards_text) + } + } + + private fun buildActiveBlock( + stakingAmount: BigDecimal?, + rewardAmount: BigDecimal?, + isBalanceHidden: Boolean, + ): EarnBlockUM.Content { + val status = cryptoCurrencyStatus + val fiatRate = status.value.fiatRate + val fiatAmount = stakingAmount?.let { fiatRate?.multiply(it) } + val rewardFiatAmount = rewardAmount?.let { fiatRate?.multiply(it) } + return EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing( + iconRes = CoreUiR.drawable.ic_staking_40, + glowColor = { TangemTheme.colors2.border.status.accent }, + ), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.staking_native), + style = EarnBlockUM.TitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.primary }, + ), + subtitleUM = getRewardSubtitle(status, rewardFiatAmount), + trailingUM = EarnBlockUM.TrailingUM.Balance( + fiatValue = fiatAmount.formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).defaultAmount( + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ) + }, + cryptoValue = stringReference( + stakingAmount.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }, + ), + isBalanceHidden = isBalanceHidden, + onClick = clickIntents::onStakeBannerClick, + ), + ) + } + + private fun getRewardSubtitle( + status: CryptoCurrencyStatus, + stakingRewardAmount: BigDecimal?, + ): EarnBlockUM.SubtitleUM? { + val blockchainId = status.currency.network.rawId + val isCoin = status.currency.id.isCoin + val stakingBalance = status.value.stakingBalance + + val rewardBlockType = when { + stakingBalance is StakingBalance.Data.P2PEthPool -> { + if (stakingBalance.totalRewards.isNullOrZero()) { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } else { + RewardBlockType.EthereumEarnedRewards + } + } + isStakingRewardUnavailable(blockchainId, isCoin) -> { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } + stakingRewardAmount.isNullOrZero() -> RewardBlockType.NoRewards + else -> RewardBlockType.Rewards + } + + val text = when (rewardBlockType) { + RewardBlockType.NoRewards -> resourceReference(R.string.staking_details_no_rewards_to_claim) + RewardBlockType.CardanoNoRewards -> resourceReference(R.string.staking_cardano_details_rewards_info_text) + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable, + RewardBlockType.RewardUnavailable.SolanaRewardUnavailable, + -> return null + RewardBlockType.EthereumEarnedRewards -> { + val cryptoRewardAmount = (stakingBalance as? StakingBalance.Data.P2PEthPool)?.totalRewards + resourceReference( + R.string.staking_details_autocompound_rewards_earned, + wrappedList( + cryptoRewardAmount.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }, + ), + ) + } + RewardBlockType.RewardsRequirementsError, + RewardBlockType.Rewards, + -> resourceReference( + R.string.staking_details_rewards_to_claim, + wrappedList( + stakingRewardAmount.format { fiat(appCurrency.code, appCurrency.symbol) }, + ), + ) + } + + val isAccent = rewardBlockType == RewardBlockType.Rewards || + rewardBlockType == RewardBlockType.RewardsRequirementsError || + rewardBlockType == RewardBlockType.EthereumEarnedRewards + + return EarnBlockUM.SubtitleUM.Text( + text = text, + style = EarnBlockUM.SubtitleUM.Style.Small, + color = if (isAccent) { + { TangemTheme.colors2.text.status.accent } + } else { + { TangemTheme.colors2.text.neutral.tertiary } + }, + ) + } +} + +private fun StakingBalance.Data?.hasPendingBalances(): Boolean = when (this) { + is StakingBalance.Data.StakeKit -> balance.items.isNotEmpty() + is StakingBalance.Data.P2PEthPool -> !unstakingAmount.isNullOrZero() + null -> false +} + +private fun StakingBalance.Data?.getPendingAmount(): BigDecimal = when (this) { + is StakingBalance.Data.StakeKit -> balance.items.sumOf { it.amount } + is StakingBalance.Data.P2PEthPool -> unstakingAmount + null -> BigDecimal.ZERO +} + +private fun StakingBalance.Data?.getRewardAmount(): BigDecimal = when (this) { + is StakingBalance.Data.StakeKit -> getRewardStakingBalance() + is StakingBalance.Data.P2PEthPool -> totalRewards + null -> BigDecimal.ZERO +} + +private val StakingOption.displayApy: BigDecimal? + get() = when (this) { + is StakingOption.StakeKit -> yield.preferredValidators + .mapNotNull { it.rewardInfo?.rate } + .maxOrNull() + is StakingOption.P2PEthPool -> apy + } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 25f0277b18..14147b34e3 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -10,10 +10,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn +import com.tangem.common.ui.earn.EarnBlock +import com.tangem.common.ui.notifications.notifications import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity @@ -59,6 +62,11 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi partialCollapsedHeight = partialCollapsedHeight, ) + val rootBackground by LocalRootBackgroundColor.current + val notificationModifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + Box( modifier = modifier.fillMaxSize(), ) { @@ -79,18 +87,18 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi ) }, body = { - LazyColumn( + TokenDetailsBody( + tokenDetailsUM = tokenDetailsUM, + rootBackground = rootBackground, modifier = Modifier .fillMaxSize() .nestedScroll(behavior.nestedScrollConnection), - ) { - // TODO [REDACTED_TASK_KEY] Token Details Make Transaction History - } + itemModifier = notificationModifier, + ) }, ) } - val rootBackground by LocalRootBackgroundColor.current val hazeIntensity by animateFloatAsState( targetValue = (behavior.state.collapsedFraction * 2f).coerceIn(0f, 1f), label = "TopBarHazeIntensity", @@ -110,6 +118,31 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi } } +@Composable +private fun TokenDetailsBody( + tokenDetailsUM: TokenDetailsUM, + rootBackground: Color, + modifier: Modifier = Modifier, + itemModifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + notifications( + notifications = tokenDetailsUM.notifications, + contentColor = rootBackground, + modifier = itemModifier, + ) + tokenDetailsUM.earnBlockState?.let { earnBlock -> + item(key = "staking_block") { + EarnBlock( + state = earnBlock, + modifier = itemModifier, + ) + } + } + // TODO [REDACTED_TASK_KEY] Token Details Make Transaction History + } +} + // region Preview @Composable @Preview(showBackground = true, widthDp = 360) @@ -137,13 +170,14 @@ private fun TokenDetailsScreen_Preview() { ), ), ), + notifications = persistentListOf(), + earnBlockState = null, balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( actionButtons = persistentListOf(), tokenBalanceTypeUM = TokenBalanceTypeUM.Single, currencyIconState = CurrencyIconState.Loading, ), marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = "USDT"), - stakingBlocksState = null, pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, onRefresh = {}, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index 21fdf29fd8..4a1b464543 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -31,7 +31,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsTopAppBar import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.ExpressStatusBottomSheet -import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlock +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlockLegacy import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.entity.TxHistoryUM @@ -133,7 +133,7 @@ internal fun TokenDetailsScreenLegacy( key = StakingBlockUM::class.java, contentType = StakingBlockUM::class.java, content = { - TokenStakingBlock( + TokenStakingBlockLegacy( state = state.stakingBlocksState, isBalanceHidden = state.isBalanceHidden, modifier = itemModifier, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlockLegacy.kt similarity index 95% rename from features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt rename to features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlockLegacy.kt index 39bfbef5ec..4b78546b82 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlockLegacy.kt @@ -42,7 +42,7 @@ import com.tangem.features.tokendetails.impl.R * @param modifier modifier */ @Composable -internal fun TokenStakingBlock(state: StakingBlockUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { +internal fun TokenStakingBlockLegacy(state: StakingBlockUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { Column( modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) @@ -60,16 +60,16 @@ internal fun TokenStakingBlock(state: StakingBlockUM, isBalanceHidden: Boolean, targetState = state, contentAlignment = Alignment.CenterStart, label = "Staking block animation", - ) { - when (it) { + ) { stakingBlock -> + when (stakingBlock) { is StakingBlockUM.TemporaryUnavailable -> StakingTemporaryUnavailableBlock() is StakingBlockUM.Loading -> StakingLoading() is StakingBlockUM.Staked -> StakingBalanceBlock( - state = it, + state = stakingBlock, isBalanceHidden = isBalanceHidden, ) is StakingBlockUM.StakeAvailable -> StakingAvailableContent( - state = it, + state = stakingBlock, ) } } @@ -165,7 +165,7 @@ private fun Preview_TokenStakingBlock( state: StakingBlockUM, ) { TangemThemePreview { - TokenStakingBlock( + TokenStakingBlockLegacy( state = state, isBalanceHidden = false, )