Updated on 2026-08-14
This commit is contained in:
parent
14726d2fb4
commit
5b031d5c0e
15 changed files with 448 additions and 70 deletions
|
|
@ -20,6 +20,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.R
|
||||
import com.tangem.common.ui.userwallet.CardImage
|
||||
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
|
||||
import com.tangem.core.ui.components.SpacerW12
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.ds.row.TangemRowContainer
|
||||
|
|
@ -111,6 +113,18 @@ fun PortfolioSelectRowV2(
|
|||
size = AccountIconSize.RedesignedDefault,
|
||||
)
|
||||
}
|
||||
} else if (state.imageState != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layoutId(TangemRowLayoutId.HEAD)
|
||||
.padding(end = TangemTheme.dimens2.x3),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CardImage(
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x10),
|
||||
imageState = state.imageState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
|
|
@ -156,6 +170,7 @@ data class PortfolioSelectUM(
|
|||
val isAccountMode: Boolean,
|
||||
val isMultiChoice: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
val imageState: UserWalletItemUM.ImageState? = null,
|
||||
)
|
||||
|
||||
@Preview(widthDp = 360, showBackground = true)
|
||||
|
|
|
|||
|
|
@ -727,6 +727,7 @@
|
|||
<string name="generic_error">An error occurred</string>
|
||||
<string name="generic_error_code">An error occurred. Code: %s.</string>
|
||||
<string name="generic_requires_memo_error">Requires memo</string>
|
||||
<string name="get_token_title">Get %1$s</string>
|
||||
<string name="give_permission_current_transaction">Transaction</string>
|
||||
<string name="give_permission_policy_type_footer">By approving, you allow the smart contract to use your tokens in future transactions.</string>
|
||||
<string name="give_permission_rows_amount">Amount %s</string>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTi
|
|||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
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.res.TangemTheme
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
|
|
@ -69,7 +70,7 @@ internal fun AddToPortfolioBottomSheet(
|
|||
AddToPortfolioRoutes.AddToken,
|
||||
AddToPortfolioRoutes.Empty,
|
||||
is AddToPortfolioRoutes.NetworkSelector,
|
||||
AddToPortfolioRoutes.TokenActions,
|
||||
is AddToPortfolioRoutes.TokenActions,
|
||||
-> true
|
||||
}
|
||||
if (isScrollableContent) {
|
||||
|
|
@ -92,11 +93,12 @@ private fun AddToPortfolioBottomSheetTitle(
|
|||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title: TextReference = when (stack.active.configuration) {
|
||||
val title: TextReference = when (val config = stack.active.configuration) {
|
||||
AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token)
|
||||
AddToPortfolioRoutes.Empty -> TextReference.EMPTY
|
||||
is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network)
|
||||
AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token)
|
||||
is AddToPortfolioRoutes.TokenActions ->
|
||||
resourceReference(R.string.get_token_title, wrappedList(config.currencyName))
|
||||
AddToPortfolioRoutes.UserPortfolio -> resourceReference(R.string.markets_portfolio_block_title)
|
||||
AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent)
|
||||
.title.collectAsStateWithLifecycle().value
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
|
|||
): ComposableContentComponent = when (config) {
|
||||
AddToPortfolioRoutes.AddToken -> addTokenComponent
|
||||
AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent
|
||||
AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
|
||||
is AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
|
||||
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
|
||||
AddToPortfolioRoutes.UserPortfolio -> createUserPortfolioComponent(componentContext)
|
||||
is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create(
|
||||
|
|
|
|||
|
|
@ -305,7 +305,9 @@ internal class AddToPortfolioModel @Inject constructor(
|
|||
setupTokenActionsFlow(selectedPortfolioSnapshot, addedToken)
|
||||
.onEach { cryptoCurrencyData ->
|
||||
tokenActionsData.emit(cryptoCurrencyData)
|
||||
navigation.replaceAll(AddToPortfolioRoutes.TokenActions)
|
||||
navigation.replaceAll(
|
||||
AddToPortfolioRoutes.TokenActions(cryptoCurrencyData.status.currency.name),
|
||||
)
|
||||
}
|
||||
.onEmpty { finishSuccessFlow(result) }
|
||||
.launchIn(this)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.model
|
|||
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
|
||||
internal data class AddToPortfolioRouteUiSpec(
|
||||
|
|
@ -42,8 +43,8 @@ internal fun AddToPortfolioRoutes.uiSpec(): AddToPortfolioRouteUiSpec = when (th
|
|||
shouldApplyHorizontalPadding = false,
|
||||
footer = AddToPortfolioFooterKind.UserPortfolioAdd,
|
||||
)
|
||||
AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec(
|
||||
title = resourceReference(R.string.common_get_token),
|
||||
is AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec(
|
||||
title = resourceReference(R.string.get_token_title, wrappedList(currencyName)),
|
||||
isScrollable = false,
|
||||
shouldApplyHorizontalPadding = true,
|
||||
footer = AddToPortfolioFooterKind.None,
|
||||
|
|
|
|||
|
|
@ -27,5 +27,5 @@ internal sealed interface AddToPortfolioRoutes : Route {
|
|||
data object UserPortfolio : AddToPortfolioRoutes
|
||||
|
||||
@Serializable
|
||||
data object TokenActions : AddToPortfolioRoutes
|
||||
data class TokenActions(val currencyName: String) : AddToPortfolioRoutes
|
||||
}
|
||||
|
|
@ -17,18 +17,23 @@ import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
|
|||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenUiBuilder.Companion.toggleProgress
|
||||
import com.tangem.features.wallet.utils.UserWalletImageFetcher
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.operations.attestation.ArtworkSize
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
@Suppress("LongParameterList")
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class AddTokenModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
private val walletImageFetcher: UserWalletImageFetcher,
|
||||
private val uiBuilder: AddTokenUiBuilder,
|
||||
private val messageSender: UiMessageSender,
|
||||
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
|
||||
|
|
@ -46,15 +51,22 @@ internal class AddTokenModel @Inject constructor(
|
|||
field = MutableStateFlow(value = null)
|
||||
|
||||
init {
|
||||
val walletImageFlow = params.selectedPortfolio
|
||||
.map { it.userWallet }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { walletImageFetcher.walletImage(it, ArtworkSize.SMALL) }
|
||||
|
||||
combine(
|
||||
flow = params.selectedNetwork.distinctUntilChanged(),
|
||||
flow2 = params.selectedPortfolio.distinctUntilChanged(),
|
||||
transform = { selectedNetwork, selectedPortfolio ->
|
||||
flow3 = walletImageFlow,
|
||||
transform = { selectedNetwork, selectedPortfolio, walletImage ->
|
||||
addTokenJob.join()
|
||||
val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio)
|
||||
uiBuilder.updateContent(
|
||||
selectedPortfolio = selectedPortfolio,
|
||||
selectedNetwork = selectedNetwork,
|
||||
walletImage = walletImage,
|
||||
isTangemIconVisible = isTangemIconVisible,
|
||||
onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
|||
import com.tangem.common.ui.account.PortfolioSelectUM
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.addtoken.AddTokenUM
|
||||
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
|
|
@ -35,13 +36,18 @@ internal class AddTokenUiBuilder @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM {
|
||||
private fun createPortfolio(
|
||||
selectedPortfolio: SelectedPortfolio,
|
||||
walletImage: UserWalletItemUM.ImageState,
|
||||
): PortfolioSelectUM {
|
||||
val accountIcon: AccountIconUM?
|
||||
val portfolioName: TextReference
|
||||
val imageState: UserWalletItemUM.ImageState?
|
||||
when (selectedPortfolio.isAccountMode) {
|
||||
false -> {
|
||||
accountIcon = null
|
||||
portfolioName = stringReference(selectedPortfolio.userWallet.name)
|
||||
imageState = walletImage
|
||||
}
|
||||
true -> {
|
||||
val accountStatus = selectedPortfolio.account.account
|
||||
|
|
@ -50,6 +56,7 @@ internal class AddTokenUiBuilder @Inject constructor(
|
|||
is CryptoPortfolio -> CryptoPortfolioIconConverter.convert(accountStatus.account.icon)
|
||||
is Payment -> AccountIconUM.Payment
|
||||
}
|
||||
imageState = null
|
||||
}
|
||||
}
|
||||
return PortfolioSelectUM(
|
||||
|
|
@ -58,12 +65,14 @@ internal class AddTokenUiBuilder @Inject constructor(
|
|||
isAccountMode = selectedPortfolio.isAccountMode,
|
||||
isMultiChoice = selectedPortfolio.isAvailableMorePortfolio,
|
||||
onClick = { params.callbacks.onChangePortfolioClick() },
|
||||
imageState = imageState,
|
||||
)
|
||||
}
|
||||
|
||||
fun updateContent(
|
||||
selectedPortfolio: SelectedPortfolio,
|
||||
selectedNetwork: SelectedNetwork,
|
||||
walletImage: UserWalletItemUM.ImageState,
|
||||
isTangemIconVisible: Boolean,
|
||||
onConfirmClick: () -> Unit,
|
||||
): AddTokenUM {
|
||||
|
|
@ -78,7 +87,7 @@ internal class AddTokenUiBuilder @Inject constructor(
|
|||
onConfirmClick = onConfirmClick,
|
||||
)
|
||||
val networkUM = createNetwork(selectedNetwork)
|
||||
val portfolioUM = createPortfolio(selectedPortfolio)
|
||||
val portfolioUM = createPortfolio(selectedPortfolio, walletImage)
|
||||
val currency = selectedNetwork.cryptoCurrency
|
||||
val tokenToAdd = TokenItemState.Content(
|
||||
id = currency.id.value,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems
|
|||
import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems
|
||||
import com.tangem.common.ui.tokens.TokenItemStateConverter
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.icons.IconTint
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
|
||||
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
|
||||
|
|
@ -55,12 +54,11 @@ internal class ChooseTokenListItemConverter(
|
|||
}
|
||||
|
||||
private val fiatAmountStateProvider: ((TotalFiatBalance, isExpanded: Boolean) -> FiatAmountState?) =
|
||||
{ totalBalance, isExpanded ->
|
||||
when {
|
||||
isSearchingState -> FiatAmountState.Empty
|
||||
!isExpanded -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative)
|
||||
else -> AccountCryptoPortfolioItemStateConverter
|
||||
.createFiatAmountState(totalBalance, appCurrency)
|
||||
{ totalBalance, _ ->
|
||||
if (isSearchingState) {
|
||||
FiatAmountState.Empty
|
||||
} else {
|
||||
AccountCryptoPortfolioItemStateConverter.createFiatAmountState(totalBalance, appCurrency)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
package com.tangem.features.commonfeatures.impl.choosetoken.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.BoundsTransform
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateIntAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -7,6 +18,7 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -15,16 +27,21 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.lerp
|
||||
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.common.ui.tokens.portfolioTokensList
|
||||
import com.tangem.common.ui.tokens.NonContentItemContent
|
||||
import com.tangem.common.ui.tokens.SlideInItemVisibility
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.components.account.toBoxSize
|
||||
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.fields.SearchBar
|
||||
|
|
@ -34,6 +51,8 @@ import com.tangem.core.ui.components.list.InfiniteListHandler
|
|||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||
import com.tangem.core.ui.components.token.AccountItemPreviewData
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState
|
||||
import com.tangem.core.ui.components.tokenlist.NON_CONTENT_TOKENS_LIST_KEY
|
||||
import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem
|
||||
import com.tangem.core.ui.components.tokenlist.TokenListItem
|
||||
import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM
|
||||
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
|
||||
|
|
@ -41,26 +60,42 @@ import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
|
|||
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
|
||||
import com.tangem.core.ui.ds.image.DeviceIconUM
|
||||
import com.tangem.core.ui.ds.image.TangemDeviceIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.row.header.TangemHeaderRow
|
||||
import com.tangem.core.ui.ds.row.internal.TangemRowTailUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRow
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.test.BuyTokenScreenTestTags
|
||||
import com.tangem.core.ui.utils.ProvideSharedTransitionScope
|
||||
import com.tangem.core.ui.utils.TangemSharedTransitionLayout
|
||||
import com.tangem.core.ui.utils.lazyListItemPosition
|
||||
import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection
|
||||
import com.tangem.core.ui.utils.sharedBoundsSafely
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
|
||||
import com.tangem.utils.StringsSigns.DOT
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val LOAD_MORE_BUFFER = 25
|
||||
private const val ACCOUNT_COLLAPSE_STEP_MS = 50
|
||||
private const val ACCOUNT_COLLAPSE_MAX_DELAY_MS = 250
|
||||
private const val ACCOUNT_COLLAPSE_BASE_DELAY_MS = 150
|
||||
private const val ACCOUNT_CONTENT_ANIM_MS = 350
|
||||
private const val ACCOUNT_CONTENT_ANIM_DELAY_MS = 90
|
||||
private const val ACCOUNT_BOUNDS_ANIM_MS = 250
|
||||
|
||||
private val ChooseTokenFullUM.isNotFoundState: Boolean
|
||||
get() {
|
||||
|
|
@ -298,11 +333,10 @@ private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBal
|
|||
when (tokensListData) {
|
||||
is TokenListUMData.AccountList -> {
|
||||
tokensListData.tokensList.forEachIndexed { index, item ->
|
||||
portfolioTokensList(
|
||||
accountWithTokens(
|
||||
portfolio = item,
|
||||
portfolioIndex = index,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
testTag = BuyTokenScreenTestTags.LAZY_LIST_ITEM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -338,6 +372,298 @@ private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isB
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun LazyListScope.accountWithTokens(
|
||||
portfolio: TokensListItemUM.Portfolio,
|
||||
portfolioIndex: Int,
|
||||
isBalanceHidden: Boolean,
|
||||
) {
|
||||
val tokens = portfolio.tokens
|
||||
val isExpanded = portfolio.isExpanded
|
||||
val lastIndex = maxOf(tokens.lastIndex.inc(), 1)
|
||||
|
||||
item(key = "account-${portfolio.id}", contentType = "choose-token-account") {
|
||||
val effectiveLastIndex by animateIntAsState(
|
||||
targetValue = if (isExpanded) lastIndex else 0,
|
||||
animationSpec = if (isExpanded) {
|
||||
snap()
|
||||
} else {
|
||||
snap(
|
||||
delayMillis = minOf(
|
||||
ACCOUNT_COLLAPSE_STEP_MS * maxOf(tokens.lastIndex, 0),
|
||||
ACCOUNT_COLLAPSE_MAX_DELAY_MS,
|
||||
) + ACCOUNT_COLLAPSE_BASE_DELAY_MS,
|
||||
)
|
||||
},
|
||||
label = "accountLastIndex",
|
||||
)
|
||||
AccountRow(
|
||||
portfolio = portfolio,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
modifier = Modifier
|
||||
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
|
||||
.semantics { lazyListItemPosition = portfolioIndex }
|
||||
.roundedShapeItemDecoration(
|
||||
currentIndex = 0,
|
||||
radius = TangemTheme.dimens.radius14,
|
||||
lastIndex = effectiveLastIndex,
|
||||
backgroundColor = TangemTheme.colors.background.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (portfolio.content is PortfolioItemContentUM.Empty) {
|
||||
item(key = "$NON_CONTENT_TOKENS_LIST_KEY account-${portfolio.id}") {
|
||||
SlideInItemVisibility(
|
||||
visible = isExpanded,
|
||||
currentIndex = 1,
|
||||
lastIndex = lastIndex,
|
||||
modifier = Modifier.roundedShapeItemDecoration(
|
||||
currentIndex = 1,
|
||||
radius = TangemTheme.dimens.radius14,
|
||||
lastIndex = 1,
|
||||
backgroundColor = TangemTheme.colors.background.primary,
|
||||
),
|
||||
) {
|
||||
NonContentItemContent(modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing16))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = tokens,
|
||||
key = { _, token -> "${token.id}-choose-account-${portfolio.id}" },
|
||||
contentType = { _, token -> token::class.java },
|
||||
) { tokenIndex, token ->
|
||||
val indexWithHeader = tokenIndex.inc()
|
||||
val lastTokenBottomPadding = TangemTheme.dimens.spacing8
|
||||
SlideInItemVisibility(
|
||||
visible = isExpanded,
|
||||
currentIndex = tokenIndex,
|
||||
lastIndex = lastIndex,
|
||||
modifier = Modifier
|
||||
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
|
||||
.roundedShapeItemDecoration(
|
||||
currentIndex = indexWithHeader,
|
||||
radius = TangemTheme.dimens.radius14,
|
||||
lastIndex = lastIndex,
|
||||
backgroundColor = TangemTheme.colors.background.primary,
|
||||
),
|
||||
) {
|
||||
PortfolioTokensListItem(
|
||||
state = token,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
modifier = Modifier.conditional(indexWithHeader == lastIndex) {
|
||||
padding(bottom = lastTokenBottomPadding)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun AccountRow(
|
||||
portfolio: TokensListItemUM.Portfolio,
|
||||
isBalanceHidden: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokenRowUM = accountTokenRowUM(portfolio)
|
||||
val subtitle = (tokenRowUM.subtitleUM as? TangemTokenRowUM.SubtitleUM.Content)?.text
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ProvideSharedTransitionScope(Modifier.weight(1f)) {
|
||||
val iconSharedContentState = rememberSharedContentState(key = "account-icon-${portfolio.id}")
|
||||
val titleSharedContentState = rememberSharedContentState(key = "account-title-${portfolio.id}")
|
||||
val boundsTransform = BoundsTransform { _, _ -> tween(ACCOUNT_BOUNDS_ANIM_MS) }
|
||||
|
||||
AnimatedContent(
|
||||
targetState = portfolio.isExpanded,
|
||||
transitionSpec = {
|
||||
fadeIn(animationSpec = tween(ACCOUNT_CONTENT_ANIM_MS, delayMillis = ACCOUNT_CONTENT_ANIM_DELAY_MS))
|
||||
.togetherWith(fadeOut(animationSpec = tween(ACCOUNT_CONTENT_ANIM_MS)))
|
||||
},
|
||||
label = "accountExpand",
|
||||
) { isExpandedState ->
|
||||
val animatedContentScope = this
|
||||
val composables = remember(tokenRowUM) {
|
||||
AccountRowComposables(
|
||||
icon = { iconModifier ->
|
||||
val iconSize = if (isExpandedState) {
|
||||
AccountIconSize.RedesignExtraSmall
|
||||
} else {
|
||||
AccountIconSize.RedesignedDefault
|
||||
}
|
||||
val sizedIcon = when (val icon = tokenRowUM.headIconUM) {
|
||||
is TangemIconUM.Currency -> icon.copy(
|
||||
currencyIconState = when (val iconState = icon.currencyIconState) {
|
||||
is CurrencyIconState.CryptoPortfolio.Icon -> iconState.copy(size = iconSize)
|
||||
is CurrencyIconState.CryptoPortfolio.Letter -> iconState.copy(size = iconSize)
|
||||
else -> iconState
|
||||
},
|
||||
)
|
||||
else -> icon
|
||||
}
|
||||
TangemIcon(
|
||||
tangemIconUM = sizedIcon,
|
||||
modifier = iconModifier
|
||||
.size(iconSize.toBoxSize())
|
||||
.sharedBoundsSafely(
|
||||
sharedContentState = iconSharedContentState,
|
||||
animatedVisibilityScope = animatedContentScope,
|
||||
boundsTransform = boundsTransform,
|
||||
),
|
||||
)
|
||||
},
|
||||
title = { titleModifier ->
|
||||
val targetFraction = if (isExpandedState) 0f else 1f
|
||||
val animationFraction = animateFloatAsState(
|
||||
targetValue = targetFraction,
|
||||
animationSpec = tween(durationMillis = ACCOUNT_CONTENT_ANIM_MS),
|
||||
label = "accountTitle",
|
||||
)
|
||||
val startStyle = TangemTheme.typography2.captionSemibold12
|
||||
val stopStyle = TangemTheme.typography2.bodySemibold16
|
||||
val textStyle by remember(animationFraction.value) {
|
||||
derivedStateOf { lerp(startStyle, stopStyle, animationFraction.value) }
|
||||
}
|
||||
val resizedTitle = when (val titleUM = tokenRowUM.titleUM) {
|
||||
is TangemTokenRowUM.TitleUM.Content -> titleUM.copy(
|
||||
text = styledStringReference(
|
||||
titleUM.text.resolveReference(),
|
||||
{ textStyle.toSpanStyle() },
|
||||
),
|
||||
)
|
||||
else -> titleUM
|
||||
}
|
||||
TokenRowTitle(
|
||||
titleUM = resizedTitle,
|
||||
modifier = titleModifier.sharedBoundsSafely(
|
||||
sharedContentState = titleSharedContentState,
|
||||
animatedVisibilityScope = animatedContentScope,
|
||||
boundsTransform = boundsTransform,
|
||||
resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpandedState) {
|
||||
TangemHeaderRow(
|
||||
subtitle = subtitle,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
titleContent = composables.title,
|
||||
headContent = composables.icon,
|
||||
tailUM = TangemRowTailUM.Empty,
|
||||
onItemClick = tokenRowUM.onItemClick,
|
||||
)
|
||||
} else {
|
||||
TangemTokenRow(
|
||||
tokenRowUM = tokenRowUM,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
headComponent = composables.icon,
|
||||
titleComponent = composables.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountTail(
|
||||
isExpanded = portfolio.isExpanded,
|
||||
onClick = { tokenRowUM.onItemClick?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountTail(isExpanded: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(start = TangemTheme.dimens2.x2, end = TangemTheme.dimens2.x4)
|
||||
.size(TangemTheme.dimens2.x9)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = isExpanded,
|
||||
contentAlignment = Alignment.Center,
|
||||
label = "accountTail",
|
||||
) { expanded ->
|
||||
if (expanded) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.offset(x = TangemTheme.dimens.spacing2)
|
||||
.size(TangemTheme.dimens2.x4),
|
||||
painter = painterResource(id = R.drawable.ic_minimize_24),
|
||||
tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
AccountExpandButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountExpandButton(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(TangemTheme.dimens2.x9)
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors2.button.backgroundSecondary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x5),
|
||||
painter = painterResource(id = R.drawable.ic_chewron_down_20),
|
||||
tint = TangemTheme.colors2.button.iconPrimary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
private class AccountRowComposables(
|
||||
val title: @Composable (Modifier) -> Unit,
|
||||
val icon: @Composable (Modifier) -> Unit,
|
||||
)
|
||||
|
||||
private fun accountTokenRowUM(portfolio: TokensListItemUM.Portfolio): TangemTokenRowUM.Content {
|
||||
val account = portfolio.tokenItemUM
|
||||
val name = (account.titleState as? TokenItemState.TitleState.Content)?.text ?: TextReference.EMPTY
|
||||
val tokensCount = (account.subtitleState as? TokenItemState.SubtitleState.TextContent)?.value
|
||||
val balance = (account.fiatAmountState as? TokenItemState.FiatAmountState.Content)?.text
|
||||
return TangemTokenRowUM.Content(
|
||||
id = portfolio.id,
|
||||
headIconUM = TangemIconUM.Currency(currencyIconState = account.iconState),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(text = name),
|
||||
subtitleUM = buildAccountSubtitle(tokensCount, balance)
|
||||
?.let { TangemTokenRowUM.SubtitleUM.Content(text = it) }
|
||||
?: TangemTokenRowUM.SubtitleUM.Empty,
|
||||
topEndContentUM = TangemTokenRowUM.EndContentUM.Empty,
|
||||
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Empty,
|
||||
tailUM = TangemRowTailUM.Empty,
|
||||
onItemClick = { account.onItemClick?.invoke(account) },
|
||||
onItemLongClick = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAccountSubtitle(tokensCount: TextReference?, balance: String?): TextReference? {
|
||||
return when {
|
||||
tokensCount != null && balance != null ->
|
||||
combinedReference(tokensCount, stringReference(" $DOT "), stringReference(balance))
|
||||
tokensCount != null -> tokensCount
|
||||
balance != null -> stringReference(balance)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) {
|
||||
item("EmptyTokensList") {
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import com.tangem.common.ui.markets.action.TokenActionsContext
|
|||
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -42,15 +41,12 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
|
|||
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
|
||||
tokenActionsComponentFactory: TokenActionsComponent.Factory,
|
||||
userPortfolioComponentFactory: UserPortfolioComponent.Factory,
|
||||
walletFeatureToggles: WalletFeatureToggles,
|
||||
) : AppComponentContext by appComponentContext, ManageFundsComponent {
|
||||
|
||||
private val model: ManageFundsModel = getOrCreateModel(params)
|
||||
|
||||
private val isCompactTokenActions: Boolean = params.launchMode is ManageFundsComponent.LaunchMode.TokenActionsOnly
|
||||
|
||||
private val isAddFundsStage1Enabled: Boolean = walletFeatureToggles.isAddFundsStage1Enabled
|
||||
|
||||
private val tokenActionsComponent: TokenActionsComponent by lazy {
|
||||
tokenActionsComponentFactory.create(
|
||||
context = child(key = "manageFundsTokenActions"),
|
||||
|
|
@ -107,7 +103,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
WithOptionalRedesignTheme(isEnabled = isAddFundsStage1Enabled) {
|
||||
TangemThemeRedesign {
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
onBack = if (canGoBack) model::onBack else ::dismiss,
|
||||
config = TangemBottomSheetConfig(
|
||||
|
|
@ -179,7 +175,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
|
|||
) {
|
||||
userPortfolioComponent.Content(modifier)
|
||||
}
|
||||
ManageFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier)
|
||||
is ManageFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,8 +187,13 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
|
|||
onCloseClick: () -> Unit,
|
||||
) {
|
||||
val spec = route.uiSpec(model.flowType)
|
||||
val title = if (route is ManageFundsModel.UiRoute.TokenActions) {
|
||||
route.title
|
||||
} else {
|
||||
spec.title
|
||||
}
|
||||
TangemTopBar(
|
||||
title = spec.title,
|
||||
title = title,
|
||||
subtitle = spec.subtitle,
|
||||
type = TangemTopBarType.BottomSheet,
|
||||
startContent = if (canGoBack) {
|
||||
|
|
@ -219,15 +220,6 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WithOptionalRedesignTheme(isEnabled: Boolean, content: @Composable () -> Unit) {
|
||||
if (isEnabled) {
|
||||
TangemThemeRedesign(content = content)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : ManageFundsComponent.Factory {
|
||||
override fun create(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
|
|||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
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.account.status.supplier.MultiAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
|
|
@ -26,6 +29,7 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPa
|
|||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
||||
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.features.commonfeatures.impl.managefunds.analytics.ManageFundsAnalyticsEvent
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.state.UserPortfolioStateController
|
||||
|
|
@ -206,7 +210,7 @@ internal class ManageFundsModel @Inject constructor(
|
|||
return@launch
|
||||
}
|
||||
tokenActionsTrigger.value = TokenActionsRequest(wallet, match.first, match.second)
|
||||
replaceRoot(UiRoute.TokenActions)
|
||||
replaceRoot(tokenActionsRoute(match.second))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +222,7 @@ internal class ManageFundsModel @Inject constructor(
|
|||
1 -> {
|
||||
val entry = entries.first()
|
||||
tokenActionsTrigger.value = TokenActionsRequest(entry.userWallet, entry.account, entry.status)
|
||||
replaceRoot(UiRoute.TokenActions)
|
||||
replaceRoot(tokenActionsRoute(entry.status))
|
||||
}
|
||||
else -> {
|
||||
filteredEntries.value = entries
|
||||
|
|
@ -261,7 +265,12 @@ internal class ManageFundsModel @Inject constructor(
|
|||
private fun openTokenActions(request: TokenActionsRequest, bottomAction: BottomAction) {
|
||||
tokenActionsTrigger.value = request
|
||||
currentBottomAction.value = bottomAction
|
||||
pushRoute(UiRoute.TokenActions)
|
||||
pushRoute(tokenActionsRoute(request.status))
|
||||
}
|
||||
|
||||
private fun tokenActionsRoute(status: CryptoCurrencyStatus): UiRoute.TokenActions {
|
||||
val title = resourceReference(R.string.get_token_title, wrappedList(status.currency.name))
|
||||
return UiRoute.TokenActions(title = title)
|
||||
}
|
||||
|
||||
private fun replaceRoot(route: UiRoute) {
|
||||
|
|
@ -277,7 +286,7 @@ internal class ManageFundsModel @Inject constructor(
|
|||
data object Loading : UiRoute
|
||||
data object ChooseToken : UiRoute
|
||||
data object UserPortfolio : UiRoute
|
||||
data object TokenActions : UiRoute
|
||||
data class TokenActions(val title: TextReference) : UiRoute
|
||||
}
|
||||
|
||||
private data class TokenActionsRequest(
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ internal fun ManageFundsModel.UiRoute.uiSpec(flowType: ManageFundsComponent.Flow
|
|||
shouldApplyHorizontalPadding = false,
|
||||
shouldFillHeight = false,
|
||||
)
|
||||
ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec(
|
||||
is ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec(
|
||||
title = resourceReference(if (isTransfer) R.string.common_transfer else R.string.common_get_token),
|
||||
subtitle = null,
|
||||
shouldApplyHorizontalPadding = true,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import com.tangem.features.commonfeatures.impl.R
|
|||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.PortfolioBadgeUM
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
|
||||
|
|
@ -42,6 +43,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
paramsContainer: ParamsContainer,
|
||||
private val designFeatureToggles: DesignFeatureToggles,
|
||||
private val getWalletIconUseCase: GetWalletIconUseCase,
|
||||
private val getWalletsUseCase: GetWalletsUseCase,
|
||||
private val walletIconUMConverter: WalletIconUMConverter,
|
||||
) {
|
||||
private val params = paramsContainer.require<TokenActionsComponent.Params>()
|
||||
|
|
@ -145,7 +147,8 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
}
|
||||
|
||||
private fun createPortfolioBadge(cryptoCurrencyData: CryptoCurrencyData): PortfolioBadgeUM {
|
||||
return if (cryptoCurrencyData.isAccountMode) {
|
||||
return when {
|
||||
cryptoCurrencyData.isAccountMode -> {
|
||||
val icon = CryptoPortfolioIconConverter.convert(cryptoCurrencyData.account.account.icon)
|
||||
val name = cryptoCurrencyData
|
||||
.account
|
||||
|
|
@ -166,7 +169,9 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
shouldRespectIconTint = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
}
|
||||
isSingleWallet() -> PortfolioBadgeUM.None
|
||||
else -> {
|
||||
val userWallet = cryptoCurrencyData.userWallet
|
||||
PortfolioBadgeUM.Wallet(
|
||||
name = stringReference(userWallet.name),
|
||||
|
|
@ -176,6 +181,12 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSingleWallet(): Boolean {
|
||||
val count = runCatching { getWalletsUseCase.invokeSync().size }.getOrNull() ?: return false
|
||||
return count <= 1
|
||||
}
|
||||
|
||||
private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? {
|
||||
return when (status.value) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue