Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-02 16:26:37 +03:00
commit 2c8c2cb579
183 changed files with 3741 additions and 638 deletions

1
features/manage-tokens/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

1
features/manage-tokens/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,14 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.managetokens.api"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.fragment.ktx)
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.managetokens.featuretoggles
interface ManageTokensFeatureToggles {
val isRedesignedScreenEnabled: Boolean
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.managetokens.navigation
import androidx.fragment.app.Fragment
interface ManageTokensRouter {
fun getEntryFragment(): Fragment
}

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,68 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.managetokens.impl"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.activity.compose)
implementation(deps.material)
/** Compose */
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.coil)
implementation(deps.compose.constraintLayout)
implementation(deps.compose.foundation)
implementation(deps.compose.material)
implementation(deps.compose.material3)
implementation(deps.compose.navigation)
implementation(deps.compose.navigation.hilt)
implementation(deps.compose.paging)
implementation(deps.compose.reorderable)
implementation(deps.compose.shimmer)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(deps.kotlin.immutable.collections)
implementation(deps.tangem.card.core)
implementation(deps.tangem.blockchain)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Core modules */
implementation(projects.core.featuretoggles)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Domain modules */
implementation(projects.common)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.settings)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
/** Feature Apis */
implementation(projects.features.manageTokens.api)
}

View file

