Updated on 2026-08-14

This commit is contained in:
Tangem 2023-10-30 11:21:47 +03:00
commit f5a9381eb2
41 changed files with 1393 additions and 268 deletions

View file

@ -366,7 +366,10 @@ class TradeCryptoMiddleware {
)
}
}
val bundle = bundleOf(SendRouter.CRYPTO_CURRENCY_KEY to currency)
val bundle = bundleOf(
SendRouter.CRYPTO_CURRENCY_KEY to currency,
SendRouter.USER_WALLET_ID_KEY to action.userWallet.walletId.stringValue,
)
store.dispatchOnMain(NavigationAction.NavigateTo(screen = AppScreen.Send, bundle = bundle))
}
}
@ -416,7 +419,10 @@ class TradeCryptoMiddleware {
is CryptoCurrency.Token -> error("Action.tokenStatus.currency is Token")
}
val bundle = bundleOf(SendRouter.CRYPTO_CURRENCY_KEY to currency)
val bundle = bundleOf(
SendRouter.CRYPTO_CURRENCY_KEY to currency,
SendRouter.USER_WALLET_ID_KEY to action.userWallet.walletId.stringValue,
)
store.dispatchOnMain(NavigationAction.NavigateTo(screen = AppScreen.Send, bundle = bundle))
}
}

View file

@ -90,6 +90,7 @@
<string name="common_like">Нравится</string>
<string name="common_locked">Заблокирован</string>
<string name="common_main_network">Основная сеть</string>
<string name="common_next">Далее</string>
<string name="common_no">Нет</string>
<string name="common_no_address">Нет адреса</string>
<string name="common_no_data">Нет данных</string>

View file

@ -89,6 +89,7 @@
<string name="common_like">Like</string>
<string name="common_locked">Locked</string>
<string name="common_main_network">Main network</string>
<string name="common_next">Next</string>
<string name="common_no">No</string>
<string name="common_no_address">No address</string>
<string name="common_no_data">No data</string>
@ -114,7 +115,6 @@
<string name="common_submit">Submit</string>
<string name="common_success">Success</string>
<string name="common_swap">Swap</string>
<string name="common_next">Next</string>
<string name="common_terms_and_conditions">terms and conditions</string>
<string name="common_transaction_failed">Transaction failed</string>
<string name="common_transactions">Transactions</string>
@ -391,6 +391,7 @@
<string name="send_fee_picker_normal">Normal</string>
<string name="send_fee_picker_priority">Priority</string>
<string name="send_max_amount_label">Maximum amount</string>
<string name="send_max_amount">Max</string>
<string name="send_network_fee_title">Network fee</string>
<string name="send_title_currency_format">Sending %s</string>
<string name="send_total_label">Total</string>
@ -399,6 +400,7 @@
<string name="send_total_subtitle_format">%s will be sent</string>
<string name="send_transaction_success">Transaction has been successfully signed and sent to the blockchain node. Wallet balance will be updated in a while</string>
<string name="send_validation_invalid_address">Invalid address</string>
<string name="send_insufficient_funds">Insufficient funds for transfer</string>
<string name="shop_buy_now">Buy now</string>
<string name="shop_i_have_a_promo_code">I have a promo code…</string>
<string name="shop_one_wallet">Tangem Wallet</string>

View file

@ -27,6 +27,7 @@ dependencies {
implementation(deps.compose.material3)
implementation(deps.compose.paging)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
/** Other libraries */
implementation(deps.compose.accompanist.systemUiController)

View file

@ -7,11 +7,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.common.*
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.res.TangemTheme
@ -172,6 +171,8 @@ fun SecondaryButton(
modifier: Modifier = Modifier,
showProgress: Boolean = false,
enabled: Boolean = true,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -181,6 +182,8 @@ fun SecondaryButton(
colors = TangemButtonsDefaults.secondaryButtonColors,
enabled = enabled,
showProgress = showProgress,
size = size,
shape = shape,
)
}

View file

@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
@ -30,13 +31,14 @@ fun TangemButton(
size: TangemButtonSize = TangemButtonSize.Default,
elevation: ButtonElevation = TangemButtonsDefaults.elevation,
textStyle: TextStyle = TangemTheme.typography.button,
shape: Shape = size.toShape(),
) {
Button(
modifier = modifier.heightIn(min = size.toHeightDp()),
onClick = { if (!showProgress) onClick() },
enabled = enabled,
elevation = elevation,
shape = size.toShape(),
shape = shape,
colors = colors,
contentPadding = size.toContentPadding(icon = icon),
) {

View file

@ -0,0 +1,161 @@
package com.tangem.core.ui.components.buttons.segmentedbutton
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
/**
* Segmented buttons
*
* [Figma component](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=1961-2004&mode=design&t=OFPQ18YhLHVAANab-4)
*
* @param config list of buttons in SegmentedButtons
* @param onClick button click
* @param modifier component modifier
* @param color default button color
* @param selectedColor selected button color
* @param dividerColor border and divider color
* @param showIndication show ripple indication
* @param buttonContent content as separate button
*/
@Composable
inline fun <reified T> SegmentedButtons(
config: PersistentList<T>,
crossinline onClick: (T) -> Unit,
modifier: Modifier = Modifier,
color: Color = TangemTheme.colors.background.tertiary,
selectedColor: Color = TangemTheme.colors.background.action,
dividerColor: Color = TangemTheme.colors.stroke.primary,
showIndication: Boolean = true,
crossinline buttonContent: @Composable (T) -> Unit,
) {
if (config.isEmpty() || config.size == 1) return
var selected by remember { mutableIntStateOf(0) }
Row(
modifier = modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius26))
.background(dividerColor)
.padding(TangemTheme.dimens.spacing1),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing1),
) {
repeat(config.size) { index ->
val leftRadius = if (index == 0) TangemTheme.dimens.radius26 else TangemTheme.dimens.radius0
val rightRadius = if (index == config.lastIndex) TangemTheme.dimens.radius26 else TangemTheme.dimens.radius0
Box(
modifier = Modifier
.weight(1f)
.background(
color = if (index == selected) selectedColor else color,
shape = RoundedCornerShape(
topStart = leftRadius,
topEnd = rightRadius,
bottomEnd = rightRadius,
bottomStart = leftRadius,
),
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = if (showIndication) LocalIndication.current else null,
) {
onClick(config[index])
selected = index
},
) {
buttonContent.invoke(config[index])
}
}
}
}
@Preview
@Composable
private fun SegmentedButtonsPreview_Light(
@PreviewParameter(SegmentedButtonsPreviewProvider::class) config: PersistentList<SegmentedButtonsConfigPreview>,
) {
TangemTheme {
SegmentedButtons(
config = config,
onClick = {},
) {
Text(
text = it.text,
modifier = Modifier.padding(TangemTheme.dimens.spacing16),
)
}
}
}
@Preview
@Composable
private fun SegmentedButtonsPreview_Dark(
@PreviewParameter(SegmentedButtonsPreviewProvider::class) config: PersistentList<SegmentedButtonsConfigPreview>,
) {
TangemTheme(isDark = true) {
SegmentedButtons(
config = config,
onClick = {},
) {
Text(
text = it.text,
modifier = Modifier.padding(TangemTheme.dimens.spacing16),
)
}
}
}
//region Preview config
/**
* Segmented buttons preview model
*
* @param text button title
*/
internal data class SegmentedButtonsConfigPreview(
val text: String,
)
/**
* Segmented button preview provider
*/
internal class SegmentedButtonsPreviewProvider :
CollectionPreviewParameterProvider<PersistentList<SegmentedButtonsConfigPreview>>(
collection = listOf(
persistentListOf(
SegmentedButtonsConfigPreview(
text = "Title 1",
),
SegmentedButtonsConfigPreview(
text = "Title 2",
),
SegmentedButtonsConfigPreview(
text = "Title 3",
),
),
persistentListOf(
SegmentedButtonsConfigPreview(
text = "Title 1",
),
SegmentedButtonsConfigPreview(
text = "Title 2",
),
),
),
)
//endregion

