Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-29 16:26:32 +05:00
parent 49800731e3
commit d664e6ecbb
13 changed files with 267 additions and 39 deletions

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.swap.DefaultSwapBestRateAnimationStore
import com.tangem.datasource.local.swap.DefaultSwapTransactionStatusStore
import com.tangem.datasource.local.swap.SwapBestRateAnimationStore
@ -22,4 +23,12 @@ object SwapStoreModule {
dataStore = RuntimeDataStore(),
)
}
@Provides
@Singleton
fun provideSwapBestRateAnimationStore(): SwapBestRateAnimationStore {
return DefaultSwapBestRateAnimationStore(
dataStore = RuntimeSharedStore(),
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.local.swap
import com.tangem.datasource.local.datastore.RuntimeSharedStore
internal class DefaultSwapBestRateAnimationStore(
private val dataStore: RuntimeSharedStore<Boolean>,
) : SwapBestRateAnimationStore, RuntimeSharedStore<Boolean> by dataStore {
/**
* Returns flag indicating whether should show best rate animation in current session.
* Animation should appear once per session
*
* If true, reset flag to false
*/
override suspend fun getSyncOrNull(): Boolean {
val value = dataStore.getSyncOrNull() ?: true
if (value) {
dataStore.store(false)
}
return value
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.local.swap
/**
* Stores flag indicating whether should show best rate animation in current session.
* Animation should appear once per session
*
* If true, reset flag to false
*/
interface SwapBestRateAnimationStore {
suspend fun getSyncOrNull(): Boolean
}

View file

@ -29,6 +29,7 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.core.navigation)
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
/** Common */
implementation(projects.common.ui)

View file

@ -68,6 +68,7 @@ internal class SwapAmountBlockComponent(
onInfoClick = model::onInfoClick,
isClickEnabled = isClickEnabled,
onClick = onClick,
onFinishAnimation = model::onFinishAnimation,
onProviderSelectClick = {
val amountUM = model.uiState.value as? SwapAmountUM.Content ?: return@SwapAmountBlockContent
val selectedProvider = amountUM.selectedQuote.provider ?: return@SwapAmountBlockContent

View file

@ -51,6 +51,7 @@ internal sealed class SwapAmountUM {
// extra data
val appCurrency: AppCurrency?,
val showBestRateAnimation: Boolean,
) : SwapAmountUM()
}

View file

@ -13,6 +13,7 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.datasource.local.swap.SwapBestRateAnimationStore
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.express.models.ExpressError
@ -73,6 +74,7 @@ internal class SwapAmountModel @Inject constructor(
private val getAllowanceUseCase: GetAllowanceUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getUserCountryUseCase: GetUserCountryUseCase,
private val swapBestRateAnimationStore: SwapBestRateAnimationStore,
private val appRouter: AppRouter,
private val swapAmountAlertFactory: SwapAmountAlertFactory,
private val swapAlertFactory: SwapAlertFactory,
@ -96,6 +98,8 @@ internal class SwapAmountModel @Inject constructor(
var userCountry: UserCountry = UserCountry.Other(Locale.getDefault().country)
val bottomSheetNavigation: SlotNavigation<SwapChooseProviderConfig> = SlotNavigation()
var showBestRateAnimation: Boolean = false
val uiState: StateFlow<SwapAmountUM>
field = MutableStateFlow(params.amountUM)
@ -107,6 +111,7 @@ internal class SwapAmountModel @Inject constructor(
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
showBestRateAnimation = swapBestRateAnimationStore.getSyncOrNull()
}
configAmountNavigation()
subscribeOnCryptoCurrencyStatusFlow()
@ -245,6 +250,12 @@ internal class SwapAmountModel @Inject constructor(
}
}
fun onFinishAnimation() {
uiState.update {
(it as? SwapAmountUM.Content)?.copy(showBestRateAnimation = false) ?: it
}
}
private fun confirmSendWithSwapClose() {
val amountParams = params as? SwapAmountComponentParams.AmountParams ?: return
val amountFieldData = uiState.value.primaryAmount.amountField as? AmountState.Data
@ -259,6 +270,7 @@ internal class SwapAmountModel @Inject constructor(
swapDirection = swapDirection,
clickIntents = this,
isBalanceHidden = params.isBalanceHidingFlow.value,
showBestRateAnimation = showBestRateAnimation,
),
)
}
@ -292,6 +304,7 @@ internal class SwapAmountModel @Inject constructor(
swapDirection = swapDirection,
clickIntents = this,
isBalanceHidden = params.isBalanceHidingFlow.value,
showBestRateAnimation = showBestRateAnimation,
),
)
}
@ -399,6 +412,7 @@ internal class SwapAmountModel @Inject constructor(
swapDirection = swapDirection,
clickIntents = this@SwapAmountModel,
isBalanceHidden = params.isBalanceHidingFlow.value,
showBestRateAnimation = showBestRateAnimation,
),
)
startLoadingQuotesTask(isSilentReload = false)

View file

@ -23,6 +23,7 @@ internal class SwapAmountPrimaryReadyStateTransformer(
private val clickIntents: AmountScreenClickIntents,
private val swapDirection: SwapDirection,
private val isBalanceHidden: Boolean,
private val showBestRateAnimation: Boolean,
) : Transformer<SwapAmountUM> {
private val amountFieldConverter = SwapAmountFieldConverter(
@ -52,6 +53,7 @@ internal class SwapAmountPrimaryReadyStateTransformer(
swapQuotes = persistentListOf(),
selectedQuote = SwapQuoteUM.Empty,
appCurrency = appCurrency,
showBestRateAnimation = showBestRateAnimation,
)
}
}

View file

@ -24,6 +24,7 @@ internal class SwapAmountSecondaryReadyStateTransformer(
private val clickIntents: AmountScreenClickIntents,
private val swapDirection: SwapDirection,
private val isBalanceHidden: Boolean,
private val showBestRateAnimation: Boolean,
) : Transformer<SwapAmountUM> {
private val amountFieldConverter = SwapAmountFieldConverter(
@ -51,6 +52,7 @@ internal class SwapAmountSecondaryReadyStateTransformer(
swapQuotes = persistentListOf(),
selectedQuote = SwapQuoteUM.Empty,
appCurrency = appCurrency,
showBestRateAnimation = showBestRateAnimation,
)
}
}

View file

@ -29,7 +29,7 @@ internal class SwapAmountSetQuotesTransformer(
val selectedQuote = if (isSilentReload) {
prevState.selectedQuote
} else {
bestQuote
(bestQuote as? SwapQuoteUM.Content)?.copy(diffPercent = DifferencePercent.Best) ?: bestQuote
}
val selectQuoteTransformer = SwapAmountSelectQuoteTransformer(

View file

@ -37,8 +37,9 @@ import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview
import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
@Suppress("DestructuringDeclarationWithTooManyEntries")
@Suppress("DestructuringDeclarationWithTooManyEntries", "LongParameterList")
@Composable
internal fun SwapAmountBlockContent(
amountUM: SwapAmountUM,
@ -46,6 +47,7 @@ internal fun SwapAmountBlockContent(
onProviderSelectClick: () -> Unit,
onInfoClick: () -> Unit,
onClick: () -> Unit,
onFinishAnimation: () -> Unit,
modifier: Modifier = Modifier,
) {
if (amountUM !is SwapAmountUM.Content) return
@ -98,9 +100,14 @@ internal fun SwapAmountBlockContent(
end.linkTo(parent.end)
},
)
val quoteContent = amountUM.selectedQuote as? SwapQuoteUM.Content
val isBestRate = quoteContent?.diffPercent is SwapQuoteUM.Content.DifferencePercent.Best
SwapChooseProviderContent(
isBestRate = isBestRate,
showBestRateAnimation = amountUM.showBestRateAnimation,
expressProvider = amountUM.selectedQuote.provider,
onClick = onProviderSelectClick,
onFinishAnimation = onFinishAnimation,
modifier = Modifier.constrainAs(provider) {
top.linkTo(to.bottom)
bottom.linkTo(parent.bottom)
@ -195,6 +202,7 @@ private fun SwapAmountBlockContent_Preview() {
onProviderSelectClick = {},
onInfoClick = {},
onClick = {},
onFinishAnimation = {},
)
}
}

View file

@ -88,6 +88,7 @@ internal data object SwapAmountContentPreview {
secondaryCryptoCurrencyStatus = cryptoCurrencyStatus,
swapRateType = ExpressRateType.Float,
appCurrency = AppCurrency.Default,
showBestRateAnimation = false,
)
val defaultState = SwapAmountUM.Content(
@ -124,5 +125,6 @@ internal data object SwapAmountContentPreview {
secondaryCryptoCurrencyStatus = cryptoCurrencyStatus,
swapRateType = ExpressRateType.Float,
isPrimaryButtonEnabled = true,
showBestRateAnimation = false,
)
}

View file

@ -1,6 +1,9 @@
package com.tangem.features.swap.v2.impl.chooseprovider.ui
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -11,6 +14,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -21,6 +26,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstrainedLayoutReference
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.ConstraintLayoutScope
import androidx.constraintlayout.compose.Visibility
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
@ -33,9 +42,17 @@ import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.features.swap.v2.impl.R
import kotlinx.coroutines.delay
@Composable
fun SwapChooseProviderContent(expressProvider: ExpressProvider?, onClick: () -> Unit, modifier: Modifier = Modifier) {
fun SwapChooseProviderContent(
expressProvider: ExpressProvider?,
isBestRate: Boolean,
showBestRateAnimation: Boolean,
onClick: () -> Unit,
onFinishAnimation: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
@ -48,57 +65,193 @@ fun SwapChooseProviderContent(expressProvider: ExpressProvider?, onClick: () ->
color = TangemTheme.colors.stroke.primary,
modifier = Modifier.padding(horizontal = 12.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = rememberVectorPainter(
ImageVector.vectorResource(R.drawable.ic_stack_new_24),
),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 12.dp),
)
Text(
text = stringResourceSafe(R.string.express_provider),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.padding(start = 8.dp),
modifier = Modifier.padding(start = 8.dp, top = 12.dp, bottom = 12.dp),
)
SpacerWMax()
SubcomposeAsyncImage(
modifier = modifier
.size(20.dp)
.clip(RoundedCornerShape(4.dp)),
model = ImageRequest.Builder(context = LocalContext.current)
.data(expressProvider?.imageLarge)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = 4.dp) },
error = {
Box(
modifier = Modifier.background(
color = TangemColorPalette.Light1,
shape = RoundedCornerShape(4.dp),
),
)
ProviderInfo(expressProvider, isBestRate, showBestRateAnimation, onFinishAnimation)
}
}
}
@Composable
private fun ProviderInfo(
expressProvider: ExpressProvider?,
isBestRate: Boolean,
showBestRateAnimation: Boolean,
onFinishAnimation: () -> Unit,
) {
ConstraintLayout {
val (imageRef, nameRef, iconRef) = createRefs()
SubcomposeAsyncImage(
model = ImageRequest.Builder(context = LocalContext.current)
.data(expressProvider?.imageLarge)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = 4.dp) },
error = {
Box(
modifier = Modifier.background(
color = TangemColorPalette.Light1,
shape = RoundedCornerShape(4.dp),
),
)
},
contentDescription = null,
modifier = Modifier
.size(20.dp)
.clip(RoundedCornerShape(4.dp))
.constrainAs(imageRef) {
start.linkTo(parent.start)
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
},
contentDescription = null,
)
)
Text(
text = expressProvider?.name.orEmpty(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(start = 6.dp)
.constrainAs(nameRef) {
start.linkTo(imageRef.end)
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
},
)
Icon(
painter = rememberVectorPainter(
ImageVector.vectorResource(R.drawable.ic_chevron_24),
),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
modifier = Modifier
.padding(start = 4.dp)
.constrainAs(iconRef) {
start.linkTo(nameRef.end)
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
end.linkTo(parent.end, 12.dp)
},
)
BestRateBadge(
showBestRateAnimation = showBestRateAnimation,
isBestRate = isBestRate,
ref = imageRef,
onFinishAnimation = onFinishAnimation,
)
}
}
@Suppress("MagicNumber", "LongMethod")
@Composable
private fun ConstraintLayoutScope.BestRateBadge(
showBestRateAnimation: Boolean,
isBestRate: Boolean,
ref: ConstrainedLayoutReference,
onFinishAnimation: () -> Unit,
modifier: Modifier = Modifier,
) {
val animateState = remember { MutableTransitionState(false) }
LaunchedEffect(showBestRateAnimation) {
if (showBestRateAnimation) {
delay(600L)
animateState.targetState = true
delay(1_500L)
animateState.targetState = false
onFinishAnimation()
}
}
val iconSize by animateDpAsState(
label = "iconSize",
targetValue = if (animateState.targetState) {
12.dp
} else {
8.dp
},
)
val iconVerticalPaddings by animateDpAsState(
label = "iconVerticalPaddings",
targetValue = if (animateState.targetState) {
3.dp
} else {
2.dp
},
)
val iconHorizontalPaddings by animateDpAsState(
label = "iconHorizontalPaddings",
targetValue = if (animateState.targetState) {
4.dp
} else {
2.dp
},
)
val startMargin by animateDpAsState(
label = "startMargin",
targetValue = if (animateState.targetState) {
(-12).dp
} else {
(-10).dp
},
)
val topMargin by animateDpAsState(
label = "topMargin",
targetValue = if (animateState.targetState) {
(-12).dp
} else {
(-9).dp
},
)
Row(
modifier = modifier
.constrainAs(createRef()) {
start.linkTo(ref.end, startMargin)
top.linkTo(ref.bottom, topMargin)
visibility = if (isBestRate) Visibility.Visible else Visibility.Gone
}
.background(TangemTheme.colors.stroke.transparency, RoundedCornerShape(120.dp))
.padding(1.5.dp)
.background(TangemTheme.colors.icon.accent, RoundedCornerShape(120.dp)),
) {
Icon(
painter = rememberVectorPainter(
ImageVector.vectorResource(R.drawable.ic_rounded_star_24),
),
contentDescription = null,
tint = TangemTheme.colors.icon.constant,
modifier = Modifier
.padding(iconHorizontalPaddings, iconVerticalPaddings)
.size(iconSize),
)
AnimatedVisibility(
visibleState = animateState,
enter = expandIn() + fadeIn(),
exit = shrinkOut() + fadeOut(),
label = "textAnimation",
modifier = Modifier.padding(end = 6.dp),
) {
Text(
text = expressProvider?.name.orEmpty(), // todo provider error
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(start = 6.dp),
)
Icon(
painter = rememberVectorPainter(
ImageVector.vectorResource(R.drawable.ic_chevron_24),
),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
modifier = Modifier.padding(start = 4.dp),
text = stringResourceSafe(R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.constantWhite,
)
}
}
@ -114,6 +267,8 @@ private fun SwapChooseProviderContent_Preview() {
modifier = Modifier.background(TangemTheme.colors.background.tertiary),
) {
SwapChooseProviderContent(
isBestRate = true,
showBestRateAnimation = true,
expressProvider = ExpressProvider(
providerId = "changelly",
rateTypes = listOf(ExpressRateType.Fixed),
@ -126,6 +281,7 @@ private fun SwapChooseProviderContent_Preview() {
slippage = null,
),
onClick = {},
onFinishAnimation = {},
)
}
}