@ -0,0 +1,41 @@
package com.tangem.managetokens
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.features.managetokens.navigation.ManageTokensRouter
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
internal class ManageTokensFragment : ComposeFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
@Inject
lateinit var manageTokensRouter: ManageTokensRouter
// private val internalManageTokensRouter: InnerManageTokensRouter
// get() = requireNotNull(manageTokensRouter as? InnerManageTokensRouter) {
// "internalManageTokensRouter should be instance of InnerManageTokensRouter"
// }
@Composable
override fun ScreenContent(modifier: Modifier) {
// val viewModel = hiltViewModel<ManageTokensViewModel>()
// viewModel.router = [REDACTED_EMAIL]
//
// LocalLifecycleOwner.current.lifecycle.addObserver(viewModel)
val systemBarsColor = TangemTheme.colors.background.secondary
SystemBarsEffect {
setSystemBarsColor(systemBarsColor)
}
// ManageTokensScreen(state = viewModel.uiState)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.managetokens.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.managetokens.featuretoggles.ManageTokensFeatureToggles
import com.tangem.managetokens.featuretoggles.DefaultManageTokensFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ManageTokensFeatureTogglesModule {
@Provides
@Singleton
fun provideWalletFeatureToggles(featureTogglesManager: FeatureTogglesManager): ManageTokensFeatureToggles {
return DefaultManageTokensFeatureToggles(featureTogglesManager = featureTogglesManager)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.managetokens.di
import com.tangem.features.managetokens.navigation.ManageTokensRouter
import com.tangem.managetokens.presentation.router.DefaultManageTokensRouter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped
@Module
@InstallIn(ActivityComponent::class)
internal object ManageTokensRouterModule {
@Provides
@ActivityScoped
fun provideManageTokensRouter(): ManageTokensRouter {
return DefaultManageTokensRouter()
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.managetokens.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.managetokens.featuretoggles.ManageTokensFeatureToggles
internal class DefaultManageTokensFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : ManageTokensFeatureToggles {
override val isRedesignedScreenEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_MANAGE_TOKENS_SCREEN_ENABLED")
}

View file

@ -0,0 +1,33 @@
package com.tangem.managetokens.presentation.managetokens.state
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.managetokens.impl.R
data class DerivationNotificationState(
val totalNeeded: Int,
val missingAddressesCount: Int,
val onGenerateClick: () -> Unit,
) {
val config = NotificationConfig(
title = resourceReference(id = R.string.warning_missing_derivation_title),
subtitle = pluralReference(
id = R.plurals.warning_missing_derivation_message,
count = totalNeeded,
formatArgs = wrappedList(totalNeeded),
),
iconResId = R.drawable.ic_alert_circle_24,
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(id = R.string.common_generate_addresses),
iconResId = R.drawable.ic_tangem_24,
onClick = onGenerateClick,
additionalText = pluralReference(
id = R.plurals.manage_tokens_number_of_wallets_android,
count = totalNeeded,
formatArgs = wrappedList(missingAddressesCount, totalNeeded),
),
),
)
}

View file

@ -0,0 +1,17 @@
package com.tangem.managetokens.presentation.managetokens.state
import kotlinx.collections.immutable.ImmutableList
internal sealed class QuotesState {
object Unknown : QuotesState()
data class Content(
val priceChange: String,
val changeType: PriceChangeType,
val chartData: ImmutableList<Float>,
) : QuotesState()
}
enum class PriceChangeType {
UP, DOWN
}

View file

@ -0,0 +1,5 @@
package com.tangem.managetokens.presentation.managetokens.state
internal enum class TokenButtonType {
ADD, EDIT, NOT_AVAILABLE
}

View file

@ -0,0 +1,10 @@
package com.tangem.managetokens.presentation.managetokens.state
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.extensions.ImageReference
internal data class TokenIconState(
val iconReference: ImageReference?,
val placeholderTint: Color,
val placeholderBackground: Color,
)

View file

@ -0,0 +1,19 @@
package com.tangem.managetokens.presentation.managetokens.state
internal sealed class TokenItemState {
abstract val id: String
data class Loading(override val id: String) : TokenItemState()
data class Loaded(
override val id: String,
val name: String,
val currencyId: String,
val tokenIcon: TokenIconState,
val quotes: QuotesState,
val rate: String?,
val availableAction: TokenButtonType,
val onButtonClick: (String) -> Unit,
) : TokenItemState()
}

View file

@ -0,0 +1,11 @@
package com.tangem.managetokens.presentation.managetokens.state.previewdata
import com.tangem.managetokens.presentation.managetokens.state.DerivationNotificationState
object DerivationNotificationStatePreviewData {
val state = DerivationNotificationState(
totalNeeded = 3,
missingAddressesCount = 2,
onGenerateClick = {},
)
}

View file

@ -0,0 +1,50 @@
package com.tangem.managetokens.presentation.managetokens.state.previewdata
import androidx.compose.ui.graphics.Color
import com.tangem.managetokens.presentation.managetokens.state.*
import kotlinx.collections.immutable.persistentListOf
internal object TokenItemStatePreviewData {
val tokenLoading: TokenItemState
get() = TokenItemState.Loading("id")
val loadedPriceDown: TokenItemState
get() = TokenItemState.Loaded(
id = "BTC",
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
currencyId = "BTC",
tokenIcon = tokenIconState,
quotes = QuotesState.Content(
priceChange = "0.43%",
changeType = PriceChangeType.DOWN,
chartData = persistentListOf(10f, 2f, 5f, 3f, 4f, 8f, 9f, 7f, 4f),
),
rate = "31 285.72$",
availableAction = TokenButtonType.ADD,
onButtonClick = {},
)
val loadedPriceUp: TokenItemState
get() = TokenItemState.Loaded(
id = "BTC",
name = "Bitcoin",
currencyId = "BTC",
tokenIcon = tokenIconState,
quotes = QuotesState.Content(
priceChange = "0.43%",
changeType = PriceChangeType.UP,
chartData = persistentListOf(1f, 3f, 4f, 8f, 12f, 10f, 8f, 3f, 5f, 7f),
),
rate = "31 285.72$",
availableAction = TokenButtonType.NOT_AVAILABLE,
onButtonClick = {},
)
private val tokenIconState: TokenIconState
get() = TokenIconState(
iconReference = null,
placeholderTint = Color.White,
placeholderBackground = Color.Black,
)
}

View file

@ -0,0 +1,143 @@
package com.tangem.managetokens.presentation.managetokens.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.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.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.PrimaryButtonIconEndTwoLines
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.notifications.NotificationConfig
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.managetokens.impl.R
import com.tangem.managetokens.presentation.managetokens.state.previewdata.DerivationNotificationStatePreviewData
@Composable
internal fun DerivationNotification(config: NotificationConfig, modifier: Modifier = Modifier) {
BaseContainer(
modifier = modifier,
) {
Column(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing16),
) {
MainContent(
iconResId = config.iconResId,
iconTint = TangemTheme.colors.icon.accent,
title = config.title,
subtitle = config.subtitle,
)
val buttonConfig = config.buttonsState
if (buttonConfig is NotificationConfig.ButtonsState.PrimaryButtonConfig) {
PrimaryButtonIconEndTwoLines(
text = buttonConfig.text.resolveReference(),
iconResId = buttonConfig.iconResId ?: R.drawable.ic_tangem_24,
onClick = buttonConfig.onClick,
modifier = Modifier
.fillMaxWidth(),
additionalText = buttonConfig.additionalText?.resolveReference(),
)
}
}
}
}
@Composable
private fun BaseContainer(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
Card(
modifier = modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size62)
.fillMaxWidth(),
shape = RoundedCornerShape(
topStart = TangemTheme.dimens.radius16,
topEnd = TangemTheme.dimens.radius16,
),
elevation = TangemTheme.dimens.elevation12,
backgroundColor = TangemTheme.colors.background.action,
) {
Box(content = content)
}
}
@Composable
private fun MainContent(iconResId: Int, iconTint: Color, title: TextReference, subtitle: TextReference) {
Row {
NotificationIcon(iconResId = iconResId, iconTint = iconTint)
SpacerW(width = TangemTheme.dimens.spacing10)
TextsBlock(title = title, subtitle = subtitle)
}
}
@Composable
private fun RowScope.NotificationIcon(iconResId: Int, iconTint: Color) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(alignment = Alignment.CenterVertically),
) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size36)
.background(
color = iconTint.copy(alpha = 0.12f),
shape = CircleShape,
),
)
Box(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.background(
color = TangemTheme.colors.background.action,
shape = CircleShape,
),
)
Icon(
painter = painterResource(id = iconResId),
contentDescription = null,
tint = iconTint,
)
}
}
@Composable
private fun TextsBlock(title: TextReference, subtitle: TextReference) {
Column(verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing2)) {
Text(
text = title.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.button,
)
Text(
text = subtitle.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
)
}
}
@Preview
@Composable
private fun Preview_ManageTokensScreen_LightTheme() {
TangemTheme(isDark = false) {
DerivationNotification(DerivationNotificationStatePreviewData.state.config)
}
}
@Preview
@Composable
private fun Preview_ManageTokensScreen_DarkTheme() {
TangemTheme(isDark = true) {
DerivationNotification(DerivationNotificationStatePreviewData.state.config)
}
}