View file

@ -0,0 +1,60 @@
package com.tangem.core.ui.components.currency
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.currency.tokenicon.LoadingIcon
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
import kotlinx.coroutines.launch
@Composable
internal inline fun DefaultCurrencyIcon(
iconData: Any,
alpha: Float,
colorFilter: ColorFilter?,
crossinline errorIcon: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
SubcomposeAsyncImage(
modifier = modifier
.background(
color = iconBackgroundColor,
shape = TangemTheme.shapes.roundedCorners8,
),
model = ImageRequest.Builder(context = LocalContext.current)
.data(iconData)
.crossfade(enable = true)
.allowHardware(false)
.listener(
onSuccess = { _, result ->
if (isDarkTheme) {
coroutineScope.launch {
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = itemBackgroundColor,
).getContrastColorIfNeeded(isDarkTheme)
iconBackgroundColor = color
}
}
},
).build(),
loading = { LoadingIcon() },
error = { errorIcon() },
alpha = alpha,
colorFilter = colorFilter,
contentDescription = null,
)
}

View file

@ -0,0 +1,38 @@
package com.tangem.core.ui.components.currency.fiaticon
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.DefaultCurrencyIcon
/**
* Simple icon from network
*
* @param url link to icon
* @param fallbackResId fallback icon
* @param modifier component modifier
*/
@Composable
fun FiatIcon(
url: String?,
modifier: Modifier = Modifier,
@DrawableRes fallbackResId: Int = R.drawable.ic_shape_circle,
) {
val iconData: Any = if (url.isNullOrBlank()) fallbackResId else url
DefaultCurrencyIcon(
modifier = modifier,
iconData = iconData,
errorIcon = {
Image(
painter = painterResource(id = fallbackResId),
contentDescription = null,
)
},
alpha = 1f,
colorFilter = null,
)
}

View file

@ -1,44 +1,36 @@
package com.tangem.feature.wallet.presentation.common.component.token.icon
package com.tangem.core.ui.components.currency.tokenicon
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import kotlinx.coroutines.launch
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.DefaultCurrencyIcon
@Composable
internal fun ContentIcon(
icon: TokenItemState.IconState,
icon: TokenIconState,
alpha: Float,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
) {
when (icon) {
is TokenItemState.IconState.CoinIcon -> CoinIcon(
is TokenIconState.CoinIcon -> CoinIcon(
modifier = modifier,
url = icon.url,
fallbackResId = icon.fallbackResId,
alpha = alpha,
colorFilter = colorFilter,
)
is TokenItemState.IconState.TokenIcon -> TokenIcon(
is TokenIconState.TokenIcon -> TokenIcon(
modifier = modifier,
url = icon.url,
alpha = alpha,
@ -52,14 +44,14 @@ internal fun ContentIcon(
)
},
)
is TokenItemState.IconState.CustomTokenIcon -> CustomTokenIcon(
is TokenIconState.CustomTokenIcon -> CustomTokenIcon(
modifier = modifier,
tint = icon.tint,
background = icon.background,
alpha = alpha,
)
TokenItemState.IconState.Loading,
TokenItemState.IconState.Locked,
TokenIconState.Loading,
TokenIconState.Locked,
-> Unit
}
}
@ -128,48 +120,4 @@ private fun CustomTokenIcon(tint: Color, background: Color, alpha: Float, modifi
contentDescription = null,
)
}
}
@Composable
private inline fun DefaultCurrencyIcon(
iconData: Any,
alpha: Float,
colorFilter: ColorFilter?,
crossinline errorIcon: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
SubcomposeAsyncImage(
modifier = modifier
.background(
color = iconBackgroundColor,
shape = TangemTheme.shapes.roundedCorners8,
),
model = ImageRequest.Builder(context = LocalContext.current)
.data(iconData)
.crossfade(enable = true)
.allowHardware(false)
.listener(
onSuccess = { _, result ->
if (isDarkTheme) {
coroutineScope.launch {
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = itemBackgroundColor,
).getContrastColorIfNeeded(isDarkTheme)
iconBackgroundColor = color
}
}
},
).build(),
loading = { LoadingIcon() },
error = { errorIcon() },
alpha = alpha,
colorFilter = colorFilter,
contentDescription = null,
)
}

View file

@ -1,4 +1,4 @@
package com.tangem.feature.wallet.presentation.common.component.token.icon
package com.tangem.core.ui.components.currency.tokenicon
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image

View file

