Updated on 2026-08-14

This commit is contained in:
Tangem 2023-10-24 13:36:53 +03:00
parent eec3307df3
commit 8bd68643e7
15 changed files with 254 additions and 176 deletions

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

@ -0,0 +1,123 @@
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.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
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.res.painterResource
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.DefaultCurrencyIcon
@Composable
internal fun ContentIcon(
icon: TokenIconState,
alpha: Float,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
) {
when (icon) {
is TokenIconState.CoinIcon -> CoinIcon(
modifier = modifier,
url = icon.url,
fallbackResId = icon.fallbackResId,
alpha = alpha,
colorFilter = colorFilter,
)
is TokenIconState.TokenIcon -> TokenIcon(
modifier = modifier,
url = icon.url,
alpha = alpha,
colorFilter = colorFilter,
errorIcon = {
CustomTokenIcon(
modifier = modifier,
tint = icon.fallbackTint,
background = icon.fallbackBackground,
alpha = alpha,
)
},
)
is TokenIconState.CustomTokenIcon -> CustomTokenIcon(
modifier = modifier,
tint = icon.tint,
background = icon.background,
alpha = alpha,
)
TokenIconState.Loading,
TokenIconState.Locked,
-> Unit
}
}
@Composable
private fun CoinIcon(
url: String?,
@DrawableRes fallbackResId: Int,
alpha: Float,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
) {
val iconData: Any = if (url.isNullOrBlank()) fallbackResId else url
DefaultCurrencyIcon(
modifier = modifier,
iconData = iconData,
errorIcon = {
Image(
painter = painterResource(id = fallbackResId),
alpha = alpha,
colorFilter = colorFilter,
contentDescription = null,
)
},
alpha = alpha,
colorFilter = colorFilter,
)
}
@Composable
private fun TokenIcon(
url: String?,
alpha: Float,
colorFilter: ColorFilter?,
errorIcon: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
if (url == null) {
errorIcon()
} else {
DefaultCurrencyIcon(
modifier = modifier,
iconData = url,
errorIcon = errorIcon,
alpha = alpha,
colorFilter = colorFilter,
)
}
}
@Composable
private fun CustomTokenIcon(tint: Color, background: Color, alpha: Float, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(
color = background.copy(alpha = alpha),
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.matchParentSize(),
painter = painterResource(id = R.drawable.ic_custom_token_44),
tint = tint.copy(alpha = alpha),
contentDescription = null,
)
}
}

View file

@ -0,0 +1,63 @@
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.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun NetworkBadge(
@DrawableRes iconResId: Int,
alpha: Float,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size18)
.background(
color = TangemTheme.colors.background.primary,
shape = CircleShape,
),
) {
Image(
modifier = Modifier
.padding(all = TangemTheme.dimens.spacing2)
.matchParentSize(),
painter = painterResource(id = iconResId),
colorFilter = colorFilter,
alpha = alpha,
contentDescription = null,
)
}
}
@Composable
internal fun CustomBadge(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size12)
.background(
color = TangemTheme.colors.background.primary,
shape = CircleShape,
),
) {
Box(
modifier = Modifier
.padding(all = TangemTheme.dimens.spacing2)
.matchParentSize()
.background(
color = TangemTheme.colors.icon.informative,
shape = CircleShape,
),
)
}
}

View file

@ -0,0 +1,118 @@
package com.tangem.core.ui.components.currency.tokenicon
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
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
fun TokenIcon(state: TokenIconState, modifier: Modifier = Modifier, shouldDisplayNetwork: Boolean = true) {
BaseContainer(modifier = modifier) {
val iconModifier = Modifier
.align(Alignment.Center)
.size(TangemTheme.dimens.size36)
when (state) {
is TokenIconState.Loading -> LoadingIcon(modifier = iconModifier)
is TokenIconState.Locked -> LockedIcon(modifier = iconModifier)
is TokenIconState.CoinIcon,
is TokenIconState.CustomTokenIcon,
is TokenIconState.TokenIcon,
-> {
ContentIconContainer(
icon = state,
modifier = iconModifier,
shouldDisplayNetwork = shouldDisplayNetwork,
)
}
}
}
}
@Composable
internal fun LoadingIcon(modifier: Modifier = Modifier) {
CircleShimmer(modifier = modifier)
}
@Composable
private fun LockedIcon(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Box(
modifier = Modifier
.matchParentSize()
.background(
color = TangemTheme.colors.field.primary,
shape = CircleShape,
),
)
}
}
@Composable
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) {
GRAY_SCALE_ALPHA to GrayscaleColorFilter
} else {
NORMAL_ALPHA to null
}
}
ContentIcon(
modifier = modifier,
icon = icon,
alpha = alpha,
colorFilter = colorFilter,
)
if (icon.networkBadgeIconResId != null && shouldDisplayNetwork) {
NetworkBadge(
modifier = Modifier
.offset(x = networkBadgeOffset, y = -networkBadgeOffset)
.align(Alignment.TopEnd),
iconResId = requireNotNull(icon.networkBadgeIconResId),
alpha = alpha,
colorFilter = colorFilter,
)
}
if (icon.showCustomBadge) {
CustomBadge(modifier = Modifier.align(Alignment.BottomEnd))
}
}
@Composable
private inline fun BaseContainer(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
Box(modifier = modifier.size(size = TangemTheme.dimens.size40), content = content)
}
private val GrayscaleColorFilter: ColorFilter
get() = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(GRAY_SCALE_SATURATION) })

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

@ -0,0 +1,55 @@
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
/**
* Converts [CryptoCurrencyStatus] to [TokenIconState]
*/
class CryptoCurrencyToIconStateConverter : Converter<CryptoCurrencyStatus, TokenIconState> {
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): TokenIconState.CoinIcon {
return TokenIconState.CoinIcon(
url = coin.iconUrl,
fallbackResId = coin.networkIconResId,
isGrayscale = coin.network.isTestnet || isUnreachable,
showCustomBadge = coin.isCustom,
)
}
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) {
TokenIconState.CustomTokenIcon(
tint = tint,
background = background,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = isGrayscale,
)
} else {
TokenIconState.TokenIcon(
url = token.iconUrl,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = isGrayscale,
fallbackTint = tint,
fallbackBackground = background,
showCustomBadge = token.isCustom, // `true` for tokens with custom derivation
)
}
}
}

View file

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

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>