View file

@ -0,0 +1,123 @@
package com.tangem.managetokens.presentation.managetokens.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* A chart with a solid line and gradient underneath. It can accept values of any range and number.
* If the last value is bigger or equal to the first, the chart is of accent color, otherwise it's warning color.
*
* @param values a list of float values for a chart.
**/
@Composable
fun PriceChangesChart(values: ImmutableList<Float>, modifier: Modifier = Modifier) {
Row(modifier = modifier) {
if (values.size < 2) return // escape without drawing when there are not enough points
val lineColor = if (values.last() >= values.first()) {
TangemTheme.colors.icon.accent
} else {
TangemTheme.colors.icon.warning
}
val gradient = Brush.verticalGradient(
colors = listOf(lineColor.copy(alpha = 0.21f), lineColor.copy(alpha = 0.0f)),
)
Chart(list = values, lineColor = lineColor, gradient = gradient, modifier = Modifier.weight(1f))
}
}
@Composable
private fun Chart(list: ImmutableList<Float>, lineColor: Color, gradient: Brush, modifier: Modifier = Modifier) {
val max = list.max()
val min = list.min()
val zipList: List<Pair<Float, Float>> = list.zipWithNext()
for (pair in zipList) {
val fromValuePercentage = getValuePercentageForRange(pair.first, max, min)
val toValuePercentage = getValuePercentageForRange(pair.second, max, min)
Canvas(
modifier = modifier.fillMaxHeight(),
onDraw = {
val fromPoint = Offset(x = 0f, y = size.height.times(1 - fromValuePercentage))
val toPoint = Offset(x = size.width, y = size.height.times(1 - toValuePercentage))
val path = drawChartLineAndCreatePath(fromPoint = fromPoint, toPoint = toPoint, lineColor = lineColor)
fillChart(
path = path,
fromPoint = fromPoint,
toPoint = toPoint,
size = size,
gradient = gradient,
)
},
)
}
}
private fun DrawScope.drawChartLineAndCreatePath(fromPoint: Offset, toPoint: Offset, lineColor: Color): Path {
val path = Path()
path.moveTo(fromPoint.x, fromPoint.y)
path.lineTo(toPoint.x, toPoint.y)
drawPath(
path = path,
color = lineColor,
style = Stroke(width = 1f),
)
return path
}
private fun DrawScope.fillChart(path: Path, fromPoint: Offset, toPoint: Offset, size: Size, gradient: Brush) {
path.lineTo(toPoint.x, size.height)
path.lineTo(fromPoint.x, size.height)
path.lineTo(0f, fromPoint.y)
drawPath(
path = path,
brush = gradient,
)
}
private fun getValuePercentageForRange(value: Float, max: Float, min: Float): Float {
return if (max == min) { // to draw a straight line when all values are the same
val modifiedMax = max + 1
val modifiedMin = min - 1
(value - modifiedMin) / (modifiedMax - modifiedMin)
} else {
(value - min) / (max - min)
}
}
@Preview(widthDp = 150, heightDp = 150, showBackground = true)
@Composable
private fun Chart_Positive_Preview() {
TangemTheme(isDark = true) {
PriceChangesChart(
persistentListOf(1f, 2f, 4f, 1f, 5f),
)
}
}
@Preview(widthDp = 150, heightDp = 150, showBackground = true)
@Composable
private fun Chart_Negative_Preview() {
TangemTheme(isDark = true) {
PriceChangesChart(
persistentListOf(10f, 2f, 4f, 1f, 5f),
)
}
}

View file

@ -0,0 +1,90 @@
package com.tangem.managetokens.presentation.managetokens.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.buttons.PrimarySmallButton
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.managetokens.state.TokenButtonType
@Composable
internal fun TokenButton(type: TokenButtonType, onClick: () -> Unit, modifier: Modifier = Modifier) {
when (type) {
TokenButtonType.ADD -> PrimarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.manage_tokens_add),
onClick = onClick,
),
modifier = modifier,
)
TokenButtonType.EDIT -> SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.manage_tokens_edit),
onClick = onClick,
),
modifier = modifier,
)
TokenButtonType.NOT_AVAILABLE -> {
Box(
modifier = modifier
.size(height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size46)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(
bounded = false,
radius = TangemTheme.dimens.size20,
),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size20),
painter = painterResource(id = R.drawable.ic_information_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}
}
}
@Preview(backgroundColor = 0xffffff, showBackground = true)
@Composable
private fun TokenButton_Light_Preview(@PreviewParameter(TokenButtonTypeProvider::class) type: TokenButtonType) {
TangemTheme(isDark = false) {
TokenButton(type = type, {})
}
}
@Preview(showBackground = true)
@Composable
private fun TokenButton_Dark_Preview(@PreviewParameter(TokenButtonTypeProvider::class) type: TokenButtonType) {
TangemTheme(isDark = true) {
TokenButton(type = type, {})
}
}
private class TokenButtonTypeProvider : PreviewParameterProvider<TokenButtonType> {
override val values = sequenceOf(
TokenButtonType.ADD,
TokenButtonType.EDIT,
TokenButtonType.NOT_AVAILABLE,
)
}

