Updated on 2026-08-14

This commit is contained in:
Tangem 2024-09-16 19:58:38 +03:00
commit afff7892bb
721 changed files with 24513 additions and 7275 deletions

View file

@ -23,8 +23,10 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.core.featuretoggles)
implementation(projects.core.navigation)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.common.routing)
implementation(projects.common.ui)
/* Project - Domain */
implementation(projects.domain.models)

View file

@ -17,8 +17,7 @@ internal class PreviewDetailsComponent : DetailsComponent {
private val previewBlocks = runBlocking {
ItemsBuilder(
router = DummyRouter(),
urlOpener = DummyUrlOpener(),
).buildAll(isWalletConnectAvailable = true, onSupportClick = {})
).buildAll(isWalletConnectAvailable = true, onSupportClick = {}, onBuyClick = {})
}
private val previewFooter = DetailsFooterUM(

View file

@ -2,6 +2,7 @@ package com.tangem.features.details.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -17,7 +18,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent {
private val previewState = UserWalletListUM(
userWallets = persistentListOf(
UserWalletListUM.UserWalletUM(
UserWalletItemUM(
id = UserWalletId("user_wallet_1".encodeToByteArray()),
name = stringReference("My Wallet"),
information = getInformation(3, "4 496,75 $"),
@ -25,7 +26,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent {
isEnabled = true,
onClick = {},
),
UserWalletListUM.UserWalletUM(
UserWalletItemUM(
id = UserWalletId("user_wallet_2".encodeToByteArray()),
name = stringReference("Old wallet"),
information = getInformation(3, "4 496,75 $"),
@ -33,7 +34,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent {
isEnabled = true,
onClick = {},
),
UserWalletListUM.UserWalletUM(
UserWalletItemUM(
id = UserWalletId("user_wallet_3".encodeToByteArray()),
name = stringReference("Multi Card"),
information = getInformation(3, "4 496,75 $"),

View file

@ -1,25 +1,14 @@
package com.tangem.features.details.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class UserWalletListUM(
val userWallets: ImmutableList<UserWalletUM>,
val userWallets: ImmutableList<UserWalletItemUM>,
val isWalletSavingInProgress: Boolean,
val addNewWalletText: TextReference,
val onAddNewWalletClick: () -> Unit,
) {
@Immutable
data class UserWalletUM(
val id: UserWalletId,
val name: TextReference,
val information: TextReference,
val imageUrl: String,
val isEnabled: Boolean,
val onClick: () -> Unit,
)
}
)

View file

@ -1,10 +1,12 @@
package com.tangem.features.details.model
import arrow.core.getOrElse
import com.tangem.core.analytics.AppInstanceIdProvider
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
@ -35,6 +37,8 @@ internal class DetailsModel @Inject constructor(
private val appVersionProvider: AppVersionProvider,
private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase,
private val router: Router,
private val urlOpener: UrlOpener,
private val appInstanceIdProvider: AppInstanceIdProvider,
paramsContainer: ParamsContainer,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val appStateHolder: ReduxStateHolder,
@ -74,6 +78,7 @@ internal class DetailsModel @Inject constructor(
items.value = itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable,
onSupportClick = ::sendFeedback,
onBuyClick = ::onBuyClick,
)
}
@ -86,6 +91,12 @@ internal class DetailsModel @Inject constructor(
}
}
private fun onBuyClick() {
modelScope.launch {
urlOpener.openUrl(buildBuyLink())
}
}
private fun updateState(items: ImmutableList<DetailsItemUM>) {
state.update { prevState ->
prevState.copy(items = items)
@ -93,4 +104,14 @@ internal class DetailsModel @Inject constructor(
}
private fun getAppVersion(): String = "${appVersionProvider.versionName} (${appVersionProvider.versionCode})"
private suspend fun buildBuyLink(): String {
return appInstanceIdProvider.getAppInstanceId()?.let {
"$BUY_TANGEM_URL&app_instance_id=$it"
} ?: BUY_TANGEM_URL
}
private companion object {
const val BUY_TANGEM_URL = "https://buy.tangem.com/?utm_source=tangem&utm_medium=app"
}
}

View file

@ -1,12 +1,12 @@
package com.tangem.features.details.model
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.features.details.entity.UserWalletListUM
import com.tangem.features.details.entity.UserWalletListUM.UserWalletUM
import com.tangem.features.details.impl.R
import com.tangem.features.details.utils.UserWalletSaver
import com.tangem.features.details.utils.UserWalletsFetcher
@ -48,7 +48,7 @@ internal class UserWalletListModel @Inject constructor(
}
private fun updateState(
userWallets: ImmutableList<UserWalletUM>,
userWallets: ImmutableList<UserWalletItemUM>,
shouldSaveUserWallets: Boolean,
isWalletSavingInProgress: Boolean,
) = state.update { value ->

View file

@ -16,6 +16,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@ -27,6 +28,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.TestTags
import com.tangem.features.details.component.preview.PreviewDetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM
@ -74,7 +76,7 @@ private fun Content(
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
modifier = modifier.testTag(TestTags.DETAILS_SCREEN),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
contentPadding = PaddingValues(
top = TangemTheme.dimens.spacing12,
@ -125,7 +127,7 @@ private fun Block(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
) {
val itemModifier = Modifier.fillMaxWidth()
val itemModifier = Modifier.fillMaxWidth().testTag(TestTags.DETAILS_SCREEN_ITEM)
when (model) {
is DetailsItemUM.Basic -> {

View file

@ -1,7 +1,6 @@
package com.tangem.features.details.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@ -10,32 +9,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.core.ui.components.block.BlockCard
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.details.entity.UserWalletListUM
import com.tangem.features.details.impl.R
import com.tangem.features.details.ui.coil.RotationTransformation
@Composable
internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
) {
state.userWallets.forEach { model ->
key(model.id) {
state.userWallets.forEach { state ->
key(state.id) {
UserWalletItem(
modifier = Modifier.fillMaxWidth(),
model = model,
state = state,
)
}
}
@ -47,96 +39,6 @@ internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = M
}
}
@Composable
private fun UserWalletItem(model: UserWalletListUM.UserWalletUM, modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
onClick = model.onClick,
enabled = model.isEnabled,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size68)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Image(imageUrl = model.imageUrl)
NameAndInfo(
name = model.name,
information = model.information,
)
}
}
}
@Composable
private fun NameAndInfo(name: TextReference, information: TextReference, modifier: Modifier = Modifier) {
Column(
modifier = modifier.heightIn(min = TangemTheme.dimens.size40),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.SpaceEvenly,
) {
Text(
text = name.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AnimatedContent(
targetState = information.resolveReference(),
label = "User wallet information",
) { information ->
Text(
text = information,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun Image(imageUrl: String, modifier: Modifier = Modifier) {
val imageModifier = modifier
.width(TangemTheme.dimens.size24)
.height(TangemTheme.dimens.size36)
.clip(TangemTheme.shapes.roundedCornersSmall)
SubcomposeAsyncImage(
modifier = imageModifier,
model = ImageRequest.Builder(LocalContext.current)
.transformations(RotationTransformation(angle = 90f))
.size(
width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() },
height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() },
)
.data(imageUrl)
.crossfade(enable = true)
.allowHardware(enable = false)
.build(),
loading = {
RectangleShimmer(
modifier = imageModifier,
radius = TangemTheme.dimens.size2,
)
},
error = {
Image(
modifier = imageModifier,
painter = painterResource(id = R.drawable.img_card_wallet_2_gray_22_36),
contentDescription = null,
)
},
contentDescription = null,
)
}
@Composable
private fun AddWalletButton(
text: TextReference,

View file

@ -3,7 +3,6 @@ package com.tangem.features.details.utils
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.block.model.BlockUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -16,19 +15,19 @@ import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
@ComponentScoped
internal class ItemsBuilder @Inject constructor(
private val router: Router,
private val urlOpener: UrlOpener,
) {
internal class ItemsBuilder @Inject constructor(private val router: Router) {
fun buildAll(isWalletConnectAvailable: Boolean, onSupportClick: () -> Unit): ImmutableList<DetailsItemUM> =
buildList {
buildWalletConnectBlock(isWalletConnectAvailable)?.let(::add)
buildUserWalletListBlock().let(::add)
buildShopBlock().let(::add)
buildSettingsBlock().let(::add)
buildSupportBlock(onSupportClick).let(::add)
}.toImmutableList()
fun buildAll(
isWalletConnectAvailable: Boolean,
onSupportClick: () -> Unit,
onBuyClick: () -> Unit,
): ImmutableList<DetailsItemUM> = buildList {
buildWalletConnectBlock(isWalletConnectAvailable)?.let(::add)
buildUserWalletListBlock().let(::add)
buildShopBlock(onBuyClick).let(::add)
buildSettingsBlock().let(::add)
buildSupportBlock(onSupportClick).let(::add)
}.toImmutableList()
private fun buildWalletConnectBlock(isWalletConnectAvailable: Boolean): DetailsItemUM? {
return if (isWalletConnectAvailable) {
@ -42,7 +41,7 @@ internal class ItemsBuilder @Inject constructor(
private fun buildUserWalletListBlock(): DetailsItemUM = DetailsItemUM.UserWalletList
private fun buildShopBlock(): DetailsItemUM = DetailsItemUM.Basic(
private fun buildShopBlock(onBuyClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic(
id = "shop",
items = persistentListOf(
DetailsItemUM.Basic.Item(
@ -50,7 +49,7 @@ internal class ItemsBuilder @Inject constructor(
block = BlockUM(
text = resourceReference(R.string.details_buy_wallet),
iconRes = R.drawable.ic_tangem_24,
onClick = { urlOpener.openUrl(BUY_TANGEM_URL) },
onClick = onBuyClick,
),
),
),
@ -102,8 +101,4 @@ internal class ItemsBuilder @Inject constructor(
),
),
)
private companion object {
const val BUY_TANGEM_URL = "https://buy.tangem.com/?utm_source=tangem&utm_medium=app"
}
}

View file

@ -1,110 +0,0 @@
package com.tangem.features.details.utils
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.tokens.model.TotalFiatBalance
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.details.entity.UserWalletListUM.UserWalletUM
import com.tangem.features.details.impl.R
import com.tangem.utils.StringsSigns.STARS
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
internal fun List<UserWallet>.toUiModels(
onClick: (UserWalletId) -> Unit,
appCurrency: AppCurrency? = null,
balances: Map<UserWalletId, TotalFiatBalance> = emptyMap(),
isLoading: Boolean = true,
isBalancesHidden: Boolean = false,
): ImmutableList<UserWalletUM> = this.map { model ->
val balance = balances[model.walletId]
model.toUiModel(
balance = balance,
appCurrency = appCurrency,
isLoading = isLoading,
isBalanceHidden = isBalancesHidden,
onClick = { onClick(model.walletId) },
)
}.toImmutableList()
private fun UserWallet.toUiModel(
balance: TotalFiatBalance?,
appCurrency: AppCurrency?,
isLoading: Boolean,
isBalanceHidden: Boolean,
onClick: () -> Unit,
): UserWalletUM = UserWalletUM(
id = walletId,
name = stringReference(name),
information = getInfo(
appCurrency = appCurrency,
balance = balance,
isBalanceHidden = isBalanceHidden,
isLoading = isLoading,
),
imageUrl = artworkUrl,
isEnabled = !isLocked,
onClick = onClick,
)
private fun UserWallet.getInfo(
appCurrency: AppCurrency?,
balance: TotalFiatBalance?,
isBalanceHidden: Boolean,
isLoading: Boolean,
): TextReference {
val dividerRef = stringReference(value = "")
val cardCount = getCardCount()
val cardCountRef = TextReference.PluralRes(
id = R.plurals.card_label_card_count,
count = cardCount,
formatArgs = wrappedList(cardCount),
)
return when {
isBalanceHidden -> combinedReference(cardCountRef, dividerRef, stringReference(STARS))
isLocked -> combinedReference(cardCountRef, dividerRef, resourceReference(R.string.common_locked))
isLoading -> cardCountRef
else -> getBalanceInfo(balance, appCurrency, cardCountRef, dividerRef)
}
}
private fun getBalanceInfo(
balance: TotalFiatBalance?,
appCurrency: AppCurrency?,
cardCountRef: TextReference,
dividerRef: TextReference,
): TextReference {
val amount = when (balance) {
is TotalFiatBalance.Loaded -> balance.amount
is TotalFiatBalance.Failed,
is TotalFiatBalance.Loading,
null,
-> null
}
return if (amount != null && appCurrency != null) {
val formattedAmount = BigDecimalFormatter.formatFiatAmount(
fiatAmount = amount,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
val amountRef = stringReference(formattedAmount)
combinedReference(cardCountRef, dividerRef, amountRef)
} else {
combinedReference(cardCountRef, dividerRef, stringReference(BigDecimalFormatter.EMPTY_BALANCE_SIGN))
}
}
private fun UserWallet.getCardCount() = when (val status = scanResponse.card.backupStatus) {
is CardDTO.BackupStatus.Active -> status.cardCount.inc()
is CardDTO.BackupStatus.CardLinked -> status.cardCount.inc()
is CardDTO.BackupStatus.NoBackup,
null,
-> 1
}

View file

@ -2,6 +2,8 @@ package com.tangem.features.details.utils
import arrow.core.Either
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
@ -22,9 +24,9 @@ import com.tangem.domain.tokens.model.TotalFiatBalance
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.details.entity.UserWalletListUM.UserWalletUM
import com.tangem.features.details.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ -40,8 +42,11 @@ internal class UserWalletsFetcher @Inject constructor(
) {
@OptIn(ExperimentalCoroutinesApi::class)
val userWallets: Flow<ImmutableList<UserWalletUM>> = getWalletsUseCase().transformLatest { wallets ->
emit(wallets.toUiModels(onClick = ::navigateToWalletSettings))
val userWallets: Flow<ImmutableList<UserWalletItemUM>> = getWalletsUseCase().transformLatest { wallets ->
val uiModels = UserWalletItemUMConverter(onClick = ::navigateToWalletSettings).convertList(wallets)
.toImmutableList()
emit(uiModels)
combine(
getSelectedAppCurrencyUseCase().distinctUntilChanged(),
@ -72,23 +77,33 @@ internal class UserWalletsFetcher @Inject constructor(
maybeAppCurrency: Either<SelectedAppCurrencyError, AppCurrency>,
maybeBalances: Lce<TokenListError, Map<UserWalletId, TotalFiatBalance>>,
balanceHidingSettings: BalanceHidingSettings,
): Lce<Error, ImmutableList<UserWalletUM>> = lce {
): Lce<Error, ImmutableList<UserWalletItemUM>> = lce {
val balances = withError(
transform = { Error.UnableToGetBalances },
block = { maybeBalances.bindOrNull().orEmpty() },
block = {
maybeBalances.bindOrNull().orEmpty()
.filterKeys { userWalletId -> wallets.any { it.walletId == userWalletId } }
.mapKeys { entry -> wallets.first { it.walletId == entry.key } }
},
)
val appCurrency = withError(
transform = { Error.UnableToGetAppCurrency },
block = { maybeAppCurrency.toLce().bind() },
)
wallets.toUiModels(
appCurrency = appCurrency,
balances = balances,
onClick = ::navigateToWalletSettings,
isBalancesHidden = balanceHidingSettings.isBalanceHidden,
isLoading = maybeBalances.isLoading(),
)
balances
.map { (userWallet, balance) ->
UserWalletItemUMConverter(
onClick = ::navigateToWalletSettings,
appCurrency = appCurrency,
balance = balance,
isBalanceHidden = balanceHidingSettings.isBalanceHidden,
isLoading = maybeBalances.isLoading(),
)
.convert(userWallet)
}
.toImmutableList()
}
private fun navigateToWalletSettings(userWalletId: UserWalletId) {

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -34,6 +35,8 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.TestTags.DISCLAIMER_SCREEN_ACCEPT_BUTTON
import com.tangem.core.ui.test.TestTags.DISCLAIMER_SCREEN_CONTAINER
import com.tangem.features.disclaimer.impl.R
import com.tangem.features.disclaimer.impl.entity.DisclaimerUM
import com.tangem.features.disclaimer.impl.entity.DummyDisclaimer
@ -57,7 +60,8 @@ internal fun DisclaimerScreen(state: DisclaimerUM) {
Box(
modifier = Modifier
.background(backgroundColor)
.statusBarsPadding(),
.statusBarsPadding()
.testTag(DISCLAIMER_SCREEN_CONTAINER),
) {
Column(
modifier = Modifier
@ -158,6 +162,7 @@ private fun BoxScope.DisclaimerButton(onAccept: (Boolean) -> Unit) {
disabledContentColor = TangemColorPalette.Dark6,
),
modifier = Modifier
.testTag(DISCLAIMER_SCREEN_ACCEPT_BUTTON)
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(

View file

@ -1,18 +1,16 @@
package com.tangem.features.managetokens.component
import androidx.compose.runtime.Composable
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.wallets.models.UserWalletId
interface AddCustomTokenComponent {
@Composable
fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit)
interface AddCustomTokenComponent : ComposableBottomSheetComponent {
data class Params(
val userWalletId: UserWalletId,
val onDismiss: () -> Unit,
val onCurrencyAdded: () -> Unit,
)
interface Factory {
fun create(params: Params): AddCustomTokenComponent
}
interface Factory : ComponentFactory<Params, AddCustomTokenComponent>
}

View file

@ -2,11 +2,11 @@ package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
interface ManageTokensComponent : ComposableContentComponent {
data class Params(val mode: Mode)
data class Params(val userWalletId: UserWalletId?)
enum class Mode { READ_ONLY, MANAGE, }
interface Factory : ComponentFactory<Params, ManageTokensComponent>
}

View file

@ -22,8 +22,11 @@ dependencies {
implementation(projects.core.featuretoggles)
/* Project - Domain */
implementation(projects.domain.wallets.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.card)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
/* AndroidX */
implementation(deps.androidx.activity.compose)
@ -43,5 +46,6 @@ dependencies {
/* Other */
implementation(deps.kotlin.immutable.collections)
implementation(deps.decompose.ext.compose)
implementation(deps.timber)
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableDialogComponent
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
internal interface CustomTokenDerivationInputComponent : ComposableDialogComponent {
data class Params(
val userWalletId: UserWalletId,
val onConfirm: (SelectedDerivationPath) -> Unit,
val onDismiss: () -> Unit,
)
interface Factory : ComponentFactory<Params, CustomTokenDerivationInputComponent>
}

View file

@ -1,19 +1,23 @@
package com.tangem.features.managetokens.component
import androidx.compose.foundation.lazy.LazyListScope
import com.tangem.domain.tokens.model.Network
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
internal interface CustomTokenFormComponent {
fun content(scope: LazyListScope)
internal interface CustomTokenFormComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val networkId: Network.ID,
val network: SelectedNetwork,
val derivationPath: SelectedDerivationPath?,
val formValues: CustomTokenFormValues,
val onSelectNetworkClick: (CustomTokenFormValues) -> Unit,
val onSelectDerivationPathClick: (CustomTokenFormValues) -> Unit,
val onCurrencyAdded: () -> Unit,
)
interface Factory {
fun create(params: Params): CustomTokenFormComponent
}
interface Factory : ComponentFactory<Params, CustomTokenFormComponent>
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.managetokens.component
import androidx.compose.foundation.lazy.LazyListScope
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.entity.SelectedNetworkUM
internal interface CustomTokenNetworkSelectorComponent {
fun content(scope: LazyListScope)
data class Params(
val userWalletId: UserWalletId,
val selectedNetwork: SelectedNetworkUM?,
val onNetworkSelected: (SelectedNetworkUM) -> Unit,
)
interface Factory {
fun create(params: Params): CustomTokenNetworkSelectorComponent
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
internal interface CustomTokenSelectorComponent : ComposableContentComponent {
sealed class Params {
data class NetworkSelector(
val userWalletId: UserWalletId,
val selectedNetwork: SelectedNetwork?,
val onNetworkSelected: (SelectedNetwork) -> Unit,
) : Params()
data class DerivationPathSelector(
val userWalletId: UserWalletId,
val selectedNetwork: SelectedNetwork,
val selectedDerivationPath: SelectedDerivationPath?,
val onDerivationPathSelected: (SelectedDerivationPath) -> Unit,
) : Params()
}
interface Factory : ComponentFactory<Params, CustomTokenSelectorComponent>
}

View file

@ -0,0 +1,185 @@
package com.tangem.features.managetokens.component.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children
import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState
import com.arkivanov.decompose.router.stack.*
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.CustomTokenFormComponent
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddCustomTokenComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddCustomTokenComponent.Params,
private val selectorComponentFactory: CustomTokenSelectorComponent.Factory,
private val formComponentFactory: CustomTokenFormComponent.Factory,
) : AddCustomTokenComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddCustomTokenConfig>()
private val contentStack = childStack(
key = "add_custom_token_content_stack",
source = navigation,
initialConfiguration = AddCustomTokenConfig(
userWalletId = params.userWalletId,
step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR,
popBack = ::dismiss,
),
handleBackButton = true,
serializer = AddCustomTokenConfig.serializer(),
childFactory = ::contentChild,
)
override fun dismiss() {
params.onDismiss()
}
@Composable
override fun BottomSheet() {
val config = remember {
TangemBottomSheetConfig(
isShow = true,
onDismissRequest = ::dismiss,
content = contentStack.active.configuration,
)
}
val childStack by contentStack.subscribeAsState()
AddCustomTokenBottomSheet(
config = config.copy(
content = childStack.active.configuration,
),
content = { modifier ->
Children(
stack = childStack,
animation = stackAnimation(),
) { child ->
child.instance.Content(modifier = modifier)
}
},
)
}
private fun contentChild(
config: AddCustomTokenConfig,
componentContext: ComponentContext,
): ComposableContentComponent = when (config.step) {
AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR -> {
selectorComponentFactory.create(
context = childByContext(componentContext),
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = config.userWalletId,
selectedNetwork = null,
onNetworkSelected = { network ->
showForm(network = network)
},
),
)
}
AddCustomTokenConfig.Step.NETWORK_SELECTOR -> {
selectorComponentFactory.create(
context = childByContext(componentContext),
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = config.userWalletId,
selectedNetwork = config.selectedNetwork,
onNetworkSelected = { network ->
showForm(network = network)
},
),
)
}
AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR -> {
selectorComponentFactory.create(
context = childByContext(componentContext),
params = CustomTokenSelectorComponent.Params.DerivationPathSelector(
userWalletId = config.userWalletId,
selectedNetwork = requireNotNull(config.selectedNetwork) {
"Network is not selected"
},
selectedDerivationPath = config.selectedDerivationPath,
onDerivationPathSelected = { derivationPath ->
showForm(derivationPath = derivationPath)
},
),
)
}
AddCustomTokenConfig.Step.FORM -> {
formComponentFactory.create(
context = childByContext(componentContext),
params = CustomTokenFormComponent.Params(
userWalletId = config.userWalletId,
network = requireNotNull(config.selectedNetwork) {
"Network is not selected"
},
derivationPath = config.selectedDerivationPath,
formValues = config.formValues,
onSelectNetworkClick = ::showNetworkSelector,
onSelectDerivationPathClick = ::showDerivationPathSelector,
onCurrencyAdded = ::dismissAndNotify,
),
)
}
}
private fun showDerivationPathSelector(formValues: CustomTokenFormValues) {
val currentConfig = contentStack.value.active.configuration
val config = currentConfig.copy(
step = AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR,
formValues = formValues,
popBack = navigation::pop,
)
navigation.push(config)
}
private fun showNetworkSelector(formValues: CustomTokenFormValues) {
val currentConfig = contentStack.value.active.configuration
val config = currentConfig.copy(
step = AddCustomTokenConfig.Step.NETWORK_SELECTOR,
formValues = formValues,
popBack = navigation::pop,
)
navigation.push(config)
}
private fun showForm(network: SelectedNetwork? = null, derivationPath: SelectedDerivationPath? = null) {
val currentConfig = contentStack.value.active.configuration
val config = currentConfig.copy(
step = AddCustomTokenConfig.Step.FORM,
selectedNetwork = network ?: currentConfig.selectedNetwork,
selectedDerivationPath = derivationPath ?: currentConfig.selectedDerivationPath,
popBack = ::dismiss,
)
navigation.replaceAll(config)
}
private fun dismissAndNotify() {
dismiss()
params.onCurrencyAdded()
}
@AssistedFactory
interface Factory : AddCustomTokenComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddCustomTokenComponent.Params,
): DefaultAddCustomTokenComponent
}
}

View file

@ -0,0 +1,127 @@
package com.tangem.features.managetokens.component.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import arrow.core.getOrElse
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.managetokens.ValidateDerivationPathUseCase
import com.tangem.domain.managetokens.model.exceptoin.DerivationPathValidationException
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent
import com.tangem.features.managetokens.entity.customtoken.CustomDerivationInputUM
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.dialog.CustomDerivationInputDialog
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: CustomTokenDerivationInputComponent.Params,
private val validateDerivationPathUseCase: ValidateDerivationPathUseCase,
) : CustomTokenDerivationInputComponent, AppComponentContext by context {
private val state: MutableStateFlow<CustomDerivationInputUM> = MutableStateFlow(
value = getInitialState(),
)
init {
observeValueUpdates()
}
override fun dismiss() {
params.onDismiss()
}
@Composable
override fun Dialog() {
val state by state.collectAsStateWithLifecycle()
CustomDerivationInputDialog(
model = state,
onDismiss = ::dismiss,
)
}
@OptIn(FlowPreview::class)
private fun observeValueUpdates() {
state
.map { it.value.text }
.distinctUntilChanged()
.sample(periodMillis = 1_000)
.onEach(::validateValue)
.launchIn(componentScope)
}
private fun getInitialState(): CustomDerivationInputUM = CustomDerivationInputUM(
value = TextFieldValue(),
error = null,
updateValue = ::updateValue,
isConfirmEnabled = false,
onConfirm = ::confirm,
)
private fun validateValue(value: String) {
validateDerivationPathUseCase(value).getOrElse { e ->
updateWithValidationError(e)
return
}
state.update { state ->
state.copy(
error = null,
isConfirmEnabled = true,
)
}
}
private fun updateWithValidationError(e: DerivationPathValidationException) {
state.update { state ->
state.copy(
error = when (e) {
DerivationPathValidationException.Empty -> null
DerivationPathValidationException.Invalid -> {
resourceReference(R.string.custom_token_invalid_derivation_path)
}
},
isConfirmEnabled = false,
)
}
}
private fun updateValue(value: TextFieldValue) {
state.update { state ->
state.copy(value = value)
}
}
private fun confirm() {
if (state.value.error != null && !state.value.isConfirmEnabled) {
return
}
val value = state.value.value.text
val model = SelectedDerivationPath(
id = null,
value = Network.DerivationPath.Custom(value),
networkName = stringReference(value = value),
)
params.onConfirm(model)
}
@AssistedFactory
interface Factory : CustomTokenDerivationInputComponent.Factory {
override fun create(
context: AppComponentContext,
params: CustomTokenDerivationInputComponent.Params,
): DefaultCustomTokenDerivationInputComponent
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.managetokens.component.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.managetokens.component.CustomTokenFormComponent
import com.tangem.features.managetokens.model.CustomTokenFormModel
import com.tangem.features.managetokens.ui.CustomTokenFormContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultCustomTokenFormComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: CustomTokenFormComponent.Params,
) : CustomTokenFormComponent, AppComponentContext by context {
private val model: CustomTokenFormModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
CustomTokenFormContent(
modifier = modifier,
model = state,
)
}
@AssistedFactory
interface Factory : CustomTokenFormComponent.Factory {
override fun create(
context: AppComponentContext,
params: CustomTokenFormComponent.Params,
): DefaultCustomTokenFormComponent
}
}

View file

@ -0,0 +1,71 @@
package com.tangem.features.managetokens.component.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableDialogComponent
import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorDialogConfig
import com.tangem.features.managetokens.model.CustomTokenSelectorModel
import com.tangem.features.managetokens.ui.CustomTokenSelectorContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultCustomTokenSelectorComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: CustomTokenSelectorComponent.Params,
private val customTokenDerivationInputComponentFactory: CustomTokenDerivationInputComponent.Factory,
) : CustomTokenSelectorComponent, AppComponentContext by context {
private val model: CustomTokenSelectorModel = getOrCreateModel(params)
private val dialogSlot = childSlot(
source = model.dialogNavigation,
serializer = CustomTokenSelectorDialogConfig.serializer(),
childFactory = ::createDialog,
)
private fun createDialog(
config: CustomTokenSelectorDialogConfig,
context: ComponentContext,
): ComposableDialogComponent = when (config) {
is CustomTokenSelectorDialogConfig.CustomDerivationInput -> customTokenDerivationInputComponentFactory.create(
context = childByContext(context),
params = CustomTokenDerivationInputComponent.Params(
userWalletId = config.userWalletId,
onConfirm = model::selectCustomDerivationPath,
onDismiss = model.dialogNavigation::dismiss,
),
)
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val dialog by dialogSlot.subscribeAsState()
CustomTokenSelectorContent(
modifier = modifier,
model = state,
)
dialog.child?.instance?.Dialog()
}
@AssistedFactory
interface Factory : CustomTokenSelectorComponent.Factory {
override fun create(
context: AppComponentContext,
params: CustomTokenSelectorComponent.Params,
): DefaultCustomTokenSelectorComponent
}
}

View file

@ -1,12 +1,21 @@
package com.tangem.features.managetokens.component.impl
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig
import com.tangem.features.managetokens.model.ManageTokensModel
import com.tangem.features.managetokens.ui.ManageTokensScreen
import dagger.assisted.Assisted
@ -16,18 +25,47 @@ import dagger.assisted.AssistedInject
internal class DefaultManageTokensComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: ManageTokensComponent.Params,
private val addCustomTokenComponentFactory: AddCustomTokenComponent.Factory,
) : ManageTokensComponent, AppComponentContext by context {
private val model: ManageTokensModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = ManageTokensBottomSheetConfig.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
BackHandler(onBack = state.popBack)
ManageTokensScreen(
modifier = modifier,
state = state,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: ManageTokensBottomSheetConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is ManageTokensBottomSheetConfig.AddCustomToken -> {
addCustomTokenComponentFactory.create(
context = childByContext(componentContext),
params = AddCustomTokenComponent.Params(
userWalletId = config.userWalletId,
onDismiss = model.bottomSheetNavigation::dismiss,
onCurrencyAdded = model::reloadList,
),
)
}
}
@AssistedFactory

View file

@ -4,81 +4,73 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent
import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM
import com.tangem.features.managetokens.entity.AddCustomTokenUM
import com.tangem.features.managetokens.entity.ClickableFieldUM
import com.tangem.features.managetokens.entity.SelectedNetworkUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig
import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
internal class PreviewAddCustomTokenComponent(
initialState: AddCustomTokenUM = AddCustomTokenUM.NetworkSelector(popBack = {}),
initialState: AddCustomTokenConfig = AddCustomTokenConfig(
userWalletId = UserWalletId(stringValue = "321"),
step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR,
popBack = {},
),
) : AddCustomTokenComponent {
private val userWalletId = UserWalletId(stringValue = "321")
private val previewState: MutableStateFlow<AddCustomTokenConfig> = MutableStateFlow(initialState)
private val previewState: MutableStateFlow<AddCustomTokenUM> = MutableStateFlow(initialState)
override fun dismiss() {
/* no-op */
}
@Composable
override fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit) {
override fun BottomSheet() {
val state by previewState.collectAsStateWithLifecycle()
val config = TangemBottomSheetConfig(
isShow = isVisible,
onDismissRequest = onDismiss,
isShow = true,
onDismissRequest = ::dismiss,
content = state,
)
AddCustomTokenBottomSheet(
config = config,
content = {
when (val s = state) {
is AddCustomTokenUM.Form -> {
PreviewCustomTokenFormComponent(
networkName = ClickableFieldUM(
label = resourceReference(R.string.custom_token_network_input_title),
value = stringReference(s.selectedNetwork.name),
onClick = { showNetworkSelector(s.selectedNetwork) },
content = { modifier ->
when (state.step) {
AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR -> {
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = state.userWalletId,
selectedNetwork = null,
onNetworkSelected = {},
),
).content(this)
).Content(modifier)
}
is AddCustomTokenUM.NetworkSelector -> {
PreviewCustomTokenNetworkSelectorComponent(
params = CustomTokenNetworkSelectorComponent.Params(
userWalletId = userWalletId,
selectedNetwork = s.selectedNetwork,
onNetworkSelected = ::showForm,
AddCustomTokenConfig.Step.NETWORK_SELECTOR -> {
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = state.userWalletId,
selectedNetwork = state.selectedNetwork,
onNetworkSelected = {},
),
networksSize = 20,
).content(this)
).Content(modifier)
}
AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR -> {
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.DerivationPathSelector(
userWalletId = state.userWalletId,
selectedNetwork = state.selectedNetwork!!,
selectedDerivationPath = state.selectedDerivationPath!!,
onDerivationPathSelected = {},
),
).Content(modifier)
}
AddCustomTokenConfig.Step.FORM -> {
PreviewCustomTokenFormComponent().Content(modifier)
}
}
},
)
}
private fun showNetworkSelector(selectedNetwork: SelectedNetworkUM) {
previewState.update {
AddCustomTokenUM.NetworkSelector(selectedNetwork, popBack = { showForm(selectedNetwork) })
}
}
private fun showForm(network: SelectedNetworkUM) {
previewState.update {
AddCustomTokenUM.Form(
popBack = {},
selectedNetwork = network,
addTokenButton = AddCustomTokenButtonUM.Visible(
isEnabled = false,
onClick = {},
),
)
}
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent
import com.tangem.features.managetokens.entity.customtoken.CustomDerivationInputUM
import com.tangem.features.managetokens.ui.dialog.CustomDerivationInputDialog
internal class PreviewCustomTokenDerivationInputComponent(
private val value: String = "",
private val error: String? = null,
) : CustomTokenDerivationInputComponent {
override fun dismiss() {
/* no-op */
}
@Composable
override fun Dialog() {
val model = CustomDerivationInputUM(
value = TextFieldValue(value),
error = error?.let(::stringReference),
updateValue = {},
isConfirmEnabled = false,
onConfirm = {},
)
CustomDerivationInputDialog(
model = model,
onDismiss = ::dismiss,
)
}
}

View file

@ -1,81 +1,87 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.managetokens.component.CustomTokenFormComponent
import com.tangem.features.managetokens.entity.ClickableFieldUM
import com.tangem.features.managetokens.entity.CustomTokenFormUM
import com.tangem.features.managetokens.entity.TextInputFieldUM
import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM
import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.customTokenFormContent
import kotlinx.collections.immutable.ImmutableList
import com.tangem.features.managetokens.ui.CustomTokenFormContent
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
internal class PreviewCustomTokenFormComponent(
networkName: ClickableFieldUM = ClickableFieldUM(
label = resourceReference(R.string.custom_token_network_input_title),
value = stringReference(value = "Ethereum"),
onClick = {},
),
derivationPath: ClickableFieldUM = ClickableFieldUM(
label = resourceReference(R.string.custom_token_derivation_path),
value = stringReference(value = "Default"),
onClick = {},
),
networkName: ClickableFieldUM = PreviewCustomTokenFormComponent.networkName,
derivationPath: ClickableFieldUM = PreviewCustomTokenFormComponent.derivationPath,
canAddToken: Boolean = false,
contractAddress: TextInputFieldUM = TextInputFieldUM(
label = resourceReference(R.string.custom_token_contract_address_input_title),
placeholder = stringReference(value = "0x000000000000000000000000000"),
value = "",
onValueChange = {},
),
tokenName: TextInputFieldUM = TextInputFieldUM(
label = resourceReference(R.string.custom_token_name_input_title),
placeholder = stringReference(value = "E.g. USD Coin"),
value = "",
onValueChange = {},
),
tokenSymbol: TextInputFieldUM = TextInputFieldUM(
label = resourceReference(R.string.custom_token_token_symbol_input_title),
placeholder = stringReference(value = "E.g. USDC"),
value = "",
onValueChange = {},
),
tokenDecimals: TextInputFieldUM = TextInputFieldUM(
label = resourceReference(R.string.custom_token_decimals_input_title),
placeholder = stringReference(value = "8"),
value = "",
onValueChange = {},
),
notifications: ImmutableList<CustomTokenFormUM.NotificationUM> = persistentListOf(
CustomTokenFormUM.NotificationUM(
id = "1",
config = NotificationConfig(
title = stringReference(value = "Note that tokens can be created by anyone"),
subtitle = stringReference(value = "Be aware of adding scam tokens, they can cost nothing"),
iconResId = R.drawable.img_attention_20,
),
),
),
tokenForm: CustomTokenFormUM.TokenFormUM? = PreviewCustomTokenFormComponent.tokenForm,
notifications: PersistentList<CustomTokenFormUM.NotificationUM> = PreviewCustomTokenFormComponent.notifications,
) : CustomTokenFormComponent {
private val previewState = CustomTokenFormUM(
networkName = networkName,
contractAddress = contractAddress,
tokenName = tokenName,
tokenSymbol = tokenSymbol,
tokenDecimals = tokenDecimals,
tokenForm = tokenForm,
derivationPath = derivationPath,
notifications = notifications,
canAddToken = canAddToken,
onDerivationPathClick = {},
onNetworkClick = {},
onAddClick = {},
saveToken = {},
)
override fun content(scope: LazyListScope) {
scope.customTokenFormContent(model = previewState)
@Composable
override fun Content(modifier: Modifier) {
CustomTokenFormContent(modifier = modifier, model = previewState)
}
companion object {
val networkName: ClickableFieldUM = ClickableFieldUM(
label = resourceReference(R.string.custom_token_network_input_title),
value = stringReference(value = "Ethereum"),
onClick = {},
)
val derivationPath: ClickableFieldUM = ClickableFieldUM(
label = resourceReference(R.string.custom_token_derivation_path),
value = stringReference(value = "Default"),
onClick = {},
)
val tokenForm: CustomTokenFormUM.TokenFormUM = CustomTokenFormUM.TokenFormUM(
contractAddress = TextInputFieldUM(
label = resourceReference(R.string.custom_token_contract_address_input_title),
placeholder = stringReference(value = "0x000000000000000000000000000"),
value = "",
onValueChange = {},
),
name = TextInputFieldUM(
label = resourceReference(R.string.custom_token_name_input_title),
placeholder = stringReference(value = "E.g. USD Coin"),
value = "",
onValueChange = {},
),
symbol = TextInputFieldUM(
label = resourceReference(R.string.custom_token_token_symbol_input_title),
placeholder = stringReference(value = "E.g. USDC"),
value = "",
onValueChange = {},
),
decimals = TextInputFieldUM(
label = resourceReference(R.string.custom_token_decimals_input_title),
placeholder = stringReference(value = "8"),
value = "",
onValueChange = {},
),
)
val notifications: PersistentList<CustomTokenFormUM.NotificationUM> = persistentListOf(
CustomTokenFormUM.NotificationUM(
id = "1",
config = NotificationConfig(
title = stringReference(value = "Note that tokens can be created by anyone"),
subtitle = stringReference(value = "Be aware of adding scam tokens, they can cost nothing"),
iconResId = R.drawable.img_attention_20,
),
),
)
}
}

View file

@ -1,50 +0,0 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.foundation.lazy.LazyListScope
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent
import com.tangem.features.managetokens.entity.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM
import com.tangem.features.managetokens.entity.SelectedNetworkUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.customTokenNetworkSelectorContent
import kotlinx.collections.immutable.toImmutableList
internal class PreviewCustomTokenNetworkSelectorComponent(
private val params: CustomTokenNetworkSelectorComponent.Params = CustomTokenNetworkSelectorComponent.Params(
userWalletId = UserWalletId(stringValue = "321"),
selectedNetwork = null,
onNetworkSelected = {},
),
networksSize: Int = 5,
) : CustomTokenNetworkSelectorComponent {
private val previewNetworks = List(size = networksSize) { networkIndex ->
val n = SelectedNetworkUM(
id = Network.ID(networkIndex.toString()),
name = "Network $networkIndex",
)
CurrencyNetworkUM(
id = n.id,
name = n.name,
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = false,
isSelected = n.id == params.selectedNetwork?.id,
onSelectedStateChange = { params.onNetworkSelected(n) },
)
}.toImmutableList()
private val previewState = CustomTokenNetworkSelectorUM(
showTitle = params.selectedNetwork == null,
networks = previewNetworks,
)
override fun content(scope: LazyListScope) {
scope.customTokenNetworkSelectorContent(
model = previewState,
)
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params
import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.item.DerivationPathUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.CustomTokenSelectorContent
import kotlinx.collections.immutable.toImmutableList
internal class PreviewCustomTokenSelectorComponent(
private val params: Params = Params.NetworkSelector(
userWalletId = UserWalletId(stringValue = "321"),
selectedNetwork = null,
onNetworkSelected = {},
),
itemsSize: Int = 5,
) : CustomTokenSelectorComponent {
private val previewItems = List(size = itemsSize) { index ->
when (params) {
is Params.DerivationPathSelector -> {
val d = SelectedDerivationPath(
id = Network.ID(index.toString()),
value = Network.DerivationPath.Card("m/44'/0'/0'/0/$index"),
networkName = stringReference(value = "Network $index"),
)
DerivationPathUM(
id = d.id?.value ?: "",
value = d.value.value.orEmpty(),
networkName = d.networkName,
isSelected = d.value == params.selectedDerivationPath?.value,
onSelectedStateChange = { params.onDerivationPathSelected(d) },
)
}
is Params.NetworkSelector -> {
val n = SelectedNetwork(
id = Network.ID(index.toString()),
name = stringReference(value = "Network $index"),
derivationPath = Network.DerivationPath.Card("m/44'/0'/0'/0/$index"),
canHandleTokens = false,
)
CurrencyNetworkUM(
network = Network(
id = n.id,
backendId = n.id.value,
name = "",
currencySymbol = "",
derivationPath = Network.DerivationPath.Card(""),
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = false,
canHandleTokens = false,
),
name = "Network $index",
type = "N$index",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = false,
isSelected = n.id == params.selectedNetwork?.id,
onSelectedStateChange = { params.onNetworkSelected(n) },
onLongClick = {},
)
}
}
}.toImmutableList()
val previewState = CustomTokenSelectorUM(
header = when (params) {
is Params.DerivationPathSelector -> CustomTokenSelectorUM.HeaderUM.CustomDerivationButton(
value = null,
onClick = {},
)
is Params.NetworkSelector -> if (params.selectedNetwork == null) {
CustomTokenSelectorUM.HeaderUM.Description
} else {
CustomTokenSelectorUM.HeaderUM.None
}
},
items = previewItems,
)
@Composable
override fun Content(modifier: Modifier) {
CustomTokenSelectorContent(modifier = modifier, model = previewState)
}
}

View file

@ -9,11 +9,14 @@ import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.entity.*
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.ManageTokensScreen
import kotlinx.collections.immutable.mutate
@ -22,7 +25,9 @@ import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
internal class PreviewManageTokensComponent : ManageTokensComponent {
internal class PreviewManageTokensComponent(
private val isLoading: Boolean = false,
) : ManageTokensComponent {
private val changedItemsIds: MutableSet<String> = mutableSetOf()
@ -47,8 +52,11 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
onActiveChange = ::toggleSearchBar,
),
hasChanges = false,
isLoading = false,
onSaveClick = {},
isInitialBatchLoading = false,
isNextBatchLoading = true,
loadMore = { false },
saveChanges = {},
isSavingInProgress = false,
),
)
@ -58,7 +66,7 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
initItems()
} else {
items.filter { currency ->
currency.model.name.contains(query, ignoreCase = true)
currency.name.contains(query, ignoreCase = true)
}.toPersistentList()
}
@ -88,42 +96,40 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
}
private fun initItems() = List(size = 30) { index ->
if (index < 2) {
getCustomItem(index)
if (isLoading) {
CurrencyItemUM.Loading(index)
} else {
getBasicItem(index)
if (index < 2) {
getCustomItem(index)
} else {
getBasicItem(index)
}
}
}.toPersistentList()
private fun getCustomItem(index: Int) = CurrencyItemUM.Custom(
id = index.toString(),
model = ChainRowUM(
name = "Custom token $index",
type = "CT$index",
icon = CurrencyIconState.CustomTokenIcon(
tint = Color.White,
background = Color.Black,
topBadgeIconResId = R.drawable.img_eth_22,
isGrayscale = false,
showCustomBadge = true,
),
showCustom = true,
id = ManagedCryptoCurrency.ID(index.toString()),
name = "Custom token $index",
symbol = "CT$index",
icon = CurrencyIconState.CustomTokenIcon(
tint = Color.White,
background = Color.Black,
topBadgeIconResId = R.drawable.img_eth_22,
isGrayscale = false,
showCustomBadge = true,
),
onRemoveClick = {},
)
private fun getBasicItem(index: Int) = CurrencyItemUM.Basic(
id = index.toString(),
model = ChainRowUM(
name = "Currency $index",
type = "C$index",
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_btc_22,
isGrayscale = false,
showCustomBadge = false,
),
showCustom = false,
id = ManagedCryptoCurrency.ID(index.toString()),
name = "Currency $index",
symbol = "C$index",
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_btc_22,
isGrayscale = false,
showCustomBadge = false,
),
networks = if (index == 2) {
CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index))
@ -135,13 +141,24 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex ->
CurrencyNetworkUM(
id = Network.ID(networkIndex.toString()),
network = Network(
id = Network.ID(networkIndex.toString()),
backendId = networkIndex.toString(),
name = "Network $networkIndex",
currencySymbol = "N$networkIndex",
derivationPath = Network.DerivationPath.Card(""),
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = false,
canHandleTokens = false,
),
name = "NETWORK$networkIndex",
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = networkIndex == 0,
isSelected = false,
onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) },
onLongClick = {},
)
}.toImmutableList()
@ -154,7 +171,9 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
CurrencyItemUM.Basic.NetworksUM.Collapsed
},
)
is CurrencyItemUM.Custom -> return
is CurrencyItemUM.Custom,
is CurrencyItemUM.Loading,
-> return
}
previewState.update { state ->
@ -189,7 +208,9 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
item.copy(networks = updatedNetworks)
}
is CurrencyItemUM.Custom -> return
is CurrencyItemUM.Custom,
is CurrencyItemUM.Loading,
-> return
}
val id = "${currencyIndex}_$networkIndex"

View file

@ -1,7 +1,7 @@
package com.tangem.features.managetokens.di
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.impl.DefaultManageTokensComponent
import com.tangem.features.managetokens.component.*
import com.tangem.features.managetokens.component.impl.*
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -15,4 +15,28 @@ internal interface ComponentModule {
@Binds
@Singleton
fun bindManageTokensComponentFactory(factory: DefaultManageTokensComponent.Factory): ManageTokensComponent.Factory
@Binds
@Singleton
fun bindAddCustomTokenComponentFactory(
factory: DefaultAddCustomTokenComponent.Factory,
): AddCustomTokenComponent.Factory
@Binds
@Singleton
fun bindCustomTokenSelectorComponentFactory(
factory: DefaultCustomTokenSelectorComponent.Factory,
): CustomTokenSelectorComponent.Factory
@Binds
@Singleton
fun bindCustomTokenFormComponentFactory(
factory: DefaultCustomTokenFormComponent.Factory,
): CustomTokenFormComponent.Factory
@Binds
@Singleton
fun bindCustomTokenDerivationInputComponentFactory(
factory: DefaultCustomTokenDerivationInputComponent.Factory,
): CustomTokenDerivationInputComponent.Factory
}

View file

@ -2,6 +2,8 @@ package com.tangem.features.managetokens.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.managetokens.model.CustomTokenFormModel
import com.tangem.features.managetokens.model.CustomTokenSelectorModel
import com.tangem.features.managetokens.model.ManageTokensModel
import dagger.Binds
import dagger.Module
@ -17,4 +19,14 @@ internal interface ModelModule {
@IntoMap
@ClassKey(ManageTokensModel::class)
fun provideManageTokensModel(model: ManageTokensModel): Model
@Binds
@IntoMap
@ClassKey(CustomTokenFormModel::class)
fun provideCustomTokenFormModel(model: CustomTokenFormModel): Model
@Binds
@IntoMap
@ClassKey(CustomTokenSelectorModel::class)
fun provideCustomTokenSelectorModel(model: CustomTokenSelectorModel): Model
}

View file

@ -1,54 +0,0 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.domain.tokens.model.Network
@Immutable
internal sealed class AddCustomTokenUM : TangemBottomSheetConfigContent {
abstract val selectedNetwork: SelectedNetworkUM?
abstract val addTokenButton: AddCustomTokenButtonUM
abstract val popBack: () -> Unit
data class NetworkSelector(
override val selectedNetwork: SelectedNetworkUM? = null,
override val popBack: () -> Unit,
) : AddCustomTokenUM() {
override val addTokenButton: AddCustomTokenButtonUM = AddCustomTokenButtonUM.Hidden
}
data class Form(
override val selectedNetwork: SelectedNetworkUM,
override val addTokenButton: AddCustomTokenButtonUM.Visible,
override val popBack: () -> Unit,
) : AddCustomTokenUM()
}
@Immutable
internal data class SelectedNetworkUM(
val id: Network.ID,
val name: String,
)
@Immutable
internal sealed class AddCustomTokenButtonUM {
open val onClick: () -> Unit = {}
open val isEnabled: Boolean = false
val isVisible: Boolean
get() = this is Visible
data object Hidden : AddCustomTokenButtonUM() {
override val onClick: () -> Unit = {}
}
data class Visible(
override val isEnabled: Boolean,
override val onClick: () -> Unit,
) : AddCustomTokenButtonUM()
}

View file

@ -1,36 +0,0 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.rows.model.ChainRowUM
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class CurrencyItemUM {
abstract val id: String
abstract val model: ChainRowUM
data class Basic(
override val id: String,
override val model: ChainRowUM,
val networks: NetworksUM,
val onExpandClick: () -> Unit,
) : CurrencyItemUM() {
@Immutable
sealed class NetworksUM {
data object Collapsed : NetworksUM()
data class Expanded(
val networks: ImmutableList<CurrencyNetworkUM>,
) : NetworksUM()
}
}
data class Custom(
override val id: String,
override val model: ChainRowUM,
val onRemoveClick: () -> Unit,
) : CurrencyItemUM()
}

View file

@ -1,15 +0,0 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.domain.tokens.model.Network
@Immutable
internal data class CurrencyNetworkUM(
val id: Network.ID,
val name: String,
val type: String,
val iconResId: Int,
val isMainNetwork: Boolean,
val isSelected: Boolean,
val onSelectedStateChange: (Boolean) -> Unit,
)

View file

@ -1,44 +0,0 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class CustomTokenFormUM(
val networkName: ClickableFieldUM,
val contractAddress: TextInputFieldUM,
val tokenName: TextInputFieldUM,
val tokenSymbol: TextInputFieldUM,
val tokenDecimals: TextInputFieldUM,
val derivationPath: ClickableFieldUM,
val notifications: ImmutableList<NotificationUM>,
val canAddToken: Boolean,
val onNetworkClick: () -> Unit,
val onDerivationPathClick: () -> Unit,
val onAddClick: () -> Unit,
) {
@Immutable
data class NotificationUM(
val id: String,
val config: NotificationConfig,
)
}
@Immutable
internal data class TextInputFieldUM(
val label: TextReference,
val placeholder: TextReference,
val value: String,
val onValueChange: (String) -> Unit,
val error: TextReference? = null,
)
@Immutable
internal data class ClickableFieldUM(
val label: TextReference,
val value: TextReference,
val onClick: () -> Unit,
)

View file

@ -1,10 +0,0 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class CustomTokenNetworkSelectorUM(
val showTitle: Boolean,
val networks: ImmutableList<CurrencyNetworkUM>,
)

View file

@ -1,44 +0,0 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class ManageTokensUM {
abstract val popBack: () -> Unit
abstract val isLoading: Boolean
abstract val items: ImmutableList<CurrencyItemUM>
abstract val topBar: ManageTokensTopBarUM
abstract val search: SearchBarUM
data class ReadContent(
override val popBack: () -> Unit,
override val isLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val search: SearchBarUM,
) : ManageTokensUM()
data class ManageContent(
override val popBack: () -> Unit,
override val isLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val search: SearchBarUM,
val onSaveClick: () -> Unit,
val hasChanges: Boolean,
) : ManageTokensUM()
fun copySealed(
search: SearchBarUM = this.search,
items: ImmutableList<CurrencyItemUM> = this.items,
hasChanges: Boolean = this is ManageContent && this.hasChanges,
): ManageTokensUM {
return when (this) {
is ManageContent -> copy(search = search, items = items, hasChanges = hasChanges)
is ReadContent -> copy(search = search, items = items)
}
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.managetokens.entity.customtoken
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.serialization.Serializable
@Serializable
internal data class AddCustomTokenConfig(
val step: Step,
val popBack: () -> Unit,
val userWalletId: UserWalletId,
val selectedNetwork: SelectedNetwork? = null,
val selectedDerivationPath: SelectedDerivationPath? = null,
val formValues: CustomTokenFormValues = CustomTokenFormValues(),
) : TangemBottomSheetConfigContent {
enum class Step {
INITIAL_NETWORK_SELECTOR,
NETWORK_SELECTOR,
DERIVATION_PATH_SELECTOR,
FORM,
}
}
@Serializable
internal data class SelectedNetwork(
val id: Network.ID,
val name: TextReference,
val derivationPath: Network.DerivationPath,
val canHandleTokens: Boolean,
)
@Serializable
internal data class SelectedDerivationPath(
val id: Network.ID?,
val value: Network.DerivationPath,
val networkName: TextReference,
)

View file

@ -0,0 +1,12 @@
package com.tangem.features.managetokens.entity.customtoken
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.extensions.TextReference
internal data class CustomDerivationInputUM(
val value: TextFieldValue,
val error: TextReference? = null,
val updateValue: (value: TextFieldValue) -> Unit,
val isConfirmEnabled: Boolean,
val onConfirm: () -> Unit,
)

View file

@ -0,0 +1,45 @@
package com.tangem.features.managetokens.entity.customtoken
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
internal data class CustomTokenFormUM(
val networkName: ClickableFieldUM,
val derivationPath: ClickableFieldUM,
val tokenForm: TokenFormUM?,
val notifications: PersistentList<NotificationUM> = persistentListOf(),
val canAddToken: Boolean = false,
val isValidating: Boolean = false,
val saveToken: () -> Unit,
) {
data class TokenFormUM(
val contractAddress: TextInputFieldUM,
val name: TextInputFieldUM,
val symbol: TextInputFieldUM,
val decimals: TextInputFieldUM,
val wasFilled: Boolean = false,
)
data class NotificationUM(
val id: String,
val config: NotificationConfig,
)
}
internal data class TextInputFieldUM(
val label: TextReference,
val placeholder: TextReference,
val value: String = "",
val error: TextReference? = null,
val isEnabled: Boolean = true,
val onValueChange: (String) -> Unit,
)
internal data class ClickableFieldUM(
val label: TextReference,
val value: TextReference,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,45 @@
package com.tangem.features.managetokens.entity.customtoken
import com.tangem.domain.managetokens.model.AddCustomTokenForm
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM.TokenFormUM
import kotlinx.serialization.Serializable
@JvmInline
@Serializable
internal value class CustomTokenFormValues private constructor(private val values: List<String>) {
constructor() : this(values = emptyList())
constructor(form: TokenFormUM?) : this(
values = if (form == null) {
emptyList()
} else {
listOf(
form.contractAddress.value,
form.name.value,
form.symbol.value,
form.decimals.value,
)
},
)
fun fillValues(to: TokenFormUM): TokenFormUM = to.copy(
contractAddress = to.contractAddress.copy(value = values.getOrElse(index = 0) { "" }),
name = to.name.copy(value = values.getOrElse(index = 1) { "" }),
symbol = to.symbol.copy(value = values.getOrElse(index = 2) { "" }),
decimals = to.decimals.copy(value = values.getOrElse(index = 3) { "" }),
)
fun toDomainModel(): AddCustomTokenForm.Raw? {
return if (values.isEmpty()) {
null
} else {
AddCustomTokenForm.Raw(
contractAddress = values.getOrElse(index = 0) { "" },
name = values.getOrElse(index = 1) { "" },
symbol = values.getOrElse(index = 2) { "" },
decimals = values.getOrElse(index = 3) { "" },
)
}
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.managetokens.entity.customtoken
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.serialization.Serializable
@Serializable
internal sealed class CustomTokenSelectorDialogConfig {
@Serializable
data class CustomDerivationInput(
val userWalletId: UserWalletId,
) : CustomTokenSelectorDialogConfig()
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.managetokens.entity.customtoken
import androidx.compose.runtime.Immutable
import com.tangem.features.managetokens.entity.item.SelectableItemUM
import kotlinx.collections.immutable.ImmutableList
internal data class CustomTokenSelectorUM(
val header: HeaderUM,
val items: ImmutableList<SelectableItemUM>,
) {
@Immutable
sealed class HeaderUM {
data object None : HeaderUM()
data object Description : HeaderUM()
data class CustomDerivationButton(
val value: String?,
val onClick: () -> Unit,
) : HeaderUM()
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.features.managetokens.entity.item
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class CurrencyItemUM {
abstract val id: ManagedCryptoCurrency.ID
abstract val name: String
abstract val symbol: String
abstract val icon: CurrencyIconState
data class Basic(
override val id: ManagedCryptoCurrency.ID,
override val name: String,
override val symbol: String,
override val icon: CurrencyIconState,
val networks: NetworksUM,
val onExpandClick: () -> Unit,
) : CurrencyItemUM() {
@Immutable
sealed class NetworksUM {
data object Collapsed : NetworksUM()
data class Expanded(
val networks: ImmutableList<CurrencyNetworkUM>,
) : NetworksUM()
}
}
data class Custom(
override val id: ManagedCryptoCurrency.ID,
override val name: String,
override val symbol: String,
override val icon: CurrencyIconState,
val onRemoveClick: () -> Unit,
) : CurrencyItemUM()
class Loading(val index: Int) : CurrencyItemUM() {
override val id: ManagedCryptoCurrency.ID = ManagedCryptoCurrency.ID(value = "loading_$index")
override val name: String = "loading"
override val symbol: String = "loading"
override val icon: CurrencyIconState = CurrencyIconState.Loading
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.managetokens.entity.item
import com.tangem.domain.tokens.model.Network
internal data class CurrencyNetworkUM(
val network: Network,
val name: String,
val type: String,
val iconResId: Int,
val isMainNetwork: Boolean,
val onLongClick: () -> Unit,
override val isSelected: Boolean,
override val onSelectedStateChange: (Boolean) -> Unit,
) : SelectableItemUM {
override val id: String = network.id.value
data class LongTapConfig(val contractAddress: String, val onLongTap: () -> Unit)
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.managetokens.entity.item
import com.tangem.core.ui.extensions.TextReference
internal data class DerivationPathUM(
override val id: String,
val value: String,
val networkName: TextReference,
override val isSelected: Boolean,
override val onSelectedStateChange: (Boolean) -> Unit,
) : SelectableItemUM

View file

@ -0,0 +1,11 @@
package com.tangem.features.managetokens.entity.item
import androidx.compose.runtime.Immutable
@Immutable
internal sealed interface SelectableItemUM {
val id: String
val isSelected: Boolean
val onSelectedStateChange: (Boolean) -> Unit
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.managetokens.entity.managetokens
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.serialization.Serializable
@Serializable
internal sealed class ManageTokensBottomSheetConfig {
@Serializable
data class AddCustomToken(
val userWalletId: UserWalletId,
) : ManageTokensBottomSheetConfig()
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.managetokens.entity
package com.tangem.features.managetokens.entity.managetokens
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM

View file

@ -0,0 +1,75 @@
package com.tangem.features.managetokens.entity.managetokens
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class ManageTokensUM {
abstract val popBack: () -> Unit
abstract val isInitialBatchLoading: Boolean
abstract val isNextBatchLoading: Boolean
abstract val items: ImmutableList<CurrencyItemUM>
abstract val topBar: ManageTokensTopBarUM
abstract val search: SearchBarUM
abstract val loadMore: () -> Boolean
abstract val scrollToTop: StateEvent<Unit>
data class ReadContent(
override val popBack: () -> Unit,
override val isInitialBatchLoading: Boolean,
override val isNextBatchLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),
) : ManageTokensUM()
data class ManageContent(
override val popBack: () -> Unit,
override val isInitialBatchLoading: Boolean,
override val isNextBatchLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),
val saveChanges: () -> Unit,
val hasChanges: Boolean,
val isSavingInProgress: Boolean,
) : ManageTokensUM()
fun copySealed(
search: SearchBarUM = this.search,
items: ImmutableList<CurrencyItemUM> = this.items,
hasChanges: Boolean = this is ManageContent && this.hasChanges,
isInitialBatchLoading: Boolean = this.isInitialBatchLoading,
isNextBatchLoading: Boolean = this.isNextBatchLoading,
isSavingInProgress: Boolean = this is ManageContent && this.isSavingInProgress,
scrollToTop: StateEvent<Unit> = this.scrollToTop,
): ManageTokensUM {
return when (this) {
is ManageContent -> copy(
search = search,
items = items,
hasChanges = hasChanges,
isInitialBatchLoading = isInitialBatchLoading,
isNextBatchLoading = isNextBatchLoading,
isSavingInProgress = isSavingInProgress,
scrollToTop = scrollToTop,
)
is ReadContent -> copy(
search = search,
items = items,
isInitialBatchLoading = isInitialBatchLoading,
isNextBatchLoading = isNextBatchLoading,
scrollToTop = scrollToTop,
)
}
}
}

View file

@ -0,0 +1,367 @@
package com.tangem.features.managetokens.model
import androidx.compose.ui.res.stringResource
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.SimpleOkDialog
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.ContentMessage
import com.tangem.domain.card.DerivePublicKeysUseCase
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.component.CustomTokenFormComponent
import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues
import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.CustomCurrencyValidator
import com.tangem.features.managetokens.utils.mapper.mapToDomainModel
import com.tangem.features.managetokens.utils.ui.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ComponentScoped
internal class CustomTokenFormModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val customCurrencyValidator: CustomCurrencyValidator,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val messageSender: UiMessageSender,
paramsContainer: ParamsContainer,
) : Model() {
private val params: CustomTokenFormComponent.Params = paramsContainer.require()
private var createdCurrency: CryptoCurrency? = null
val state: MutableStateFlow<CustomTokenFormUM> = MutableStateFlow(
value = getInitialState(),
)
init {
observeValidatorUpdates()
if (params.network.canHandleTokens) {
observeTokenFormUpdates()
} else {
modelScope.launch {
customCurrencyValidator.createCoin(
userWalletId = params.userWalletId,
networkId = params.network.id,
derivationPath = getDerivationPath(),
)
}
}
}
private fun getInitialState(): CustomTokenFormUM {
return CustomTokenFormUM(
networkName = ClickableFieldUM(
label = resourceReference(R.string.custom_token_network_input_title),
value = params.network.name,
onClick = ::selectNetwork,
),
tokenForm = if (params.network.canHandleTokens) {
getInitialTokenForm()
} else {
null
},
derivationPath = ClickableFieldUM(
label = resourceReference(R.string.custom_token_derivation_path),
value = if (params.derivationPath == null || params.derivationPath.id == params.network.id) {
resourceReference(R.string.custom_token_derivation_path_default)
} else {
params.derivationPath.networkName
},
onClick = ::selectDerivationPath,
),
saveToken = ::addCurrency,
)
}
@OptIn(FlowPreview::class)
private fun observeTokenFormUpdates() {
state
.transform { state ->
val form = state.tokenForm
if (form != null && !form.wasFilled) {
emit(form.mapToDomainModel())
}
}
.distinctUntilChanged()
.sample(periodMillis = 1_000)
.onEach { formValues ->
customCurrencyValidator.validateForm(
userWalletId = params.userWalletId,
networkId = params.network.id,
derivationPath = getDerivationPath(),
formValues = formValues,
)
}
.launchIn(modelScope)
}
private fun observeValidatorUpdates() = modelScope.launch {
customCurrencyValidator.consumeUpdates { validatorState ->
createdCurrency = null
when (validatorState) {
is CustomCurrencyValidator.Status.NotStarted,
is CustomCurrencyValidator.Status.Validating,
-> Unit
is CustomCurrencyValidator.Status.SearchingToken -> updateStateWithProgress()
is CustomCurrencyValidator.Status.UnexpectedException -> showErrorDialog()
is CustomCurrencyValidator.Status.FormValidationException -> updateStateWithExceptions(
exceptions = validatorState.exceptions,
)
is CustomCurrencyValidator.Status.TokenNotFound -> updateStateWithNotFoundNotification()
is CustomCurrencyValidator.Status.Validated -> {
createdCurrency = validatorState.currency
updateStateWithCurrency(
currency = validatorState.currency,
fillForm = validatorState.fillForm,
isAlreadyAdded = validatorState.isAlreadyAdded,
isCustom = validatorState.isCustom,
)
}
}
}
}
private fun updateStateWithCurrency(
currency: CryptoCurrency,
fillForm: Boolean,
isAlreadyAdded: Boolean,
isCustom: Boolean,
) {
state.update { state ->
var updatedState = state
.updateWithProgress(
showProgress = false,
canAddToken = !isAlreadyAdded,
isWasFilled = fillForm,
clearNotifications = true,
clearFieldErrors = true,
disableSecondaryFields = !isCustom,
)
if (fillForm) {
updatedState = updatedState.updateWithCurrency(currency)
}
if (isAlreadyAdded) {
updatedState = updatedState.updateWithCurrencyAlreadyAddedNotification()
}
if (isCustom) {
updatedState = updatedState.updateWithCurrencyNotFoundNotification()
}
updatedState
}
}
private fun updateStateWithNotFoundNotification() {
state.update { state ->
state
.updateWithProgress(
showProgress = false,
canAddToken = false,
clearNotifications = true,
clearFieldErrors = true,
)
.updateWithCurrencyNotFoundNotification()
}
}
private fun updateStateWithProgress() {
state.update { state ->
state.updateWithProgress(
showProgress = true,
canAddToken = false,
clearNotifications = false,
clearFieldErrors = false,
)
}
}
private fun showErrorDialog() {
val dialog = ContentMessage { onDismiss ->
SimpleOkDialog(
message = stringResource(R.string.common_unknown_error),
onDismissDialog = onDismiss,
)
}
messageSender.send(dialog)
}
private fun updateStateWithExceptions(exceptions: List<CustomTokenFormValidationException>) {
state.update { state ->
val validatedState = state
.updateWithProgress(
showProgress = false,
canAddToken = false,
clearNotifications = true,
clearFieldErrors = true,
)
exceptions.fold(validatedState) { stateAcc, exception ->
when (exception) {
is CustomTokenFormValidationException.ContractAddress -> {
stateAcc.updateWithContractAddressException(exception)
}
is CustomTokenFormValidationException.Decimals -> {
stateAcc.updateWithDecimalsException(exception)
}
is CustomTokenFormValidationException.EmptyName -> {
stateAcc // No need to display error
}
is CustomTokenFormValidationException.EmptySymbol -> {
stateAcc // No need to display error
}
is CustomTokenFormValidationException.DataError -> {
Timber.e(exception.cause, "Unable to validate custom currency")
stateAcc
}
}
}
}
}
private fun getInitialTokenForm(): CustomTokenFormUM.TokenFormUM {
val formValues = params.formValues
val form = CustomTokenFormUM.TokenFormUM(
contractAddress = TextInputFieldUM(
label = resourceReference(R.string.custom_token_contract_address_input_title),
placeholder = stringReference(CONTRACT_ADDRESS_PLACEHOLDER),
onValueChange = ::updateContractAddress,
),
name = TextInputFieldUM(
label = resourceReference(R.string.custom_token_name_input_title),
placeholder = resourceReference(R.string.custom_token_name_input_placeholder),
onValueChange = ::updateTokenName,
),
symbol = TextInputFieldUM(
label = resourceReference(R.string.custom_token_token_symbol_input_title),
placeholder = resourceReference(R.string.custom_token_token_symbol_input_placeholder),
onValueChange = ::updateTokenSymbol,
),
decimals = TextInputFieldUM(
label = resourceReference(R.string.custom_token_decimals_input_title),
placeholder = stringReference(DECIMALS_PLACEHOLDER),
onValueChange = ::updateDecimals,
),
)
return formValues.fillValues(form)
}
private fun getDerivationPath(): Network.DerivationPath {
return params.derivationPath?.value ?: params.network.derivationPath
}
private fun updateContractAddress(value: String) {
state.update { state ->
state.updateTokenForm {
copy(
contractAddress = contractAddress.updateValue(value),
wasFilled = false,
)
}
}
}
private fun updateTokenName(value: String) {
state.update { state ->
state.updateTokenForm {
copy(
name = name.updateValue(value),
wasFilled = false,
)
}
}
}
private fun updateTokenSymbol(value: String) {
state.update { state ->
state.updateTokenForm {
copy(
symbol = symbol.updateValue(value),
wasFilled = false,
)
}
}
}
private fun updateDecimals(value: String) {
state.update { state ->
state.updateTokenForm {
copy(
decimals = decimals.updateValue(value),
wasFilled = false,
)
}
}
}
private fun addCurrency() = resource(
acquire = {
state.update { state ->
state.updateWithProgress(showProgress = true)
}
},
release = {
state.update { state ->
state.updateWithProgress(showProgress = false)
}
},
) {
val currency = createdCurrency
if (currency == null) {
Timber.e("Trying to add currency without validation")
showErrorDialog()
return@resource
}
derivePublicKeysUseCase(params.userWalletId, listOf(currency)).getOrElse {
Timber.e(it, "Failed to derive public keys")
showErrorDialog()
return@resource
}
addCryptoCurrenciesUseCase(params.userWalletId, currency).getOrElse {
Timber.e(it, "Failed to add currency")
showErrorDialog()
return@resource
}
params.onCurrencyAdded()
}
private fun selectNetwork() {
params.onSelectNetworkClick(CustomTokenFormValues(state.value.tokenForm))
}
private fun selectDerivationPath() {
params.onSelectDerivationPathClick(CustomTokenFormValues(state.value.tokenForm))
}
private companion object {
const val CONTRACT_ADDRESS_PLACEHOLDER = "0x000000000000000000000000000..."
const val DECIMALS_PLACEHOLDER = "0"
}
}

View file

@ -0,0 +1,172 @@
package com.tangem.features.managetokens.model
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.GetSupportedNetworksUseCase
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.DerivationPathSelector
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.NetworkSelector
import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorDialogConfig
import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM
import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM.HeaderUM
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
import com.tangem.features.managetokens.entity.item.DerivationPathUM
import com.tangem.features.managetokens.entity.item.SelectableItemUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.mapper.toCurrencyNetworkModel
import com.tangem.features.managetokens.utils.mapper.toDerivationPathModel
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@ComponentScoped
internal class CustomTokenSelectorModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val getSupportedNetworksUseCase: GetSupportedNetworksUseCase,
private val messageSender: UiMessageSender,
paramsContainer: ParamsContainer,
) : Model() {
private val params: CustomTokenSelectorComponent.Params = paramsContainer.require()
val dialogNavigation: SlotNavigation<CustomTokenSelectorDialogConfig> = SlotNavigation()
val state: MutableStateFlow<CustomTokenSelectorUM> = MutableStateFlow(
value = getInitialState(),
)
init {
loadItems()
}
private fun getInitialState(): CustomTokenSelectorUM = when (params) {
is NetworkSelector -> CustomTokenSelectorUM(
header = if (params.selectedNetwork == null) {
HeaderUM.Description
} else {
HeaderUM.None
},
items = persistentListOf(),
)
is DerivationPathSelector -> CustomTokenSelectorUM(
header = HeaderUM.CustomDerivationButton(
value = (params.selectedDerivationPath?.value as? Network.DerivationPath.Custom)?.value,
onClick = ::showCustomDerivationInput,
),
items = persistentListOf(),
)
}
private fun loadItems() = modelScope.launch {
val items = when (params) {
is NetworkSelector -> loadNetworks(params).toImmutableList()
is DerivationPathSelector -> loadDerivationPaths(params).toImmutableList()
}
state.update { state ->
state.copy(items = items)
}
}
private suspend fun loadNetworks(selector: NetworkSelector): List<SelectableItemUM> {
return getSupportedNetworks(selector.userWalletId).map { network ->
network.toCurrencyNetworkModel(
isSelected = network.id == selector.selectedNetwork?.id,
onSelectedStateChange = {
val model = SelectedNetwork(
id = network.id,
name = stringReference(network.name),
derivationPath = network.derivationPath,
canHandleTokens = network.canHandleTokens,
)
selector.onNetworkSelected(model)
},
)
}
}
private suspend fun loadDerivationPaths(selector: DerivationPathSelector): List<SelectableItemUM> {
val derivationPaths = mutableListOf<DerivationPathUM>()
val defaultPath = selector.selectedNetwork.let { network ->
network.toDerivationPathModel(
isSelected = network.id == selector.selectedDerivationPath?.id,
onSelectedStateChange = {
val model = SelectedDerivationPath(
id = network.id,
networkName = resourceReference(R.string.custom_token_derivation_path_default),
value = network.derivationPath,
)
selector.onDerivationPathSelected(model)
},
)
}
if (defaultPath != null) {
derivationPaths.add(defaultPath)
}
getSupportedNetworks(selector.userWalletId)
.mapNotNullTo(derivationPaths) { network ->
if (network.id == selector.selectedNetwork.id) {
return@mapNotNullTo null // Skip default path
}
network.toDerivationPathModel(
isSelected = network.id == selector.selectedDerivationPath?.id,
onSelectedStateChange = {
val model = SelectedDerivationPath(
id = network.id,
networkName = stringReference(network.name),
value = network.derivationPath,
)
selector.onDerivationPathSelected(model)
},
)
}
return derivationPaths
}
private suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> {
return getSupportedNetworksUseCase(userWalletId).getOrElse { e ->
val message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error))
messageSender.send(message)
emptyList()
}
}
private fun showCustomDerivationInput() {
val config = when (params) {
is NetworkSelector -> return
is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput(params.userWalletId)
}
dialogNavigation.activate(config)
}
fun selectCustomDerivationPath(value: SelectedDerivationPath) {
when (params) {
is NetworkSelector -> return
is DerivationPathSelector -> params.onDerivationPathSelected(value)
}
}
}

View file

@ -1,53 +1,97 @@
package com.tangem.features.managetokens.model
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.util.fastForEachIndexed
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.Network
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.SaveManagedTokensUseCase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.entity.*
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig
import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.list.ChangedCurrencies
import com.tangem.features.managetokens.utils.list.ManageTokensListManager
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ComponentScoped
internal class ManageTokensModel @Inject constructor(
paramsContainer: ParamsContainer,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val manageTokensListManager: ManageTokensListManager,
private val messageSender: UiMessageSender,
private val saveManagedTokensUseCase: SaveManagedTokensUseCase,
paramsContainer: ParamsContainer,
) : Model() {
private val params: ManageTokensComponent.Params = paramsContainer.require()
private val changedItemsIds: MutableSet<String> = mutableSetOf()
private var items = initItems()
val state: MutableStateFlow<ManageTokensUM> = MutableStateFlow(value = getInitialState(mode = params.mode))
val state: MutableStateFlow<ManageTokensUM> = MutableStateFlow(getInitialState(params.userWalletId))
val bottomSheetNavigation: SlotNavigation<ManageTokensBottomSheetConfig> = SlotNavigation()
private fun getInitialState(mode: ManageTokensComponent.Mode): ManageTokensUM {
return when (mode) {
ManageTokensComponent.Mode.READ_ONLY -> createReadContentModel()
ManageTokensComponent.Mode.MANAGE -> createManageContentModel()
init {
manageTokensListManager.uiItems
.onEach { items -> updateItems(items) }
.launchIn(modelScope)
manageTokensListManager.paginationStatus
.onEach { status -> updatePaginationStatus(status) }
.launchIn(modelScope)
combine(
manageTokensListManager.currenciesToAdd,
manageTokensListManager.currenciesToRemove,
::updateChangedItems,
).launchIn(modelScope)
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(params.userWalletId)
}
}
fun reloadList() {
modelScope.launch {
manageTokensListManager.reload(params.userWalletId)
}
}
private fun getInitialState(userWalletId: UserWalletId?): ManageTokensUM {
return if (userWalletId == null) {
createReadContentModel()
} else {
createManageContentModel()
}
}
private fun createReadContentModel(): ManageTokensUM.ReadContent {
return ManageTokensUM.ReadContent(
popBack = router::pop,
isLoading = false,
items = initItems(),
isInitialBatchLoading = true,
isNextBatchLoading = false,
items = getLoadingItems(),
topBar = ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_search_tokens),
onBackButtonClick = router::pop,
@ -59,20 +103,22 @@ internal class ManageTokensModel @Inject constructor(
isActive = false,
onActiveChange = ::toggleSearchBar,
),
loadMore = ::loadMoreItems,
)
}
private fun createManageContentModel(): ManageTokensUM.ManageContent {
return ManageTokensUM.ManageContent(
popBack = router::pop,
isLoading = false,
items = initItems(),
isInitialBatchLoading = true,
isNextBatchLoading = false,
items = getLoadingItems(),
topBar = ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = router::pop,
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = ::onAddCustomToken,
onIconClicked = ::navigateToAddCustomToken,
),
),
search = SearchBarUM(
@ -82,161 +128,179 @@ internal class ManageTokensModel @Inject constructor(
isActive = false,
onActiveChange = ::toggleSearchBar,
),
onSaveClick = ::onSaveClick,
hasChanges = false,
saveChanges = ::saveChanges,
loadMore = ::loadMoreItems,
isSavingInProgress = false,
)
}
private fun onAddCustomToken() {
// TODO: [REDACTED_JIRA]
@OptIn(FlowPreview::class)
private fun observeSearchQueryChanges() {
state
.distinctUntilChanged { old, new ->
// It's also used to skip search activation to avoid searching an empty query
old.search.query == new.search.query &&
(old.search.isActive == new.search.isActive || new.search.isActive)
}
.transform { state ->
val query = state.search.query
if (state.search.isActive) {
emit(query)
}
}
.sample(periodMillis = 1_000)
.onEach { query ->
manageTokensListManager.search(
userWalletId = params.userWalletId,
query = query,
)
}
.launchIn(modelScope)
}
private fun onSaveClick() {
// TODO: [REDACTED_JIRA]
}
@Suppress("UnusedPrivateMember")
private fun searchCurrencies(query: String) {
// TODO: [REDACTED_JIRA]
val newItems = if (query.isBlank()) {
initItems()
} else {
state.value.items.filter { currency ->
currency.model.name.contains(query, ignoreCase = true)
}.toPersistentList()
}
private fun updateItems(items: ImmutableList<CurrencyItemUM>) {
state.update { state ->
state.copySealed(search = state.search.copy(query = query), items = newItems)
state.copySealed(
items = items,
)
}
}
private fun updatePaginationStatus(status: PaginationStatus<*>) {
state.update { state ->
when (status) {
is PaginationStatus.None,
is PaginationStatus.InitialLoading,
-> {
if (state.search.isActive) {
state.copySealed(
items = getLoadingItems(),
)
} else {
state.copySealed(
items = getLoadingItems(),
isInitialBatchLoading = true,
)
}
}
is PaginationStatus.NextBatchLoading -> state.copySealed(
isNextBatchLoading = true,
)
is PaginationStatus.InitialLoadingError -> {
val message = SnackbarMessage(
message = status.throwable.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
state.copySealed(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
is PaginationStatus.Paginating -> {
(status.lastResult as? BatchFetchResult.Error)?.let { fetchError ->
Timber.e(fetchError.throwable)
}
state.copySealed(
isInitialBatchLoading = false,
isNextBatchLoading = false,
scrollToTop = if (state.isInitialBatchLoading && state.items.isNotEmpty()) {
triggeredEvent(
data = Unit,
onConsume = ::consumeScrollToTopEvent,
)
} else {
state.scrollToTop
},
)
}
is PaginationStatus.EndOfPagination -> state.copySealed(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
}
}
private fun getLoadingItems(): ImmutableList<CurrencyItemUM> {
return List(size = 10) { index ->
CurrencyItemUM.Loading(index)
}.toPersistentList()
}
private fun consumeScrollToTopEvent() {
this.state.update { state ->
state.copySealed(
scrollToTop = consumedEvent(),
)
}
}
private fun updateChangedItems(currenciesToAdd: ChangedCurrencies, currenciesToRemove: ChangedCurrencies) {
state.update { state ->
state.copySealed(
hasChanges = currenciesToAdd.isNotEmpty() || currenciesToRemove.isNotEmpty(),
)
}
}
private fun loadMoreItems(): Boolean {
val state = state.value
if (state.isInitialBatchLoading || state.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(
userWalletId = params.userWalletId,
query = state.search.query,
)
}
return true
}
private fun navigateToAddCustomToken() {
params.userWalletId?.let {
bottomSheetNavigation.activate(ManageTokensBottomSheetConfig.AddCustomToken(it))
}
}
private fun saveChanges() {
modelScope.launch {
state.update { state -> state.copySealed(isSavingInProgress = true) }
saveManagedTokensUseCase.invoke(
userWalletId = requireNotNull(params.userWalletId),
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).fold(
ifLeft = { Timber.e(it, "Failed to save changes") },
ifRight = { router.pop() },
)
state.update { state -> state.copySealed(isSavingInProgress = false) }
}
}
private fun searchCurrencies(query: String) {
state.update { state ->
state.copySealed(
search = state.search.copy(
query = query,
isActive = true,
),
)
}
}
private fun toggleSearchBar(isActive: Boolean) {
state.update { state ->
state.copySealed(
search = state.search.copy(isActive = isActive),
)
}
}
private fun initItems() = List(size = 30) { index ->
if (index < 2) {
getCustomItem(index)
} else {
getBasicItem(index)
}
}.toPersistentList()
private fun getCustomItem(index: Int) = CurrencyItemUM.Custom(
id = index.toString(),
model = ChainRowUM(
name = "Custom token $index",
type = "CT$index",
icon = CurrencyIconState.CustomTokenIcon(
tint = Color.White,
background = Color.Black,
topBadgeIconResId = R.drawable.img_eth_22,
isGrayscale = false,
showCustomBadge = true,
),
showCustom = true,
),
onRemoveClick = {},
)
private fun getBasicItem(index: Int) = CurrencyItemUM.Basic(
id = index.toString(),
model = ChainRowUM(
name = "Currency $index",
type = "C$index",
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_btc_22,
isGrayscale = false,
showCustomBadge = false,
),
showCustom = false,
),
networks = if (index == 2) {
CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index))
} else {
CurrencyItemUM.Basic.NetworksUM.Collapsed
},
onExpandClick = { toggleCurrency(index) },
)
private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex ->
CurrencyNetworkUM(
id = Network.ID(networkIndex.toString()),
name = "NETWORK$networkIndex",
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = networkIndex == 0,
isSelected = false,
onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) },
)
}.toImmutableList()
private fun toggleCurrency(index: Int) {
val updatedItem = when (val item = items[index]) {
is CurrencyItemUM.Basic -> item.copy(
networks = if (item.networks is CurrencyItemUM.Basic.NetworksUM.Collapsed) {
CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index))
} else {
CurrencyItemUM.Basic.NetworksUM.Collapsed
},
)
is CurrencyItemUM.Custom -> return
}
state.update { state ->
items = items.mutate {
it[index] = updatedItem
}
state.copySealed(items = items)
}
}
private fun toggleNetwork(currencyIndex: Int, networkIndex: Int, isSelected: Boolean) {
val updatedItem = when (val item = items[currencyIndex]) {
is CurrencyItemUM.Basic -> {
val updatedNetworks = (item.networks as? CurrencyItemUM.Basic.NetworksUM.Expanded)
?.copy(
networks = item.networks.networks.toPersistentList().mutate {
it.fastForEachIndexed { index, network ->
if (index == networkIndex) {
it[index] = network.copy(
iconResId = if (isSelected) {
R.drawable.img_eth_22
} else {
R.drawable.ic_eth_16
},
isSelected = isSelected,
)
}
}
},
)
?: return
item.copy(networks = updatedNetworks)
}
is CurrencyItemUM.Custom -> return
}
val id = "${currencyIndex}_$networkIndex"
if (changedItemsIds.contains(id)) {
changedItemsIds.remove(id)
} else {
changedItemsIds.add(id)
}
state.update { state ->
items = items.mutate {
it[currencyIndex] = updatedItem
}
state.copySealed(
items = items,
hasChanges = changedItemsIds.isNotEmpty(),
search = state.search.copy(
isActive = isActive,
),
)
}
}

View file

@ -1,146 +1,80 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle
import com.tangem.core.ui.components.isOpened
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.preview.PreviewAddCustomTokenComponent
import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM
import com.tangem.features.managetokens.entity.AddCustomTokenUM
import com.tangem.features.managetokens.entity.SelectedNetworkUM
import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
import com.tangem.features.managetokens.impl.R
@Composable
internal fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig, content: LazyListScope.() -> Unit) {
TangemBottomSheet<AddCustomTokenUM>(
internal fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig, content: @Composable (Modifier) -> Unit) {
TangemBottomSheet<AddCustomTokenConfig>(
config = config,
addBottomInsets = false,
title = { model ->
Title(model)
},
containerColor = TangemTheme.colors.background.secondary,
content = { model ->
Content(
model = model,
content = content,
)
content = {
val contentModifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxSize()
content(contentModifier)
},
)
}
@Composable
private fun Title(model: AddCustomTokenUM, modifier: Modifier = Modifier) {
val showTokenNetworkTitle = model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null
if (showTokenNetworkTitle) {
TangemTopAppBar(
modifier = modifier,
title = resourceReference(R.string.custom_token_network_selector_title),
titleAlignment = Alignment.CenterHorizontally,
startButton = TopAppBarButtonUM.Back(model.popBack),
height = TangemTopAppBarHeight.BOTTOM_SHEET,
)
} else {
TangemBottomSheetTitle(
modifier = modifier,
title = resourceReference(R.string.add_custom_token_title),
)
}
}
@Composable
private fun Content(model: AddCustomTokenUM, content: LazyListScope.() -> Unit, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val keyboardState by keyboardAsState()
var fabHeight by remember { mutableStateOf(0.dp) }
Scaffold(
modifier = modifier.imePadding(),
containerColor = TangemTheme.colors.background.secondary,
floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
AnimatedVisibility(
modifier = Modifier.onSizeChanged {
fabHeight = with(density) { it.height.toDp() }
},
visible = model.addTokenButton.isVisible && !keyboardState.isOpened,
enter = fadeIn(),
exit = fadeOut(),
label = "Add button visibility",
) {
PrimaryButton(
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing16)
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResource(id = R.string.custom_token_add_token),
enabled = model.addTokenButton.isEnabled,
onClick = model.addTokenButton.onClick,
)
}
},
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues),
contentPadding = PaddingValues(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing32 + fabHeight,
),
) {
item {
if (model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null) {
Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing12))
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = TangemTheme.dimens.spacing16),
contentAlignment = Alignment.Center,
) {
Text(
modifier = Modifier.fillMaxWidth(fraction = 0.7f),
text = stringResource(id = R.string.custom_token_subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
}
}
content()
private fun Title(model: AddCustomTokenConfig, modifier: Modifier = Modifier) {
when (model.step) {
AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR,
AddCustomTokenConfig.Step.FORM,
-> {
TangemBottomSheetTitle(
modifier = modifier,
title = resourceReference(R.string.add_custom_token_title),
)
}
AddCustomTokenConfig.Step.NETWORK_SELECTOR -> {
TangemTopAppBar(
modifier = modifier,
title = resourceReference(R.string.custom_token_network_selector_title),
titleAlignment = Alignment.CenterHorizontally,
startButton = TopAppBarButtonUM.Back(model.popBack),
height = TangemTopAppBarHeight.BOTTOM_SHEET,
)
}
AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR -> {
TangemTopAppBar(
modifier = modifier,
title = resourceReference(R.string.custom_token_derivation_path),
titleAlignment = Alignment.CenterHorizontally,
startButton = TopAppBarButtonUM.Back(model.popBack),
height = TangemTopAppBarHeight.BOTTOM_SHEET,
)
}
}
}
@ -153,7 +87,7 @@ private fun Preview_AddCustomTokenBottomSheet(
@PreviewParameter(AddCustomTokenComponentPreviewProvider::class) component: AddCustomTokenComponent,
) {
TangemThemePreview {
component.BottomSheet(isVisible = true, onDismiss = {})
component.BottomSheet()
}
}
@ -162,24 +96,40 @@ private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider<
get() = sequenceOf(
PreviewAddCustomTokenComponent(),
PreviewAddCustomTokenComponent(
initialState = AddCustomTokenUM.NetworkSelector(
initialState = AddCustomTokenConfig(
userWalletId = UserWalletId(stringValue = "321"),
step = AddCustomTokenConfig.Step.FORM,
popBack = {},
selectedNetwork = SelectedNetworkUM(
id = Network.ID(value = "0"),
name = "Ethereum",
selectedNetwork = SelectedNetwork(
id = Network.ID(value = "1"),
name = stringReference("Ethereum"),
derivationPath = Network.DerivationPath.None,
canHandleTokens = false,
),
),
),
PreviewAddCustomTokenComponent(
initialState = AddCustomTokenUM.Form(
initialState = AddCustomTokenConfig(
userWalletId = UserWalletId(stringValue = "321"),
step = AddCustomTokenConfig.Step.NETWORK_SELECTOR,
popBack = {},
selectedNetwork = SelectedNetworkUM(
id = Network.ID(value = "1"),
name = "Ethereum",
selectedNetwork = SelectedNetwork(
id = Network.ID(value = "0"),
name = stringReference("Ethereum"),
derivationPath = Network.DerivationPath.None,
canHandleTokens = false,
),
addTokenButton = AddCustomTokenButtonUM.Visible(
isEnabled = false,
onClick = {},
),
),
PreviewAddCustomTokenComponent(
initialState = AddCustomTokenConfig(
userWalletId = UserWalletId(stringValue = "321"),
step = AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR,
popBack = {},
selectedDerivationPath = SelectedDerivationPath(
id = Network.ID(value = "0"),
value = Network.DerivationPath.None,
networkName = stringReference("Ethereum"),
),
),
),

View file

@ -2,97 +2,153 @@ package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.components.isOpened
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.managetokens.component.preview.PreviewCustomTokenFormComponent
import com.tangem.features.managetokens.entity.ClickableFieldUM
import com.tangem.features.managetokens.entity.CustomTokenFormUM
import com.tangem.features.managetokens.entity.TextInputFieldUM
import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM
import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.component.AddCustomTokenDescription
internal fun LazyListScope.customTokenFormContent(model: CustomTokenFormUM) {
item {
ClickableField(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
model = model.networkName,
)
}
@Composable
internal fun CustomTokenFormContent(model: CustomTokenFormUM, modifier: Modifier = Modifier) {
val keyboard by keyboardAsState()
val bottomBarHeight by animateDpAsState(
label = "Bottom bar height",
targetValue = if (keyboard.isOpened) {
TangemTheme.dimens.spacing0
} else {
with(LocalDensity.current) {
WindowInsets.systemBars.getBottom(density = this).toDp()
}
},
)
Box(
modifier = modifier
.imePadding()
.fillMaxSize()
.background(color = TangemTheme.colors.background.secondary),
) {
val scrollState = rememberScrollState()
item {
Column(
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing12)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
.verticalScroll(scrollState)
.fillMaxSize()
.padding(bottom = TangemTheme.dimens.spacing76),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
TextField(
model = model.contractAddress,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
),
)
TextField(
model = model.tokenName,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
),
)
TextField(
model = model.tokenSymbol,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
),
)
TextField(
model = model.tokenDecimals,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next,
),
AddCustomTokenDescription()
FormContent(model)
}
PrimaryButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = TangemTheme.dimens.spacing16 + bottomBarHeight)
.fillMaxWidth(),
text = stringResource(id = R.string.custom_token_add_token),
enabled = model.canAddToken,
showProgress = model.isValidating,
onClick = model.saveToken,
)
}
}
@Composable
private fun FormContent(model: CustomTokenFormUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
ClickableField(
model = model.networkName,
)
val tokenForm = model.tokenForm
if (tokenForm != null) {
TokenForm(tokenForm)
}
ClickableField(
model = model.derivationPath,
)
model.notifications.fastForEach { notification ->
Notification(
config = notification.config,
containerColor = TangemTheme.colors.button.disabled,
)
}
}
}
item {
ClickableField(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
model = model.derivationPath,
@Composable
private fun TokenForm(tokenForm: CustomTokenFormUM.TokenFormUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
) {
TextField(
model = tokenForm.contractAddress,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
),
)
}
items(
items = model.notifications,
key = { it.id },
) { notification ->
Notification(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
config = notification.config,
containerColor = TangemTheme.colors.button.disabled,
TextField(
model = tokenForm.name,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
),
)
TextField(
model = tokenForm.symbol,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
),
)
TextField(
model = tokenForm.decimals,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next,
),
)
}
}
@ -104,14 +160,25 @@ private fun TextField(
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
var isFocused by remember { mutableStateOf(value = false) }
InformationBlock(
modifier = modifier,
title = {
val color by animateColorAsState(
targetValue = if (model.error != null) {
TangemTheme.colors.text.warning
} else {
TangemTheme.colors.text.tertiary
targetValue = when {
!model.isEnabled -> {
TangemTheme.colors.text.disabled
}
model.error != null -> {
TangemTheme.colors.text.warning
}
model.value.isNotBlank() || isFocused -> {
TangemTheme.colors.text.tertiary
}
else -> {
TangemTheme.colors.text.disabled
}
},
label = "Field label color",
)
@ -123,12 +190,27 @@ private fun TextField(
)
},
content = {
val color by animateColorAsState(
targetValue = if (model.isEnabled) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.disabled
},
label = "Field value color",
)
SimpleTextField(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing12)
.fillMaxWidth()
.onFocusChanged {
isFocused = it.isFocused
},
value = model.value,
color = color,
onValueChange = model.onValueChange,
readOnly = false,
placeholder = model.placeholder,
readOnly = !model.isEnabled && !isFocused,
singleLine = true,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
@ -170,9 +252,7 @@ private fun Preview_CustomTokenFormContent(
component: PreviewCustomTokenFormComponent,
) {
TangemThemePreview {
LazyColumn(
modifier = Modifier.background(color = TangemTheme.colors.background.secondary),
) { component.content(scope = this) }
component.Content(modifier = Modifier)
}
}
@ -181,16 +261,30 @@ private class PreviewCustomTokenFormComponentProvider :
override val values: Sequence<PreviewCustomTokenFormComponent>
get() = sequenceOf(
PreviewCustomTokenFormComponent(),
PreviewCustomTokenFormComponent(
contractAddress = TextInputFieldUM(
label = stringReference("Contract address"),
value = "0x1234567890",
error = stringReference("Contract address is invalid"),
placeholder = stringReference("0x1234567890"),
onValueChange = {},
tokenForm = PreviewCustomTokenFormComponent.tokenForm.copy(
contractAddress = TextInputFieldUM(
label = stringReference("Contract address"),
value = "0x1234567890",
placeholder = stringReference("0x1234567890"),
onValueChange = {},
),
),
),
PreviewCustomTokenFormComponent(
tokenForm = PreviewCustomTokenFormComponent.tokenForm.copy(
contractAddress = TextInputFieldUM(
label = stringReference("Contract address"),
value = "0x1234567890",
error = stringReference("Contract address is invalid"),
placeholder = stringReference("0x1234567890"),
onValueChange = {},
),
),
),
PreviewCustomTokenFormComponent(
tokenForm = null,
),
)
}
// endregion Preview

View file

@ -1,158 +0,0 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.rows.ChainRow
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent
import com.tangem.features.managetokens.component.preview.PreviewCustomTokenNetworkSelectorComponent
import com.tangem.features.managetokens.entity.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM
import com.tangem.features.managetokens.entity.SelectedNetworkUM
import com.tangem.features.managetokens.impl.R
internal fun LazyListScope.customTokenNetworkSelectorContent(model: CustomTokenNetworkSelectorUM) {
val lastIndex = model.networks.lastIndex
if (model.showTitle) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size36)
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.bottomSheet,
),
) {
Text(
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing6,
)
.padding(horizontal = TangemTheme.dimens.spacing12),
text = stringResource(R.string.add_custom_token_choose_network),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
itemsIndexed(
items = model.networks,
key = { _, item -> item.id.value },
) { index, item ->
NetworkItem(
modifier = Modifier
.fillMaxWidth()
.clip(
shape = when {
!model.showTitle && index == 0 -> RoundedCornerShape(
topStart = TangemTheme.dimens.radius16,
topEnd = TangemTheme.dimens.radius16,
)
index == lastIndex -> RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius16,
bottomEnd = TangemTheme.dimens.radius16,
)
else -> RectangleShape
},
)
.background(color = TangemTheme.colors.background.primary)
.clickable(onClick = { item.onSelectedStateChange(true) })
.padding(horizontal = TangemTheme.dimens.spacing4),
model = item,
)
}
}
@Composable
private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier) {
ChainRow(
modifier = modifier,
model = with(model) {
ChainRowUM(
name = name,
type = type,
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = model.iconResId,
isGrayscale = false,
showCustomBadge = false,
),
showCustom = false,
)
},
action = {
AnimatedVisibility(
modifier = Modifier.size(TangemTheme.dimens.size24),
visible = model.isSelected,
) {
Icon(
painter = painterResource(id = R.drawable.ic_check_24),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
}
},
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_CustomTokenNetworkSelectorContent(
@PreviewParameter(CustomTokenNetworkSelectorComponentPreviewProvider::class)
component: CustomTokenNetworkSelectorComponent,
) {
TangemThemePreview {
LazyColumn {
component.content(this)
}
}
}
private class CustomTokenNetworkSelectorComponentPreviewProvider :
PreviewParameterProvider<CustomTokenNetworkSelectorComponent> {
override val values: Sequence<CustomTokenNetworkSelectorComponent>
get() = sequenceOf(
PreviewCustomTokenNetworkSelectorComponent(),
PreviewCustomTokenNetworkSelectorComponent(
params = CustomTokenNetworkSelectorComponent.Params(
userWalletId = UserWalletId(stringValue = "321"),
selectedNetwork = SelectedNetworkUM(
id = Network.ID(value = "0"),
name = "",
),
onNetworkSelected = {},
),
),
)
}
// endregion Preview

View file

@ -0,0 +1,288 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.block.information.InformationBlock
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.rows.ChainRow
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.component.preview.PreviewCustomTokenSelectorComponent
import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.item.DerivationPathUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.component.AddCustomTokenDescription
@Composable
internal fun CustomTokenSelectorContent(model: CustomTokenSelectorUM, modifier: Modifier = Modifier) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
val lastIndex = model.items.lastIndex
LazyColumn(
modifier = modifier.background(
color = TangemTheme.colors.background.secondary,
),
contentPadding = PaddingValues(
bottom = TangemTheme.dimens.spacing16 + bottomBarHeight,
),
) {
item {
Header(model.header)
}
itemsIndexed(
items = model.items,
key = { _, item -> item.id },
) { index, item ->
val itemModifier = Modifier
.fillMaxWidth()
.clip(
shape = when {
model.header !is CustomTokenSelectorUM.HeaderUM.Description && index == 0 -> {
RoundedCornerShape(
topStart = TangemTheme.dimens.radius16,
topEnd = TangemTheme.dimens.radius16,
)
}
index == lastIndex -> {
RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius16,
bottomEnd = TangemTheme.dimens.radius16,
)
}
else -> {
RectangleShape
}
},
)
.background(color = TangemTheme.colors.background.primary)
.clickable(onClick = { item.onSelectedStateChange(true) })
.padding(horizontal = TangemTheme.dimens.spacing4)
when (item) {
is CurrencyNetworkUM -> {
NetworkItem(
modifier = itemModifier,
model = item,
)
}
is DerivationPathUM -> {
DerivationPathItem(
modifier = itemModifier,
model = item,
)
}
}
}
}
}
@Composable
private fun Header(header: CustomTokenSelectorUM.HeaderUM, modifier: Modifier = Modifier) {
when (header) {
is CustomTokenSelectorUM.HeaderUM.CustomDerivationButton -> {
CustomDerivationButton(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing16),
enteredDerivationPath = header.value,
isSelected = header.value != null,
onClick = header.onClick,
)
}
is CustomTokenSelectorUM.HeaderUM.Description -> {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AddCustomTokenDescription()
Box(
modifier = modifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size36)
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.bottomSheet,
),
) {
Text(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12)
.padding(horizontal = TangemTheme.dimens.spacing12),
text = stringResource(R.string.add_custom_token_choose_network),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
is CustomTokenSelectorUM.HeaderUM.None -> {
Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing16))
}
}
}
@Composable
private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier) {
ChainRow(
modifier = modifier,
model = with(model) {
ChainRowUM(
name = name,
type = type,
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = model.iconResId,
isGrayscale = false,
showCustomBadge = false,
),
showCustom = false,
)
},
action = {
SelectedIcon(isVisible = model.isSelected)
},
)
}
@Composable
private fun CustomDerivationButton(
enteredDerivationPath: String?,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
InformationBlock(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable(onClick = onClick),
title = {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
Text(
text = stringResource(id = R.string.custom_token_custom_derivation),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
)
Text(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
text = enteredDerivationPath ?: stringResource(id = R.string.custom_token_custom_derivation_title),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
},
action = {
SelectedIcon(isVisible = isSelected)
},
)
}
@Composable
private fun DerivationPathItem(model: DerivationPathUM, modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
shape = RectangleShape,
title = {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
Text(
text = model.networkName.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
)
Text(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
text = model.value,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
},
action = {
SelectedIcon(isVisible = model.isSelected)
},
)
}
@Composable
private fun SelectedIcon(isVisible: Boolean, modifier: Modifier = Modifier) {
AnimatedVisibility(
modifier = modifier.size(TangemTheme.dimens.size24),
visible = isVisible,
) {
Icon(
painter = painterResource(id = R.drawable.ic_check_24),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_CustomTokenNetworkSelectorContent(
@PreviewParameter(CustomTokenNetworkSelectorComponentPreviewProvider::class)
component: CustomTokenSelectorComponent,
) {
TangemThemePreview {
component.Content(modifier = Modifier)
}
}
private class CustomTokenNetworkSelectorComponentPreviewProvider :
PreviewParameterProvider<CustomTokenSelectorComponent> {
override val values: Sequence<CustomTokenSelectorComponent>
get() = sequenceOf(
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.DerivationPathSelector(
userWalletId = UserWalletId(stringValue = "321"),
selectedNetwork = SelectedNetwork(
id = Network.ID(value = "0"),
name = stringReference("Ethereum"),
derivationPath = Network.DerivationPath.Card("m/44'/0'/0'/0/0"),
canHandleTokens = true,
),
selectedDerivationPath = SelectedDerivationPath(
id = Network.ID(value = "0"),
value = Network.DerivationPath.Card("m/44'/0'/0'/0/0"),
networkName = stringReference(""),
),
onDerivationPathSelected = {},
),
),
)
}
// endregion Preview

View file

@ -1,15 +1,18 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
@ -20,49 +23,80 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.rows.ArrowRow
import com.tangem.core.ui.components.rows.BlockchainRow
import com.tangem.core.ui.components.rows.ChainRow
import com.tangem.core.ui.components.rows.ChainRowContainer
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent
import com.tangem.features.managetokens.entity.CurrencyItemUM
import com.tangem.features.managetokens.entity.CurrencyItemUM.Basic.NetworksUM
import com.tangem.features.managetokens.entity.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.ManageTokensUM
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM
import com.tangem.features.managetokens.impl.R
import kotlinx.collections.immutable.ImmutableList
private const val CHEVRON_ROTATION_EXPANDED = 180f
private const val CHEVRON_ROTATION_COLLAPSED = 0f
private const val LOAD_ITEMS_BUFFER = 10
@Composable
internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.popBack)
val keyboardController = LocalSoftwareKeyboardController.current
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
keyboardController?.hide()
return super.onPreScroll(available, source)
}
}
}
Scaffold(
modifier = modifier,
modifier = modifier.nestedScroll(nestedScrollConnection),
containerColor = TangemTheme.colors.background.primary,
contentWindowInsets = WindowInsetsZero,
topBar = {
ManageTokensTopBar(
modifier = Modifier.statusBarsPadding(),
topBar = state.topBar,
search = state.search,
)
},
content = { innerPadding ->
@ -70,10 +104,13 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
search = state.search,
items = state.items,
isLoading = state.isLoading,
hasChanges = state is ManageTokensUM.ManageContent && state.hasChanges,
state = state,
)
},
snackbarHost = {
TangemSnackbarHost(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
hostState = LocalSnackbarHostState.current,
)
},
floatingActionButtonPosition = FabPosition.Center,
@ -81,10 +118,12 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi
if (state is ManageTokensUM.ManageContent) {
SaveChangesButton(
modifier = Modifier
.navigationBarsPadding()
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
isVisible = state.hasChanges,
onClick = state.onSaveClick,
showProgress = state.isSavingInProgress,
onClick = state.saveChanges,
)
}
},
@ -92,20 +131,35 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi
}
@Composable
private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, modifier: Modifier = Modifier) {
TangemTopAppBar(
modifier = modifier,
title = topBar.title.resolveReference(),
startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick),
endButton = when (topBar) {
is ManageTokensTopBarUM.ManageContent -> topBar.endButton
is ManageTokensTopBarUM.ReadContent -> null
},
)
private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, search: SearchBarUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.background(TangemTheme.colors.background.primary),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
TangemTopAppBar(
title = topBar.title.resolveReference(),
startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick),
endButton = when (topBar) {
is ManageTokensTopBarUM.ManageContent -> topBar.endButton
is ManageTokensTopBarUM.ReadContent -> null
},
)
SearchBar(
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing12)
.padding(horizontal = TangemTheme.dimens.spacing16),
state = search,
)
}
}
@Composable
private fun SaveChangesButton(isVisible: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
private fun SaveChangesButton(
isVisible: Boolean,
showProgress: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
modifier = modifier,
visible = isVisible,
@ -116,86 +170,64 @@ private fun SaveChangesButton(isVisible: Boolean, onClick: () -> Unit, modifier:
PrimaryButtonIconEnd(
text = stringResource(id = R.string.common_save),
iconResId = R.drawable.ic_tangem_24,
showProgress = showProgress,
onClick = onClick,
)
}
}
@Composable
private fun LoadingContent() {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = TangemTheme.colors.background.primary),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = TangemTheme.colors.icon.accent)
}
}
private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
@Composable
private fun Content(
search: SearchBarUM,
items: ImmutableList<CurrencyItemUM>,
isLoading: Boolean,
hasChanges: Boolean,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
Currencies(
modifier = Modifier.fillMaxSize(),
items = items,
search = search,
listState = listState,
items = state.items,
showLoadingItem = state.isNextBatchLoading,
onLoadMore = state.loadMore,
isEditable = state is ManageTokensUM.ManageContent,
)
AnimatedVisibility(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
visible = hasChanges,
label = "bottom_fade_visibility",
) {
BottomFade()
}
BottomFade(modifier = Modifier.align(Alignment.BottomCenter))
}
Crossfade(targetState = isLoading, label = "ManageTokensLoadingContent") {
if (it) {
LoadingContent()
}
EventEffect(event = state.scrollToTop) {
listState.animateScrollToItem(index = 0)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Currencies(items: ImmutableList<CurrencyItemUM>, search: SearchBarUM, modifier: Modifier = Modifier) {
private fun Currencies(
listState: LazyListState,
items: ImmutableList<CurrencyItemUM>,
showLoadingItem: Boolean,
isEditable: Boolean,
onLoadMore: () -> Boolean,
modifier: Modifier = Modifier,
) {
val bottomBarHeight = with(LocalDensity.current) {
WindowInsets.systemBars.getBottom(density = this).toDp()
}
LazyColumn(
modifier = modifier,
state = listState,
contentPadding = PaddingValues(
bottom = TangemTheme.dimens.spacing76 + bottomBarHeight,
),
) {
stickyHeader(key = "search") {
Column(
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.padding(
top = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing12,
)
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
) {
SearchBar(state = search)
}
}
items(
items = items,
key = CurrencyItemUM::id,
key = { it.id.value },
) { item ->
when (item) {
is CurrencyItemUM.Basic -> {
BasicCurrencyItem(
modifier = Modifier.fillMaxWidth(),
item = item,
isEditable = isEditable,
)
}
is CurrencyItemUM.Custom -> {
@ -204,16 +236,78 @@ private fun Currencies(items: ImmutableList<CurrencyItemUM>, search: SearchBarUM
item = item,
)
}
is CurrencyItemUM.Loading -> {
LoadingItem(
modifier = Modifier.fillMaxWidth(),
)
}
}
}
if (showLoadingItem) {
item(key = "loading_item") {
ProgressIndicator(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
}
}
InfiniteListHandler(
listState = listState,
buffer = LOAD_ITEMS_BUFFER,
onLoadMore = onLoadMore,
)
}
@Composable
private fun ProgressIndicator(modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(color = TangemTheme.colors.background.primary),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = TangemTheme.colors.icon.informative)
}
}
@Composable
private fun LoadingItem(modifier: Modifier = Modifier) {
ChainRowContainer(
modifier = modifier,
icon = {
CurrencyIcon(CurrencyIconState.Loading)
},
text = {
TextShimmer(
modifier = Modifier.width(70.dp),
style = TangemTheme.typography.subtitle2,
)
},
action = {
RectangleShimmer(
modifier = Modifier.size(
width = 24.dp,
height = 16.dp,
),
)
},
)
}
@Composable
private fun CustomCurrencyItem(item: CurrencyItemUM.Custom, modifier: Modifier = Modifier) {
ChainRow(
modifier = modifier,
model = item.model,
model = with(item) {
ChainRowUM(
name = name,
type = symbol,
icon = icon,
showCustom = true,
)
},
action = {
SecondarySmallButton(
config = SmallButtonConfig(
@ -226,13 +320,20 @@ private fun CustomCurrencyItem(item: CurrencyItemUM.Custom, modifier: Modifier =
}
@Composable
private fun BasicCurrencyItem(item: CurrencyItemUM.Basic, modifier: Modifier = Modifier) {
private fun BasicCurrencyItem(item: CurrencyItemUM.Basic, isEditable: Boolean, modifier: Modifier = Modifier) {
val isExpanded = item.networks is NetworksUM.Expanded
Column(modifier = modifier) {
ChainRow(
modifier = Modifier.clickable(onClick = item.onExpandClick),
model = item.model,
model = with(item) {
ChainRowUM(
name = name,
type = symbol,
icon = icon,
showCustom = false,
)
},
action = {
val rotation by animateFloatAsState(
targetValue = if (isExpanded) {
@ -260,13 +361,22 @@ private fun BasicCurrencyItem(item: CurrencyItemUM.Basic, modifier: Modifier = M
end = TangemTheme.dimens.spacing8,
),
networks = item.networks,
currencyId = item.id,
currencyId = item.id.value,
isEditable = isEditable,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Modifier = Modifier) {
private fun NetworksList(
networks: NetworksUM,
currencyId: String,
isEditable: Boolean,
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
AnimatedVisibility(
modifier = modifier,
visible = networks is NetworksUM.Expanded,
@ -286,8 +396,17 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod
isLastItem = index == currentItems.lastIndex,
content = {
BlockchainRow(
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing8)
.combinedClickable(
onLongClick = network.onLongClick,
onClick = {},
indication = null,
interactionSource = remember { MutableInteractionSource() },
),
model = with(network) {
BlockchainRowUM(
id = id,
name = name,
type = type,
iconResId = iconResId,
@ -296,10 +415,19 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod
)
},
action = {
TangemSwitch(
checked = network.isSelected,
onCheckedChange = network.onSelectedStateChange,
)
if (isEditable) {
TangemSwitch(
checked = network.isSelected,
onCheckedChange = { checked ->
if (checked) {
hapticManager.perform(TangemHapticEffect.View.ToggleOn)
} else {
hapticManager.perform(TangemHapticEffect.View.ToggleOff)
}
network.onSelectedStateChange(checked)
},
)
}
},
)
},
@ -313,9 +441,19 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod
@Preview(showBackground = true, widthDp = 360, heightDp = 800)
@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ManageTokens() {
private fun Preview_ManageTokens(
@PreviewParameter(PreviewManageTokensComponentProvider::class) component: ManageTokensComponent,
) {
TangemThemePreview {
PreviewManageTokensComponent().Content(Modifier.fillMaxWidth())
component.Content(Modifier.fillMaxWidth())
}
}
private class PreviewManageTokensComponentProvider : PreviewParameterProvider<ManageTokensComponent> {
override val values: Sequence<ManageTokensComponent>
get() = sequenceOf(
PreviewManageTokensComponent(),
PreviewManageTokensComponent(isLoading = true),
)
}
// endregion Preview

View file

@ -0,0 +1,21 @@
package com.tangem.features.managetokens.ui.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
@Composable
internal fun AddCustomTokenDescription(modifier: Modifier = Modifier) {
Text(
modifier = modifier.fillMaxWidth(fraction = 0.7f),
text = stringResource(id = R.string.custom_token_subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.managetokens.ui.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.features.managetokens.impl.R
@Composable
internal fun CurrencyUnsupportedDialog(title: TextReference, message: TextReference, onDismiss: () -> Unit) {
BasicDialog(
title = title.resolveReference(),
message = message.resolveReference(),
confirmButton = DialogButtonUM(
title = stringResource(R.string.common_ok),
onClick = onDismiss,
),
onDismissDialog = onDismiss,
)
}

View file

@ -0,0 +1,71 @@
package com.tangem.features.managetokens.ui.dialog
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.res.stringResource
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.AdditionalTextInputDialogUM
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.components.TextInputDialog
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent
import com.tangem.features.managetokens.component.preview.PreviewCustomTokenDerivationInputComponent
import com.tangem.features.managetokens.entity.customtoken.CustomDerivationInputUM
import com.tangem.features.managetokens.impl.R
@Composable
internal fun CustomDerivationInputDialog(model: CustomDerivationInputUM, onDismiss: () -> Unit) {
val value by rememberUpdatedState(newValue = model.value)
TextInputDialog(
title = stringResource(id = R.string.custom_token_custom_derivation_title),
fieldValue = value,
confirmButton = DialogButtonUM(
title = stringResource(id = R.string.common_ok),
enabled = model.isConfirmEnabled,
onClick = model.onConfirm,
),
dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
onClick = onDismiss,
),
onDismissDialog = onDismiss,
onValueChange = model.updateValue,
textFieldParams = AdditionalTextInputDialogUM(
label = model.error?.resolveReference() ?: stringResource(id = R.string.custom_token_derivation_path),
isError = model.error != null,
),
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_CustomDerivationInputDialog(
@PreviewParameter(ComponentPreviewProvider::class) component: CustomTokenDerivationInputComponent,
) {
TangemThemePreview {
component.Dialog()
}
}
private class ComponentPreviewProvider : PreviewParameterProvider<CustomTokenDerivationInputComponent> {
override val values: Sequence<CustomTokenDerivationInputComponent>
get() = sequenceOf(
PreviewCustomTokenDerivationInputComponent(),
PreviewCustomTokenDerivationInputComponent(
value = "m/44'/60'/0'/0/0",
),
PreviewCustomTokenDerivationInputComponent(
value = "m/44'/60'/0'/0/0",
error = "Invalid derivation path",
),
)
}
// endregion Preview

View file

@ -0,0 +1,30 @@
package com.tangem.features.managetokens.ui.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.impl.R
@Composable
internal fun HasLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network, onDismiss: () -> Unit) {
BasicDialog(
title = stringResource(
R.string.token_details_unable_hide_alert_title,
currency.name,
),
message = stringResource(
R.string.token_details_unable_hide_alert_message,
currency.name,
currency.symbol,
network.name,
),
confirmButton = DialogButtonUM(
title = stringResource(R.string.common_ok),
onClick = onDismiss,
),
onDismissDialog = onDismiss,
)
}

View file

@ -0,0 +1,29 @@
package com.tangem.features.managetokens.ui.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.features.managetokens.impl.R
@Composable
internal fun HideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit, onDismiss: () -> Unit) {
BasicDialog(
title = stringResource(
R.string.token_details_hide_alert_title,
currency.name,
),
message = stringResource(R.string.token_details_hide_alert_message),
confirmButton = DialogButtonUM(
title = stringResource(R.string.token_details_hide_alert_hide),
warning = true,
onClick = onConfirm,
),
dismissButton = DialogButtonUM(
title = stringResource(R.string.common_cancel),
onClick = onDismiss,
),
onDismissDialog = onDismiss,
)
}

View file

@ -0,0 +1,255 @@
package com.tangem.features.managetokens.utils
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase
import com.tangem.domain.managetokens.CreateCurrencyUseCase
import com.tangem.domain.managetokens.FindTokenUseCase
import com.tangem.domain.managetokens.ValidateTokenFormUseCase
import com.tangem.domain.managetokens.model.AddCustomTokenForm
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.managetokens.model.exceptoin.FindTokenException
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveInAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ComponentScoped
internal class CustomCurrencyValidator @Inject constructor(
private val validateTokenFormUseCase: ValidateTokenFormUseCase,
private val createCustomCurrencyUseCase: CreateCurrencyUseCase,
private val findTokenUseCase: FindTokenUseCase,
private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase,
) {
private val validateFormJobHolder = JobHolder()
private val state: MutableStateFlow<State> = MutableStateFlow(
value = State(
prevValidatedForm = null,
prevFoundOrCreatedCurrency = null,
status = Status.NotStarted,
),
)
suspend fun consumeUpdates(block: suspend (Status) -> Unit) {
state
.map { it.status }
.distinctUntilChanged()
.collectLatest { block(it) }
}
suspend fun validateForm(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
formValues: AddCustomTokenForm.Raw,
) = coroutineScope {
updateStatus(Status.Validating)
val result = validateTokenFormUseCase(
networkId = networkId,
formValues = formValues,
)
val validatedForm = result.getOrElse { e ->
updateStatus(Status.FormValidationException(e))
return@coroutineScope
}
if (state.value.prevValidatedForm == validatedForm) {
return@coroutineScope
} else {
state.update { state ->
state.copy(prevValidatedForm = validatedForm)
}
}
launch {
when (validatedForm) {
is AddCustomTokenForm.Validated.All -> {
findOrCreateCurrency(userWalletId, networkId, derivationPath, validatedForm)
}
is AddCustomTokenForm.Validated.ContractAddress -> {
findToken(userWalletId, networkId, derivationPath, validatedForm)
}
}
}.saveInAndJoin(validateFormJobHolder)
}
suspend fun createCoin(userWalletId: UserWalletId, networkId: Network.ID, derivationPath: Network.DerivationPath) {
createCurrency(userWalletId, networkId, derivationPath, validatedForm = null)
}
private suspend fun findOrCreateCurrency(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
validatedForm: AddCustomTokenForm.Validated.All,
) {
val currentState = state.value
if (currentState.prevFoundOrCreatedCurrency is CryptoCurrency.Token &&
currentState.prevFoundOrCreatedCurrency.contractAddress == validatedForm.contractAddress
) {
// No need to search for token again if contract address is not changed
createCurrency(userWalletId, networkId, derivationPath, validatedForm)
return
}
updateStatus(Status.SearchingToken)
val foundToken = findTokenUseCase(
userWalletId = userWalletId,
contractAddress = validatedForm.contractAddress,
networkId = networkId,
derivationPath = derivationPath,
).getOrElse { e ->
when (e) {
is FindTokenException.DataError -> {
Timber.e(e.cause, "Unable to find custom currency")
updateStatus(Status.UnexpectedException(e.cause))
return
}
is FindTokenException.NotFound -> {
null
}
}
}
if (foundToken != null) {
updateStateToValidated(userWalletId, foundToken, fillForm = true, isCustom = false)
} else {
createCurrency(userWalletId, networkId, derivationPath, validatedForm)
}
}
private suspend fun findToken(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
validatedForm: AddCustomTokenForm.Validated.ContractAddress,
) {
updateStatus(Status.SearchingToken)
val token = findTokenUseCase(
userWalletId = userWalletId,
contractAddress = validatedForm.contractAddress,
networkId = networkId,
derivationPath = derivationPath,
).getOrElse { e ->
val newStatus = when (e) {
is FindTokenException.DataError -> {
Timber.e(e.cause, "Unable to find custom currency")
Status.UnexpectedException(e.cause)
}
is FindTokenException.NotFound -> {
Status.TokenNotFound
}
}
updateStatus(newStatus)
return
}
updateStateToValidated(userWalletId, token, fillForm = true, isCustom = false)
}
private suspend fun createCurrency(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
validatedForm: AddCustomTokenForm.Validated.All?,
) {
val currency = createCustomCurrencyUseCase(
networkId = networkId,
derivationPath = derivationPath,
formValues = validatedForm,
).getOrElse { e ->
Timber.e(e, "Unable to create custom currency")
updateStatus(Status.UnexpectedException(e))
return
}
updateStateToValidated(userWalletId, currency, fillForm = false, isCustom = validatedForm != null)
}
private suspend fun updateStateToValidated(
userWalletId: UserWalletId,
currency: CryptoCurrency,
fillForm: Boolean,
isCustom: Boolean,
) {
val currentStatus = state.value.status
if (currentStatus is Status.Validated && currentStatus.currency == currency) return
val isNotAdded = checkIsCurrencyNotAddedUseCase(
userWalletId = userWalletId,
networkId = currency.network.id,
derivationPath = currency.network.derivationPath,
contractAddress = when (currency) {
is CryptoCurrency.Coin -> null
is CryptoCurrency.Token -> currency.contractAddress
},
).getOrElse { e ->
Timber.e(e, "Unable to check if currency is already added")
updateStatus(Status.UnexpectedException(e))
return
}
state.update { state ->
state.copy(
status = Status.Validated(
currency = currency,
fillForm = fillForm,
isAlreadyAdded = !isNotAdded,
isCustom = isCustom,
),
prevFoundOrCreatedCurrency = currency,
)
}
}
private fun updateStatus(status: Status) {
state.update { state ->
state.copy(status = status)
}
}
data class State(
val prevValidatedForm: AddCustomTokenForm.Validated?,
val prevFoundOrCreatedCurrency: CryptoCurrency?,
val status: Status,
)
sealed class Status {
data object NotStarted : Status()
data object SearchingToken : Status()
data object Validating : Status()
data class Validated(
val currency: CryptoCurrency,
val fillForm: Boolean,
val isAlreadyAdded: Boolean,
val isCustom: Boolean,
) : Status()
data class FormValidationException(
val exceptions: List<CustomTokenFormValidationException>,
) : Status()
data object TokenNotFound : Status()
data class UnexpectedException(
val cause: Throwable,
) : Status()
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.features.managetokens.utils.list
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.tokens.model.Network
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
internal typealias ChangedCurrencies = Map<ManagedCryptoCurrency.Token, Set<Network>>
internal class ChangedCurrenciesManager {
val currenciesToAdd: MutableStateFlow<ChangedCurrencies> = MutableStateFlow(emptyMap())
val currenciesToRemove: MutableStateFlow<ChangedCurrencies> = MutableStateFlow(emptyMap())
fun addCurrency(currency: ManagedCryptoCurrency.Token, network: Network) {
updateChangedItems(currency, network, currenciesToRemove, currenciesToAdd)
}
fun removeCurrency(currency: ManagedCryptoCurrency.Token, network: Network) {
updateChangedItems(currency, network, currenciesToAdd, currenciesToRemove)
}
fun containsCurrency(currency: ManagedCryptoCurrency.Token, network: Network): Boolean {
return network in currenciesToAdd.value[currency].orEmpty() ||
network in currenciesToRemove.value[currency].orEmpty()
}
private fun updateChangedItems(
currency: ManagedCryptoCurrency.Token,
network: Network,
removeFromIfPresent: MutableStateFlow<ChangedCurrencies>,
addToIfNotPresent: MutableStateFlow<ChangedCurrencies>,
) {
val present = removeFromIfPresent.value[currency].orEmpty()
if (network in present) {
removeFromIfPresent.update { items ->
items.toMutableMap().apply {
val ids = present - network
if (ids.isEmpty()) {
remove(currency)
} else {
set(currency, ids)
}
}
}
} else {
addToIfNotPresent.update { items ->
val alreadyAdded = items[currency] ?: emptySet()
if (network in alreadyAdded) {
return@update items
}
items + (currency to alreadyAdded + network)
}
}
}
}

View file

@ -0,0 +1,257 @@
package com.tangem.features.managetokens.utils.list
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.GetManagedTokensUseCase
import com.tangem.domain.managetokens.RemoveCustomManagedCryptoCurrencyUseCase
import com.tangem.domain.managetokens.CheckHasLinkedTokensUseCase
import com.tangem.domain.managetokens.model.*
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.impl.R
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ComponentScoped
internal class ManageTokensListManager @Inject constructor(
private val getManagedTokensUseCase: GetManagedTokensUseCase,
private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase,
private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase,
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val messageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
clipboardManager: ClipboardManager,
) : ManageTokensUiActions {
private lateinit var scope: CoroutineScope
private val jobHolder = JobHolder()
private val actionsFlow: MutableSharedFlow<ManageTokensBatchAction> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val state: MutableStateFlow<ManageTokensListState> = MutableStateFlow(ManageTokensListState())
private val changedCurrenciesManager = ChangedCurrenciesManager()
private val uiManager = ManageTokensUiManager(
state = state,
messageSender = messageSender,
dispatchers = dispatchers,
actions = this,
scopeProvider = Provider { scope },
clipboardManager = clipboardManager,
)
val currenciesToAdd: StateFlow<ChangedCurrencies> = changedCurrenciesManager.currenciesToAdd.asStateFlow()
val currenciesToRemove: StateFlow<ChangedCurrencies> = changedCurrenciesManager.currenciesToRemove.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val paginationStatus: Flow<PaginationStatus<*>> = state
.mapLatest { it.status }
.distinctUntilChanged()
val uiItems: Flow<ImmutableList<CurrencyItemUM>> = uiManager.items
suspend fun launchPagination(userWalletId: UserWalletId?) = coroutineScope {
scope = this
val batchFlow = getManagedTokensUseCase(
context = ManageTokensListBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = this,
),
)
batchFlow.state
.onEach { state -> updateState(state, userWalletId) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
// Initial load
reload(userWalletId)
}
suspend fun reload(userWalletId: UserWalletId?) {
state.value = ManageTokensListState()
actionsFlow.emit(
BatchAction.Reload(
requestParams = ManageTokensListConfig(userWalletId, searchText = null),
),
)
}
suspend fun loadMore(userWalletId: UserWalletId?, query: String) {
actionsFlow.emit(
BatchAction.LoadMore(
requestParams = ManageTokensListConfig(userWalletId, query),
),
)
}
suspend fun search(userWalletId: UserWalletId?, query: String) {
state.value = ManageTokensListState()
actionsFlow.emit(
BatchAction.Reload(
requestParams = ManageTokensListConfig(
userWalletId = userWalletId,
searchText = query,
),
),
)
}
private fun updateState(
batchListState: BatchListState<Int, List<ManagedCryptoCurrency>>,
userWalletId: UserWalletId?,
) {
state.update { state ->
state.copy(
status = batchListState.status,
)
}
state.update { state ->
val newBatches = batchListState.data
val currentBatches = state.currencyBatches
// Distinct until changed
if (newBatches.size == currentBatches.size &&
newBatches.map { it.key } == currentBatches.map { it.key } &&
newBatches.flatMap { it.data } == currentBatches.flatMap { it.data }
) {
return
}
val canEditItems = userWalletId != null
state.copy(
userWalletId = userWalletId,
currencyBatches = newBatches,
uiBatches = uiManager.createOrUpdateUiBatches(newBatches, canEditItems),
canEditItems = canEditItems,
)
}
}
override fun addCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network) {
changedCurrenciesManager.addCurrency(currency, network)
sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = true)
}
override fun removeCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network) {
changedCurrenciesManager.removeCurrency(currency, network)
sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = false)
}
override fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) {
scope.launch {
removeCustomCurrencyUseCase.invoke(userWalletId, currency)
.onRight { reload(userWalletId) }
.onLeft { Timber.e(it) }
}
}
override fun checkNeedToShowRemoveNetworkWarning(
currency: ManagedCryptoCurrency.Token,
network: Network,
): Boolean = !changedCurrenciesManager.containsCurrency(currency, network)
private fun sendSelectCurrencyAction(
batchKey: Int,
currencyId: ManagedCryptoCurrency.ID,
network: Network,
isSelected: Boolean,
) {
val request = ManageTokensUpdateAction.AddCurrency(
currencyId = currencyId,
network = network,
isSelected = isSelected,
)
val action = BatchAction.UpdateBatches(
keys = setOf(batchKey),
async = true,
updateRequest = request,
)
actionsFlow.tryEmit(action)
}
override suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean {
return checkHasLinkedTokensUseCase(
userWalletId = userWalletId,
network = network,
tempAddedTokens = changedCurrenciesManager.currenciesToAdd.value,
tempRemovedTokens = changedCurrenciesManager.currenciesToRemove.value,
).getOrElse {
Timber.e(
it,
"""
Failed to check linked tokens
|- User wallet ID: $userWalletId
|- Network ID: ${network.id}
""".trimIndent(),
)
val message = SnackbarMessage(
message = it.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
false
}
}
override suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
sourceNetwork: ManagedCryptoCurrency.SourceNetwork,
): CurrencyUnsupportedState? {
return checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
sourceNetwork = sourceNetwork,
).getOrElse {
Timber.e(
it,
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Source Network: $sourceNetwork
""".trimIndent(),
)
val message = SnackbarMessage(
message = it.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
null
}
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.managetokens.utils.list
import com.tangem.domain.managetokens.model.ManageTokensListConfig
import com.tangem.domain.managetokens.model.ManageTokensUpdateAction
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.PaginationStatus
internal typealias ManageTokensBatchAction = BatchAction<Int, ManageTokensListConfig, ManageTokensUpdateAction>
internal data class ManageTokensListState(
val status: PaginationStatus<*> = PaginationStatus.None,
val userWalletId: UserWalletId? = null,
val uiBatches: List<Batch<Int, List<CurrencyItemUM>>> = mutableListOf(),
val currencyBatches: List<Batch<Int, List<ManagedCryptoCurrency>>> = mutableListOf(),
val canEditItems: Boolean = true,
) {
fun batchIndexByCurrencyId(currencyId: ManagedCryptoCurrency.ID): Int {
return currencyBatches
.indexOfFirst { batch -> batch.data.any { it.id == currencyId } }
.takeIf { it != -1 }
?: error("Batch with currency '$currencyId' not found")
}
fun updateUiBatchesItem(
indexToBatch: Pair<Int, Batch<Int, List<CurrencyItemUM>>>,
indexToItem: Pair<Int, CurrencyItemUM>,
): ManageTokensListState {
val updatedUiBatch = indexToBatch.second.copy(
data = indexToBatch.second.data.toMutableList().apply {
set(indexToItem.first, indexToItem.second)
},
)
return copy(
uiBatches = uiBatches.toMutableList().apply {
set(indexToBatch.first, updatedUiBatch)
},
)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.managetokens.utils.list
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
internal interface ManageTokensUiActions {
fun addCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network)
fun removeCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network)
fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom)
fun checkNeedToShowRemoveNetworkWarning(currency: ManagedCryptoCurrency.Token, network: Network): Boolean
suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean
suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
sourceNetwork: ManagedCryptoCurrency.SourceNetwork,
): CurrencyUnsupportedState?
}

View file

@ -0,0 +1,257 @@
package com.tangem.features.managetokens.utils.list
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.ContentMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.dialog.CurrencyUnsupportedDialog
import com.tangem.features.managetokens.ui.dialog.HasLinkedTokensWarning
import com.tangem.features.managetokens.ui.dialog.HideTokenWarning
import com.tangem.features.managetokens.utils.mapper.toUiModel
import com.tangem.features.managetokens.utils.ui.toggleExpanded
import com.tangem.features.managetokens.utils.ui.update
import com.tangem.pagination.Batch
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
internal class ManageTokensUiManager(
private val state: MutableStateFlow<ManageTokensListState>,
private val messageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
private val scopeProvider: Provider<CoroutineScope>,
private val actions: ManageTokensUiActions,
private val clipboardManager: ClipboardManager,
) {
private val scope: CoroutineScope
get() = scopeProvider()
@OptIn(ExperimentalCoroutinesApi::class)
val items: Flow<ImmutableList<CurrencyItemUM>> = state
.mapLatest { state ->
state.uiBatches.asSequence()
.flatMap { it.data }
.toImmutableList()
}
.distinctUntilChanged()
fun createOrUpdateUiBatches(
newCurrencyBatches: List<Batch<Int, List<ManagedCryptoCurrency>>>,
canEditItems: Boolean,
): List<Batch<Int, List<CurrencyItemUM>>> {
val currentUiBatches = state.value.uiBatches
val batches = currentUiBatches.toMutableList()
newCurrencyBatches.forEach { (key, data) ->
val indexToUpdate = currentUiBatches.indexOfFirst { it.key == key }
val currencyBatch = state.value.currencyBatches.getOrNull(indexToUpdate)
if (indexToUpdate == -1 || currencyBatch == null) {
val newBatch = Batch(
key = key,
data = data.map { item ->
item.toUiModel(
isEditable = canEditItems,
onRemoveCustomCurrencyClick = ::removeCustomCurrency,
onExpandNetworksClick = ::toggleCurrencyNetworksVisibility,
)
},
)
batches.addOrReplace(newBatch) { it.key == key }
} else {
val uiBatchToUpdate = currentUiBatches[indexToUpdate]
if (uiBatchToUpdate.data == data) {
return@forEach
}
val updatedBatch = uiBatchToUpdate.copy(
data = data.mapIndexed { index, item ->
if (item == currencyBatch.data[index]) {
return@mapIndexed uiBatchToUpdate.data[index]
}
val previousUiItem = uiBatchToUpdate.data.getOrNull(index)
if (previousUiItem == null || previousUiItem.id != item.id) {
item.toUiModel(
isEditable = canEditItems,
onRemoveCustomCurrencyClick = ::removeCustomCurrency,
onExpandNetworksClick = ::toggleCurrencyNetworksVisibility,
)
} else {
previousUiItem.update(item)
}
},
)
batches[indexToUpdate] = updatedBatch
}
}
return batches
}
private fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) = scope.launch(dispatchers.default) {
showRemoveNetworkWarning(
currency = currency,
network = currency.network,
isCoin = currency is ManagedCryptoCurrency.Custom.Coin,
onConfirm = {
val userWalletId = requireNotNull(state.value.userWalletId) { "UserWalletId is null. Can not remove" }
actions.removeCustomCurrency(userWalletId = userWalletId, currency = currency)
},
)
}
private fun toggleCurrencyNetworksVisibility(currency: ManagedCryptoCurrency.Token) = scope.launch(
dispatchers.default,
) {
state.update { batches ->
val batchIndex = batches.batchIndexByCurrencyId(currency.id)
val currencyBatch = batches.currencyBatches[batchIndex]
val currencyIndex = currencyBatch.currencyIndexById(currency.id)
val uiBatch = batches.uiBatches[batchIndex]
val updatedUiItem = uiBatch.data[currencyIndex].toggleExpanded(
currency = currencyBatch.data[currencyIndex],
isEditable = batches.canEditItems,
onSelectCurrencyNetwork = { networkId, isSelected ->
selectNetwork(currencyBatch.key, currency, networkId, isSelected)
},
onLongTap = ::copyContractAddress,
)
batches.updateUiBatchesItem(
indexToBatch = batchIndex to uiBatch,
indexToItem = currencyIndex to updatedUiItem,
)
}
}
private fun copyContractAddress(source: ManagedCryptoCurrency.SourceNetwork) {
if (source is ManagedCryptoCurrency.SourceNetwork.Default) {
clipboardManager.setText(text = source.contractAddress)
showSnackbarMessage(resourceReference(R.string.contract_address_copied_message))
}
}
private fun showSnackbarMessage(messageText: TextReference) {
val message = SnackbarMessage(message = messageText)
messageSender.send(message)
}
private fun selectNetwork(
batchKey: Int,
currency: ManagedCryptoCurrency,
source: ManagedCryptoCurrency.SourceNetwork,
isSelected: Boolean,
) = scope.launch(dispatchers.default) {
if (currency !is ManagedCryptoCurrency.Token) return@launch
if (isSelected) {
val userWalletId = state.value.userWalletId
val unsupportedState = userWalletId?.let { actions.checkCurrencyUnsupportedState(it, source) }
if (unsupportedState != null) {
showUnsupportedWarning(unsupportedState)
} else {
actions.addCurrency(batchKey, currency, source.network)
}
} else {
if (actions.checkNeedToShowRemoveNetworkWarning(currency, source.network)) {
showRemoveNetworkWarning(
currency = currency,
network = source.network,
isCoin = source is ManagedCryptoCurrency.SourceNetwork.Main,
onConfirm = {
actions.removeCurrency(batchKey, currency, source.network)
},
)
} else {
actions.removeCurrency(batchKey, currency, source.network)
}
}
}
private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) {
val message = ContentMessage { onDismiss ->
CurrencyUnsupportedDialog(
title = resourceReference(R.string.common_warning),
message = when (unsupportedState) {
is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},
onDismiss = onDismiss,
)
}
messageSender.send(message)
}
private suspend fun showRemoveNetworkWarning(
currency: ManagedCryptoCurrency,
network: Network,
isCoin: Boolean,
onConfirm: () -> Unit,
) {
val userWalletId = state.value.userWalletId
val hasLinkedTokens = if (userWalletId == null || !isCoin) {
false
} else {
actions.checkHasLinkedTokens(userWalletId, network)
}
val message = ContentMessage { onDismiss ->
if (hasLinkedTokens) {
HasLinkedTokensWarning(
currency = currency,
network = network,
onDismiss = onDismiss,
)
} else {
HideTokenWarning(
currency = currency,
onConfirm = {
onConfirm()
onDismiss()
},
onDismiss = onDismiss,
)
}
}
messageSender.send(message)
}
private fun Batch<Int, List<ManagedCryptoCurrency>>.currencyIndexById(id: ManagedCryptoCurrency.ID): Int {
return data
.indexOfFirst { it.id == id }
.takeIf { it != -1 }
?: error("Currency with currency '$id' not found in batch #$key")
}
}

View file

@ -0,0 +1,77 @@
package com.tangem.features.managetokens.utils.mapper
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.getTintForTokenIcon
import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM
import com.tangem.features.managetokens.utils.ui.getIconRes
internal fun ManagedCryptoCurrency.toUiModel(
isEditable: Boolean,
onExpandNetworksClick: (ManagedCryptoCurrency.Token) -> Unit,
onRemoveCustomCurrencyClick: (ManagedCryptoCurrency.Custom) -> Unit,
): CurrencyItemUM = when (this) {
is ManagedCryptoCurrency.Custom -> toUiModel(onRemoveCustomCurrencyClick)
is ManagedCryptoCurrency.Token -> toUiModel(isEditable, onExpandNetworksClick)
}
private fun ManagedCryptoCurrency.Custom.toUiModel(
onRemoveCustomCurrency: (ManagedCryptoCurrency.Custom) -> Unit,
): CurrencyItemUM = CurrencyItemUM.Custom(
id = id,
name = name,
symbol = symbol,
icon = when (this) {
is ManagedCryptoCurrency.Custom.Coin -> {
CurrencyIconState.CoinIcon(
url = iconUrl,
fallbackResId = network.id.getIconRes(isColored = true),
isGrayscale = false,
showCustomBadge = true,
)
}
is ManagedCryptoCurrency.Custom.Token -> {
val background = tryGetBackgroundForTokenIcon(contractAddress)
CurrencyIconState.TokenIcon(
url = iconUrl,
fallbackBackground = background,
fallbackTint = getTintForTokenIcon(background),
topBadgeIconResId = network.id.getIconRes(isColored = true),
isGrayscale = false,
showCustomBadge = true,
)
}
},
onRemoveClick = {
onRemoveCustomCurrency(this)
},
)
private fun ManagedCryptoCurrency.Token.toUiModel(
isEditable: Boolean,
onExpandNetworksClick: (ManagedCryptoCurrency.Token) -> Unit,
): CurrencyItemUM {
val background = TangemColorPalette.Black
return CurrencyItemUM.Basic(
id = id,
name = name,
symbol = symbol,
icon = CurrencyIconState.TokenIcon(
url = iconUrl,
topBadgeIconResId = null,
isGrayscale = if (isEditable) !isAdded else false,
showCustomBadge = false,
fallbackTint = getTintForTokenIcon(background),
fallbackBackground = background,
),
networks = NetworksUM.Collapsed,
onExpandClick = {
onExpandNetworksClick(this)
},
)
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.managetokens.utils.mapper
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM
import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM
import com.tangem.features.managetokens.utils.ui.getIconRes
import kotlinx.collections.immutable.toImmutableList
internal fun ManagedCryptoCurrency.Token.toUiNetworksModel(
isExpanded: Boolean,
isItemsEditable: Boolean,
onSelectedStateChange: (SourceNetwork, Boolean) -> Unit,
onLongTap: (SourceNetwork) -> Unit,
): NetworksUM {
return if (isExpanded) {
NetworksUM.Expanded(
networks = availableNetworks.map {
it.toCurrencyNetworkModel(
isSelected = it.network in addedIn,
isEditable = isItemsEditable,
onSelectedStateChange = onSelectedStateChange,
onLongTap = onLongTap,
)
}.toImmutableList(),
)
} else {
NetworksUM.Collapsed
}
}
internal fun Network.toCurrencyNetworkModel(
isSelected: Boolean,
onSelectedStateChange: (Boolean) -> Unit,
): CurrencyNetworkUM {
return CurrencyNetworkUM(
network = this,
name = name,
iconResId = id.getIconRes(isColored = true),
isSelected = isSelected,
type = standardType.name,
onLongClick = {},
isMainNetwork = false,
onSelectedStateChange = onSelectedStateChange,
)
}
private fun SourceNetwork.toCurrencyNetworkModel(
isSelected: Boolean,
isEditable: Boolean,
onSelectedStateChange: (SourceNetwork, Boolean) -> Unit,
onLongTap: (SourceNetwork) -> Unit,
): CurrencyNetworkUM {
return CurrencyNetworkUM(
network = network,
name = network.name.uppercase(),
iconResId = id.getIconRes(isColored = isSelected || !isEditable),
isSelected = isSelected || !isEditable,
type = typeName,
onLongClick = { onLongTap(this) },
isMainNetwork = this is SourceNetwork.Main,
onSelectedStateChange = { selected ->
onSelectedStateChange(this, selected)
},
)
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.managetokens.utils.mapper
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
import com.tangem.features.managetokens.entity.item.DerivationPathUM
import com.tangem.features.managetokens.impl.R
internal fun Network.toDerivationPathModel(
isSelected: Boolean,
onSelectedStateChange: (Boolean) -> Unit,
): DerivationPathUM? {
return DerivationPathUM(
id = id.value,
value = derivationPath.value ?: return null,
networkName = stringReference(name),
isSelected = isSelected,
onSelectedStateChange = onSelectedStateChange,
)
}
internal fun SelectedNetwork.toDerivationPathModel(
isSelected: Boolean,
onSelectedStateChange: (Boolean) -> Unit,
): DerivationPathUM? {
return DerivationPathUM(
id = id.value,
value = derivationPath.value ?: return null,
networkName = resourceReference(R.string.custom_token_derivation_path_default),
isSelected = isSelected,
onSelectedStateChange = onSelectedStateChange,
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.managetokens.utils.mapper
import com.tangem.domain.managetokens.model.AddCustomTokenForm
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM
internal fun CustomTokenFormUM.TokenFormUM.mapToDomainModel(): AddCustomTokenForm.Raw {
return AddCustomTokenForm.Raw(
contractAddress = contractAddress.value,
symbol = symbol.value,
name = name.value,
decimals = decimals.value,
)
}

View file

@ -0,0 +1,72 @@
package com.tangem.features.managetokens.utils.ui
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM
import com.tangem.features.managetokens.utils.mapper.toUiNetworksModel
import kotlinx.collections.immutable.toImmutableList
internal fun CurrencyItemUM.toggleExpanded(
currency: ManagedCryptoCurrency,
isEditable: Boolean,
onSelectCurrencyNetwork: (SourceNetwork, Boolean) -> Unit,
onLongTap: (SourceNetwork) -> Unit,
): CurrencyItemUM {
if (currency !is ManagedCryptoCurrency.Token) return this
return when (this) {
is CurrencyItemUM.Custom,
is CurrencyItemUM.Loading,
-> this
is CurrencyItemUM.Basic -> {
val isExpanded = networks !is NetworksUM.Expanded
copy(
icon = icon.copySealed(
isGrayscale = if (isEditable) !currency.isAdded && !isExpanded else false,
),
networks = currency.toUiNetworksModel(
isExpanded = isExpanded,
isItemsEditable = isEditable,
onSelectedStateChange = onSelectCurrencyNetwork,
onLongTap = onLongTap,
),
)
}
}
}
internal fun CurrencyItemUM.update(currency: ManagedCryptoCurrency): CurrencyItemUM {
return when (this) {
is CurrencyItemUM.Custom,
is CurrencyItemUM.Loading,
-> this
is CurrencyItemUM.Basic -> {
if (currency !is ManagedCryptoCurrency.Token) {
return this
}
copy(
icon = icon.copySealed(
isGrayscale = networks is NetworksUM.Collapsed && !currency.isAdded,
),
networks = updateNetworks(currency),
)
}
}
}
private fun CurrencyItemUM.Basic.updateNetworks(currency: ManagedCryptoCurrency.Token): NetworksUM = when (networks) {
is NetworksUM.Collapsed -> networks
is NetworksUM.Expanded -> networks.copy(
networks = networks.networks.map { network ->
val isSelected = network.network in currency.addedIn
network.copy(
iconResId = network.network.id.getIconRes(isSelected),
isSelected = isSelected,
)
}.toImmutableList(),
)
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.managetokens.utils.ui
import androidx.annotation.DrawableRes
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.getGreyedOutIconRes
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM
internal fun CurrencyNetworkUM.select(isSelected: Boolean): CurrencyNetworkUM {
return copy(
iconResId = network.id.getIconRes(isSelected),
isSelected = isSelected,
)
}
@DrawableRes
internal fun Network.ID.getIconRes(isColored: Boolean): Int = if (isColored) {
getActiveIconRes(value)
} else {
getGreyedOutIconRes(value)
}

View file

@ -0,0 +1,157 @@
package com.tangem.features.managetokens.utils.ui
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.managetokens.ValidateTokenFormUseCase
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormUM
import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM
import com.tangem.features.managetokens.impl.R
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
internal fun CustomTokenFormUM.updateTokenForm(
block: CustomTokenFormUM.TokenFormUM.() -> CustomTokenFormUM.TokenFormUM,
): CustomTokenFormUM {
val form = tokenForm ?: return this
val updatedForm = form.block()
return copy(tokenForm = updatedForm)
}
internal fun TextInputFieldUM.updateValue(
value: String = this.value,
error: TextReference? = this.error,
isEnabled: Boolean = this.isEnabled,
clearError: Boolean = false,
): TextInputFieldUM {
return copy(
value = value,
error = if (clearError) null else error,
isEnabled = isEnabled,
)
}
internal fun CustomTokenFormUM.updateWithProgress(
showProgress: Boolean,
isWasFilled: Boolean = this.tokenForm?.wasFilled ?: false,
canAddToken: Boolean = this.canAddToken,
clearNotifications: Boolean = false,
clearFieldErrors: Boolean = false,
disableSecondaryFields: Boolean = false,
): CustomTokenFormUM {
return copy(
isValidating = showProgress,
canAddToken = canAddToken,
notifications = if (clearNotifications) persistentListOf() else notifications,
).updateTokenForm {
copy(
contractAddress = contractAddress.updateValue(
clearError = clearFieldErrors,
),
name = name.updateValue(
isEnabled = !showProgress && !disableSecondaryFields,
clearError = clearFieldErrors,
),
symbol = symbol.updateValue(
isEnabled = !showProgress && !disableSecondaryFields,
clearError = clearFieldErrors,
),
decimals = decimals.updateValue(
isEnabled = !showProgress && !disableSecondaryFields,
clearError = clearFieldErrors,
),
wasFilled = isWasFilled,
)
}
}
internal fun CustomTokenFormUM.updateWithCurrency(currency: CryptoCurrency): CustomTokenFormUM {
return updateTokenForm {
copy(
contractAddress = contractAddress.updateValue(error = null),
name = name.updateValue(currency.name),
symbol = symbol.updateValue(currency.symbol),
decimals = decimals.updateValue(currency.decimals.toString()),
)
}
}
internal fun CustomTokenFormUM.updateWithContractAddressException(
exception: CustomTokenFormValidationException.ContractAddress,
): CustomTokenFormUM {
return updateTokenForm {
copy(
contractAddress = contractAddress.updateValue(
error = when (exception) {
CustomTokenFormValidationException.ContractAddress.Empty -> {
null
}
CustomTokenFormValidationException.ContractAddress.Invalid -> {
resourceReference(R.string.custom_token_creation_error_invalid_contract_address)
}
},
),
)
}
}
internal fun CustomTokenFormUM.updateWithDecimalsException(
exception: CustomTokenFormValidationException.Decimals,
): CustomTokenFormUM {
return updateTokenForm {
copy(
decimals = decimals.updateValue(
error = when (exception) {
is CustomTokenFormValidationException.Decimals.Empty -> {
null
}
is CustomTokenFormValidationException.Decimals.Invalid -> {
resourceReference(
R.string.custom_token_creation_error_wrong_decimals,
wrappedList(ValidateTokenFormUseCase.MAX_DECIMALS),
)
}
},
),
)
}
}
internal fun CustomTokenFormUM.updateWithCurrencyNotFoundNotification(): CustomTokenFormUM {
val notification = CustomTokenFormUM.NotificationUM(
id = "currency_not_found",
config = NotificationConfig(
title = resourceReference(R.string.custom_token_validation_error_not_found_title),
subtitle = resourceReference(R.string.custom_token_validation_error_not_found_description),
iconResId = R.drawable.img_attention_20,
),
)
return copy(
notifications = notifications.mutate {
it.add(notification)
},
)
}
internal fun CustomTokenFormUM.updateWithCurrencyAlreadyAddedNotification(): CustomTokenFormUM {
val notification = CustomTokenFormUM.NotificationUM(
id = "currency_already_added",
config = NotificationConfig(
title = resourceReference(R.string.custom_token_creation_error_token_already_exist_title),
subtitle = resourceReference(R.string.custom_token_creation_error_token_already_exist_message),
iconResId = R.drawable.img_attention_20,
),
)
return copy(
notifications = notifications.mutate {
it.add(notification)
},
)
}

View file

@ -1,6 +1,7 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
id("kotlin-parcelize")
id("configuration")
}
@ -15,4 +16,10 @@ dependencies {
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/* Project - Domain */
implementation(projects.domain.core)
implementation(projects.domain.tokens.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.markets.models)
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.markets.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.markets.entry.BottomSheetState
import kotlinx.serialization.Serializable
@Stable
interface MarketsTokenDetailsComponent : ComposableContentComponent {
@Serializable
data class Params(
val token: TokenMarketParams,
val appCurrency: AppCurrency,
val showPortfolio: Boolean,
val analyticsParams: AnalyticsParams?,
)
@Serializable
data class AnalyticsParams(
val blockchain: String?,
val source: String,
)
@Composable
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
)
interface Factory : ComponentFactory<Params, MarketsTokenDetailsComponent>
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.component
package com.tangem.features.markets.entry
enum class BottomSheetState {
EXPANDED,

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.component
package com.tangem.features.markets.entry
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable

View file

@ -0,0 +1,20 @@
package com.tangem.features.markets.token.block
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.tokens.model.CryptoCurrency
import kotlinx.serialization.Serializable
@Stable
interface TokenMarketBlockComponent : ComposableContentComponent {
@Serializable
data class Params(
val cryptoCurrency: CryptoCurrency,
)
interface Factory {
fun create(appComponentContext: AppComponentContext, params: Params): TokenMarketBlockComponent
}
}

View file

@ -17,9 +17,26 @@ dependencies {
implementation(projects.core.navigation)
/* Domain */
implementation(projects.domain.markets)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.manageTokens)
implementation(projects.domain.markets)
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.
// Instead, create some kind of interface for such cases.
/* Redux -_- */
implementation(projects.domain.legacy)
implementation(deps.reKotlin)
/* Compose */
implementation(deps.compose.coil)
@ -31,6 +48,7 @@ dependencies {
implementation(deps.compose.ui.utils)
implementation(deps.lifecycle.compose)
implementation(deps.androidx.activity.compose)
implementation(deps.markdown.composeview)
/* DI */
implementation(deps.hilt.android)
@ -45,7 +63,15 @@ dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.featuretoggles)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
/* Common */
implementation(projects.common.ui)
implementation(projects.common.uiCharts)
implementation(projects.common.routing)
/* Libs */
implementation(projects.libs.crypto)
implementation(projects.libs.blockchainSdk)
}

View file

@ -1,164 +0,0 @@
package com.tangem.features.markets
import androidx.compose.animation.Animatable
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children
import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.*
import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState
import com.arkivanov.decompose.router.stack.*
import com.arkivanov.decompose.value.Value
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.component.BottomSheetState
import com.tangem.features.markets.component.MarketsEntryComponent
import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.api.toSerializable
import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Stable
internal class DefaultMarketsEntryComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
private val marketsEntryChildFactory: MarketsEntryChildFactory,
) : MarketsEntryComponent, AppComponentContext by context {
private val stackNavigation = StackNavigation<MarketsEntryChildFactory.Child>()
val stack: Value<ChildStack<MarketsEntryChildFactory.Child, Any>> = childStack(
key = "main",
source = stackNavigation,
serializer = MarketsEntryChildFactory.Child.serializer(),
initialConfiguration = MarketsEntryChildFactory.Child.TokenList,
handleBackButton = true,
childFactory = { configuration, componentContext ->
marketsEntryChildFactory.createChild(
child = configuration,
appComponentContext = childByContext(componentContext),
onTokenSelected = ::marketsListTokenSelected,
onDetailsBack = ::onDetailsBack,
)
},
)
@Suppress("LongMethod")
@Composable
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
val primary = TangemTheme.colors.background.primary
val secondary = TangemTheme.colors.background.secondary
val backgroundColor = remember { Animatable(primary) }
val stackState = stack.subscribeAsState()
LocalMainBottomSheetColor.current.value = backgroundColor.value
Children(
stack = stackState.value,
animation = stackAnimation(slide()),
) {
when (it.configuration) {
is MarketsEntryChildFactory.Child.TokenDetails -> {
(it.instance as MarketsTokenDetailsComponent).BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = onHeaderSizeChange,
modifier = modifier,
)
}
MarketsEntryChildFactory.Child.TokenList -> {
(it.instance as MarketsTokenListComponent).BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = onHeaderSizeChange,
modifier = modifier,
)
}
}
}
// order of LaunchedEffects is important here
val activeChild = stackState.value.active.configuration
LaunchedEffect(activeChild) {
when (activeChild) {
is MarketsEntryChildFactory.Child.TokenDetails -> {
backgroundColor.animateTo(
secondary,
animationSpec = tween(durationMillis = 500),
)
}
MarketsEntryChildFactory.Child.TokenList -> {
backgroundColor.animateTo(
primary,
animationSpec = tween(durationMillis = 500),
)
}
}
}
LaunchedEffect(bottomSheetState.value) {
if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) {
when (bottomSheetState.value) {
BottomSheetState.EXPANDED -> {
backgroundColor.animateTo(
secondary,
animationSpec = tween(durationMillis = 100),
)
}
BottomSheetState.COLLAPSED -> {
backgroundColor.animateTo(
primary,
animationSpec = tween(durationMillis = 100),
)
}
}
}
}
LaunchedEffect(primary, secondary) {
if (backgroundColor.isRunning) return@LaunchedEffect
when (activeChild) {
is MarketsEntryChildFactory.Child.TokenDetails -> {
backgroundColor.snapTo(secondary)
}
MarketsEntryChildFactory.Child.TokenList -> {
backgroundColor.snapTo(primary)
}
}
}
}
@OptIn(ExperimentalDecomposeApi::class)
private fun marketsListTokenSelected(token: TokenMarket, appCurrency: AppCurrency) {
stackNavigation.pushNew(
configuration = MarketsEntryChildFactory.Child.TokenDetails(
params = MarketsTokenDetailsComponent.Params(
token = token.toSerializable(),
appCurrency = appCurrency,
),
),
)
}
private fun onDetailsBack() {
stackNavigation.popWhile { it != MarketsEntryChildFactory.Child.TokenList }
}
@AssistedFactory
interface Factory : MarketsEntryComponent.Factory {
override fun create(context: AppComponentContext): DefaultMarketsEntryComponent
}
}

View file

@ -1,32 +0,0 @@
package com.tangem.features.markets.details.api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.markets.component.BottomSheetState
import kotlinx.serialization.Serializable
@Stable
interface MarketsTokenDetailsComponent {
@Serializable
data class Params(
val token: TokenMarketSerializable,
val appCurrency: AppCurrency,
)
@Composable
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
)
interface Factory {
fun create(context: AppComponentContext, params: Params, onBack: () -> Unit): MarketsTokenDetailsComponent
}
}

View file

@ -1,40 +0,0 @@
package com.tangem.features.markets.details.api
import com.tangem.domain.core.serialization.SerializedBigDecimal
import com.tangem.domain.markets.TokenMarket
import kotlinx.serialization.Serializable
@Serializable
data class TokenMarketSerializable(
val id: String,
val name: String,
val symbol: String,
val marketCap: SerializedBigDecimal?,
val tokenQuotes: Quotes,
val imageUrl: String,
) {
@Serializable
data class Quotes(
val currentPrice: SerializedBigDecimal,
val h24Percent: SerializedBigDecimal,
val weekPercent: SerializedBigDecimal,
val monthPercent: SerializedBigDecimal,
)
}
fun TokenMarket.toSerializable(): TokenMarketSerializable {
return TokenMarketSerializable(
id = id,
name = name,
symbol = symbol,
marketCap = marketCap,
tokenQuotes = TokenMarketSerializable.Quotes(
currentPrice = tokenQuotesShort.currentPrice,
h24Percent = tokenQuotesShort.h24ChangePercent,
weekPercent = tokenQuotesShort.weekChangePercent,
monthPercent = tokenQuotesShort.monthChangePercent,
),
imageUrl = imageUrlLarge,
)
}

View file

@ -1,28 +1,85 @@
package com.tangem.features.markets.details.impl
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.markets.component.BottomSheetState
import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.MarketsTokenDetailsComponent.Params
import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel
import com.tangem.features.markets.details.impl.model.state.TokenNetworksState
import com.tangem.features.markets.details.impl.ui.MarketsTokenDetailsContent
import com.tangem.features.markets.entry.BottomSheetState
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@Stable
internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: MarketsTokenDetailsComponent.Params,
@Assisted private val onBack: () -> Unit,
@Assisted params: Params,
analyticsEventHandler: AnalyticsEventHandler,
portfolioComponentFactory: MarketsPortfolioComponent.Factory,
) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent {
private val model: MarketsTokenDetailsModel = getOrCreateModel(params)
// applying l2 compatibility
private val updatedParams = params.copy(
token = params.token.copy(
id = getTokenIdIfL2Network(params.token.id),
),
)
private val analyticsParams = params.analyticsParams
private val model: MarketsTokenDetailsModel = getOrCreateModel(updatedParams)
private val portfolioComponent: MarketsPortfolioComponent? = if (updatedParams.showPortfolio) {
portfolioComponentFactory.create(
context = child("my_portfolio"),
params = MarketsPortfolioComponent.Params(
updatedParams.token,
analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) },
),
)
} else {
null
}
init {
componentScope.launch {
model.networksState.collectLatest {
when (it) {
is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(it.networks)
TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable()
else -> {}
}
}
}
// === Analytics ===
if (analyticsParams != null) {
analyticsEventHandler.send(
MarketDetailsAnalyticsEvent.EventBuilder(
token = params.token,
).screenOpened(
blockchain = analyticsParams.blockchain,
source = analyticsParams.source,
),
)
}
}
@Composable
override fun BottomSheetContent(
@ -41,23 +98,66 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor(
val bsState by bottomSheetState
LaunchedEffect(bsState) {
model.containerBottomSheetState.value = bsState
model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED
}
BackHandler(enabled = bsState == BottomSheetState.EXPANDED) {
navigateBack()
}
MarketsTokenDetailsContent(
state = state,
onBackClick = onBack,
onHeaderSizeChange = onHeaderSizeChange,
modifier = modifier,
backgroundColor = LocalMainBottomSheetColor.current.value,
addTopBarStatusBarPadding = false,
state = state,
onBackClick = {
if (bsState == BottomSheetState.EXPANDED) {
navigateBack()
}
},
onHeaderSizeChange = onHeaderSizeChange,
portfolioBlock = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)
}
},
)
}
@Composable
override fun Content(modifier: Modifier) {
BackHandler {
navigateBack()
}
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
MarketsTokenDetailsContent(
modifier = modifier,
backgroundColor = TangemTheme.colors.background.tertiary,
addTopBarStatusBarPadding = true,
state = state,
onBackClick = ::navigateBack,
onHeaderSizeChange = {},
portfolioBlock = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)
}
},
)
}
private fun navigateBack() = router.pop()
@AssistedFactory
interface Factory : MarketsTokenDetailsComponent.Factory {
override fun create(
context: AppComponentContext,
params: MarketsTokenDetailsComponent.Params,
onBack: () -> Unit,
): DefaultMarketsTokenDetailsComponent
override fun create(context: AppComponentContext, params: Params): DefaultMarketsTokenDetailsComponent
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.markets.details.impl.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketParams
internal class MarketDetailsAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) {
data class EventBuilder(
val token: TokenMarketParams,
) {
fun screenOpened(blockchain: String?, source: String) = MarketDetailsAnalyticsEvent(
event = "Token Chart Screen Opened",
params = buildMap {
put("Token", token.symbol)
blockchain?.let { put("blockchain", it) }
put("Source", source)
},
)
fun intervalChanged(intervalType: IntervalType, interval: PriceChangeInterval) = MarketDetailsAnalyticsEvent(
event = "Button - Period",
params = mapOf(
"Token" to token.symbol,
"Period" to interval.toAnalyticsString(),
"Source" to intervalType.source,
),
)
fun readMoreClicked() = MarketDetailsAnalyticsEvent(
event = "Button - Read More",
params = mapOf(
"Token" to token.symbol,
),
)
fun linkClicked(linkTitle: String) = MarketDetailsAnalyticsEvent(
event = "Button - Links",
params = mapOf(
"Token" to token.symbol,
"Link" to linkTitle,
),
)
}
enum class IntervalType(val source: String) {
Chart("Chart"),
PricePerformance("Price"),
Insights("Insights"),
}
}
private fun PriceChangeInterval.toAnalyticsString() = when (this) {
PriceChangeInterval.H24 -> "24h"
PriceChangeInterval.WEEK -> "7d"
PriceChangeInterval.MONTH -> "1m"
PriceChangeInterval.MONTH3 -> "3m"
PriceChangeInterval.MONTH6 -> "6m"
PriceChangeInterval.YEAR -> "1y"
PriceChangeInterval.ALL_TIME -> "All"
}

View file

@ -1,6 +1,6 @@
package com.tangem.features.markets.details.impl.di
import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.DefaultMarketsTokenDetailsComponent
import dagger.Binds
import dagger.Module

View file

@ -2,7 +2,11 @@ package com.tangem.features.markets.details.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.charts.state.*
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.sorted
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
@ -10,24 +14,26 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.features.markets.component.BottomSheetState
import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter
import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter
import com.tangem.features.markets.details.impl.model.formatter.*
import com.tangem.features.markets.details.impl.model.formatter.formatAsPrice
import com.tangem.features.markets.details.impl.model.formatter.getChangePercentBetween
import com.tangem.features.markets.details.impl.model.formatter.getPercentByInterval
import com.tangem.features.markets.details.impl.model.state.QuotesStateUpdater
import com.tangem.features.markets.details.impl.model.state.TokenNetworksState
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
@ -43,18 +49,21 @@ import javax.inject.Inject
@Suppress("LargeClass", "LongParameterList")
@Stable
@ComponentScoped
internal class MarketsTokenDetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenQuotesUseCase: GetTokenQuotesUseCase,
private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private var quotesJob = JobHolder()
private val params = paramsContainer.require<MarketsTokenDetailsComponent.Params>()
private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token)
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
@ -70,13 +79,36 @@ internal class MarketsTokenDetailsModel @Inject constructor(
onInfoClick = {
showInfoBottomSheet(it)
},
onLinkClick = {
urlOpener.openUrl(it.url)
onLinkClick = { link ->
urlOpener.openUrl(link.url)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.linkClicked(linkTitle = link.title))
},
// === Analytics ===
onPricePerformanceIntervalChanged = {
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance,
interval = it,
),
)
},
onInsightsIntervalChanged = {
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights,
interval = it,
),
)
},
// ==================
)
private val descriptionConverter = DescriptionConverter(
onReadModeClicked = {
showInfoBottomSheet(it)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked())
},
)
@ -93,7 +125,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
BigDecimalFormatter.formatFiatPriceUncapped(
fiatAmount = value,
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = "",
fiatCurrencySymbol = currentAppCurrency.value.symbol,
)
},
)
@ -113,10 +145,11 @@ internal class MarketsTokenDetailsModel @Inject constructor(
),
)
private var lastUpdatedTimestamp: Long = DateTime.now().millis
private val currentTokenInfo = MutableStateFlow<TokenMarketInfo?>(null)
private val lastUpdatedTimestamp = MutableStateFlow(DateTime.now().millis)
val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED)
val isVisibleOnScreen = MutableStateFlow(false)
val networksState = MutableStateFlow<TokenNetworksState>(TokenNetworksState.Loading)
val state = MutableStateFlow(
MarketsTokenDetailsUM(
@ -127,19 +160,16 @@ internal class MarketsTokenDetailsModel @Inject constructor(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
),
dateTimeText = resourceReference(R.string.common_today),
priceChangePercentText = BigDecimalFormatter.formatPercent(
percent = params.token.tokenQuotes.h24Percent,
useAbsoluteValue = true,
),
priceChangeType = if (params.token.tokenQuotes.h24Percent < BigDecimal.ZERO) {
PriceChangeType.DOWN
} else {
PriceChangeType.UP
priceChangePercentText = params.token.tokenQuotes.h24Percent?.let {
BigDecimalFormatter.formatPercent(
percent = it,
useAbsoluteValue = true,
)
},
priceChangeType = params.token.tokenQuotes.h24Percent.percentChangeType(),
iconUrl = params.token.imageUrl,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = chartDataProducer,
chartLook = MarketChartLook(),
onLoadRetryClick = ::onLoadRetryClicked,
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = ::onMarkerPointSelected,
@ -157,6 +187,22 @@ internal class MarketsTokenDetailsModel @Inject constructor(
),
)
private val quotesStateUpdater = QuotesStateUpdater(
currentAppCurrency = Provider { currentAppCurrency.value },
state = state,
currentQuotes = currentQuotes,
lastUpdatedTimestamp = lastUpdatedTimestamp,
currentTokenInfo = currentTokenInfo,
onPricePerformanceIntervalChanged = {
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance,
interval = it,
),
)
},
)
private val loadChartJobHolder = JobHolder()
init {
@ -180,7 +226,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private fun loadQuotes() {
modelScope.launch {
val result = getTokenQuotesUseCase(
val result = getTokenFullQuotesUseCase(
tokenId = params.token.id,
appCurrency = currentAppCurrency.value,
)
@ -209,6 +255,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
appCurrency = currentAppCurrency.value,
interval = interval,
tokenId = params.token.id,
tokenSymbol = params.token.symbol,
preview = false,
)
state.update {
@ -225,9 +273,9 @@ internal class MarketsTokenDetailsModel @Inject constructor(
chart.onRight {
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
y = it.priceY.toImmutableList(),
)
x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted()
updateLook {
it.copy(
@ -242,6 +290,11 @@ internal class MarketsTokenDetailsModel @Inject constructor(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.DATA,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) {
MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked)
} else {
it.body
},
)
}
}.onLeft {
@ -272,34 +325,11 @@ internal class MarketsTokenDetailsModel @Inject constructor(
val tokenMarketInfo = getTokenMarketInfoUseCase(
appCurrency = currentAppCurrency.value,
tokenId = params.token.id,
tokenSymbol = params.token.symbol,
)
tokenMarketInfo.fold(
ifRight = { result ->
currentQuotes.value = result.quotes
val percent = result.quotes.getPercentByInterval(interval = state.value.selectedInterval)
state.update {
it.copy(
priceText = result.quotes.currentPrice.formatAsPrice(currentAppCurrency.value),
priceChangePercentText = result.quotes.getFormattedPercentByInterval(
interval = it.selectedInterval,
),
priceChangeType = percent.percentChangeType(),
body = MarketsTokenDetailsUM.Body.Content(
description = descriptionConverter.convert(result),
infoBlocks = infoConverter.convert(result),
),
)
}
chartDataProducer.runTransaction {
updateLook {
it.copy(
type = getChartTypeByPercent(percent),
)
}
}
},
ifRight = { result -> updateInfo(result) },
ifLeft = {
state.update {
if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) {
@ -319,48 +349,54 @@ internal class MarketsTokenDetailsModel @Inject constructor(
}
}
private suspend fun updateQuotes(newQuotes: TokenQuotes) {
val triggerPriceChangeType = getFormattedPriceChange(
currentPrice = currentQuotes.value.currentPrice,
updatedPrice = newQuotes.currentPrice,
)
val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) {
triggeredEvent(
data = triggerPriceChangeType,
onConsume = {
state.update { it.copy(triggerPriceChange = consumedEvent()) }
},
private fun updateInfo(newInfo: TokenMarketInfo) {
lastUpdatedTimestamp.value = DateTime.now().millis
currentTokenInfo.value = newInfo
currentQuotes.value = newInfo.quotes
val percent = newInfo.quotes.getPercentByInterval(interval = state.value.selectedInterval)
state.update {
it.copy(
priceText = newInfo.quotes.currentPrice.formatAsPrice(currentAppCurrency.value),
priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval(
interval = it.selectedInterval,
),
priceChangeType = percent.percentChangeType(),
body = MarketsTokenDetailsUM.Body.Content(
description = descriptionConverter.convert(newInfo),
infoBlocks = infoConverter.convert(newInfo),
),
)
} else {
consumedEvent()
}
val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval)
val priceChangeType = percent.percentChangeType()
val networks = newInfo.networks?.filter {
BlockchainUtils.isSupportedNetworkId(it.networkId)
}
// wait until marker is removed
state.first { it.markerSet.not() }
currentQuotes.value = newQuotes
lastUpdatedTimestamp = DateTime.now().millis
state.update { stateToUpdate ->
stateToUpdate.copy(
priceText = newQuotes.currentPrice.formatAsPrice(currentAppCurrency.value),
priceChangePercentText = newQuotes.getFormattedPercentByInterval(
interval = stateToUpdate.selectedInterval,
),
priceChangeType = priceChangeType,
triggerPriceChange = trigger,
dateTimeText = getDefaultDateTimeString(stateToUpdate.selectedInterval),
)
networksState.value = if (networks.isNullOrEmpty()) {
TokenNetworksState.NoNetworksAvailable
} else {
TokenNetworksState.NetworksAvailable(networks)
}
chartDataProducer.runTransaction {
updateLook {
it.copy(
type = getChartTypeByPercent(percent),
)
it.copy(type = percent.percentChangeType().toChartType())
}
}
}
private suspend fun updateQuotes(newQuotes: TokenQuotes) {
quotesStateUpdater.updateQuotes(newQuotes)
val percent = newQuotes
.getPercentByInterval(interval = state.value.selectedInterval)
chartDataProducer.runTransaction {
updateLook {
it.copy(type = percent.percentChangeType().toChartType())
}
}
}
@ -368,6 +404,15 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private fun onSelectedIntervalChange(interval: PriceChangeInterval) {
if (state.value.selectedInterval == interval) return
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.Chart,
interval = interval,
),
)
// ==================
val quotes = currentQuotes.value
val priceChangePercent = quotes.getFormattedPercentByInterval(interval)
@ -428,7 +473,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
chartDataProducer.runTransaction {
updateLook {
it.copy(
type = getChartTypeByPercent(percent),
type = percent.percentChangeType().toChartType(),
)
}
}
@ -475,9 +520,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
launch {
while (true) {
delay(timeMillis)
// Update quotes only when the container bottom sheet is in the expanded state
containerBottomSheetState.first { it == BottomSheetState.EXPANDED }
// and is visible on the screen
// Update quotes only when content is visible on the screen
isVisibleOnScreen.first { it }
loadQuotes()
@ -490,7 +533,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
interval = interval,
startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval(
interval = interval,
currentTimestamp = lastUpdatedTimestamp,
currentTimestamp = lastUpdatedTimestamp.value,
),
)
}

View file

@ -32,6 +32,7 @@ internal class DescriptionConverter(
),
),
body = stringReference(value.fullDescription ?: ""),
showGeneratedAINotification = true,
),
)
},

View file

@ -2,9 +2,10 @@ package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
@ -14,13 +15,14 @@ import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@Stable
internal class InsightsConverter(
private val appCurrency: Provider<AppCurrency>,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
private val onIntervalChanged: (PriceChangeInterval) -> Unit,
) : Converter<TokenMarketInfo.Insights, InsightsUM> {
override fun convert(value: TokenMarketInfo.Insights): InsightsUM {
@ -48,10 +50,14 @@ internal class InsightsConverter(
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_insights),
body = stringReference("//TODO"),
body = resourceReference(
R.string.markets_insights_info_description_message,
wrappedList(value.sourceNetworks.joinToString { it.name }),
),
),
)
},
onIntervalChanged = onIntervalChanged,
)
}
}
@ -62,74 +68,79 @@ internal class InsightsConverter(
liquidityChange: BigDecimal?,
buyPressureChange: BigDecimal?,
): ImmutableList<InfoPointUM> {
return persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = experiencedBuyerChange.convertChange(),
change = experiencedBuyerChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
body = resourceReference(R.string.markets_token_details_experienced_buyers_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = buyPressureChange.convertChange(isFiatValue = true),
change = buyPressureChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_buy_pressure),
body = resourceReference(R.string.markets_token_details_buy_pressure_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = holdersChange.convertChange(),
change = holdersChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_holders),
body = resourceReference(R.string.markets_token_details_holders_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = liquidityChange.convertChange(),
change = liquidityChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_liquidity),
body = resourceReference(R.string.markets_token_details_liquidity_description),
),
)
},
),
)
return listOfNotNull(
experiencedBuyerChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = experiencedBuyerChange.convertChange(),
change = experiencedBuyerChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
body = resourceReference(R.string.markets_token_details_experienced_buyers_description),
),
)
},
)
},
buyPressureChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = buyPressureChange.convertChange(isFiatValue = true),
change = buyPressureChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_buy_pressure),
body = resourceReference(R.string.markets_token_details_buy_pressure_description),
),
)
},
)
},
holdersChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = holdersChange.convertChange(),
change = holdersChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_holders),
body = resourceReference(R.string.markets_token_details_holders_description),
),
)
},
)
},
liquidityChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = liquidityChange.convertChange(),
change = liquidityChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_liquidity),
body = resourceReference(R.string.markets_token_details_liquidity_description),
),
)
},
)
},
).toImmutableList()
}
private fun BigDecimal?.changeType(): InfoPointUM.ChangeType? {
private fun BigDecimal.changeType(): InfoPointUM.ChangeType? {
return when {
this == null -> null
this > BigDecimal.ZERO -> InfoPointUM.ChangeType.UP
this < BigDecimal.ZERO -> InfoPointUM.ChangeType.DOWN
else -> null
}
}
private fun BigDecimal?.convertChange(isFiatValue: Boolean = false): String {
if (this == null) return StringsSigns.DASH_SIGN
private fun BigDecimal.convertChange(isFiatValue: Boolean = false): String {
val value = if (isFiatValue) {
val currency = appCurrency()
BigDecimalFormatter.formatCompactFiatAmount(
@ -141,11 +152,9 @@ internal class InsightsConverter(
BigDecimalFormatter.formatCompactAmount(amount = this.abs())
}
val spacing = if (isFiatValue) " " else ""
return when {
this > BigDecimal.ZERO -> StringsSigns.PLUS + spacing + value
this < BigDecimal.ZERO -> StringsSigns.MINUS + spacing + value
this > BigDecimal.ZERO -> StringsSigns.PLUS + value
this < BigDecimal.ZERO -> StringsSigns.MINUS + value
this == BigDecimal.ZERO -> value
else -> StringsSigns.DASH_SIGN
}

View file

@ -1,9 +1,9 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.details.impl.ui.state.LinksUM.Link
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
@ -25,7 +25,7 @@ internal class LinksConverter(
private fun TokenMarketInfo.Link.convert(): LinksUM.Link {
return LinksUM.Link(
title = stringReference(title),
title = title,
iconRes = getIconById(id),
url = link,
)

View file

@ -14,9 +14,6 @@ import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale
@Stable
internal class MetricsConverter(
@ -115,20 +112,16 @@ internal class MetricsConverter(
}
private fun BigDecimal?.formatAmount(crypto: Boolean = false): String {
if (this == null) return StringsSigns.DASH_SIGN
return if (crypto) {
val formatter = NumberFormat.getNumberInstance(Locale.getDefault()).apply {
maximumFractionDigits = 0
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
formatter.format(this)
BigDecimalFormatter.formatCompactAmount(amount = this)
} else {
val currency = appCurrency()
BigDecimalFormatter.formatFiatAmount(
fiatAmount = this,
BigDecimalFormatter.formatCompactFiatAmount(
amount = this,
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
decimals = 0,
)
}
}

View file

@ -3,29 +3,31 @@ package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
import java.math.RoundingMode
@Stable
internal class PricePerformanceConverter(
private val appCurrency: Provider<AppCurrency>,
) : Converter<TokenMarketInfo.PricePerformance, PricePerformanceUM> {
private val onIntervalChanged: (PriceChangeInterval) -> Unit,
) {
override fun convert(value: TokenMarketInfo.PricePerformance): PricePerformanceUM {
fun convert(value: TokenMarketInfo.PricePerformance, currentPrice: BigDecimal): PricePerformanceUM {
return PricePerformanceUM(
h24 = value.day.convert(),
month = value.month.convert(),
all = value.allTime.convert(),
h24 = value.day.convert(currentPrice),
month = value.month.convert(currentPrice),
all = value.allTime.convert(currentPrice),
onIntervalChanged = onIntervalChanged,
)
}
private fun TokenMarketInfo.Range?.convert(): PricePerformanceUM.Value {
if (this == null) {
private fun TokenMarketInfo.Range?.convert(currentPrice: BigDecimal): PricePerformanceUM.Value {
if (this == null || this.low == null || this.high == null) {
return PricePerformanceUM.Value(
low = StringsSigns.DASH_SIGN,
high = StringsSigns.DASH_SIGN,
@ -36,7 +38,7 @@ internal class PricePerformanceConverter(
return PricePerformanceUM.Value(
low = low.convert(),
high = high.convert(),
indicatorFraction = calculateFraction(),
indicatorFraction = calculateFraction(currentPrice),
)
}
@ -50,10 +52,15 @@ internal class PricePerformanceConverter(
)
}
private fun TokenMarketInfo.Range.calculateFraction(): Float {
if (low == null || high == null || low == BigDecimal.ZERO) return 0f
return (high!! - low!!).divide(low!!, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP)
.toFloat().coerceAtMost(1f)
private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float {
return when {
low == null || high == null || high == BigDecimal.ZERO || currentPrice < low -> 0f
currentPrice > high || low == high -> 1f
else -> {
(currentPrice - low!!).divide(high!! - low!!, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP)
.toFloat().coerceAtMost(1f)
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.LinksUM
@ -14,20 +15,37 @@ internal class TokenMarketInfoConverter(
appCurrency: Provider<AppCurrency>,
onInfoClick: (InfoBottomSheetContent) -> Unit,
onLinkClick: (LinksUM.Link) -> Unit,
onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit,
onInsightsIntervalChanged: (PriceChangeInterval) -> Unit,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.InformationBlocks> {
private val insightsConverter = InsightsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick)
private val insightsConverter = InsightsConverter(
appCurrency = appCurrency,
onInfoClick = onInfoClick,
onIntervalChanged = onInsightsIntervalChanged,
)
@Suppress("UnusedPrivateMember")
// TODO second markets iteration
private val securityScoreConverter = SecurityScoreConverter(onInfoClick = onInfoClick)
private val metricsConverter = MetricsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick)
private val pricePerformanceConverter = PricePerformanceConverter(appCurrency = appCurrency)
private val pricePerformanceConverter = PricePerformanceConverter(
appCurrency = appCurrency,
onIntervalChanged = onPricePerformanceIntervalChanged,
)
private val linksConverter = LinksConverter(onLinkClick = onLinkClick)
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks {
return MarketsTokenDetailsUM.InformationBlocks(
insights = value.insights?.let { insightsConverter.convert(it) },
securityScore = securityScoreConverter.convert(Unit),
securityScore = null,
metrics = value.metrics?.let { metricsConverter.convert(it) },
pricePerformance = value.pricePerformance?.let { pricePerformanceConverter.convert(it) },
pricePerformance = value.pricePerformance?.let {
pricePerformanceConverter.convert(
value = it,
currentPrice = value.quotes.currentPrice,
)
},
links = value.links?.let { linksConverter.convert(it) },
)
}

View file

@ -48,11 +48,13 @@ internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): Bi
}
}
@Suppress("MagicNumber")
internal fun BigDecimal?.percentChangeType(): PriceChangeType {
val scaled = this?.setScale(4, RoundingMode.HALF_UP)
return when {
this == null -> PriceChangeType.NEUTRAL
this > BigDecimal.ZERO -> PriceChangeType.UP
this < BigDecimal.ZERO -> PriceChangeType.DOWN
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
@ -67,10 +69,8 @@ internal fun getChangePercentBetween(currentPrice: BigDecimal, previousPrice: Bi
}
internal fun getFormattedPriceChange(currentPrice: BigDecimal, updatedPrice: BigDecimal): PriceChangeType {
val updatedPriceDecimals = BigDecimalFormatter.getProperFiatPriceDecimals(updatedPrice)
val current = currentPrice.setScale(updatedPriceDecimals, RoundingMode.HALF_UP)
val updated = updatedPrice.setScale(updatedPriceDecimals, RoundingMode.HALF_UP)
val current = BigDecimalFormatter.getFiatPriceUncappedWithScale(value = currentPrice).first
val updated = BigDecimalFormatter.getFiatPriceUncappedWithScale(value = updatedPrice).first
return when {
updated > current -> PriceChangeType.UP
@ -85,15 +85,4 @@ internal fun PriceChangeType.toChartType(): MarketChartLook.Type {
PriceChangeType.DOWN -> MarketChartLook.Type.Falling
PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral
}
}
@Suppress("MagicNumber")
internal fun getChartTypeByPercent(percent: BigDecimal?): MarketChartLook.Type {
val scaled = percent?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> return MarketChartLook.Type.Neutral
scaled > BigDecimal.ZERO -> MarketChartLook.Type.Growing
scaled < BigDecimal.ZERO -> MarketChartLook.Type.Falling
else -> MarketChartLook.Type.Neutral
}
}

View file

@ -23,7 +23,7 @@ internal object MarketsDateTimeFormatters {
private val dateFormatter = DateTimeFormatters.dateDDMMYYYY
internal fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String {
fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String {
return when (interval) {
PriceChangeInterval.H24 -> { value: BigDecimal ->
value.toLong().formatAsDateTime(DateTimeFormatters.timeFormatter)
@ -44,7 +44,7 @@ internal object MarketsDateTimeFormatters {
}
}
internal fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference {
fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference {
return when (interval) {
PriceChangeInterval.H24 -> resourceReference(R.string.common_today)
PriceChangeInterval.WEEK,
@ -78,10 +78,7 @@ internal object MarketsDateTimeFormatters {
}
}
internal fun formatDateByIntervalWithMarker(
interval: PriceChangeInterval,
markerTimestamp: BigDecimal,
): TextReference {
fun formatDateByIntervalWithMarker(interval: PriceChangeInterval, markerTimestamp: BigDecimal): TextReference {
return when (interval) {
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
@ -127,4 +124,14 @@ internal object MarketsDateTimeFormatters {
PriceChangeInterval.ALL_TIME -> 0
}
}
fun getDefaultDateTimeString(interval: PriceChangeInterval, currentTimestamp: Long): TextReference {
return formatDateByInterval(
interval = interval,
startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval(
interval = interval,
currentTimestamp = currentTimestamp,
),
)
}
}

View file

@ -0,0 +1,88 @@
package com.tangem.features.markets.details.impl.model.state
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenQuotes
import com.tangem.features.markets.details.impl.model.converters.PricePerformanceConverter
import com.tangem.features.markets.details.impl.model.formatter.*
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.utils.Provider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import org.joda.time.DateTime
import java.math.BigDecimal
internal class QuotesStateUpdater(
private val currentAppCurrency: Provider<AppCurrency>,
private val state: MutableStateFlow<MarketsTokenDetailsUM>,
private val currentQuotes: MutableStateFlow<TokenQuotes>,
private val lastUpdatedTimestamp: MutableStateFlow<Long>,
private val currentTokenInfo: MutableStateFlow<TokenMarketInfo?>,
private val onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit,
) {
private val pricePerformanceConverter = PricePerformanceConverter(
currentAppCurrency,
onIntervalChanged = onPricePerformanceIntervalChanged,
)
suspend fun updateQuotes(newQuotes: TokenQuotes) {
val triggerPriceChangeType = getFormattedPriceChange(
currentPrice = currentQuotes.value.currentPrice,
updatedPrice = newQuotes.currentPrice,
)
val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) {
triggeredEvent(
data = triggerPriceChangeType,
onConsume = {
state.update { it.copy(triggerPriceChange = consumedEvent()) }
},
)
} else {
consumedEvent()
}
val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval)
val priceChangeType = percent.percentChangeType()
// wait until marker is removed
state.first { it.markerSet.not() }
currentQuotes.value = newQuotes
lastUpdatedTimestamp.value = DateTime.now().millis
state.update { stateToUpdate ->
stateToUpdate.copy(
priceText = newQuotes.currentPrice.formatAsPrice(currentAppCurrency()),
priceChangePercentText = newQuotes.getFormattedPercentByInterval(
interval = stateToUpdate.selectedInterval,
),
priceChangeType = priceChangeType,
triggerPriceChange = trigger,
dateTimeText = MarketsDateTimeFormatters.getDefaultDateTimeString(
stateToUpdate.selectedInterval,
currentTimestamp = lastUpdatedTimestamp.value,
),
body = stateToUpdate.body.updatePricePerformance(newQuotes.currentPrice),
)
}
}
private fun MarketsTokenDetailsUM.Body.updatePricePerformance(price: BigDecimal): MarketsTokenDetailsUM.Body {
val currentPricePerformance = currentTokenInfo.value?.pricePerformance ?: return this
return if (this is MarketsTokenDetailsUM.Body.Content) {
copy(
infoBlocks = infoBlocks.copy(
pricePerformance = pricePerformanceConverter.convert(currentPricePerformance, price),
),
)
} else {
this
}
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.markets.details.impl.model.state
import com.tangem.domain.markets.TokenMarketInfo
internal sealed class TokenNetworksState {
data object Loading : TokenNetworksState()
data object NoNetworksAvailable : TokenNetworksState()
data class NetworksAvailable(val networks: List<TokenMarketInfo.Network>) : TokenNetworksState()
}

Some files were not shown because too many files have changed in this diff Show more