@ -1,4 +1,4 @@
package com.tangem.feature.wallet.presentation.common.component.token.icon
package com.tangem.core.ui.components.currency.tokenicon
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@ -14,14 +14,22 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.common.state.TokenItemState.IconState as TokenIconState
private const val GRAY_SCALE_SATURATION = 0f
private const val GRAY_SCALE_ALPHA = 0.4f
private const val NORMAL_ALPHA = 1f
/**
* Cryptocurrency icon with network badge
*
* TODO [separate domain from ui]([REDACTED_JIRA])
*
* @param state cryptocurrency icon config
* @param modifier component modifier
* @param shouldDisplayNetwork specifies whether to display network badge
*/
@Composable
internal fun TokenIcon(state: TokenIconState, modifier: Modifier = Modifier) {
fun TokenIcon(state: TokenIconState, modifier: Modifier = Modifier, shouldDisplayNetwork: Boolean = true) {
BaseContainer(modifier = modifier) {
val iconModifier = Modifier
.align(Alignment.Center)
@ -34,7 +42,11 @@ internal fun TokenIcon(state: TokenIconState, modifier: Modifier = Modifier) {
is TokenIconState.CustomTokenIcon,
is TokenIconState.TokenIcon,
-> {
ContentIconContainer(modifier = iconModifier, icon = state)
ContentIconContainer(
icon = state,
modifier = iconModifier,
shouldDisplayNetwork = shouldDisplayNetwork,
)
}
}
}
@ -60,7 +72,11 @@ private fun LockedIcon(modifier: Modifier = Modifier) {
}
@Composable
private fun BoxScope.ContentIconContainer(icon: TokenIconState, modifier: Modifier = Modifier) {
private fun BoxScope.ContentIconContainer(
icon: TokenIconState,
modifier: Modifier = Modifier,
shouldDisplayNetwork: Boolean = true,
) {
val networkBadgeOffset = TangemTheme.dimens.spacing4
val (alpha, colorFilter) = remember(icon.isGrayscale) {
if (icon.isGrayscale) {
@ -77,7 +93,7 @@ private fun BoxScope.ContentIconContainer(icon: TokenIconState, modifier: Modifi
colorFilter = colorFilter,
)
if (icon.networkBadgeIconResId != null) {
if (icon.networkBadgeIconResId != null && shouldDisplayNetwork) {
NetworkBadge(
modifier = Modifier
.offset(x = networkBadgeOffset, y = -networkBadgeOffset)

View file

@ -0,0 +1,85 @@
package com.tangem.core.ui.components.currency.tokenicon
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
/**
* Represents the various states an icon can be in.
*
* [REDACTED_TODO_COMMENT]
*/
@Immutable
sealed class TokenIconState {
abstract val isGrayscale: Boolean
abstract val showCustomBadge: Boolean
abstract val networkBadgeIconResId: Int?
/**
* Represents a coin icon.
*
* @property url The URL where the coin icon can be fetched from. May be `null` if not found.
* @property fallbackResId The drawable resource ID to be used as a fallback if the URL is not available.
* @property isGrayscale Specifies whether to show the icon in grayscale.
* @property showCustomBadge Specifies whether to show the custom token badge.
*/
data class CoinIcon(
val url: String?,
@DrawableRes val fallbackResId: Int,
override val isGrayscale: Boolean,
override val showCustomBadge: Boolean,
) : TokenIconState() {
override val networkBadgeIconResId: Int? = null
}
/**
* Represents a token icon.
*
* @property url The URL where the token icon can be fetched from. May be `null` if not found.
* @property networkBadgeIconResId The drawable resource ID for the network badge.
* @property isGrayscale Specifies whether to show the icon in grayscale.
* @property showCustomBadge Specifies whether to show the custom token badge.
* @property fallbackTint The color to be used for tinting the fallback icon.
* @property fallbackBackground The background color to be used for the fallback icon.
*/
data class TokenIcon(
val url: String?,
@DrawableRes override val networkBadgeIconResId: Int,
override val isGrayscale: Boolean,
override val showCustomBadge: Boolean,
val fallbackTint: Color,
val fallbackBackground: Color,
) : TokenIconState()
/**
* Represents a custom token icon.
*
* @property tint The color to be used for tinting the icon.
* @property background The background color to be used for the icon.
* @property networkBadgeIconResId The drawable resource ID for the network badge.
* @property isGrayscale Specifies whether to show the icon in grayscale.
*/
data class CustomTokenIcon(
val tint: Color,
val background: Color,
@DrawableRes override val networkBadgeIconResId: Int,
override val isGrayscale: Boolean,
) : TokenIconState() {
override val showCustomBadge: Boolean = true
}
object Loading : TokenIconState() {
override val isGrayscale: Boolean = false
override val showCustomBadge: Boolean = false
override val networkBadgeIconResId: Int? = null
}
object Locked : TokenIconState() {
override val isGrayscale: Boolean = false
override val showCustomBadge: Boolean = false
override val networkBadgeIconResId: Int? = null
}
}

View file

@ -1,27 +1,27 @@
package com.tangem.feature.wallet.presentation.common.utils
package com.tangem.core.ui.components.currency.tokenicon.converter
import com.tangem.common.Converter
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.extensions.getTintForTokenIcon
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.utils.converter.Converter
internal class CryptoCurrencyToIconStateConverter : Converter<CryptoCurrencyStatus, TokenItemState.IconState> {
/**
* Converts [CryptoCurrencyStatus] to [TokenIconState]
*/
class CryptoCurrencyToIconStateConverter : Converter<CryptoCurrencyStatus, TokenIconState> {
override fun convert(value: CryptoCurrencyStatus): TokenItemState.IconState {
override fun convert(value: CryptoCurrencyStatus): TokenIconState {
return when (val currency = value.currency) {
is CryptoCurrency.Coin -> getIconStateForCoin(currency, value.value.isError)
is CryptoCurrency.Token -> getIconStateForToken(currency, value.value.isError)
}
}
private fun getIconStateForCoin(
coin: CryptoCurrency.Coin,
isUnreachable: Boolean,
): TokenItemState.IconState.CoinIcon {
return TokenItemState.IconState.CoinIcon(
private fun getIconStateForCoin(coin: CryptoCurrency.Coin, isUnreachable: Boolean): TokenIconState.CoinIcon {
return TokenIconState.CoinIcon(
url = coin.iconUrl,
fallbackResId = coin.networkIconResId,
isGrayscale = coin.network.isTestnet || isUnreachable,
@ -29,20 +29,20 @@ internal class CryptoCurrencyToIconStateConverter : Converter<CryptoCurrencyStat
)
}
private fun getIconStateForToken(token: CryptoCurrency.Token, isErrorStatus: Boolean): TokenItemState.IconState {
private fun getIconStateForToken(token: CryptoCurrency.Token, isErrorStatus: Boolean): TokenIconState {
val isGrayscale = token.network.isTestnet || isErrorStatus
val background = token.tryGetBackgroundForTokenIcon(isGrayscale)
val tint = getTintForTokenIcon(background)
return if (token.isCustom && token.iconUrl == null) {
TokenItemState.IconState.CustomTokenIcon(
TokenIconState.CustomTokenIcon(
tint = tint,
background = background,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = isGrayscale,
)
} else {
TokenItemState.IconState.TokenIcon(
TokenIconState.TokenIcon(
url = token.iconUrl,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = isGrayscale,

View file

@ -170,7 +170,6 @@ class TangemColors internal constructor(
plain = other.plain
action = other.action
fade = other.fade
tertiary = other.tertiary
}
}

View file

@ -12,7 +12,9 @@ object BigDecimalFormatter {
private const val TEMP_CURRENCY_CODE = "USD"
fun formatCryptoAmount(cryptoAmount: BigDecimal, cryptoCurrency: String, decimals: Int): String {
fun formatCryptoAmount(cryptoAmount: BigDecimal?, cryptoCurrency: String, decimals: Int): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = NumberFormat.getNumberInstance().apply {
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
@ -23,11 +25,13 @@ object BigDecimalFormatter {
}
fun formatFiatAmount(
fiatAmount: BigDecimal,
fiatAmount: BigDecimal?,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
if (fiatAmount == null) return EMPTY_BALANCE_SIGN
val formatterCurrency = getCurrency(fiatCurrencyCode)
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="40dp"
android:height="40dp" />
</shape>

View file

@ -8,5 +8,6 @@ interface SendRouter {
companion object {
const val CRYPTO_CURRENCY_KEY = "send_crypto_currency"
const val USER_WALLET_ID_KEY = "send_user_wallet_id"
}
}

View file

@ -14,7 +14,12 @@ dependencies {
/** AndroidX */
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.appCompat)
/** Other dependencies */
implementation(deps.kotlin.immutable.collections)
implementation(deps.material)
implementation(deps.arrow.core)
implementation(deps.tangem.card.core)
/** Compose */
implementation(deps.compose.accompanist.systemUiController)
@ -23,13 +28,24 @@ dependencies {
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.navigation)
implementation(deps.compose.navigation.hilt)
/** Common */
implementation(projects.common)
/** Core modules */
implementation(projects.core.featuretoggles)
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Domain modules */
implementation(projects.domain.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
/** Feature modules */
implementation(projects.features.send.api)

View file

@ -4,10 +4,14 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.hilt.navigation.compose.hiltViewModel
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.screen.ComposeBottomSheetFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.features.send.impl.presentation.send.state.SendUiState
import com.tangem.features.send.impl.presentation.send.ui.SendScreen
import com.tangem.features.send.impl.presentation.send.viewmodel.SendViewModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@ -24,13 +28,16 @@ internal class SendFragment : ComposeBottomSheetFragment() {
@Composable
override fun ScreenContent(modifier: Modifier) {
SystemBarsEffect {
setSystemBarsColor(color = Color.Transparent)
val viewModel = hiltViewModel<SendViewModel>()
LocalLifecycleOwner.current.lifecycle.addObserver(viewModel)
SystemBarsEffect { setSystemBarsColor(color = Color.Transparent) }
BackHandler { dismiss() }
when (val state = viewModel.uiState) {
is SendUiState.Content -> SendScreen(state)
SendUiState.Dismiss -> dismiss()
}
BackHandler {
dismiss()
}
SendScreen()
}
companion object {

View file

@ -0,0 +1,21 @@
package com.tangem.features.send.impl.presentation.send.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.extensions.TextReference
/**
* Segmented buttons config
*
* @param title button title
* @param iconState currency icon state
* @param iconUrl currency icon url
* @param isFiat is fiat currency
*/
@Immutable
internal data class SendAmountSegmentedButtonsConfig(
val title: TextReference,
val iconState: TokenIconState? = null,
val iconUrl: String? = null,
val isFiat: Boolean,
)

View file

@ -0,0 +1,63 @@
package com.tangem.features.send.impl.presentation.send.state
import arrow.core.Either
import com.tangem.common.Provider
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.utils.BigDecimalFormatter.formatCryptoAmount
import com.tangem.core.ui.utils.BigDecimalFormatter.formatFiatAmount
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.presentation.send.state.fields.SendAmountFieldConverter
import com.tangem.features.send.impl.presentation.send.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
internal class SendAmountStateConverter(
private val currentStateProvider: Provider<SendUiState>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val userWalletProvider: Provider<UserWallet?>,
private val clickIntents: SendClickIntents,
private val iconStateConverter: CryptoCurrencyToIconStateConverter,
private val sendAmountFieldConverter: SendAmountFieldConverter,
) : Converter<Either<CurrencyStatusError, CryptoCurrencyStatus>, SendUiState> {
override fun convert(value: Either<CurrencyStatusError, CryptoCurrencyStatus>): SendUiState {
val userWallet = userWalletProvider() ?: return currentStateProvider()
val appCurrency = appCurrencyProvider()
return value.fold(
ifLeft = {
// TODO add error handling
currentStateProvider()
},
ifRight = {
val fiat = formatFiatAmount(it.value.fiatAmount, appCurrency.code, appCurrency.symbol)
val crypto = formatCryptoAmount(it.value.amount, it.currency.symbol, it.currency.decimals)
SendUiState.Content.AmountState(
cryptoCurrencyStatus = it,
walletName = userWallet.name,
walletBalance = "$crypto ($fiat)",
tokenIconState = iconStateConverter.convert(it),
appCurrency = appCurrency,
amountTextField = sendAmountFieldConverter.convert(Unit),
isFiatValue = false,
clickIntents = clickIntents,
segmentedButtonConfig = persistentListOf(
SendAmountSegmentedButtonsConfig(
title = stringReference(it.currency.symbol),
iconState = iconStateConverter.convert(it),
isFiat = false,
),
SendAmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconState = iconStateConverter.convert(it),
isFiat = true,
),
),
)
},
)
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.send.impl.presentation.send.state
import arrow.core.Either
import com.tangem.common.Provider
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.presentation.send.state.fields.SendAmountFieldChangeConverter
import com.tangem.features.send.impl.presentation.send.state.fields.SendAmountFieldConverter
import com.tangem.features.send.impl.presentation.send.viewmodel.SendClickIntents
internal class SendStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val userWalletProvider: Provider<UserWallet?>,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val amountFieldConverter by lazy { SendAmountFieldConverter(clickIntents) }
private val amountFieldChangeConverter by lazy { SendAmountFieldChangeConverter(currentStateProvider) }
private val amountStateConverter by lazy {
SendAmountStateConverter(
currentStateProvider = currentStateProvider,
appCurrencyProvider = appCurrencyProvider,
clickIntents = clickIntents,
iconStateConverter = iconStateConverter,
userWalletProvider = userWalletProvider,
sendAmountFieldConverter = amountFieldConverter,
)
}
fun getInitialState(): SendUiState = SendUiState.Content.Initial(clickIntents = clickIntents)
fun getAmountState(cryptoCurrencyStatus: Either<CurrencyStatusError, CryptoCurrencyStatus>): SendUiState {
return amountStateConverter.convert(cryptoCurrencyStatus)
}
fun getOnReceiveState(): SendUiState = SendUiState.Content.Initial(clickIntents = clickIntents)
fun getOnAmountValueChange(value: String) = amountFieldChangeConverter.convert(value)
fun getOnCurrencyChangedState(isFiat: Boolean): SendUiState {
val state = currentStateProvider()
val amountState = state as? SendUiState.Content.AmountState ?: return state
return if (amountState.isFiatValue == isFiat) {
state
} else {
return state.copy(isFiatValue = isFiat)
}
}
}

View file

@ -0,0 +1,70 @@
package com.tangem.features.send.impl.presentation.send.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.send.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.send.viewmodel.SendClickIntents
import kotlinx.collections.immutable.PersistentList
/**
* Ui states of the send screen
*/
@Immutable
internal sealed class SendUiState {
/** States with content */
sealed class Content : SendUiState() {
/** Is primary button enabled */
abstract val isPrimaryButtonEnabled: Boolean
/** Click intents */
abstract val clickIntents: SendClickIntents
/** Initial state */
data class Initial(
override val isPrimaryButtonEnabled: Boolean = false,
override val clickIntents: SendClickIntents,
) : Content()
/** Amount state */
data class AmountState(
override val isPrimaryButtonEnabled: Boolean = false,
override val clickIntents: SendClickIntents,
val walletName: String,
val walletBalance: String,
val tokenIconState: TokenIconState,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrency: AppCurrency,
val isFiatValue: Boolean,
val segmentedButtonConfig: PersistentList<SendAmountSegmentedButtonsConfig>,
val amountTextField: SendTextField.Amount,
) : Content()
// todo [REDACTED_JIRA]
/** Recipient state */
data class RecipientState(
override val isPrimaryButtonEnabled: Boolean = false,
override val clickIntents: SendClickIntents,
) : Content()
// todo [REDACTED_JIRA]
/** Fee and speed state */
data class FeeState(
override val isPrimaryButtonEnabled: Boolean = false,
override val clickIntents: SendClickIntents,
) : Content()
// todo [REDACTED_JIRA]
/** Send state */
data class SendState(
override val isPrimaryButtonEnabled: Boolean = true,
override val clickIntents: SendClickIntents,
) : Content()
}
/** Dismiss screen */
object Dismiss : SendUiState()
}

View file

@ -0,0 +1,97 @@
package com.tangem.features.send.impl.presentation.send.state.fields
import com.tangem.common.Provider
import com.tangem.features.send.impl.presentation.send.state.SendUiState
import com.tangem.utils.converter.Converter
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
internal class SendAmountFieldChangeConverter(
private val currentStateProvider: Provider<SendUiState>,
) : Converter<String, SendUiState> {
override fun convert(value: String): SendUiState {
val state = currentStateProvider()
if (
state !is SendUiState.Content.AmountState ||
value.checkDecimalSeparatorDuplicate()
) {
return state
}
if (value.isEmpty()) return state.emptyState()
val fiatRate = state.cryptoCurrencyStatus.value.fiatRate
val trimmedValue = value.trim()
val cryptoValue = if (state.isFiatValue) {
if (value.isNotBlank()) {
trimmedValue.toBigDecimal().divide(fiatRate)?.stripTrailingZeros()?.toPlainString().orEmpty()
} else {
DEFAULT_VALUE
}
} else {
trimmedValue
}
val fiatValue = if (!state.isFiatValue) {
if (value.isNotBlank()) {
trimmedValue.toBigDecimal().multiply(fiatRate)?.stripTrailingZeros()?.toPlainString().orEmpty()
} else {
DEFAULT_VALUE
}
} else {
trimmedValue
}
val isExceedBalance = value.checkExceedBalance(state)
return state.copy(
amountTextField = state.amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,
isError = isExceedBalance,
),
isPrimaryButtonEnabled = !isExceedBalance,
)
}
private fun SendUiState.Content.AmountState.emptyState(): SendUiState {
return copy(
amountTextField = amountTextField.copy(
value = if (!isFiatValue) "" else DEFAULT_VALUE,
fiatValue = if (isFiatValue) "" else DEFAULT_VALUE,
isError = false,
),
isPrimaryButtonEnabled = false,
)
}
private fun String.checkDecimalSeparatorDuplicate(): Boolean {
val regex = "[\\.\\,]".toRegex()
val decimalSeparatorCount = regex.findAll(this).count()
return decimalSeparatorCount > 1
}
private fun String.checkExceedBalance(state: SendUiState.Content.AmountState): Boolean {
val currencyStatus = state.cryptoCurrencyStatus.value
return if (state.isFiatValue) {
toBigDecimal() > currencyStatus.fiatAmount
} else {
toBigDecimal() > currencyStatus.amount
}
}
private fun String.trim(): String {
var trimmedValue = this
if (length > 1 && firstOrNull() == '0' && get(1).isDigit()) trimmedValue = drop(1)
val separatorChar = DecimalFormatSymbols.getInstance().decimalSeparator.toString()
return trimmedValue.replace("[\\.\\,]".toRegex(), separatorChar)
}
companion object {
private val DEFAULT_VALUE = NumberFormat.getInstance().format(0.00)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.send.impl.presentation.send.state.fields
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.send.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
import java.text.NumberFormat
internal class SendAmountFieldConverter(
private val clickIntents: SendClickIntents,
) : Converter<Unit, SendTextField.Amount> {
override fun convert(value: Unit): SendTextField.Amount {
return SendTextField.Amount(
value = "",
fiatValue = DEFAULT_VALUE,
onValueChange = clickIntents::onAmountValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number,
),
label = TextReference.Str(""),
placeholder = TextReference.Str(DEFAULT_VALUE),
isError = false,
error = TextReference.Res(R.string.send_insufficient_funds),
)
}
companion object {
private val DEFAULT_VALUE = NumberFormat.getInstance().format(0.00)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.send.impl.presentation.send.state.fields
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class SendTextField {
/** Current value */
abstract val value: String
/** Lambda be invoked when value is been changed */
abstract val onValueChange: (String) -> Unit
/** Keyboard options */
abstract val keyboardOptions: KeyboardOptions
/** Label */
abstract val label: TextReference
/** Placeholder (hint) */
abstract val placeholder: TextReference
data class Amount(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val fiatValue: String,
val isError: Boolean,
val error: TextReference,
) : SendTextField()
}

View file

@ -0,0 +1,109 @@
package com.tangem.features.send.impl.presentation.send.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.currency.fiaticon.FiatIcon
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.send.state.SendAmountSegmentedButtonsConfig
import com.tangem.features.send.impl.presentation.send.state.SendUiState
import com.tangem.features.send.impl.presentation.send.ui.amount.AmountFieldContainer
@Composable
internal fun SendAmountContent(amountState: SendUiState.Content.AmountState) {
Column(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary),
) {
Text(
text = stringResource(R.string.common_send),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing16)
.align(CenterHorizontally),
)
AmountFieldContainer(amountState = amountState)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing12,
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
),
) {
SegmentedButtons(
modifier = Modifier
.height(TangemTheme.dimens.size40)
.weight(1f),
config = amountState.segmentedButtonConfig,
onClick = { amountState.clickIntents.onCurrencyChangeClick(it.isFiat) },
) {
SendAmountCurrencyButton(it)
}
SecondaryButton(
text = stringResource(R.string.send_max_amount),
onClick = amountState.clickIntents::onMaxValueClick,
size = TangemButtonSize.Text,
shape = RoundedCornerShape(TangemTheme.dimens.radius26),
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing8)
.height(TangemTheme.dimens.size40),
)
}
}
}
@Composable
private fun SendAmountCurrencyButton(button: SendAmountSegmentedButtonsConfig) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(
horizontal = TangemTheme.dimens.spacing10,
),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (button.isFiat) {
FiatIcon(
url = button.iconUrl,
modifier = Modifier
.size(TangemTheme.dimens.size18),
)
} else {
button.iconState?.let {
TokenIcon(
state = it,
shouldDisplayNetwork = false,
modifier = Modifier
.size(TangemTheme.dimens.size18),
)
}
}
Text(
text = button.title.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.button,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing8,
),
)
}
}

View file

@ -1,7 +0,0 @@
package com.tangem.features.send.impl.presentation.send.ui
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
data class SendBottomSheetConfig(
val currentState: SendStates,
) : TangemBottomSheetConfigContent

View file

@ -18,17 +18,18 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.send.state.SendUiState
@Composable
internal fun SendNavigationButtons(currentState: SendStates) {
internal fun SendNavigationButtons(uiState: SendUiState.Content) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = TangemTheme.dimens.spacing12),
) {
SendSecondaryNavigationButton(currentState)
SendSecondaryNavigationButton(uiState)
SendPrimaryNavigationButton(
currentState,
uiState = uiState,
modifier = Modifier
.weight(1f)
.padding(horizontal = TangemTheme.dimens.spacing16),
@ -37,9 +38,9 @@ internal fun SendNavigationButtons(currentState: SendStates) {
}
@Composable
private fun SendSecondaryNavigationButton(currentState: SendStates) {
private fun SendSecondaryNavigationButton(uiState: SendUiState.Content) {
AnimatedVisibility(
visible = currentState == SendStates.Recipient || currentState == SendStates.Fee,
visible = uiState is SendUiState.Content.RecipientState || uiState is SendUiState.Content.FeeState,
) {
Icon(
modifier = Modifier
@ -57,40 +58,37 @@ private fun SendSecondaryNavigationButton(currentState: SendStates) {
}
@Composable
private fun SendPrimaryNavigationButton(currentState: SendStates, modifier: Modifier = Modifier) {
val buttonTextId = when (currentState) {
SendStates.Amount,
SendStates.Recipient,
SendStates.Fee,
private fun SendPrimaryNavigationButton(uiState: SendUiState.Content, modifier: Modifier = Modifier) {
val buttonTextId = when (uiState) {
is SendUiState.Content.AmountState,
is SendUiState.Content.RecipientState,
is SendUiState.Content.FeeState,
-> R.string.common_next
SendStates.Filled -> R.string.common_send
SendStates.Done -> R.string.common_close
is SendUiState.Content.SendState -> R.string.common_send
else -> R.string.common_close
}
AnimatedContent(
targetState = buttonTextId,
label = "Update send screen state",
modifier = modifier,
) { textId ->
when (currentState) {
SendStates.Amount,
SendStates.Recipient,
SendStates.Fee,
SendStates.Done,
-> PrimaryButton(
if (uiState is SendUiState.Content.SendState) {
PrimaryButtonIconEnd(
text = stringResource(textId),
iconResId = R.drawable.ic_tangem_24,
enabled = uiState.isPrimaryButtonEnabled,
onClick = {
// todo add next click
},
)
} else {
PrimaryButton(
text = stringResource(textId),
enabled = uiState.isPrimaryButtonEnabled,
onClick = {
// todo add next click
},
)
SendStates.Filled -> {
PrimaryButtonIconEnd(
text = stringResource(textId),
iconResId = R.drawable.ic_tangem_24,
onClick = {
// todo add next click
},
)
}
}
}
}

View file

@ -9,17 +9,14 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetDraggableHeader
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.send.state.SendUiState
@Composable
fun SendScreen() {
// todo will be removed
val config = remember { SendBottomSheetConfig(SendStates.Amount) }
internal fun SendScreen(uiState: SendUiState.Content) {
Column(
modifier = Modifier
.imePadding()
@ -40,13 +37,17 @@ fun SendScreen() {
.weight(1f)
.scrollable(state = rememberScrollState(), orientation = Orientation.Vertical),
) {
SendScreenContent()
SendScreenContent(uiState)
}
SendNavigationButtons(config.currentState)
SendNavigationButtons(uiState)
}
}
@Composable
fun SendScreenContent() {
// todo work in progress
private fun SendScreenContent(uiState: SendUiState.Content) {
when (uiState) {
is SendUiState.Content.AmountState -> SendAmountContent(uiState)
else -> { /* [REDACTED_TODO_COMMENT]*/
}
}
}

View file

@ -1,34 +0,0 @@
package com.tangem.features.send.impl.presentation.send.ui
/**
* Screen states of Send
*/
enum class SendStates {
Amount,
Recipient,
Fee,
Filled,
Done,
;
companion object {
/** Get next [SendStates] */
fun SendStates.next(): SendStates {
return if (this.ordinal < SendStates.values().last().ordinal) {
SendStates.values()[this.ordinal + 1]
} else {
this
}
}
/** Get previous [SendStates] */
fun SendStates.previous(): SendStates {
return if (this.ordinal > 0) {
SendStates.values()[this.ordinal - 1]
} else {
this
}
}
}
}

View file

@ -0,0 +1,168 @@
package com.tangem.features.send.impl.presentation.send.ui.amount
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomCenter
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.send.state.fields.SendTextField
@Composable
internal fun ColumnScope.AmountField(
sendField: SendTextField.Amount,
cryptoSymbol: String,
fiatSymbol: String,
isFiat: Boolean,
) {
val value = if (isFiat) sendField.fiatValue else sendField.value
val secondaryValue = if (!isFiat) sendField.fiatValue else sendField.value
val symbol = if (isFiat) fiatSymbol else cryptoSymbol
val secondarySymbol = if (!isFiat) fiatSymbol else cryptoSymbol
AmountFieldInner(
value = value,
placeholder = sendField.placeholder,
symbol = symbol,
onValueChange = sendField.onValueChange,
keyboardOptions = sendField.keyboardOptions,
modifier = Modifier
.align(CenterHorizontally)
.padding(
top = TangemTheme.dimens.spacing24,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
)
Box(
modifier = Modifier
.align(CenterHorizontally)
.padding(
top = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
) {
Text(
text = "$secondaryValue $secondarySymbol",
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.align(BottomCenter)
.padding(bottom = TangemTheme.dimens.spacing32),
)
AmountFieldError(
isError = sendField.isError,
error = sendField.error,
modifier = Modifier
.align(BottomCenter)
.padding(bottom = TangemTheme.dimens.spacing12),
)
}
}
@Composable
private fun AmountFieldInner(
value: String,
placeholder: TextReference,
symbol: String,
onValueChange: (String) -> Unit,
keyboardOptions: KeyboardOptions,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier
.focusRequester(focusRequester)
.background(TangemTheme.colors.background.action),
textStyle = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
keyboardOptions = keyboardOptions,
singleLine = true,
visualTransformation = AmountVisualTransformation(symbol),
decorationBox = { innerTextField ->
Box {
if (value.isBlank()) {
Text(
text = "${placeholder.resolveReference()} $symbol",
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.disabled,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopCenter),
)
}
innerTextField()
}
},
)
}
@Composable
private fun AmountFieldError(isError: Boolean, error: TextReference, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = isError,
enter = fadeIn(),
exit = fadeOut(),
modifier = modifier,
) {
Text(
text = error.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.warning,
textAlign = TextAlign.Center,
)
}
}
private class AmountVisualTransformation(
private val symbol: String,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
buildAnnotatedString {
append(text)
if (text.isNotBlank()) {
append(" ")
append(symbol)
}
},
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return text.length
}
override fun transformedToOriginal(offset: Int): Int {
return text.length
}
},
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.send.impl.presentation.send.ui.amount
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.clip
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.send.state.SendUiState
@Composable
internal fun AmountFieldContainer(amountState: SendUiState.Content.AmountState, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing4,
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
)
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action),
) {
Text(
text = amountState.walletName,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing14)
.align(Alignment.CenterHorizontally),
)
Text(
text = amountState.walletBalance,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing2)
.align(Alignment.CenterHorizontally),
)
TokenIcon(
state = amountState.tokenIconState,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing32)
.align(Alignment.CenterHorizontally),
)
AmountField(
sendField = amountState.amountTextField,
cryptoSymbol = amountState.cryptoCurrencyStatus.currency.symbol,
fiatSymbol = amountState.appCurrency.symbol,
isFiat = amountState.isFiatValue,
)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.send.impl.presentation.send.viewmodel
interface SendClickIntents {
fun onNextClick()
fun onPrevClick()
fun onAmountValueChange(value: String)
fun onCurrencyChangeClick(isFiat: Boolean)
fun onMaxValueClick()
}

View file

@ -0,0 +1,155 @@
package com.tangem.features.send.impl.presentation.send.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import arrow.core.getOrElse
import com.tangem.common.Provider
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.presentation.send.state.SendStateFactory
import com.tangem.features.send.impl.presentation.send.state.SendUiState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class SendViewModel @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver, SendClickIntents {
private val userWalletId: UserWalletId = savedStateHandle.get<String>(SendRouter.USER_WALLET_ID_KEY)
?.let { stringValue -> UserWalletId(stringValue) }
?: error("This screen can't open without `UserWalletId`")
private val cryptoCurrency: CryptoCurrency = savedStateHandle[SendRouter.CRYPTO_CURRENCY_KEY]
?: error("This screen can't open without `CryptoCurrency`")
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
private val stateFactory = SendStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
)
var uiState: SendUiState by mutableStateOf(stateFactory.getInitialState())
private set
private var userWallet: UserWallet? = null
private var balanceJobHolder = JobHolder()
override fun onCreate(owner: LifecycleOwner) {
subscribeOnCurrencyStatusUpdates(owner)
}
private fun subscribeOnCurrencyStatusUpdates(owner: LifecycleOwner) {
viewModelScope.launch(dispatchers.io) {
getUserWalletUseCase(userWalletId).fold(
ifRight = { wallet ->
userWallet = wallet
getCurrencyStatusUpdates(owner, wallet)
},
ifLeft = {
// TODO add error handling
return@launch
},
)
}
}
private fun getCurrencyStatusUpdates(owner: LifecycleOwner, wallet: UserWallet) {
val isSingleWallet = wallet.scanResponse.walletData?.token != null && !wallet.isMultiCurrency
getCurrencyStatusUpdatesUseCase(
userWalletId = userWalletId,
currencyId = cryptoCurrency.id,
derivationPath = cryptoCurrency.network.derivationPath,
isSingleWalletWithTokens = isSingleWallet,
)
.flowWithLifecycle(owner.lifecycle)
.conflate()
.distinctUntilChanged()
.onEach { either ->
uiState = stateFactory.getAmountState(
cryptoCurrencyStatus = either,
)
}
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(balanceJobHolder)
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
}
// region screen state navigation
override fun onNextClick() {
when (uiState) {
is SendUiState.Content.AmountState -> onRecipientStateClick()
is SendUiState.Content.RecipientState -> onFeeStateClick()
else -> {
// todo implement
}
}
}
override fun onPrevClick() {
// todo implement
}
private fun onRecipientStateClick() {
stateFactory.getOnReceiveState()
}
private fun onFeeStateClick() {
// todo implement
}
// endregion
// region amount state clicks
override fun onCurrencyChangeClick(isFiat: Boolean) {
uiState = stateFactory.getOnCurrencyChangedState(isFiat)
}
override fun onAmountValueChange(value: String) {
uiState = stateFactory.getOnAmountValueChange(value)
}
override fun onMaxValueClick() {
val amountState = uiState as? SendUiState.Content.AmountState ?: return
val amount = if (amountState.isFiatValue) {
amountState.cryptoCurrencyStatus.value.fiatAmount
} else {
amountState.cryptoCurrencyStatus.value.amount
}
onAmountValueChange(amount?.toPlainString() ?: "0.00")
}
// endregion
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.event.consumedEvent
@ -87,7 +88,7 @@ internal object WalletPreviewData {
}
val coinIconState
get() = TokenItemState.IconState.CoinIcon(
get() = TokenIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_polygon_22,
isGrayscale = false,
@ -95,7 +96,7 @@ internal object WalletPreviewData {
)
private val tokenIconState
get() = TokenItemState.IconState.TokenIcon(
get() = TokenIconState.TokenIcon(
url = null,
networkBadgeIconResId = R.drawable.img_polygon_22,
fallbackTint = TangemColorPalette.Black,
@ -105,7 +106,7 @@ internal object WalletPreviewData {
)
private val customTokenIconState
get() = TokenItemState.IconState.CustomTokenIcon(
get() = TokenIconState.CustomTokenIcon(
tint = TangemColorPalette.Black,
background = TangemColorPalette.Meadow,
networkBadgeIconResId = R.drawable.img_polygon_22,

View file

@ -14,11 +14,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Constraints
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.common.WalletPreviewData
import com.tangem.feature.wallet.presentation.common.component.token.*
import com.tangem.feature.wallet.presentation.common.component.token.icon.TokenIcon
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import org.burnoutcrew.reorderable.ReorderableLazyListState
import kotlin.math.max

View file

@ -1,9 +1,8 @@
package com.tangem.feature.wallet.presentation.common.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
/** Token item state */
@Immutable
@ -11,7 +10,7 @@ internal sealed class TokenItemState {
abstract val id: String
abstract val iconState: IconState
abstract val iconState: TokenIconState
abstract val titleState: TitleState
@ -24,7 +23,7 @@ internal sealed class TokenItemState {
/** Loading token state */
data class Loading(
override val id: String,
override val iconState: IconState,
override val iconState: TokenIconState,
override val titleState: TitleState.Content,
) : TokenItemState() {
override val fiatAmountState: FiatAmountState = FiatAmountState.Loading
@ -34,7 +33,7 @@ internal sealed class TokenItemState {
/** Locked token state */
data class Locked(override val id: String) : TokenItemState() {
override val iconState: IconState = IconState.Locked
override val iconState: TokenIconState = TokenIconState.Locked
override val titleState: TitleState = TitleState.Locked
override val fiatAmountState: FiatAmountState = FiatAmountState.Locked
override val cryptoAmountState: CryptoAmountState = CryptoAmountState.Locked
@ -52,7 +51,7 @@ internal sealed class TokenItemState {
*/
data class Content(
override val id: String,
override val iconState: IconState,
override val iconState: TokenIconState,
override val titleState: TitleState,
override val fiatAmountState: FiatAmountState,
override val cryptoAmountState: CryptoAmountState.Content,
@ -70,7 +69,7 @@ internal sealed class TokenItemState {
*/
data class Draggable(
override val id: String,
override val iconState: IconState,
override val iconState: TokenIconState,
override val titleState: TitleState,
override val cryptoAmountState: CryptoAmountState,
) : TokenItemState() {
@ -89,7 +88,7 @@ internal sealed class TokenItemState {
*/
data class Unreachable(
override val id: String,
override val iconState: IconState,
override val iconState: TokenIconState,
override val titleState: TitleState,
val onItemClick: () -> Unit,
val onItemLongClick: () -> Unit,
@ -109,7 +108,7 @@ internal sealed class TokenItemState {
*/
data class NoAddress(
override val id: String,
override val iconState: IconState,
override val iconState: TokenIconState,
override val titleState: TitleState,
val onItemLongClick: () -> Unit,
) : TokenItemState() {
@ -118,84 +117,6 @@ internal sealed class TokenItemState {
override val priceChangeState: PriceChangeState? = null
}
/**
* Represents the various states an icon can be in.
*/
@Immutable
sealed class IconState {
abstract val isGrayscale: Boolean
abstract val showCustomBadge: Boolean
abstract val networkBadgeIconResId: Int?
/**
* Represents a coin icon.
*
* @property url The URL where the coin icon can be fetched from. May be `null` if not found.
* @property fallbackResId The drawable resource ID to be used as a fallback if the URL is not available.
* @property isGrayscale Specifies whether to show the icon in grayscale.
* @property showCustomBadge Specifies whether to show the custom token badge.
*/
data class CoinIcon(
val url: String?,
@DrawableRes val fallbackResId: Int,
override val isGrayscale: Boolean,
override val showCustomBadge: Boolean,
) : IconState() {
override val networkBadgeIconResId: Int? = null
}
/**
* Represents a token icon.
*
* @property url The URL where the token icon can be fetched from. May be `null` if not found.
* @property networkBadgeIconResId The drawable resource ID for the network badge.
* @property isGrayscale Specifies whether to show the icon in grayscale.
* @property showCustomBadge Specifies whether to show the custom token badge.
* @property fallbackTint The color to be used for tinting the fallback icon.
* @property fallbackBackground The background color to be used for the fallback icon.
*/
data class TokenIcon(
val url: String?,
@DrawableRes override val networkBadgeIconResId: Int,
override val isGrayscale: Boolean,
override val showCustomBadge: Boolean,
val fallbackTint: Color,
val fallbackBackground: Color,
) : IconState()
/**
* Represents a custom token icon.
*
* @property tint The color to be used for tinting the icon.
* @property background The background color to be used for the icon.
* @property networkBadgeIconResId The drawable resource ID for the network badge.
* @property isGrayscale Specifies whether to show the icon in grayscale.
*/
data class CustomTokenIcon(
val tint: Color,
val background: Color,
@DrawableRes override val networkBadgeIconResId: Int,
override val isGrayscale: Boolean,
) : IconState() {
override val showCustomBadge: Boolean = true
}
object Loading : IconState() {
override val isGrayscale: Boolean = false
override val showCustomBadge: Boolean = false
override val networkBadgeIconResId: Int? = null
}
object Locked : IconState() {
override val isGrayscale: Boolean = false
override val showCustomBadge: Boolean = false
override val networkBadgeIconResId: Int? = null
}
}
@Immutable
sealed class TitleState {

View file

@ -5,7 +5,7 @@ import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.utils.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getGroupHeaderId
import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getTokenItemId

View file

@ -6,7 +6,7 @@ import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.utils.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletClickIntents
import com.tangem.utils.converter.Converter
import java.math.BigDecimal