View file

@ -0,0 +1,114 @@
package com.tangem.managetokens.presentation.managetokens.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.components.CircleShimmer
import com.tangem.core.ui.extensions.ImageReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.managetokens.state.TokenIconState
import kotlinx.coroutines.launch
@Composable
internal fun TokenIcon(state: TokenIconState, modifier: Modifier = Modifier) {
val iconModifier = modifier
.size(TangemTheme.dimens.size36)
if (state.iconReference != null) {
DefaultCurrencyIcon(
modifier = iconModifier,
iconReference = state.iconReference,
errorIcon = {
PlaceholderIcon(
modifier = iconModifier,
tint = state.placeholderTint,
background = state.placeholderBackground,
)
},
)
} else {
PlaceholderIcon(
modifier = iconModifier,
tint = state.placeholderTint,
background = state.placeholderBackground,
)
}
}
@Composable
private fun PlaceholderIcon(tint: Color, background: Color, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(
color = background,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.matchParentSize(),
painter = painterResource(id = R.drawable.ic_custom_token_44),
tint = tint,
contentDescription = null,
)
}
}
@Composable
private inline fun DefaultCurrencyIcon(
iconReference: ImageReference,
crossinline errorIcon: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) }
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
SubcomposeAsyncImage(
modifier = modifier
.background(
color = iconBackgroundColor,
shape = TangemTheme.shapes.roundedCorners8,
),
model = ImageRequest.Builder(context = LocalContext.current)
.data(iconReference.getReference())
.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() },
contentDescription = null,
)
}
@Composable
internal fun LoadingIcon(modifier: Modifier = Modifier) {
CircleShimmer(modifier = modifier)
}

View file

