Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-29 20:13:49 +03:00
commit a401075a98
24 changed files with 585 additions and 89 deletions

View file

@ -21,7 +21,7 @@ data class ExpressError(
data class ExpressErrorValue(
@Json(name = "minAmount")
val minAmount: BigDecimal?,
val minAmount: String?,
@Json(name = "decimals")
val decimals: Int?,

View file

@ -201,6 +201,7 @@
<string name="express_exchange_notification_failed_title">Операция не выполнена провайдером</string>
<string name="express_exchange_notification_verification_text">Посетите сайт провайдера для проверки</string>
<string name="express_exchange_notification_verification_title">Провайдер: требуется верификация</string>
<string name="express_exchange_token_list_subtitle">Список токенов в вашем кошельке</string>
<string name="express_fetch_best_rates">Получение наилучших курсов...</string>
<string name="express_provider">Провайдер</string>
<string name="express_provider_best_rate">Лучший курс</string>

View file

@ -215,6 +215,7 @@
<string name="express_exchange_status_title">Exchange status</string>
<string name="express_exchange_status_verified">Verified</string>
<string name="express_exchange_status_verifying">Verification required</string>
<string name="express_exchange_token_list_subtitle">List of all tokens added to your wallet</string>
<string name="express_fetch_best_rates">Fetching best rates...</string>
<string name="express_floating_rate">Floating rate</string>
<string name="express_go_to_provider">Go to provider</string>

View file

@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.text.TextLayoutResult
@ -18,10 +17,12 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.TextUnit
import com.tangem.core.ui.components.atoms.text.BoundCounter
/**
* https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose
*/
@Deprecated("Use EllipsisText with TextEllipsis.Middle ellipsis instead")
@Suppress("LongMethod")
@Composable
fun MiddleEllipsisText(
@ -138,38 +139,4 @@ fun MiddleEllipsisText(
private const val ELLIPSIS_CHARACTERS_COUNT = 3
private const val ELLIPSIS_CHARACTER = '.'
private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "")
private class BoundCounter(
private val text: String,
private val textLayoutResult: TextLayoutResult,
private val charPosition: (Int) -> Int,
) {
var string = ""
private set
var width = 0f
private set
private var _nextCharWidth: Float? = null
private var invalidCharsCount = 0
fun widthWithNextChar(): Float = width + nextCharWidth()
private fun nextCharWidth(): Float = _nextCharWidth ?: run {
var boundingBox: Rect
// invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630
invalidCharsCount--
do {
boundingBox = textLayoutResult
.getBoundingBox(charPosition(string.count() + ++invalidCharsCount))
} while (boundingBox.right == 0f)
_nextCharWidth = boundingBox.width
boundingBox.width
}
fun addNextChar() {
string += text[charPosition(string.count())]
width += nextCharWidth()
_nextCharWidth = null
}
}
private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "")

View file

@ -0,0 +1,38 @@
package com.tangem.core.ui.components.atoms.text
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.text.TextLayoutResult
internal class BoundCounter(
private val text: String,
private val textLayoutResult: TextLayoutResult,
private val charPosition: (Int) -> Int,
) {
var string = ""
private set
var width = 0f
private set
private var _nextCharWidth: Float? = null
private var invalidCharsCount = 0
fun widthWithNextChar(): Float = width + nextCharWidth()
private fun nextCharWidth(): Float = _nextCharWidth ?: run {
var boundingBox: Rect
// invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630
invalidCharsCount--
do {
boundingBox = textLayoutResult
.getBoundingBox(charPosition(string.count() + ++invalidCharsCount))
} while (boundingBox.right == 0f)
_nextCharWidth = boundingBox.width
boundingBox.width
}
fun addNextChar() {
string += text[charPosition(string.count())]
width += nextCharWidth()
_nextCharWidth = null
}
}

View file

@ -0,0 +1,242 @@
package com.tangem.core.ui.components.atoms.text
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.TextUnit
import com.tangem.core.ui.res.TangemTheme
sealed class TextEllipsis {
object Middle : TextEllipsis()
object End : TextEllipsis()
data class OffsetEnd(
val offsetEnd: Int = 0,
val hasSeparator: Boolean = true,
) : TextEllipsis()
}
/**
* https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose
*
* Customized Text with ellipsis. Ellipsis can be placed in: Middle, End or OffsetEnd (OffsetEnd with separator).
*
* * OffsetEnd can be useful to display big amounts with currency symbol. OffsetEnd 0 is equal to End.
*/
@Suppress("LongMethod")
@Composable
fun EllipsisText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
softWrap: Boolean = true,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
ellipsis: TextEllipsis = TextEllipsis.End,
) {
val ellipsisText = remember(text) {
if (ellipsis is TextEllipsis.OffsetEnd && ellipsis.hasSeparator) {
ELLIPSIS_TEXT_WITH_SEPARATOR
} else {
ELLIPSIS_TEXT
}
}
// some letters, like "r", will have less width when placed right before "."
// adding a space to prevent such case
val layoutText = remember(text) { "$text $ellipsisText" }
val textLayoutResultState = remember(layoutText) {
mutableStateOf<TextLayoutResult?>(null)
}
SubcomposeLayout(modifier) { constraints ->
// result is ignored - we only need to fill our textLayoutResult
subcompose("measure") {
Text(
text = layoutText,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = softWrap,
maxLines = 1,
onTextLayout = { textLayoutResultState.value = it },
style = style,
)
}.first().measure(Constraints())
// to allow smart cast
val textLayoutResult = textLayoutResultState.value
?: // shouldn't happen - onTextLayout is called before subcompose finishes
return@SubcomposeLayout layout(0, 0) {}
val placeable = subcompose("visible") {
val finalText = remember(text, textLayoutResult, constraints.maxWidth) {
if (
text.isEmpty() ||
textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth
) {
// text not including ellipsis fits on the first line.
return@remember text
}
var ellipsisWidth = 0f
layoutText.indices.toList()
.takeLast(ellipsisText.length)
.forEach widthLet@{
ellipsisWidth += textLayoutResult.getBoundingBox(it).width
}
val availableWidth = constraints.maxWidth - ellipsisWidth
val startCounter = BoundCounter(text, textLayoutResult) { it }
val endCounter = BoundCounter(text, textLayoutResult) { text.indices.last - it }
when (ellipsis) {
TextEllipsis.Middle -> {
middleEllipsisText(
availableWidth,
startCounter,
endCounter,
)
}
TextEllipsis.End -> {
offsetEndEllipsisText(
availableWidth = availableWidth,
startCounter = startCounter,
endCounter = endCounter,
)
}
is TextEllipsis.OffsetEnd -> {
offsetEndEllipsisText(
availableWidth = availableWidth,
startCounter = startCounter,
endCounter = endCounter,
offsetEnd = ellipsis.offsetEnd,
withSeparator = ellipsis.hasSeparator,
)
}
}
}
Text(
text = finalText,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = softWrap,
onTextLayout = onTextLayout,
style = style,
)
}[0].measure(constraints)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
private const val ELLIPSIS_SEPARATOR = ""
private const val ELLIPSIS_TEXT = "..."
private const val ELLIPSIS_TEXT_WITH_SEPARATOR = ELLIPSIS_TEXT.plus(ELLIPSIS_SEPARATOR)
private fun middleEllipsisText(availableWidth: Float, startCounter: BoundCounter, endCounter: BoundCounter): String {
while (availableWidth - startCounter.width - endCounter.width > 0) {
val possibleEndWidth = endCounter.widthWithNextChar()
if (
startCounter.width >= possibleEndWidth &&
availableWidth - startCounter.width - possibleEndWidth >= 0
) {
endCounter.addNextChar()
} else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) {
startCounter.addNextChar()
} else {
break
}
}
return startCounter.string.trimEnd() + ELLIPSIS_TEXT + endCounter.string.reversed().trimStart()
}
private fun offsetEndEllipsisText(
availableWidth: Float,
startCounter: BoundCounter,
endCounter: BoundCounter,
offsetEnd: Int = 0,
withSeparator: Boolean = false,
): String {
while (availableWidth - startCounter.width - endCounter.width > 0) {
val possibleEndWidth = endCounter.widthWithNextChar()
if (
offsetEnd > endCounter.string.length &&
availableWidth - startCounter.width - possibleEndWidth >= 0
) {
endCounter.addNextChar()
} else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) {
startCounter.addNextChar()
} else {
break
}
}
val ellipsis = if (withSeparator) ELLIPSIS_TEXT_WITH_SEPARATOR else ELLIPSIS_TEXT
return startCounter.string.trimEnd() + ellipsis + endCounter.string.reversed().trimStart()
}
//region Preview
@Preview(widthDp = 200)
@Composable
private fun EllipsisTexPreview(@PreviewParameter(EllipsisTexPreviewParameterProvider::class) ellipsis: TextEllipsis) {
TangemTheme {
EllipsisText(
text = "11111111111111111111111111111111111111111111111111 END",
ellipsis = ellipsis,
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.fillMaxWidth(),
)
}
}
private class EllipsisTexPreviewParameterProvider : PreviewParameterProvider<TextEllipsis> {
override val values: Sequence<TextEllipsis>
get() = sequenceOf(
TextEllipsis.Middle,
TextEllipsis.End,
TextEllipsis.OffsetEnd("TEXT".length),
TextEllipsis.OffsetEnd("TEXT".length, false),
)
}
//endregion

View file

@ -3,13 +3,13 @@ package com.tangem.core.ui.components.inputrow
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
@ -56,6 +56,7 @@ fun InputRowApprox(
iconState = leftIcon,
title = leftTitle,
subtitle = leftSubtitle,
modifier = Modifier.weight(1f),
)
Icon(
painter = painterResource(id = R.drawable.ic_approx_24),
@ -71,6 +72,7 @@ fun InputRowApprox(
iconState = rightIcon,
title = rightTitle,
subtitle = rightSubtitle,
modifier = Modifier.weight(1f),
)
}
}
@ -97,12 +99,12 @@ private fun InputRowApproxItem(
start = TangemTheme.dimens.spacing12,
),
) {
Text(
EllipsisText(
text = title.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Text(
EllipsisText(
text = subtitle.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
@ -123,7 +125,7 @@ private fun InputRowApproxPreview_Light() {
leftTitle = TextReference.Str("Left title"),
leftSubtitle = TextReference.Str("Left subtitle"),
rightIcon = TokenIconState.Loading,
rightTitle = TextReference.Str("Right title"),
rightTitle = TextReference.Str("Right title Right title Right title Right title Right title"),
rightSubtitle = TextReference.Str("Right subtitle"),
modifier = Modifier
.background(TangemTheme.colors.background.action),
@ -137,7 +139,7 @@ private fun InputRowApproxPreview_Dark() {
TangemTheme(isDark = true) {
InputRowApprox(
leftIcon = TokenIconState.Loading,
leftTitle = TextReference.Str("Left title"),
leftTitle = TextReference.Str("Left title Left title Left title Left title Left title"),
leftSubtitle = TextReference.Str("Left subtitle"),
rightIcon = TokenIconState.Loading,
rightTitle = TextReference.Str("Right title"),

View file

@ -20,4 +20,9 @@ internal class MockQuotesRepository(
override suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote> {
return getQuotesUpdates(currenciesIds).first()
}
override suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote {
return quotes.map { it.getOrElse { e -> throw e } }.first()
.first { it.rawCurrencyId == currencyId.rawCurrencyId }
}
}

View file

@ -3,7 +3,7 @@ package com.tangem.feature.swap.converters
import com.squareup.moshi.JsonAdapter
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.createFromAmountWithOffset
import com.tangem.utils.converter.Converter
internal class ErrorsDataConverter(
@ -23,7 +23,7 @@ internal class ErrorsDataConverter(
2240 -> DataError.ExchangeNotPossibleError(code = error.code)
2250 -> DataError.ExchangeTooSmallAmountError(
code = error.code,
amount = SwapAmount(
amount = createFromAmountWithOffset(
requireNotNull(error.value?.minAmount),
requireNotNull(error.value?.decimals),
),

View file

@ -716,7 +716,14 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
} else {
return SwapState.SwapError(quoteDataModel.error)
val rates = getQuotes(fromToken.currency.id)
val fromTokenSwapInfo = TokenSwapInfo(
tokenAmount = amount,
amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value)
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
)
return SwapState.SwapError(fromTokenSwapInfo, quoteDataModel.error)
}
}
@ -796,7 +803,17 @@ internal class SwapInteractorImpl @Inject constructor(
),
)
} else {
return SwapState.SwapError(it.error)
val rates = getQuotes(fromToken.currency.id)
val fromTokenSwapInfo = TokenSwapInfo(
tokenAmount = amount,
amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value)
?: BigDecimal.ZERO,
cryptoCurrencyStatus = fromToken,
)
return SwapState.SwapError(
fromTokenSwapInfo,
it.error,
)
}
}
}

View file

@ -31,7 +31,10 @@ sealed interface SwapState {
val zeroAmountEquivalent: String,
) : SwapState
data class SwapError(val error: DataError) : SwapState
data class SwapError(
val fromTokenInfo: TokenSwapInfo,
val error: DataError,
) : SwapState
}
sealed class PermissionDataState {

View file

@ -123,6 +123,7 @@ sealed interface SwapWarning {
data class HighPriceImpact(val priceImpact: Int, val notificationConfig: NotificationConfig) : SwapWarning
data class TooSmallAmountWarning(val notificationConfig: NotificationConfig) : SwapWarning
data class UnableToCoverFeeWarning(val notificationConfig: NotificationConfig) : SwapWarning
data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning
}
enum class GenericWarningType {

View file

@ -13,6 +13,7 @@ import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.NetworkInfo
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
@ -24,6 +25,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.min
/**
* State builder creates a specific states for SwapScreen
@ -175,6 +177,7 @@ internal class StateBuilder(
balance = if (!canSelectReceiveToken) uiStateHolder.receiveCardData.balance else "",
isBalanceHidden = isBalanceHiddenProvider(),
),
warnings = emptyList(),
fee = FeeItemState.Empty,
swapButton = SwapButton(enabled = false, loading = true, onClick = {}),
providerState = ProviderState.Loading(),
@ -293,6 +296,109 @@ internal class StateBuilder(
)
}
fun createQuotesErrorState(
uiStateHolder: SwapStateHolder,
swapProvider: SwapProvider,
fromToken: TokenSwapInfo,
toToken: CryptoCurrencyStatus?,
dataError: DataError,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warning = getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency)
val providerState = getProviderStateForError(
swapProvider = swapProvider,
fromToken = fromToken.cryptoCurrencyStatus.currency,
dataError = dataError,
selectionType = ProviderState.SelectionType.CLICK,
)
val receiveCardData = toToken?.let {
SwapCardState.SwapCardData(
type = TransactionCardType.ReceiveCard(),
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
token = toToken,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
coinId = toToken.currency.network.backendId,
isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken,
tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency,
canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken,
balance = toToken.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
)
} ?: SwapCardState.Empty(
type = TransactionCardType.ReceiveCard(),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountTextFieldValue = TextFieldValue(
text = "0",
),
canSelectAnotherToken = true,
)
return uiStateHolder.copy(
sendCardData = uiStateHolder.sendCardData.copy(
amountEquivalent = getFormattedFiatAmount(fromToken.amountFiat),
),
receiveCardData = receiveCardData,
warnings = listOf(warning),
permissionState = SwapPermissionState.Empty,
fee = FeeItemState.Empty,
swapButton = SwapButton(
enabled = false,
loading = false,
onClick = actions.onSwapClick,
),
updateInProgress = false,
providerState = providerState,
)
}
private fun getProviderStateForError(
swapProvider: SwapProvider,
fromToken: CryptoCurrency,
dataError: DataError,
selectionType: ProviderState.SelectionType,
): ProviderState {
return when (dataError) {
is DataError.ExchangeTooSmallAmountError -> {
swapProvider.convertToUnavailableProviderState(
alertText = resourceReference(
R.string.express_provider_min_amount,
wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)),
),
selectionType = selectionType,
onProviderClick = actions.onProviderClick,
)
}
else -> {
ProviderState.Empty()
}
}
}
private fun getWarningForError(dataError: DataError, fromToken: CryptoCurrency): SwapWarning {
return when (dataError) {
is DataError.ExchangeTooSmallAmountError -> SwapWarning.TooSmallAmountWarning(
notificationConfig = NotificationConfig(
title = resourceReference(
id = R.string.warning_express_too_minimal_amount_title,
formatArgs = wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)),
),
subtitle = resourceReference(R.string.warning_express_too_minimal_amount_description),
iconResId = R.drawable.ic_alert_circle_24,
),
)
else -> SwapWarning.GeneralWarning(
notificationConfig = NotificationConfig(
title = resourceReference(R.string.common_error),
subtitle = resourceReference(R.string.generic_error_code, wrappedList(dataError.code.toString())),
iconResId = R.drawable.ic_alert_circle_24,
),
)
}
}
fun createQuotesEmptyAmountState(
uiStateHolder: SwapStateHolder,
emptyAmountState: SwapState.EmptyAmountState,
@ -507,16 +613,6 @@ internal class StateBuilder(
)
}
fun mapError(uiState: SwapStateHolder, error: DataError, onClick: () -> Unit): SwapStateHolder {
return when (error) {
// todo use if needed later
// DataError.InsufficientLiquidity -> TODO()
// DataError.NoError -> TODO()
is DataError.ExchangeTooSmallAmountError -> addWarning(uiState, error.amount.toString(), true, onClick)
else -> addWarning(uiState, null, false) {}
}
}
fun addAlert(uiState: SwapStateHolder, onClick: () -> Unit): SwapStateHolder {
return uiState.copy(
alert = SwapWarning.GenericWarning(
@ -735,11 +831,11 @@ internal class StateBuilder(
onProviderClick = onProviderSelect,
selectionType = ProviderState.SelectionType.SELECT,
)
// todo handle error
is SwapState.SwapError -> provider.convertToUnavailableProviderState(
alertText = resourceReference(R.string.express_provider_min_amount, wrappedList("10")),
selectionType = ProviderState.SelectionType.NONE,
onProviderClick = onProviderSelect,
is SwapState.SwapError -> getProviderStateForError(
swapProvider = provider,
fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency,
dataError = state.error,
selectionType = ProviderState.SelectionType.SELECT,
)
}
}
@ -802,10 +898,9 @@ internal class StateBuilder(
selectionType: ProviderState.SelectionType,
onProviderClick: (String) -> Unit,
): ProviderState {
val rate = toTokenInfo.tokenAmount.value.divide(
val rate = toTokenInfo.tokenAmount.value.calculateRate(
fromTokenInfo.tokenAmount.value,
toTokenInfo.cryptoCurrencyStatus.currency.decimals,
RoundingMode.HALF_UP,
)
val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol
val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol
@ -831,10 +926,9 @@ internal class StateBuilder(
): ProviderState {
val fromTokenInfo = state.fromTokenInfo
val toTokenInfo = state.toTokenInfo
val rate = toTokenInfo.tokenAmount.value.divide(
val rate = toTokenInfo.tokenAmount.value.calculateRate(
fromTokenInfo.tokenAmount.value,
toTokenInfo.cryptoCurrencyStatus.currency.decimals,
RoundingMode.HALF_UP,
)
val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol
val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol
@ -895,6 +989,14 @@ internal class StateBuilder(
return BigDecimalFormatter.formatFiatAmount(amount, appCurrency.code, appCurrency.symbol)
}
private fun SwapAmount.getFormattedCryptoAmount(token: CryptoCurrency): String {
return "${this.formatToUIRepresentation()} ${token.network.currencySymbol}"
}
private fun BigDecimal.calculateRate(to: BigDecimal, decimals: Int): BigDecimal {
return this.divide(to, min(decimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP)
}
private companion object {
const val ADDRESS_MIN_LENGTH = 11
const val ADDRESS_FIRST_PART_LENGTH = 7
@ -902,5 +1004,6 @@ internal class StateBuilder(
private const val PRICE_IMPACT_THRESHOLD = 0.1
private const val HUNDRED_PERCENTS = 100
private const val UNKNOWN_AMOUNT_SIGN = ""
private const val MAX_DECIMALS_TO_SHOW = 8
}
}

View file

@ -43,7 +43,7 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit)
placeholderSearchText = stringResource(id = R.string.common_search_tokens),
onSearchChange = state.onSearchEntered,
onSearchDisplayClose = { state.onSearchEntered("") },
subtitle = "", // todo add title
subtitle = stringResource(id = R.string.express_exchange_token_list_subtitle),
)
},
)
@ -55,7 +55,7 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier =
LazyColumn(
modifier = modifier
.background(color = screenBackgroundColor)
.fillMaxWidth(),
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
item { SpacerH8() }

View file

@ -286,9 +286,13 @@ internal class SwapViewModel @Inject constructor(
)
}
is SwapState.SwapError -> {
Timber.e("SwapError when loading quotes ${state.error}")
// todo handle when change token and error
uiState = stateBuilder.mapError(uiState, state.error) { startLoadingQuotesFromLastState() }
uiState = stateBuilder.createQuotesErrorState(
uiStateHolder = uiState,
swapProvider = provider,
fromToken = state.fromTokenInfo,
toToken = dataState.toCryptoCurrency,
dataError = state.error,
)
}
}
}

View file

@ -101,6 +101,7 @@ internal object TokenDetailsPreviewData {
),
dialogConfig = null,
pendingTxs = persistentListOf(),
swapTxs = persistentListOf(),
pullToRefreshConfig = pullToRefreshConfig,
bottomSheetConfig = null,
isBalanceHidden = false,

View file

@ -2,21 +2,25 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotifications
import kotlinx.collections.immutable.PersistentList
import java.math.BigDecimal
internal data class SwapTransactionsState(
val txId: String,
val providerId: Int,
val provider: SwapProvider,
val txUrl: String? = null,
val rate: BigDecimal,
val timestamp: Long,
val status: PersistentList<ExchangeStatusState>,
val statuses: PersistentList<ExchangeStatusState>,
val activeStatus: ExchangeStatus?,
val fiatSymbol: String,
val notification: ExchangeStatusNotifications? = null,
val toCryptoAmount: String,
val toCryptoSymbol: String,
val toFiatAmount: String,
val toCurrencyIcon: TokenIconState,
val fromCryptoAmount: String,
val fromCryptoSymbol: String,
val fromFiatAmount: String,
val fromCurrencyIcon: TokenIconState,
val onClick: () -> Unit,
@ -25,7 +29,6 @@ internal data class SwapTransactionsState(
internal class ExchangeStatusState(
val status: ExchangeStatus,
val text: String,
val isActive: Boolean,
val isDone: Boolean,
)

View file

@ -19,6 +19,7 @@ internal data class TokenDetailsState(
val marketPriceBlockState: MarketPriceBlockState,
val notifications: ImmutableList<TokenDetailsNotification>,
val pendingTxs: PersistentList<TransactionState>,
val swapTxs: PersistentList<SwapTransactionsState>,
val txHistoryState: TxHistoryState,
val dialogConfig: TokenDetailsDialogConfig?,
val pullToRefreshConfig: TokenDetailsPullToRefreshConfig,

View file

@ -0,0 +1,38 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.components
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.tokendetails.impl.R
@Immutable
internal sealed class ExchangeStatusNotifications(val config: NotificationConfig) {
data class NeedVerification(
val onGoToProviderClick: () -> Unit,
) : ExchangeStatusNotifications(
config = NotificationConfig(
title = TextReference.Res(R.string.express_exchange_notification_verification_title),
subtitle = TextReference.Res(R.string.express_exchange_notification_verification_text),
iconResId = R.drawable.ic_alert_triangle_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = TextReference.Res(R.string.express_go_to_provider),
onClick = onGoToProviderClick,
),
),
)
data class Failed(
val onGoToProviderClick: () -> Unit,
) : ExchangeStatusNotifications(
config = NotificationConfig(
title = TextReference.Res(R.string.express_exchange_notification_failed_title),
subtitle = TextReference.Res(R.string.express_exchange_notification_failed_text),
iconResId = R.drawable.ic_alert_circle_24,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = TextReference.Res(R.string.express_go_to_provider),
onClick = onGoToProviderClick,
),
),
)
}

View file

@ -45,6 +45,7 @@ internal class TokenDetailsSkeletonStateConverter(
marketPriceBlockState = MarketPriceBlockState.Loading(value.symbol),
notifications = persistentListOf(),
pendingTxs = persistentListOf(),
swapTxs = persistentListOf(),
txHistoryState = TxHistoryState.Content(
contentItems = MutableStateFlow(
value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick),

View file

@ -2,7 +2,9 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
@ -40,6 +42,9 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsDialogs
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.exchange.ExchangeStatusBottomSheet
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheetConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.swapTransactionsItems
// TODO: Split to blocks [REDACTED_JIRA]
@Suppress("LongMethod")
@ -117,6 +122,11 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) {
)
}
swapTransactionsItems(
state.swapTxs,
itemModifier,
)
txHistoryItems(
state = state.txHistoryState,
isBalanceHidden = state.isBalanceHidden,
@ -141,6 +151,9 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) {
is ChooseAddressBottomSheetConfig -> {
ChooseAddressBottomSheet(config = config)
}
is ExchangeStatusBottomSheetConfig -> {
ExchangeStatusBottomSheet(config = config)
}
}
}

View file

@ -26,7 +26,7 @@ import kotlinx.collections.immutable.PersistentList
@Composable
internal fun ExchangeStatusBlock(
status: PersistentList<ExchangeStatusState>,
statuses: PersistentList<ExchangeStatusState>,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -70,10 +70,10 @@ internal fun ExchangeStatusBlock(
}
}
status.forEachIndexed { index, item ->
statuses.forEachIndexed { index, item ->
ExchangeStatusStep(
stepStatus = item,
isLast = index == status.lastIndex,
isLast = index == statuses.lastIndex,
)
}
}
@ -128,7 +128,7 @@ private fun ExchangeStatusStepText(stepStatus: ExchangeStatusState) {
}
Text(
text = stepStatus.text,
text = getStatusText(stepStatus)?.let { stringResource(it) }.orEmpty(),
style = TangemTheme.typography.body2,
color = textColor,
modifier = Modifier
@ -206,4 +206,34 @@ private fun ExchangeStepSeparator() {
shape = CircleShape,
),
)
}
private fun getStatusText(stepStatus: ExchangeStatusState) = when (stepStatus.status) {
ExchangeStatus.Failed -> R.string.express_exchange_status_failed
ExchangeStatus.Verifying -> if (stepStatus.isDone) {
R.string.express_exchange_status_verified
} else {
R.string.express_exchange_status_verifying
}
ExchangeStatus.New, ExchangeStatus.Waiting -> if (stepStatus.isDone) {
R.string.express_exchange_status_received
} else {
R.string.express_exchange_status_receiving
}
ExchangeStatus.Confirming -> if (stepStatus.isDone) {
R.string.express_exchange_status_confirmed
} else {
R.string.express_exchange_status_confirming
}
ExchangeStatus.Exchanging -> if (stepStatus.isDone) {
R.string.express_exchange_status_exchanged
} else {
R.string.express_exchange_status_exchanging
}
ExchangeStatus.Sending -> if (stepStatus.isDone) {
R.string.express_exchange_status_sent
} else {
R.string.express_exchange_status_sending
}
else -> null
}

View file

@ -1,5 +1,6 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
@ -15,6 +16,7 @@ import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.toDateFormat
@ -64,17 +66,25 @@ private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetC
toFiatAmount = TextReference.Str(config.toFiatAmount),
)
SpacerH12()
// todo replace with real provider data
ExchangeProvider(
providerName = TextReference.Str(config.providerId.toString()),
providerType = TextReference.Str("CEX"),
imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/changenow_512.png",
providerName = TextReference.Str(config.provider.name),
providerType = TextReference.Str(config.provider.type.name),
imageUrl = config.provider.imageLarge,
)
SpacerH12()
ExchangeStatusBlock(
status = config.status,
statuses = config.statuses,
onClick = config.onGoToProviderClick,
)
AnimatedContent(
targetState = config.notification,
label = "Exchange Status Notification Change",
) {
it?.let {
SpacerH12()
Notification(config = it.config)
}
}
SpacerH24()
}
}

View file

@ -17,7 +17,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.constraintlayout.compose.Visibility
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.res.TangemTheme
@ -46,11 +49,13 @@ internal fun LazyListScope.swapTransactionsItems(
}
ExchangeStatusItem(
providerName = item.providerId.toString(),
providerName = item.provider.name,
fromTokenIconState = item.fromCurrencyIcon,
toTokenIconState = item.toCurrencyIcon,
fromAmount = item.fromCryptoAmount,
fromSymbol = item.fromCryptoSymbol,
toAmount = item.toCryptoAmount,
toSymbol = item.toCryptoSymbol,
onClick = item.onClick,
infoIconRes = iconRes,
infoIconTint = tint,
@ -67,7 +72,9 @@ private fun ExchangeStatusItem(
fromTokenIconState: TokenIconState,
toTokenIconState: TokenIconState,
fromAmount: String,
fromSymbol: String,
toAmount: String,
toSymbol: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
@DrawableRes infoIconRes: Int? = null,
@ -104,14 +111,17 @@ private fun ExchangeStatusItem(
bottom.linkTo(parent.bottom)
},
)
Text(
EllipsisText(
text = fromAmount,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
ellipsis = TextEllipsis.OffsetEnd(fromSymbol.length),
modifier = Modifier.constrainAs(fromRef) {
start.linkTo(fromIconRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
end.linkTo(swapIconRef.start)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints
},
)
Icon(
@ -123,6 +133,7 @@ private fun ExchangeStatusItem(
.constrainAs(swapIconRef) {
start.linkTo(fromRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
end.linkTo(toIconRef.start)
bottom.linkTo(parent.bottom)
},
)
@ -134,17 +145,21 @@ private fun ExchangeStatusItem(
.constrainAs(toIconRef) {
start.linkTo(swapIconRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
end.linkTo(toRef.start)
bottom.linkTo(parent.bottom)
},
)
Text(
EllipsisText(
text = toAmount,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
ellipsis = TextEllipsis.OffsetEnd(toSymbol.length),
modifier = Modifier.constrainAs(toRef) {
start.linkTo(toIconRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
end.linkTo(infoIconRef.start, padding6)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints
},
)
Icon(