Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-20 13:41:48 +04:00
commit 7453e8d179
49 changed files with 1320 additions and 408 deletions

View file

@ -7,7 +7,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory
@ -20,12 +20,12 @@ internal fun SettingsAlertDialog(dialog: Dialog.Alert) {
title = dialog.title.resolveReference(),
message = dialog.description.resolveReference(),
isDismissable = false,
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = dialog.confirmText.resolveReference(),
warning = true,
onClick = dialog.onConfirm,
),
dismissButton = DialogButton(
dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
onClick = dialog.onDismiss,
),

View file

@ -6,7 +6,7 @@ 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.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.components.SelectorDialog
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemThemePreview
@ -21,7 +21,7 @@ internal fun SettingsSelectorDialog(dialog: Dialog.Selector) {
title = dialog.title.resolveReference(),
selectedItemIndex = dialog.selectedItemIndex,
items = dialog.items.map { it.resolveReference() }.toImmutableList(),
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(R.string.common_cancel),
onClick = dialog.onDismiss,
),

View file

@ -190,11 +190,11 @@ private fun CommonResetDialog(dialog: ResetCardScreenState.Dialog) {
BasicDialog(
title = stringResource(dialog.titleResId),
message = stringResource(dialog.messageResId),
dismissButton = DialogButton(
dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
onClick = dialog.onDismiss,
),
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(id = R.string.card_settings_action_sheet_reset),
warning = true,
onClick = dialog.onConfirmClick,
@ -208,7 +208,7 @@ private fun CompletedResetDialog(dialog: ResetCardDialog) {
BasicDialog(
title = stringResource(id = dialog.titleResId),
message = stringResource(id = dialog.messageResId),
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(id = R.string.common_ok),
onClick = dialog.onConfirmClick,
),

View file

@ -98,7 +98,7 @@ internal class HomeFragment : ComposeFragment(), StoreSubscriber<HomeState> {
viewModel.onSearchClick()
} else {
Analytics.send(IntroductionProcess.ButtonTokensList())
store.dispatchNavigationAction { push(AppRoute.ManageTokens(readOnlyContent = true)) }
store.dispatchNavigationAction { push(AppRoute.ManageTokens()) }
store.dispatch(TokensAction.SetArgs.ReadAccess)
}
},

View file

@ -61,7 +61,7 @@ internal class HomeViewModel @Inject constructor(
analyticsEventHandler.send(IntroductionProcess.ButtonTokensList())
store.dispatch(TokensAction.SetArgs.ReadAccess)
store.dispatchNavigationAction { push(AppRoute.ManageTokens(readOnlyContent = true)) }
store.dispatchNavigationAction { push(AppRoute.ManageTokens()) }
}
private fun scanCard() {

View file

@ -9,9 +9,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.tap.features.saveWallet.ui.models.EnrollBiometricsDialog
@Composable
@ -19,11 +19,11 @@ fun EnrollBiometricsDialogContent(dialog: EnrollBiometricsDialog) {
BasicDialog(
title = stringResource(R.string.save_user_wallet_agreement_enroll_biometrics_title),
message = stringResource(R.string.save_user_wallet_agreement_enroll_biometrics_description),
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(R.string.common_enable),
onClick = dialog.onEnroll,
),
dismissButton = DialogButton(
dismissButton = DialogButtonUM(
onClick = dialog.onCancel,
),
onDismissDialog = dialog.onCancel,

View file

@ -7,7 +7,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.tap.features.welcome.ui.model.WarningModel
import com.tangem.wallet.R
@ -27,7 +27,7 @@ internal fun WarningDialog(warning: WarningModel?) {
},
),
onDismissDialog = warning.onDismiss,
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(id = R.string.common_ok),
onClick = warning.onDismiss,
),
@ -38,7 +38,7 @@ internal fun WarningDialog(warning: WarningModel?) {
title = stringResource(id = R.string.common_attention),
message = stringResource(id = R.string.key_invalidated_warning_description),
onDismissDialog = warning.onDismiss,
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(id = R.string.common_ok),
onClick = warning.onDismiss,
),
@ -50,7 +50,7 @@ internal fun WarningDialog(warning: WarningModel?) {
message = stringResource(id = R.string.biometric_unavailable_warning),
onDismissDialog = warning.onDismiss,
isDismissable = false,
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(id = R.string.common_ok),
onClick = warning.onDismiss,
),

View file

@ -126,13 +126,7 @@ internal class ChildFactory @Inject constructor(
if (manageTokensToggles.isFeatureEnabled) {
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = ManageTokensComponent.Params(
mode = if (route.readOnlyContent) {
ManageTokensComponent.Mode.READ_ONLY
} else {
ManageTokensComponent.Mode.MANAGE
},
),
params = ManageTokensComponent.Params(route.userWalletId),
componentFactory = manageTokensComponentFactory,
)
} else {

View file

@ -177,8 +177,8 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class ManageTokens(
val readOnlyContent: Boolean,
) : AppRoute(path = "/manage_tokens/$readOnlyContent"), RouteBundleParams {
val userWalletId: UserWalletId? = null,
) : AppRoute(path = "/manage_tokens/$userWalletId"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}

View file

@ -59,7 +59,7 @@ fun GiveTxPermissionBottomSheet(config: TangemBottomSheetConfig) {
BasicDialog(
message = content.data.dialogText.resolveReference(),
title = stringResource(id = R.string.common_approve),
confirmButton = DialogButton { isPermissionAlertShow = false },
confirmButton = DialogButtonUM { isPermissionAlertShow = false },
onDismissDialog = {},
)
}

View file

@ -49,10 +49,10 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun BasicDialog(
message: String,
confirmButton: DialogButton,
confirmButton: DialogButtonUM,
onDismissDialog: () -> Unit,
title: String? = null,
dismissButton: DialogButton? = null,
dismissButton: DialogButtonUM? = null,
isDismissable: Boolean = true,
) {
TangemDialog(
@ -72,7 +72,7 @@ fun BasicDialog(
fun SimpleOkDialog(message: String, onDismissDialog: () -> Unit) {
TangemDialog(
type = DialogType.Message(message),
confirmButton = DialogButton(onClick = onDismissDialog),
confirmButton = DialogButtonUM(onClick = onDismissDialog),
onDismissDialog = onDismissDialog,
title = null,
dismissButton = null,
@ -97,12 +97,12 @@ fun SimpleOkDialog(message: String, onDismissDialog: () -> Unit) {
@Composable
fun TextInputDialog(
fieldValue: TextFieldValue,
confirmButton: DialogButton,
confirmButton: DialogButtonUM,
onDismissDialog: () -> Unit,
onValueChange: (TextFieldValue) -> Unit,
textFieldParams: AdditionalTextInputDialogParams = remember { AdditionalTextInputDialogParams() },
textFieldParams: AdditionalTextInputDialogUM = remember { AdditionalTextInputDialogUM() },
title: String? = null,
dismissButton: DialogButton? = null,
dismissButton: DialogButtonUM? = null,
isDismissable: Boolean = true,
) {
TangemDialog(
@ -125,12 +125,12 @@ fun TextInputDialog(
@Composable
fun TextInputDialog(
fieldValue: String,
confirmButton: DialogButton,
confirmButton: DialogButtonUM,
onDismissDialog: () -> Unit,
onValueChange: (String) -> Unit,
textFieldParams: AdditionalTextInputDialogParams = remember { AdditionalTextInputDialogParams() },
textFieldParams: AdditionalTextInputDialogUM = remember { AdditionalTextInputDialogUM() },
title: String? = null,
dismissButton: DialogButton? = null,
dismissButton: DialogButtonUM? = null,
isDismissable: Boolean = true,
) {
TangemDialog(
@ -154,7 +154,7 @@ fun TextInputDialog(
fun SelectorDialog(
selectedItemIndex: Int,
items: ImmutableList<String>,
confirmButton: DialogButton,
confirmButton: DialogButtonUM,
onSelect: (index: Int) -> Unit,
onDismissDialog: () -> Unit,
title: String? = null,
@ -180,7 +180,7 @@ fun SelectorDialog(
* @param enabled If false button will be disabled
* @param onClick Button click callback
*/
data class DialogButton(
data class DialogButtonUM(
val title: String? = null,
val warning: Boolean = false,
val enabled: Boolean = true,
@ -190,7 +190,7 @@ data class DialogButton(
/**
* Additional params for dialog text field
*/
data class AdditionalTextInputDialogParams(
data class AdditionalTextInputDialogUM(
val label: String? = null,
val placeholder: String? = null,
val caption: String? = null,
@ -203,10 +203,10 @@ data class AdditionalTextInputDialogParams(
@Composable
private fun TangemDialog(
type: DialogType,
confirmButton: DialogButton,
confirmButton: DialogButtonUM,
onDismissDialog: () -> Unit,
title: String? = null,
dismissButton: DialogButton? = null,
dismissButton: DialogButtonUM? = null,
properties: DialogProperties = DialogProperties(),
) {
Dialog(properties = properties, onDismissRequest = onDismissDialog) {
@ -304,7 +304,11 @@ private fun DialogContent(type: DialogType, modifier: Modifier = Modifier) {
}
@Composable
private fun DialogButtons(confirmButton: DialogButton, dismissButton: DialogButton?, modifier: Modifier = Modifier) {
private fun DialogButtons(
confirmButton: DialogButtonUM,
dismissButton: DialogButtonUM?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(
@ -413,13 +417,13 @@ private sealed class DialogType {
data class TextInput(
val value: TextFieldValue,
val onValueChange: (TextFieldValue) -> Unit,
val params: AdditionalTextInputDialogParams = AdditionalTextInputDialogParams(),
val params: AdditionalTextInputDialogUM = AdditionalTextInputDialogUM(),
) : DialogType()
data class SimpleTextInput(
val value: String,
val onValueChange: (String) -> Unit,
val params: AdditionalTextInputDialogParams = AdditionalTextInputDialogParams(),
val params: AdditionalTextInputDialogUM = AdditionalTextInputDialogUM(),
) : DialogType()
data class Selector(
@ -445,8 +449,8 @@ private fun BasicDialogPreview() {
message = "All protected passwords will be deleted from the secure storage, you must enter the wallet " +
"password to work with the app",
title = "Attention",
confirmButton = DialogButton {},
dismissButton = DialogButton {},
confirmButton = DialogButtonUM {},
dismissButton = DialogButtonUM {},
onDismissDialog = {},
)
}
@ -478,8 +482,8 @@ private fun WarningBasicDialogSample(modifier: Modifier = Modifier) {
message = "All protected passwords will be deleted from the secure storage, you must enter the wallet " +
"password to work with the app",
title = "Attention",
confirmButton = DialogButton(warning = true) {},
dismissButton = DialogButton {},
confirmButton = DialogButtonUM(warning = true) {},
dismissButton = DialogButtonUM {},
onDismissDialog = {},
)
}
@ -502,10 +506,10 @@ private fun TextInputDialogSample(modifier: Modifier = Modifier) {
TextInputDialog(
fieldValue = TextFieldValue(text = ""),
title = "Rename Wallet",
confirmButton = DialogButton {},
confirmButton = DialogButtonUM {},
onDismissDialog = {},
onValueChange = {},
textFieldParams = AdditionalTextInputDialogParams(
textFieldParams = AdditionalTextInputDialogUM(
label = "Wallet name",
),
)
@ -530,7 +534,7 @@ private fun SelectorDialogPreview(@PreviewParameter(SelctorDialogParamsProvider:
title = param.title,
items = param.items,
selectedItemIndex = param.selectedItemIndex,
confirmButton = DialogButton(title = "Cancel", onClick = {}),
confirmButton = DialogButtonUM(title = "Cancel", onClick = {}),
onSelect = {},
onDismissDialog = {},
)

View file

@ -38,7 +38,7 @@ sealed class CurrencyIconState {
* Represents a token icon.
*
* @property url The URL where the token icon can be fetched from. May be `null` if not found.
* @property topBadgeIconResId The drawable resource ID for the network badge.
* @property topBadgeIconResId The drawable resource ID for the network badge. May be `null`.
* @property isGrayscale Specifies whether to show the icon in grayscale.
* @property showCustomBadge Specifies whether to show the custom token badge.
* @property fallbackTint The color to be used for tinting the fallback icon.
@ -46,7 +46,7 @@ sealed class CurrencyIconState {
*/
data class TokenIcon(
val url: String?,
@DrawableRes override val topBadgeIconResId: Int,
@DrawableRes override val topBadgeIconResId: Int?,
override val isGrayscale: Boolean,
override val showCustomBadge: Boolean,
val fallbackTint: Color,
@ -81,4 +81,28 @@ sealed class CurrencyIconState {
override val showCustomBadge: Boolean = false
override val topBadgeIconResId: Int? = null
}
fun copySealed(
isGrayscale: Boolean = this.isGrayscale,
showCustomBadge: Boolean = this.showCustomBadge,
topBadgeIconResId: Int? = this.topBadgeIconResId,
): CurrencyIconState = when (this) {
is CoinIcon -> copy(
isGrayscale = isGrayscale,
showCustomBadge = showCustomBadge,
)
is CustomTokenIcon -> copy(
isGrayscale = isGrayscale,
showCustomBadge = showCustomBadge,
topBadgeIconResId = topBadgeIconResId ?: this.topBadgeIconResId,
)
is TokenIcon -> copy(
isGrayscale = isGrayscale,
showCustomBadge = showCustomBadge,
topBadgeIconResId = topBadgeIconResId,
)
is Loading,
is Locked,
-> this
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.core.ui.components.list
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
@Composable
fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Boolean, buffer: Int = 2) {
val loadMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > totalItemsNumber - buffer
}
}
val totalItemsCount by remember { derivedStateOf { listState.layoutInfo.totalItemsCount } }
var emitted by remember(totalItemsCount) { mutableStateOf(false) }
LaunchedEffect(loadMore) {
if (loadMore && !emitted) {
emitted = onLoadMore()
}
}
}

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="28"
android:viewportHeight="28">
<path
android:fillColor="#1E1E1E"
android:pathData="M8.82,17.87C9.04,16.02 9.74,14.31 10.8,12.89C10.41,13.08 9.93,13.2 9.38,13.24V14H8.55V13.25C6.83,13.13 5.74,12.22 5.74,10.86H7.02C7.08,11.56 7.66,12.04 8.55,12.14V9.48L8.02,9.35C6.67,9.02 5.94,8.23 5.94,7.09C5.94,5.75 6.93,4.85 8.55,4.71V3.9H9.38V4.71C10.94,4.84 11.97,5.77 12,7.06H10.74C10.72,6.42 10.18,5.92 9.38,5.83V8.34L9.94,8.47C11.45,8.82 12.16,9.56 12.16,10.77C12.16,11.03 12.13,11.27 12.07,11.49C13.63,10.05 15.64,9.08 17.87,8.82C17.81,3.94 13.83,0 8.94,0C4,0 0,4 0,8.94C0,13.83 3.94,17.81 8.82,17.87ZM17.78,10.22C13.87,10.79 10.79,13.87 10.22,17.78L17.78,10.22ZM8.55,8.17C7.69,8.01 7.23,7.59 7.23,6.99C7.23,6.35 7.78,5.86 8.55,5.82V8.17ZM9.38,12.15V9.63C10.4,9.83 10.88,10.24 10.88,10.91C10.88,11.65 10.33,12.1 9.38,12.15Z" />
<path
android:fillColor="#1E1E1E"
android:pathData="M10.13,19.07C10.13,14.13 14.13,10.13 19.06,10.13C24,10.13 28,14.13 28,19.07C28,24 24,28 19.06,28C14.13,28 10.13,24 10.13,19.07ZM19.53,17.53L20.48,14.82L19.07,14.32L17.78,17.96L15.34,18.57L15.7,20.03L17.18,19.66L16.23,22.36C16.15,22.59 16.18,22.84 16.33,23.04C16.47,23.24 16.69,23.36 16.94,23.36H22.61V21.86H18L18.93,19.22L21.37,18.61L21.01,17.16L19.53,17.53Z" />
</vector>

View file

@ -1,17 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="28dp"
android:viewportWidth="28"
android:viewportHeight="28">
<group>
<clip-path android:pathData="M0,0h28v28h-28z" />
<path
android:pathData="M8.821,17.869C9.035,16.016 9.741,14.312 10.803,12.892C10.407,13.076 9.929,13.195 9.38,13.241V14H8.552V13.247C6.83,13.134 5.744,12.22 5.738,10.863H7.015C7.077,11.556 7.657,12.04 8.552,12.142V9.483L8.015,9.346C6.67,9.017 5.935,8.234 5.935,7.093C5.935,5.749 6.929,4.852 8.552,4.709V3.902H9.38V4.709C10.941,4.84 11.966,5.766 12.003,7.063H10.744C10.719,6.424 10.176,5.922 9.38,5.826V8.342L9.941,8.467C11.454,8.82 12.163,9.561 12.163,10.768C12.163,11.028 12.13,11.27 12.065,11.493C13.628,10.047 15.64,9.079 17.869,8.822C17.808,3.939 13.831,0 8.935,0C4,0 0,4 0,8.935C0,13.831 3.939,17.808 8.821,17.869ZM17.776,10.222C13.873,10.786 10.786,13.873 10.222,17.776L17.776,10.222ZM8.552,8.174C7.688,8.007 7.225,7.589 7.225,6.991C7.225,6.352 7.781,5.862 8.552,5.82V8.174ZM9.38,12.154V9.632C10.404,9.83 10.88,10.236 10.88,10.905C10.88,11.646 10.33,12.1 9.38,12.154Z"
android:fillColor="#000000"
android:fillType="evenOdd" />
<path
android:pathData="M10.13,19.066C10.13,14.132 14.13,10.131 19.064,10.131C23.998,10.131 27.999,14.132 27.999,19.066C27.999,24 23.998,28.001 19.064,28.001C14.13,28.001 10.13,24 10.13,19.066ZM19.527,17.526L20.48,14.823L19.066,14.323L17.782,17.962L15.337,18.573L15.701,20.028L17.183,19.658L16.23,22.36C16.149,22.589 16.184,22.844 16.325,23.043C16.465,23.241 16.694,23.36 16.937,23.36H22.61V21.86H17.997L18.928,19.222L21.374,18.61L21.01,17.155L19.527,17.526Z"
android:fillColor="#000000"
android:fillType="evenOdd" />
</group>
</vector>

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,10 @@ dependencies {
implementation(projects.core.featuretoggles)
/* Project - Domain */
implementation(projects.domain.wallets.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
/* AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -1,5 +1,6 @@
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
@ -24,6 +25,8 @@ internal class DefaultManageTokensComponent @AssistedInject constructor(
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
BackHandler(onBack = state.popBack)
ManageTokensScreen(
modifier = modifier,
state = state,

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.CurrencyItemUM
import com.tangem.features.managetokens.entity.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.ManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.ManageTokensScreen
import kotlinx.collections.immutable.mutate
@ -47,8 +50,10 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
onActiveChange = ::toggleSearchBar,
),
hasChanges = false,
isLoading = false,
onSaveClick = {},
isInitialBatchLoading = false,
isNextBatchLoading = true,
loadMore = { false },
saveChanges = {},
),
)
@ -58,7 +63,7 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
initItems()
} else {
items.filter { currency ->
currency.model.name.contains(query, ignoreCase = true)
currency.name.contains(query, ignoreCase = true)
}.toPersistentList()
}
@ -96,34 +101,28 @@ internal class PreviewManageTokensComponent : ManageTokensComponent {
}.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))

View file

@ -1,18 +1,23 @@
package com.tangem.features.managetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.rows.model.ChainRowUM
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: String
abstract val model: ChainRowUM
abstract val id: ManagedCryptoCurrency.ID
abstract val name: String
abstract val symbol: String
abstract val icon: CurrencyIconState
data class Basic(
override val id: String,
override val model: ChainRowUM,
override val id: ManagedCryptoCurrency.ID,
override val name: String,
override val symbol: String,
override val icon: CurrencyIconState,
val networks: NetworksUM,
val onExpandClick: () -> Unit,
) : CurrencyItemUM() {
@ -29,8 +34,10 @@ internal sealed class CurrencyItemUM {
}
data class Custom(
override val id: String,
override val model: ChainRowUM,
override val id: ManagedCryptoCurrency.ID,
override val name: String,
override val symbol: String,
override val icon: CurrencyIconState,
val onRemoveClick: () -> Unit,
) : CurrencyItemUM()
}

View file

@ -8,26 +8,32 @@ import kotlinx.collections.immutable.ImmutableList
internal sealed class ManageTokensUM {
abstract val popBack: () -> Unit
abstract val isLoading: Boolean
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
data class ReadContent(
override val popBack: () -> Unit,
override val isLoading: Boolean,
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,
) : ManageTokensUM()
data class ManageContent(
override val popBack: () -> Unit,
override val isLoading: Boolean,
override val isInitialBatchLoading: Boolean,
override val isNextBatchLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val search: SearchBarUM,
val onSaveClick: () -> Unit,
override val loadMore: () -> Boolean,
val saveChanges: () -> Unit,
val hasChanges: Boolean,
) : ManageTokensUM()
@ -35,10 +41,23 @@ internal sealed class ManageTokensUM {
search: SearchBarUM = this.search,
items: ImmutableList<CurrencyItemUM> = this.items,
hasChanges: Boolean = this is ManageContent && this.hasChanges,
isInitialBatchLoading: Boolean = this.isInitialBatchLoading,
isNextBatchLoading: Boolean = this.isNextBatchLoading,
): ManageTokensUM {
return when (this) {
is ManageContent -> copy(search = search, items = items, hasChanges = hasChanges)
is ReadContent -> copy(search = search, items = items)
is ManageContent -> copy(
search = search,
items = items,
hasChanges = hasChanges,
isInitialBatchLoading = isInitialBatchLoading,
isNextBatchLoading = isNextBatchLoading,
)
is ReadContent -> copy(
search = search,
items = items,
isInitialBatchLoading = isInitialBatchLoading,
isNextBatchLoading = isNextBatchLoading,
)
}
}
}

View file

@ -1,53 +1,78 @@
package com.tangem.features.managetokens.model
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.util.fastForEachIndexed
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.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.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.entity.*
import com.tangem.features.managetokens.entity.CurrencyItemUM
import com.tangem.features.managetokens.entity.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.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.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
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,
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))
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)
modelScope.launch {
manageTokensListManager.launchPagination(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 = getInitialItems(),
topBar = ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_search_tokens),
onBackButtonClick = router::pop,
@ -59,14 +84,16 @@ 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 = getInitialItems(),
topBar = ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = router::pop,
@ -82,162 +109,123 @@ internal class ManageTokensModel @Inject constructor(
isActive = false,
onActiveChange = ::toggleSearchBar,
),
onSaveClick = ::onSaveClick,
hasChanges = false,
saveChanges = ::saveChanges,
loadMore = ::loadMoreItems,
)
}
private fun updateItems(items: ImmutableList<CurrencyItemUM>) {
state.update { state ->
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
} else {
state.copySealed(
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,
is PaginationStatus.EndOfPagination,
-> state.copySealed(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
}
}
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 getInitialItems(): ImmutableList<CurrencyItemUM> {
return persistentListOf()
}
private fun onAddCustomToken() {
// TODO: [REDACTED_JIRA]
}
private fun onSaveClick() {
private fun saveChanges() {
// 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()
}
state.update { state ->
state.copySealed(search = state.search.copy(query = query), items = newItems)
state.copySealed(
search = state.search.copy(
query = query,
isActive = true,
),
)
}
modelScope.launch {
manageTokensListManager.search(params.userWalletId, query)
}
}
private fun toggleSearchBar(isActive: Boolean) {
state.update { state ->
state.copySealed(
search = state.search.copy(isActive = isActive),
search = state.search.copy(
query = if (isActive) state.search.query else "",
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
modelScope.launch {
if (!isActive) {
manageTokensListManager.reload(params.userWalletId)
}
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(),
)
}
}
}

View file

@ -102,7 +102,7 @@ private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier)
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = model.iconResId,
isGrayscale = false,
isGrayscale = !model.isSelected,
showCustomBadge = false,
),
showCustom = false,

View file

@ -1,15 +1,14 @@
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.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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,6 +19,12 @@ 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
@ -33,14 +38,17 @@ import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
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.model.BlockchainRowUM
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
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.preview.PreviewManageTokensComponent
import com.tangem.features.managetokens.entity.CurrencyItemUM
import com.tangem.features.managetokens.entity.CurrencyItemUM.Basic.NetworksUM
@ -51,18 +59,30 @@ 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 +90,7 @@ 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,
)
},
floatingActionButtonPosition = FabPosition.Center,
@ -81,10 +98,11 @@ 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,
onClick = state.saveChanges,
)
}
},
@ -92,16 +110,26 @@ 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
@ -122,80 +150,58 @@ private fun SaveChangesButton(isVisible: Boolean, onClick: () -> Unit, modifier:
}
@Composable
private fun LoadingContent() {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = TangemTheme.colors.background.primary),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = TangemTheme.colors.icon.accent)
}
}
@Composable
private fun Content(
search: SearchBarUM,
items: ImmutableList<CurrencyItemUM>,
isLoading: Boolean,
hasChanges: Boolean,
modifier: Modifier = Modifier,
) {
private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Currencies(
modifier = Modifier.fillMaxSize(),
items = items,
search = search,
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()
Crossfade(targetState = state.isInitialBatchLoading, label = "ManageTokensLoadingContent") { isVisible ->
if (isVisible) {
ProgressIndicator(
modifier = Modifier.fillMaxSize(),
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Currencies(items: ImmutableList<CurrencyItemUM>, search: SearchBarUM, modifier: Modifier = Modifier) {
private fun Currencies(
items: ImmutableList<CurrencyItemUM>,
showLoadingItem: Boolean,
isEditable: Boolean,
onLoadMore: () -> Boolean,
modifier: Modifier = Modifier,
) {
val bottomBarHeight = with(LocalDensity.current) {
WindowInsets.systemBars.getBottom(density = this).toDp()
}
val listState = rememberLazyListState()
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 -> {
@ -206,6 +212,32 @@ private fun Currencies(items: ImmutableList<CurrencyItemUM>, search: SearchBarUM
}
}
}
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)
}
}
@ -213,7 +245,14 @@ private fun Currencies(items: ImmutableList<CurrencyItemUM>, search: SearchBarUM
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 +265,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 +306,19 @@ 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,
)
}
}
@Composable
private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Modifier = Modifier) {
private fun NetworksList(
networks: NetworksUM,
currencyId: String,
isEditable: Boolean,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
modifier = modifier,
visible = networks is NetworksUM.Expanded,
@ -297,10 +349,12 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod
)
},
action = {
TangemSwitch(
checked = network.isSelected,
onCheckedChange = network.onSelectedStateChange,
)
if (isEditable) {
TangemSwitch(
checked = network.isSelected,
onCheckedChange = network.onSelectedStateChange,
)
}
},
)
},

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,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.ID, Set<Network.ID>>
internal class ChangedCurrenciesManager {
val currenciesToAdd: MutableStateFlow<ChangedCurrencies> = MutableStateFlow(emptyMap())
val currenciesToRemove: MutableStateFlow<ChangedCurrencies> = MutableStateFlow(emptyMap())
fun addCurrency(currencyId: ManagedCryptoCurrency.ID, networkId: Network.ID) {
updateChangedItems(currencyId, networkId, currenciesToRemove, currenciesToAdd)
}
fun removeCurrency(currencyId: ManagedCryptoCurrency.ID, networkId: Network.ID) {
updateChangedItems(currencyId, networkId, currenciesToAdd, currenciesToRemove)
}
fun containsCurrency(currencyId: ManagedCryptoCurrency.ID, networkId: Network.ID): Boolean {
return networkId in currenciesToAdd.value[currencyId].orEmpty() ||
networkId in currenciesToRemove.value[currencyId].orEmpty()
}
private fun updateChangedItems(
currencyId: ManagedCryptoCurrency.ID,
networkId: Network.ID,
removeFromIfPresent: MutableStateFlow<ChangedCurrencies>,
addToIfNotPresent: MutableStateFlow<ChangedCurrencies>,
) {
val present = removeFromIfPresent.value[currencyId].orEmpty()
if (networkId in present) {
removeFromIfPresent.update { items ->
items.toMutableMap().apply {
val ids = present - networkId
if (ids.isEmpty()) {
remove(currencyId)
} else {
set(currencyId, ids)
}
}
}
} else {
addToIfNotPresent.update { items ->
val alreadyAdded = items[currencyId] ?: emptySet()
if (networkId in alreadyAdded) {
return@update items
}
items + (currencyId to alreadyAdded + networkId)
}
}
}
}

View file

@ -0,0 +1,209 @@
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.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.GetManagedTokensUseCase
import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext
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.tokens.CheckHasLinkedTokensUseCase
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.entity.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 timber.log.Timber
import javax.inject.Inject
@ComponentScoped
internal class ManageTokensListManager @Inject constructor(
private val getManagedTokensUseCase: GetManagedTokensUseCase,
private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase,
private val messageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
) : 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 },
)
val currenciesToAdd: StateFlow<ChangedCurrencies> = changedCurrenciesManager.currenciesToAdd
val currenciesToRemove: StateFlow<ChangedCurrencies> = changedCurrenciesManager.currenciesToRemove
@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?) {
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, currencyId: ManagedCryptoCurrency.ID, networkId: Network.ID) {
changedCurrenciesManager.addCurrency(currencyId, networkId)
sendSelectCurrencyAction(batchKey, currencyId, networkId, isSelected = true)
}
override fun removeCurrency(batchKey: Int, currencyId: ManagedCryptoCurrency.ID, networkId: Network.ID) {
changedCurrenciesManager.removeCurrency(currencyId, networkId)
sendSelectCurrencyAction(batchKey, currencyId, networkId, isSelected = false)
}
override fun checkNeedToShowRemoveNetworkWarning(
currencyId: ManagedCryptoCurrency.ID,
networkId: Network.ID,
): Boolean = !changedCurrenciesManager.containsCurrency(currencyId, networkId)
private fun sendSelectCurrencyAction(
batchKey: Int,
currencyId: ManagedCryptoCurrency.ID,
networkId: Network.ID,
isSelected: Boolean,
) {
val request = ManageTokensUpdateAction.AddCurrency(
currencyId = currencyId,
networkId = networkId,
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, network).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
}
}
}

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.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,16 @@
package com.tangem.features.managetokens.utils.list
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, currencyId: ManagedCryptoCurrency.ID, networkId: Network.ID)
fun removeCurrency(batchKey: Int, currencyId: ManagedCryptoCurrency.ID, networkId: Network.ID)
fun checkNeedToShowRemoveNetworkWarning(currencyId: ManagedCryptoCurrency.ID, networkId: Network.ID): Boolean
suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean
}

View file

@ -0,0 +1,203 @@
package com.tangem.features.managetokens.utils.list
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.message.ContentMessage
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.entity.CurrencyItemUM
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 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 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 }
if (indexToUpdate == -1) {
val newBatch = Batch(
key = key,
data = data.map { item ->
item.toUiModel(
isEditable = canEditItems,
onRemoveCustomCurrencyClick = ::removeCustomCurrency,
onExpandNetworksClick = ::toggleCurrencyNetworksVisibility,
)
},
)
batches.add(newBatch)
} else {
val uiBatchToUpdate = currentUiBatches[indexToUpdate]
if (uiBatchToUpdate.data == data) {
return@forEach
}
val currentCurrencyBatches = state.value.currencyBatches
val currencyBatch = currentCurrencyBatches[indexToUpdate]
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 = {
// TODO: [REDACTED_JIRA]
},
)
}
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)
},
)
batches.updateUiBatchesItem(
indexToBatch = batchIndex to uiBatch,
indexToItem = currencyIndex to updatedUiItem,
)
}
}
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) {
actions.addCurrency(batchKey, currency.id, source.id)
} else {
if (actions.checkNeedToShowRemoveNetworkWarning(currency.id, source.id)) {
showRemoveNetworkWarning(
currency = currency,
network = source.network,
isCoin = source is ManagedCryptoCurrency.SourceNetwork.Main,
onConfirm = {
actions.removeCurrency(batchKey, currency.id, source.id)
},
)
} else {
actions.removeCurrency(batchKey, currency.id, source.id)
}
}
}
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.CurrencyItemUM
import com.tangem.features.managetokens.entity.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,46 @@
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.features.managetokens.entity.CurrencyItemUM.Basic.NetworksUM
import com.tangem.features.managetokens.entity.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,
): NetworksUM {
return if (isExpanded) {
NetworksUM.Expanded(
networks = availableNetworks.map {
it.toUiModel(
isSelected = it.id in addedIn,
isEditable = isItemsEditable,
onSelectedStateChange = onSelectedStateChange,
)
}.toImmutableList(),
)
} else {
NetworksUM.Collapsed
}
}
private fun SourceNetwork.toUiModel(
isSelected: Boolean,
isEditable: Boolean,
onSelectedStateChange: (SourceNetwork, Boolean) -> Unit,
): CurrencyNetworkUM {
return CurrencyNetworkUM(
id = id,
name = network.name.uppercase(),
iconResId = id.getIconRes(isColored = isSelected || !isEditable),
isSelected = isSelected || !isEditable,
type = typeName,
isMainNetwork = this is SourceNetwork.Main,
onSelectedStateChange = { selected ->
onSelectedStateChange(this, selected)
},
)
}

View file

@ -0,0 +1,66 @@
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.CurrencyItemUM
import com.tangem.features.managetokens.entity.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,
): CurrencyItemUM {
if (currency !is ManagedCryptoCurrency.Token) return this
return when (this) {
is CurrencyItemUM.Custom -> 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,
),
)
}
}
}
internal fun CurrencyItemUM.update(currency: ManagedCryptoCurrency): CurrencyItemUM {
return when (this) {
is CurrencyItemUM.Custom -> 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.id in currency.addedIn
network.copy(
iconResId = 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.CurrencyNetworkUM
internal fun CurrencyNetworkUM.select(isSelected: Boolean): CurrencyNetworkUM {
return copy(
iconResId = id.getIconRes(isSelected),
isSelected = isSelected,
)
}
@DrawableRes
internal fun Network.ID.getIconRes(isColored: Boolean): Int = if (isColored) {
getActiveIconRes(value)
} else {
getGreyedOutIconRes(value)
}

View file

@ -13,6 +13,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
@ -196,26 +197,4 @@ private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) {
state.visibleIdsChanged(visibleItems)
}
}
}
@Composable
fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Boolean, buffer: Int = 2) {
val loadMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > totalItemsNumber - buffer
}
}
val totalItemsCount by remember { derivedStateOf { listState.layoutInfo.totalItemsCount } }
var emitted by remember(totalItemsCount) { mutableStateOf(false) }
LaunchedEffect(loadMore) {
if (loadMore && !emitted) {
emitted = onLoadMore()
}
}
}

View file

@ -195,7 +195,7 @@ private fun ReferralInfo(
}
is ReferralInfoState.Loading -> {
LoadingCondition(iconResId = R.drawable.ic_tether_28)
LoadingCondition(iconResId = R.drawable.ic_tether_24)
SpacerH32()
LoadingCondition(iconResId = R.drawable.ic_discount_28)
}
@ -211,7 +211,7 @@ private fun Conditions(state: ReferralInfoContentState) {
@Composable
private fun ConditionForYou(state: ReferralInfoContentState) {
Condition(iconResId = R.drawable.ic_tether_28) {
Condition(iconResId = R.drawable.ic_tether_24) {
when (state) {
is ReferralInfoState.ParticipantContent -> InfoForYou(
award = state.award,

View file

@ -6,7 +6,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.resolveReference
@ -45,24 +45,24 @@ internal fun SendEventEffect(event: StateEvent<SendEvent>, snackbarHostState: Sn
@Composable
internal fun SendAlert(state: SendAlertState, onDismiss: () -> Unit) {
val confirmButton: DialogButton
val dismissButton: DialogButton?
val confirmButton: DialogButtonUM
val dismissButton: DialogButtonUM?
val onActionClick = state.onConfirmClick
if (onActionClick != null) {
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = {
onActionClick()
onDismiss()
},
)
dismissButton = DialogButton(
dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
onClick = onDismiss,
)
} else {
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = onDismiss,
)

View file

@ -110,7 +110,7 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi
BasicDialog(
title = state.alert.title?.resolveReference(),
message = message,
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(id = R.string.common_ok),
onClick = state.alert.onClick,
),

View file

@ -2,7 +2,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components
import androidx.compose.runtime.Composable
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig
@ -19,7 +19,7 @@ internal fun TokenDetailsDialogs(state: TokenDetailsState) {
private fun TokenDetailsDialog(config: TokenDetailsDialogConfig) {
BasicDialog(
message = config.content.message.resolveReference(),
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = config.content.confirmButtonConfig.text.resolveReference(),
warning = config.content.confirmButtonConfig.warning,
onClick = config.content.confirmButtonConfig.onClick,
@ -27,7 +27,7 @@ private fun TokenDetailsDialog(config: TokenDetailsDialogConfig) {
onDismissDialog = config.onDismissRequest,
title = config.content.title?.resolveReference(),
dismissButton = config.content.cancelButtonConfig?.let { cancelButtonConfig ->
DialogButton(
DialogButtonUM(
title = cancelButtonConfig.text.resolveReference(),
warning = cancelButtonConfig.warning,
onClick = cancelButtonConfig.onClick,

View file

@ -15,6 +15,7 @@ dependencies {
/* Project - API */
implementation(projects.features.walletSettings.api)
implementation(projects.features.manageTokens.api)
/* Project - Core */
implementation(projects.core.decompose)

View file

@ -8,6 +8,7 @@ import com.tangem.feature.walletsettings.component.WalletSettingsComponent
import com.tangem.feature.walletsettings.entity.WalletSettingsUM
import com.tangem.feature.walletsettings.ui.WalletSettingsScreen
import com.tangem.feature.walletsettings.utils.ItemsBuilder
import com.tangem.features.managetokens.ManageTokensToggles
internal class PreviewWalletSettingsComponent : WalletSettingsComponent {
@ -15,6 +16,9 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent {
popBack = {},
items = ItemsBuilder(
router = DummyRouter(),
manageTokensToggles = object : ManageTokensToggles {
override val isFeatureEnabled: Boolean = true
},
).buildItems(
userWalletId = UserWalletId("011"),
userWalletName = "My Wallet",

View file

@ -13,7 +13,7 @@ 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.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.ContentMessage
import com.tangem.core.ui.message.SnackbarMessage
@ -93,7 +93,7 @@ internal class WalletSettingsModel @Inject constructor(
BasicDialog(
message = stringResource(R.string.user_wallet_list_delete_prompt),
onDismissDialog = onDismiss,
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(R.string.common_delete),
warning = true,
onClick = {
@ -101,7 +101,7 @@ internal class WalletSettingsModel @Inject constructor(
onDismiss()
},
),
dismissButton = DialogButton(
dismissButton = DialogButtonUM(
title = stringResource(R.string.common_cancel),
onClick = onDismiss,
),

View file

@ -6,8 +6,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.AdditionalTextInputDialogParams
import com.tangem.core.ui.components.DialogButton
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.res.TangemThemePreview
import com.tangem.feature.walletsettings.component.preview.PreviewRenameWalletComponent
@ -21,18 +21,18 @@ internal fun RenameWalletDialog(model: RenameWalletUM, onDismiss: () -> Unit) {
TextInputDialog(
title = stringResource(id = R.string.user_wallet_list_rename_popup_title),
fieldValue = value,
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = stringResource(id = R.string.common_ok),
enabled = model.isConfirmEnabled,
onClick = model.onConfirm,
),
dismissButton = DialogButton(
dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
onClick = onDismiss,
),
onDismissDialog = onDismiss,
onValueChange = model.updateValue,
textFieldParams = AdditionalTextInputDialogParams(
textFieldParams = AdditionalTextInputDialogUM(
label = stringResource(id = R.string.user_wallet_list_rename_popup_placeholder),
),
)

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM
import com.tangem.feature.walletsettings.impl.R
import com.tangem.features.managetokens.ManageTokensToggles
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -17,6 +18,7 @@ import javax.inject.Inject
@ComponentScoped
internal class ItemsBuilder @Inject constructor(
private val router: Router,
private val manageTokensToggles: ManageTokensToggles,
) {
@Suppress("LongParameterList")
@ -50,6 +52,14 @@ internal class ItemsBuilder @Inject constructor(
id = "card",
description = resourceReference(R.string.settings_card_settings_footer),
blocks = buildList {
if (manageTokensToggles.isFeatureEnabled) {
BlockUM(
text = resourceReference(R.string.add_tokens_title),
iconRes = R.drawable.ic_tether_24,
onClick = { router.push(AppRoute.ManageTokens(userWalletId)) },
).let(::add)
}
if (isLinkMoreCardsAvailable) {
BlockUM(
text = resourceReference(R.string.details_row_title_create_backup),

View file

@ -139,8 +139,8 @@ internal class DefaultWalletRouter(
return router.stack.lastOrNull() is AppRoute.Wallet
}
override fun openManageTokensScreen() {
router.push(AppRoute.ManageTokens(readOnlyContent = false))
override fun openManageTokensScreen(userWalletId: UserWalletId) {
router.push(AppRoute.ManageTokens(userWalletId = userWalletId))
}
override fun openScanFailedDialog(onTryAgain: () -> Unit) {

View file

@ -54,7 +54,7 @@ internal interface InnerWalletRouter : WalletRouter {
fun isWalletLastScreen(): Boolean
/** Open manage tokens screen */
fun openManageTokensScreen()
fun openManageTokensScreen(userWalletId: UserWalletId)
/** Open scan failed dialog */
fun openScanFailedDialog(onTryAgain: () -> Unit)

View file

@ -3,9 +3,9 @@ package com.tangem.feature.wallet.presentation.wallet.ui
import androidx.compose.runtime.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.components.AdditionalTextInputDialogParams
import com.tangem.core.ui.components.AdditionalTextInputDialogUM
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.components.TextInputDialog
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.feature.wallet.impl.R
@ -21,12 +21,12 @@ internal fun WalletAlert(state: WalletAlertState, onDismiss: () -> Unit) {
@Composable
private fun BasicAlert(state: WalletAlertState.Basic, onDismiss: () -> Unit) {
val confirmButton: DialogButton
val dismissButton: DialogButton?
val confirmButton: DialogButtonUM
val dismissButton: DialogButtonUM?
val onActionClick = state.onConfirmClick
if (onActionClick != null) {
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
warning = state.isWarningConfirmButton,
onClick = {
@ -34,12 +34,12 @@ private fun BasicAlert(state: WalletAlertState.Basic, onDismiss: () -> Unit) {
onDismiss()
},
)
dismissButton = DialogButton(
dismissButton = DialogButtonUM(
title = stringResource(id = R.string.common_cancel),
onClick = onDismiss,
)
} else {
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
warning = state.isWarningConfirmButton,
onClick = onDismiss,
@ -62,7 +62,7 @@ private fun TextInputAlert(state: WalletAlertState.TextInput, onDismiss: () -> U
TextInputDialog(
fieldValue = value,
confirmButton = DialogButton(
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
enabled = value.text.isNotEmpty() &&
value.text != state.text &&
@ -75,8 +75,8 @@ private fun TextInputAlert(state: WalletAlertState.TextInput, onDismiss: () -> U
onDismissDialog = onDismiss,
onValueChange = { value = it },
title = state.title.resolveReference(),
dismissButton = DialogButton(title = stringResource(id = R.string.common_cancel), onClick = onDismiss),
textFieldParams = AdditionalTextInputDialogParams(
dismissButton = DialogButtonUM(title = stringResource(id = R.string.common_cancel), onClick = onDismiss),
textFieldParams = AdditionalTextInputDialogUM(
label = state.label.resolveReference(),
isError = state.errorTextProvider(value.text) != null,
caption = state.errorTextProvider(value.text)?.resolveReference(),

View file

@ -90,7 +90,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
override fun onManageTokensClick() {
analyticsEventHandler.send(PortfolioEvent.ButtonManageTokens)
reduxStateHolder.dispatch(action = TokensAction.SetArgs.ManageAccess)
router.openManageTokensScreen()
router.openManageTokensScreen(userWalletId = stateHolder.getSelectedWalletId())
}
override fun onOrganizeTokensClick() {