@ -0,0 +1,79 @@
package com.tangem.managetokens.presentation.managetokens.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
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.text.style.TextOverflow
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.managetokens.state.PriceChangeType
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
@Composable
internal fun TokenPriceChange(state: QuotesState, modifier: Modifier = Modifier) {
when (state) {
is QuotesState.Content ->
PriceChangeBlock(modifier = modifier, type = state.changeType, text = state.priceChange)
QuotesState.Unknown -> PriceChangeBlock(modifier = modifier)
}
}
@Composable
private fun PriceChangeBlock(modifier: Modifier = Modifier, type: PriceChangeType? = null, text: String? = null) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
PriceChangeIcon(type = type)
SpacerW4()
PriceChangeText(type = type, text = text)
}
}
@Composable
private fun PriceChangeIcon(type: PriceChangeType?) {
AnimatedContent(targetState = type, label = "Update the price change's arrow") { animatedType ->
animatedType ?: return@AnimatedContent
Icon(
painter = painterResource(
id = when (animatedType) {
PriceChangeType.UP -> R.drawable.ic_arrow_up_8
PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8
},
),
tint = when (animatedType) {
PriceChangeType.UP -> TangemTheme.colors.icon.accent
PriceChangeType.DOWN -> TangemTheme.colors.icon.warning
},
contentDescription = null,
)
}
}
@Composable
private fun PriceChangeText(type: PriceChangeType?, text: String?) {
AnimatedContent(targetState = text, label = "Update the price change's text") { animatedText ->
animatedText ?: return@AnimatedContent
Text(
text = animatedText,
color = when (type) {
PriceChangeType.UP -> TangemTheme.colors.text.accent
PriceChangeType.DOWN -> TangemTheme.colors.text.warning
null -> TangemTheme.colors.text.primary1
},
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
}

View file

@ -0,0 +1,205 @@
package com.tangem.managetokens.presentation.managetokens.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.components.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.managetokens.presentation.managetokens.state.previewdata.TokenItemStatePreviewData
private val TOKEN_ITEM_HEIGHT: Dp
@Composable
@ReadOnlyComposable
get() = TangemTheme.dimens.size68
@Composable
internal fun TokenRowItem(state: TokenItemState, modifier: Modifier = Modifier) {
when (state) {
is TokenItemState.Loading -> LoadingTokenItem(modifier)
is TokenItemState.Loaded -> LoadedTokenItem(state, modifier)
}
}
@Composable
private fun LoadedTokenItem(state: TokenItemState.Loaded, modifier: Modifier = Modifier) {
BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = TOKEN_ITEM_HEIGHT)
.background(TangemTheme.colors.background.primary),
contentAlignment = Alignment.CenterStart,
) {
val width = maxWidth
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
TokenIcon(state = state.tokenIcon)
SpacerW12()
Column(
modifier = Modifier
.weight(weight = 1f),
) {
TokenName(name = state.name, currencyId = state.currencyId)
TokenPriceData(price = state.rate, quotesState = state.quotes)
}
SpacerW24()
if (width > TangemTheme.dimens.size350 && // hide chart for small screens
state.quotes is QuotesState.Content
) {
Chart(quotes = state.quotes)
SpacerW24()
}
TokenButton(
type = state.availableAction,
onClick = { state.onButtonClick(state.currencyId) },
)
}
}
}
@Composable
private fun TokenName(name: String, currencyId: String) {
Row {
Text(
text = name,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(weight = 1f, fill = false),
)
SpacerW(width = TangemTheme.dimens.spacing6)
Text(
text = currencyId,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun TokenPriceData(price: String?, quotesState: QuotesState) {
if (price != null) {
Row {
Text(
text = price,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(weight = 1f, fill = false),
)
SpacerW(width = TangemTheme.dimens.spacing6)
TokenPriceChange(state = quotesState)
}
}
}
@Composable
private fun Chart(quotes: QuotesState.Content) {
Box(
modifier = Modifier
.size(width = TangemTheme.dimens.size50, height = TangemTheme.dimens.size28),
) {
PriceChangesChart(values = quotes.chartData)
}
}
@Composable
private fun LoadingTokenItem(modifier: Modifier = Modifier) {
BaseSurface(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing4,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
CircleShimmer(modifier = Modifier.size(size = TangemTheme.dimens.size36))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10)) {
RectangleShimmer(
modifier = Modifier.size(
width = TangemTheme.dimens.size70,
height = TangemTheme.dimens.size12,
),
)
RectangleShimmer(
modifier = Modifier.size(
width = TangemTheme.dimens.size52,
height = TangemTheme.dimens.size12,
),
)
}
RectangleShimmer(
modifier = Modifier.size(
width = TangemTheme.dimens.size46,
height = TangemTheme.dimens.size12,
),
)
}
}
}
}
@Composable
private fun BaseSurface(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Surface(
modifier = modifier.defaultMinSize(minHeight = TOKEN_ITEM_HEIGHT),
color = TangemTheme.colors.background.primary,
) {
content()
}
}
@Preview()
@Composable
private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) {
TangemTheme(isDark = false) {
TokenRowItem(state)
}
}
@Preview
@Composable
private fun Preview_Tokens_DarkTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) {
TangemTheme(isDark = true) {
TokenRowItem(state)
}
}
private class TokenConfigProvider : CollectionPreviewParameterProvider<TokenItemState>(
collection = listOf(
TokenItemStatePreviewData.tokenLoading,
TokenItemStatePreviewData.loadedPriceDown,
TokenItemStatePreviewData.loadedPriceUp,
),
)

View file

@ -0,0 +1,9 @@
package com.tangem.managetokens.presentation.router
import androidx.fragment.app.Fragment
import com.tangem.features.managetokens.navigation.ManageTokensRouter
import com.tangem.managetokens.ManageTokensFragment
internal class DefaultManageTokensRouter : ManageTokensRouter {
override fun getEntryFragment(): Fragment = ManageTokensFragment()
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.send.api.featuretoggles
/**
* Send feature toggles
*/
interface SendFeatureToggles {
/** Availability of redesigned send screen */
val isRedesignedSendEnabled: Boolean
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.send.navigation
package com.tangem.features.send.api.navigation
import androidx.fragment.app.Fragment
@ -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"
}
}

1
features/send/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,56 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.send.impl"
}
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)
implementation(deps.compose.material3)
implementation(deps.compose.foundation)
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)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.send.impl.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.features.send.impl.featuretoggles.DefaultSendFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* DI module provides implementation of [SendFeatureToggles]
*/
@Module
@InstallIn(SingletonComponent::class)
internal object SendFeatureTogglesModule {
@Provides
@Singleton
fun provideSendFeatureToggles(featureTogglesManager: FeatureTogglesManager): SendFeatureToggles {
return DefaultSendFeatureToggles(featureTogglesManager = featureTogglesManager)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.features.send.impl.di
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.DefaultSendRouter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped
/**
* DI module provides implementation of [SendRouter]
*/
@Module
@InstallIn(ActivityComponent::class)
internal object SendRouterModule {
@Provides
@ActivityScoped
fun provideSendRouter(): SendRouter {
return DefaultSendRouter()
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.send.impl.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
/**
* Default implementation of Send feature toggles
*
* @property featureTogglesManager manager for getting information about the availability of feature toggles
*/
internal class DefaultSendFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : SendFeatureToggles {
override val isRedesignedSendEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_SEND_SCREEN_ENABLED")
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.send.impl.navigation
import androidx.fragment.app.Fragment
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.presentation.SendFragment
internal class DefaultSendRouter : SendRouter {
override fun getEntryFragment(): Fragment = SendFragment.create()
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.send.impl.presentation
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
/**
* Send fragment
*/
@AndroidEntryPoint
internal class SendFragment : ComposeBottomSheetFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
override val expandedHeightFraction: Float = 1f
@Composable
override fun ScreenContent(modifier: Modifier) {
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()
}
}
companion object {
/** Create send fragment instance */
fun create(): SendFragment = SendFragment()
}
}

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

@ -0,0 +1,94 @@
package com.tangem.features.send.impl.presentation.send.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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(uiState: SendUiState.Content) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = TangemTheme.dimens.spacing12),
) {
SendSecondaryNavigationButton(uiState)
SendPrimaryNavigationButton(
uiState = uiState,
modifier = Modifier
.weight(1f)
.padding(horizontal = TangemTheme.dimens.spacing16),
)
}
}
@Composable
private fun SendSecondaryNavigationButton(uiState: SendUiState.Content) {
AnimatedVisibility(
visible = uiState is SendUiState.Content.RecipientState || uiState is SendUiState.Content.FeeState,
) {
Icon(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing16)
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.background(TangemTheme.colors.button.secondary)
.clickable {
// todo add prev click
}
.padding(TangemTheme.dimens.spacing12),
painter = painterResource(R.drawable.ic_back_24),
contentDescription = null,
)
}
}
@Composable
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
is SendUiState.Content.SendState -> R.string.common_send
else -> R.string.common_close
}
AnimatedContent(
targetState = buttonTextId,
label = "Update send screen state",
modifier = modifier,
) { textId ->
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
},
)
}
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.features.send.impl.presentation.send.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.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
internal fun SendScreen(uiState: SendUiState.Content) {
Column(
modifier = Modifier
.imePadding()
.background(
color = TangemTheme.colors.background.tertiary,
shape = RoundedCornerShape(
topStart = TangemTheme.dimens.radius24,
topEnd = TangemTheme.dimens.radius24,
),
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemBottomSheetDraggableHeader(
color = TangemTheme.colors.background.tertiary,
)
Box(
modifier = Modifier
.weight(1f)
.scrollable(state = rememberScrollState(), orientation = Orientation.Vertical),
) {
SendScreenContent(uiState)
}
SendNavigationButtons(uiState)
}
}
@Composable
private fun SendScreenContent(uiState: SendUiState.Content) {
when (uiState) {
is SendUiState.Content.AmountState -> SendAmountContent(uiState)
else -> { /* [REDACTED_TODO_COMMENT]*/
}
}
}

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

@ -18,6 +18,7 @@ dependencies {
/** Domain modules **/
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -7,7 +7,7 @@ import androidx.lifecycle.*
import com.tangem.common.Provider
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.utils.InputNumberFormatter
import com.tangem.domain.balancehiding.IsBalanceHiddenUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.tokens.model.Network
import com.tangem.feature.swap.analytics.SwapEvents
@ -49,7 +49,7 @@ internal class SwapViewModel @Inject constructor(
private val blockchainInteractor: BlockchainInteractor,
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val isBalanceHiddenUseCase: IsBalanceHiddenUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val listenToFlipsUseCase: ListenToFlipsUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver {
@ -96,10 +96,10 @@ internal class SwapViewModel @Inject constructor(
}
override fun onCreate(owner: LifecycleOwner) {
isBalanceHiddenUseCase()
getBalanceHidingSettingsUseCase()
.flowWithLifecycle(owner.lifecycle)
.onEach { hidden ->
isBalanceHidden = hidden
.onEach {
isBalanceHidden = it.isBalanceHidden
withContext(dispatchers.main) {
uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden)
}

View file

@ -63,6 +63,7 @@ dependencies {
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
/** Feature Apis */
implementation(projects.features.tokendetails.api)

View file

@ -24,7 +24,7 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
iconResId = iconResId,
buttonsState = buttonsState,
),
)
) {
sealed class Informational(
title: TextReference,
@ -108,5 +108,35 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
id = R.string.warning_send_blocked_pending_transactions_message,
formatArgs = wrappedList(coinSymbol),
),
)
) {
data class RentInfo(
private val rentInfo: CryptoCurrencyWarning.Rent,
private val onCloseClick: () -> Unit,
) : Informational(
title = TextReference.Res(R.string.warning_rent_fee_title),
subtitle = TextReference.Res(
id = R.string.warning_solana_rent_fee_message,
formatArgs = wrappedList(rentInfo.rent, rentInfo.exemptionAmount),
),
onCloseClick = onCloseClick,
)
data class ExistentialDeposit(
private val existentialInfo: CryptoCurrencyWarning.ExistentialDeposit,
) : Informational(
title = resourceReference(R.string.warning_existential_deposit_title),
subtitle = TextReference.Res(
id = R.string.warning_existential_deposit_message,
formatArgs = wrappedList(existentialInfo.currencyName, existentialInfo.edStringValueWithSymbol),
),
)
class NetworksNoAccount(val network: String, val symbol: String, val amount: String) : Informational(
title = resourceReference(R.string.warning_no_account_title),
subtitle = resourceReference(
R.string.no_account_generic,
wrappedList(network, amount, symbol),
),
)
}
}

View file

@ -41,7 +41,7 @@ internal class TokenDetailsLoadedBalanceConverter(
return state.copy(
tokenBalanceBlockState = TokenDetailsBalanceBlockState.Error(state.tokenBalanceBlockState.actionButtons),
marketPriceBlockState = MarketPriceBlockState.Error(state.marketPriceBlockState.currencySymbol),
notifications = persistentListOf(TokenDetailsNotification.NetworksUnreachable),
notifications = persistentListOf(TokenDetailsNotification.Warning.NetworksUnreachable),
)
}

View file

@ -19,20 +19,20 @@ internal class TokenDetailsNotificationConverter(
fun removeRentInfo(currentState: TokenDetailsState): ImmutableList<TokenDetailsNotification> {
val newNotifications = currentState.notifications.toMutableList()
newNotifications.removeBy { it is TokenDetailsNotification.RentInfo }
newNotifications.removeBy { it is TokenDetailsNotification.Informational.RentInfo }
return newNotifications.toImmutableList()
}
private fun mapToNotification(warning: CryptoCurrencyWarning): TokenDetailsNotification {
return when (warning) {
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> TokenDetailsNotification.NetworkFee(
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> TokenDetailsNotification.Warning.NetworkFee(
feeInfo = warning,
onBuyClick = { clickIntents.onBuyCoinClick(warning.coinCurrency) },
)
is CryptoCurrencyWarning.ExistentialDeposit -> TokenDetailsNotification.ExistentialDeposit(
is CryptoCurrencyWarning.ExistentialDeposit -> TokenDetailsNotification.Informational.ExistentialDeposit(
existentialInfo = warning,
)
is CryptoCurrencyWarning.Rent -> TokenDetailsNotification.RentInfo(
is CryptoCurrencyWarning.Rent -> TokenDetailsNotification.Informational.RentInfo(
rentInfo = warning,
onCloseClick = clickIntents::onCloseRentInfoNotification,
)

View file

@ -15,7 +15,7 @@ import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.IsBalanceHiddenUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
@ -65,7 +65,7 @@ internal class TokenDetailsViewModel @Inject constructor(
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase,
private val removeCurrencyUseCase: RemoveCurrencyUseCase,
private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase,
private val isBalanceHiddenUseCase: IsBalanceHiddenUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val listenToFlipsUseCase: ListenToFlipsUseCase,
private val getCurrencyWarningsUseCase: GetCurrencyWarningsUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
@ -117,11 +117,11 @@ internal class TokenDetailsViewModel @Inject constructor(
}
private fun handleBalanceHiding(owner: LifecycleOwner) {
isBalanceHiddenUseCase()
getBalanceHidingSettingsUseCase()
.flowWithLifecycle(owner.lifecycle)
.onEach { hidden ->
.onEach {
uiState = stateFactory.getStateWithUpdatedHidden(
isBalanceHidden = hidden,
isBalanceHidden = it.isBalanceHidden,
)
}
.launchIn(viewModelScope)

View file

@ -65,6 +65,7 @@ dependencies {
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
//TODO: Create api/impl modules for onboarding [REDACTED_JIRA]
implementation(projects.features.onboarding)

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
@ -88,7 +89,7 @@ internal object WalletPreviewData {
}
val coinIconState
get() = TokenItemState.IconState.CoinIcon(
get() = TokenIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_polygon_22,
isGrayscale = false,
@ -96,7 +97,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,
@ -106,7 +107,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,175 +0,0 @@
package com.tangem.feature.wallet.presentation.common.component.token.icon
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.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
@Composable
internal fun ContentIcon(
icon: TokenItemState.IconState,
alpha: Float,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
) {
when (icon) {
is TokenItemState.IconState.CoinIcon -> CoinIcon(
modifier = modifier,
url = icon.url,
fallbackResId = icon.fallbackResId,
alpha = alpha,
colorFilter = colorFilter,
)
is TokenItemState.IconState.TokenIcon -> TokenIcon(
modifier = modifier,
url = icon.url,
alpha = alpha,
colorFilter = colorFilter,
errorIcon = {
CustomTokenIcon(
modifier = modifier,
tint = icon.fallbackTint,
background = icon.fallbackBackground,
alpha = alpha,
)
},
)
is TokenItemState.IconState.CustomTokenIcon -> CustomTokenIcon(
modifier = modifier,
tint = icon.tint,
background = icon.background,
alpha = alpha,
)
TokenItemState.IconState.Loading,
TokenItemState.IconState.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,
)
}
}
@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,63 +0,0 @@
package com.tangem.feature.wallet.presentation.common.component.token.icon
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

@ -1,102 +0,0 @@
package com.tangem.feature.wallet.presentation.common.component.token.icon
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
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
@Composable
internal fun TokenIcon(state: TokenIconState, modifier: Modifier = Modifier) {
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(modifier = iconModifier, icon = state)
}
}
}
}
@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) {
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) {
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

@ -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

@ -1,55 +0,0 @@
package com.tangem.feature.wallet.presentation.common.utils
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> {
override fun convert(value: CryptoCurrencyStatus): TokenItemState.IconState {
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(
url = coin.iconUrl,
fallbackResId = coin.networkIconResId,
isGrayscale = coin.network.isTestnet || isUnreachable,
showCustomBadge = coin.isCustom,
)
}
private fun getIconStateForToken(token: CryptoCurrency.Token, isErrorStatus: Boolean): TokenItemState.IconState {
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(
tint = tint,
background = background,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = isGrayscale,
)
} else {
TokenItemState.IconState.TokenIcon(
url = token.iconUrl,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = isGrayscale,
fallbackTint = tint,
fallbackBackground = background,
showCustomBadge = token.isCustom, // `true` for tokens with custom derivation
)
}
}
}

View file

@ -7,7 +7,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.IsBalanceHiddenUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.tokens.ApplyTokenListSortingUseCase
import com.tangem.domain.tokens.GetTokenListUseCase
@ -38,7 +38,7 @@ internal class OrganizeTokensViewModel @Inject constructor(
private val toggleTokenListSortingUseCase: ToggleTokenListSortingUseCase,
private val applyTokenListSortingUseCase: ApplyTokenListSortingUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val isBalanceHiddenUseCase: IsBalanceHiddenUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val listenToFlipsUseCase: ListenToFlipsUseCase,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val dispatchers: CoroutineDispatcherProvider,
@ -74,10 +74,10 @@ internal class OrganizeTokensViewModel @Inject constructor(
override fun onCreate(owner: LifecycleOwner) {
analyticsEventsHandler.send(PortfolioOrganizeTokensAnalyticsEvent.ScreenOpened)
isBalanceHiddenUseCase()
getBalanceHidingSettingsUseCase()
.flowWithLifecycle(owner.lifecycle)
.onEach { hidden ->
isBalanceHidden = hidden
.onEach {
isBalanceHidden = it.isBalanceHidden
stateHolder.updateHiddenState(isBalanceHidden)
}
.launchIn(viewModelScope)

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

@ -134,7 +134,7 @@ internal class DefaultWalletRouter(private val reduxNavController: ReduxNavContr
override fun isWalletLastScreen(): Boolean = reduxNavController.getBackStack().lastOrNull() == AppScreen.Wallet
override fun openManageTokensScreen() {
reduxNavController.navigate(action = NavigationAction.NavigateTo(AppScreen.AddTokens))
reduxNavController.navigate(action = NavigationAction.NavigateTo(AppScreen.ManageTokens))
}
override fun openScanFailedDialog() {

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

View file

@ -159,6 +159,18 @@ internal class WalletNotificationsListFactory(
addMissingAddressesNotification(maybeMissedAddressCurrencies)
}
private fun MutableList<WalletNotification>.addInformationalNotifications(
cardTypesResolver: CardTypesResolver,
maybeMissedAddressCurrencies: Either<GetCurrenciesError, List<CryptoCurrency>>,
) {
addIf(
element = WalletNotification.Informational.DemoCard,
condition = isDemoCardUseCase(cardId = cardTypesResolver.getCardId()),
)
addMissingAddressesNotification(maybeMissedAddressCurrencies)
}
private fun MutableList<WalletNotification>.addIf(element: WalletNotification, condition: Boolean) {
if (condition) {
add(element = element)

View file

@ -27,7 +27,7 @@ import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.analytics.ChangeCardAnalyticsContextUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.IsBalanceHiddenUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.card.DerivePublicKeysUseCase
import com.tangem.domain.card.SetCardWasScannedUseCase
@ -116,6 +116,7 @@ internal class WalletViewModel @Inject constructor(
private val canUseBiometryUseCase: CanUseBiometryUseCase,
private val shouldSaveUserWalletsUseCase: ShouldSaveUserWalletsUseCase,
private val isBalanceHiddenUseCase: IsBalanceHiddenUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val listenToFlipsUseCase: ListenToFlipsUseCase,
private val removeCurrencyUseCase: RemoveCurrencyUseCase,
private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase,
@ -212,12 +213,12 @@ internal class WalletViewModel @Inject constructor(
}
}
isBalanceHiddenUseCase()
getBalanceHidingSettingsUseCase()
.flowWithLifecycle(owner.lifecycle)
.onEach { hidden ->
isBalanceHidden = hidden
WalletStateCache.updateAll { copySealed(isBalanceHidden = hidden) }
uiState = stateFactory.getHiddenBalanceState(isBalanceHidden = hidden)
.onEach {
isBalanceHidden = it.isBalanceHidden
WalletStateCache.updateAll { copySealed(isBalanceHidden = it.isBalanceHidden) }
uiState = stateFactory.getHiddenBalanceState(isBalanceHidden = it.isBalanceHidden)
}
.launchIn(viewModelScope)