Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-04 12:03:02 +03:00
commit 5223d4b417
784 changed files with 24026 additions and 3170 deletions

View file

@ -9,7 +9,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.essenty.instancekeeper.getOrCreateSimple
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.features.biometry.AskBiometryComponent

View file

@ -23,7 +23,6 @@ import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.SaveWalletError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase
import com.tangem.features.details.impl.R
@ -38,7 +37,7 @@ import kotlin.coroutines.resume
internal class UserWalletSaver @Inject constructor(
private val scanCardProcessor: ScanCardProcessor,
private val saveWalletUseCase: SaveWalletUseCase,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val shouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase,
private val reduxStateHolder: ReduxStateHolder,
private val messageSender: UiMessageSender,
@ -56,17 +55,20 @@ internal class UserWalletSaver @Inject constructor(
}
},
ifRight = { scanResponse ->
recover(block = {
scanResponse ?: return
val userWallet = createUserWallet(scanResponse)
saveWallet(userWallet)
}, recover = {
recover(
block = {
scanResponse ?: return
val userWallet = createUserWallet(scanResponse)
saveWallet(userWallet)
},
recover = {
val message = it.message
if (!message.isNullOrEmpty()) {
messageSender.send(SnackbarMessage(message))
}
},)
},
)
},
)
}
@ -110,7 +112,7 @@ internal class UserWalletSaver @Inject constructor(
}
private suspend fun Raise<Error>.createUserWallet(response: ScanResponse): UserWallet {
val userWallet = UserWalletBuilder(response, generateWalletNameUseCase).build()
val userWallet = userWalletBuilderFactory.create(scanResponse = response).build()
return ensureNotNull(userWallet) { Error.Unknown }
}

View file

@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview

View file

@ -1,7 +1,5 @@
package com.tangem.features.managetokens.utils.list
import androidx.compose.ui.util.fastForEachIndexed
import androidx.compose.ui.util.fastMap
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
@ -10,10 +8,7 @@ import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.CheckHasLinkedTokensUseCase
import com.tangem.domain.managetokens.GetManagedTokensUseCase
import com.tangem.domain.managetokens.RemoveCustomManagedCryptoCurrencyUseCase
import com.tangem.domain.managetokens.*
import com.tangem.domain.managetokens.model.*
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
@ -43,6 +38,7 @@ import javax.inject.Inject
@ModelScoped
internal class ManageTokensListManager @Inject constructor(
private val getManagedTokensUseCase: GetManagedTokensUseCase,
private val getDistinctManagedTokensUseCase: GetDistinctManagedCurrenciesUseCase,
private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase,
private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase,
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
@ -166,47 +162,27 @@ internal class ManageTokensListManager @Inject constructor(
return
}
state.update { state ->
val newBatches = distinctCurrencies(batchListState.data)
val currentBatches = state.currencyBatches
scope.launch {
state.update { state ->
val newBatches = getDistinctManagedTokensUseCase(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,
)
}
}
// FIXME: Add interception functionality to BatchFlow state and do this on domain
// [REDACTED_JIRA]
private fun distinctCurrencies(
batches: List<Batch<Int, List<ManagedCryptoCurrency>>>,
): List<Batch<Int, List<ManagedCryptoCurrency>>> {
val allCurrenciesIds = mutableListOf<ManagedCryptoCurrency.ID>()
return batches.fastMap { batch ->
val batchCurrencies = batch.data.toMutableList()
batch.data.fastForEachIndexed { index, currency ->
if (currency.id in allCurrenciesIds) {
batchCurrencies.removeAt(index)
} else {
allCurrenciesIds.add(currency.id)
// 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@launch
}
}
batch.copy(data = batchCurrencies)
val canEditItems = userWalletId != null
state.copy(
userWalletId = userWalletId,
currencyBatches = newBatches,
uiBatches = uiManager.createOrUpdateUiBatches(newBatches, canEditItems),
canEditItems = canEditItems,
)
}
}
}

View file

@ -19,7 +19,7 @@ import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig

View file

@ -7,9 +7,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference

View file

@ -16,7 +16,7 @@ import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.rememberMarketChartState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData
import com.tangem.core.ui.components.UnableToLoadData
@Composable
internal fun MarketTokenDetailsChart(

View file

@ -23,9 +23,9 @@ import androidx.compose.ui.util.fastForEachIndexed
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference

View file

@ -9,7 +9,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData
import com.tangem.core.ui.components.UnableToLoadData
internal fun LazyListScope.tokenMarketDetailsBody(
state: MarketsTokenDetailsUM.Body,

View file

@ -6,8 +6,8 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModels
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
@ -102,12 +102,12 @@ internal class TokenActionsHandler @AssistedInject constructor(
}
},
content = TokenReceiveBottomSheetConfig(
name = currency.name,
symbol = currency.symbol,
network = currency.network.name,
addresses = networkAddress.availableAddresses
.mapToAddressModels(currency)
.toImmutableList(),
asset = TokenReceiveBottomSheetConfig.Asset.Currency(
name = currency.name,
symbol = currency.symbol,
),
network = currency.network,
networkAddress = networkAddress,
showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE,
onCopyClick = {},
onShareClick = {},

View file

@ -26,7 +26,7 @@ import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition

View file

@ -18,7 +18,7 @@ import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SmallButtonShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheet
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition

View file

@ -9,9 +9,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SimpleSettingsRow
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme

View file

@ -18,7 +18,7 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme

View file

@ -134,7 +134,7 @@ internal class MarketsTokenItemConverter(
TrendInterval.D7 -> tokenQuotesShort.weekChangePercent
TrendInterval.M1 -> tokenQuotesShort.monthChangePercent
}
val scaled = percent.setScale(4, RoundingMode.HALF_UP)
val scaled = percent?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP

View file

@ -10,6 +10,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.list.InfiniteListHandler

View file

@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.inputrow.InputRowChecked
import com.tangem.core.ui.components.inputrow.inner.DividerContainer

View file

@ -1,47 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.impl.R
@Composable
internal fun UnableToLoadData(onRetryClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.markets_loading_error_title),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.try_to_load_data_again_button_title),
onClick = onRetryClick,
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
UnableToLoadData(onRetryClick = {})
}
}

View file

@ -12,6 +12,8 @@ dependencies {
/* Project - Domain */
implementation(projects.domain.models)
implementation(projects.domain.nft.models)
implementation(projects.domain.wallets.models)
/* Project - Core */
implementation(projects.core.decompose)

View file

@ -0,0 +1,14 @@
package com.tangem.features.nft.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
interface NFTCollectionsComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
)
interface Factory : ComponentFactory<Params, NFTCollectionsComponent>
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.nft.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.wallets.models.UserWalletId
interface NFTDetailsComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val nftAsset: NFTAsset,
)
interface Factory : ComponentFactory<Params, NFTDetailsComponent>
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.nft.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
interface NFTReceiveComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
)
interface Factory : ComponentFactory<Params, NFTReceiveComponent>
}

View file

@ -26,6 +26,12 @@ dependencies {
implementation(projects.core.navigation)
implementation(projects.core.datasource)
/** Domain modules */
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)

View file

@ -1,10 +1,23 @@
package com.tangem.features.nft
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.decompose.model.Model
import com.tangem.features.nft.collections.DefaultNFTCollectionsComponent
import com.tangem.features.nft.collections.model.NFTCollectionsModel
import com.tangem.features.nft.component.NFTCollectionsComponent
import com.tangem.features.nft.component.NFTDetailsComponent
import com.tangem.features.nft.component.NFTReceiveComponent
import com.tangem.features.nft.details.DefaultNFTDetailsComponent
import com.tangem.features.nft.details.model.NFTDetailsModel
import com.tangem.features.nft.receive.DefaultNFTReceiveComponent
import com.tangem.features.nft.receive.model.NFTReceiveModel
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@ -16,4 +29,37 @@ internal object NFTFeatureModule {
fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): NFTFeatureToggles {
return DefaultNFTFeatureToggles(featureTogglesManager)
}
}
@Module
@InstallIn(SingletonComponent::class)
internal interface NFTFeatureModuleBinds {
@Binds
@Singleton
fun bindNFTCollectionsComponentFactory(
impl: DefaultNFTCollectionsComponent.Factory,
): NFTCollectionsComponent.Factory
@Binds
@IntoMap
@ClassKey(NFTCollectionsModel::class)
fun bindNFTCollectionsModel(model: NFTCollectionsModel): Model
@Binds
@Singleton
fun bindNFTReceiveComponentFactory(impl: DefaultNFTReceiveComponent.Factory): NFTReceiveComponent.Factory
@Binds
@IntoMap
@ClassKey(NFTReceiveModel::class)
fun bindNFTReceiveModel(model: NFTReceiveModel): Model
@Binds
@Singleton
fun bindNFTDetailsComponentFactory(impl: DefaultNFTDetailsComponent.Factory): NFTDetailsComponent.Factory
@Binds
@IntoMap
@ClassKey(NFTDetailsModel::class)
fun bindNFTDetailsModel(model: NFTDetailsModel): Model
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.nft.collections
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.nft.collections.model.NFTCollectionsModel
import com.tangem.features.nft.collections.ui.NFTCollections
import com.tangem.features.nft.component.NFTCollectionsComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultNFTCollectionsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: NFTCollectionsComponent.Params,
) : NFTCollectionsComponent, AppComponentContext by context {
private val model: NFTCollectionsModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
NFTCollections(state, modifier)
}
@AssistedFactory
interface Factory : NFTCollectionsComponent.Factory {
override fun create(
context: AppComponentContext,
params: NFTCollectionsComponent.Params,
): DefaultNFTCollectionsComponent
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.nft.collections.entity
internal data class NFTCollectionAssetUM(
val id: String,
val name: String,
val imageUrl: String?,
val price: NFTSalePriceUM,
val onItemClick: () -> Unit,
)

View file

@ -0,0 +1,12 @@
package com.tangem.features.nft.collections.entity
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class NFTCollectionAssetsListUM {
data object Init : NFTCollectionAssetsListUM()
data class Loading(val itemsCount: Int) : NFTCollectionAssetsListUM()
data class Failed(val onRetryClick: () -> Unit) : NFTCollectionAssetsListUM()
data class Content(val items: ImmutableList<NFTCollectionAssetUM>) : NFTCollectionAssetsListUM()
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.nft.collections.entity
import androidx.annotation.DrawableRes
import com.tangem.core.ui.extensions.TextReference
internal data class NFTCollectionUM(
val id: String,
val name: String,
@DrawableRes val networkIconId: Int,
val logoUrl: String?,
val description: TextReference,
val assets: NFTCollectionAssetsListUM,
val isExpanded: Boolean,
val onExpandClick: () -> Unit,
)

View file

@ -0,0 +1,6 @@
package com.tangem.features.nft.collections.entity
internal data class NFTCollectionsStateUM(
val onBackClick: () -> Unit,
val content: NFTCollectionsUM,
)

View file

@ -0,0 +1,29 @@
package com.tangem.features.nft.collections.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class NFTCollectionsUM {
data class Empty(
val onReceiveClick: () -> Unit,
) : NFTCollectionsUM()
data class Loading(
val onReceiveClick: () -> Unit,
) : NFTCollectionsUM()
data class Failed(
val onRetryClick: () -> Unit,
val onReceiveClick: () -> Unit,
) : NFTCollectionsUM()
data class Content(
val search: SearchBarUM,
val collections: ImmutableList<NFTCollectionUM>,
val warnings: ImmutableList<NFTCollectionsWarningUM>,
val onReceiveClick: () -> Unit,
) : NFTCollectionsUM()
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.nft.collections.entity
import com.tangem.core.ui.components.notifications.NotificationConfig
internal data class NFTCollectionsWarningUM(
val id: String,
val config: NotificationConfig,
)

View file

@ -0,0 +1,10 @@
package com.tangem.features.nft.collections.entity
import androidx.compose.runtime.Immutable
@Immutable
internal sealed class NFTSalePriceUM {
data object Loading : NFTSalePriceUM()
data object Failed : NFTSalePriceUM()
data class Content(val price: String) : NFTSalePriceUM()
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList
internal class ChangeCollectionExpandedStateTransformer(
private val collectionId: NFTCollection.Identifier,
private val onFirstExpanded: () -> Unit,
) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
content = when (prevState.content) {
is NFTCollectionsUM.Empty,
is NFTCollectionsUM.Loading,
is NFTCollectionsUM.Failed,
-> prevState.content
is NFTCollectionsUM.Content -> prevState.content.copy(
collections = prevState.content.collections.map {
if (it.id == collectionId.toString()) {
if (!it.isExpanded) {
onFirstExpanded()
}
it.copy(isExpanded = !it.isExpanded)
} else {
it
}
}.toPersistentList(),
)
},
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.utils.transformer.Transformer
internal class ToggleSearchBarTransformer(private val isActive: Boolean) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
content = when (val content = prevState.content) {
is NFTCollectionsUM.Content -> content.copy(
search = content.search.copy(
isActive = isActive,
),
)
is NFTCollectionsUM.Empty,
is NFTCollectionsUM.Loading,
is NFTCollectionsUM.Failed,
-> content
},
)
}

View file

@ -0,0 +1,134 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.nft.models.*
import com.tangem.features.nft.collections.entity.*
import com.tangem.features.nft.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Suppress("LongParameterList")
internal class UpdateDataStateTransformer(
private val nftCollections: List<NFTCollections>,
private val searchQuery: String,
private val onReceiveClick: () -> Unit,
private val onRetryClick: () -> Unit,
private val onExpandCollectionClick: (NFTCollection) -> Unit,
private val onRetryAssetsClick: (NFTCollection) -> Unit,
private val onAssetClick: (NFTAsset) -> Unit,
private val initialSearchBarFactory: () -> SearchBarUM,
) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
content = when {
nftCollections.allCollectionsFailed() ->
NFTCollectionsUM.Failed(onRetryClick, onReceiveClick)
nftCollections.anyCollectionFailed() && nftCollections.allLoadedCollectionsEmpty() ->
NFTCollectionsUM.Failed(onRetryClick, onReceiveClick)
nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() ->
NFTCollectionsUM.Empty(onReceiveClick)
!nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() ->
NFTCollectionsUM.Loading(onReceiveClick)
else -> {
NFTCollectionsUM.Content(
search = if (prevState.content is NFTCollectionsUM.Content) {
prevState.content.search.copy(
query = searchQuery,
)
} else {
initialSearchBarFactory()
},
collections = nftCollections
.map { it.content }
.asSequence()
.filterIsInstance<NFTCollections.Content.Collections>()
.map { it.collections.orEmpty().transform(prevState, searchQuery) }
.flatten()
.toPersistentList(),
warnings = transformNotifications(),
onReceiveClick = onReceiveClick,
)
}
},
)
private fun List<NFTCollection>.transform(
state: NFTCollectionsStateUM,
query: String,
): ImmutableList<NFTCollectionUM> = mapNotNull {
if (query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true) {
NFTCollectionUM(
id = it.id.toString(),
networkIconId = getActiveIconRes(it.network.id.value),
name = it.name.orEmpty(),
description = TextReference.PluralRes(
R.plurals.nft_collections_count,
it.count,
wrappedList(it.count),
),
logoUrl = it.logoUrl,
assets = it.transformAssets(),
onExpandClick = {
onExpandCollectionClick(it)
},
isExpanded = it.isExpanded(state),
)
} else {
null
}
}.toPersistentList()
private fun transformNotifications(): ImmutableList<NFTCollectionsWarningUM> = buildList {
if (nftCollections.anyCollectionFailed()) {
add(
NFTCollectionsWarningUM(
id = "loading troubles",
config = NotificationConfig(
title = TextReference.Res(R.string.nft_collections_warning_title),
subtitle = TextReference.Res(R.string.nft_collections_warning_subtitle),
iconResId = R.drawable.ic_alert_triangle_20,
),
),
)
}
}.toPersistentList()
private fun NFTCollection.transformAssets(): NFTCollectionAssetsListUM = when (val assets = this.assets) {
is NFTCollection.Assets.Empty -> NFTCollectionAssetsListUM.Init
is NFTCollection.Assets.Loading -> NFTCollectionAssetsListUM.Loading(count)
is NFTCollection.Assets.Failed -> NFTCollectionAssetsListUM.Failed { onRetryAssetsClick(this) }
is NFTCollection.Assets.Value -> NFTCollectionAssetsListUM.Content(
items = assets
.items
.map { it.transform() }
.toPersistentList(),
)
}
private fun NFTAsset.transform(): NFTCollectionAssetUM = NFTCollectionAssetUM(
id = id.toString(),
name = name.orEmpty(),
imageUrl = media?.url,
price = when (val salePrice = salePrice) {
is NFTSalePrice.Empty -> NFTSalePriceUM.Failed
is NFTSalePrice.Loading -> NFTSalePriceUM.Loading
is NFTSalePrice.Error -> NFTSalePriceUM.Failed
is NFTSalePrice.Value -> NFTSalePriceUM.Content(salePrice.value.toString())
},
onItemClick = {
onAssetClick(this)
},
)
private fun NFTCollection.isExpanded(state: NFTCollectionsStateUM): Boolean =
(state.content as? NFTCollectionsUM.Content)
?.collections
?.firstOrNull { it.id == id.toString() }
?.isExpanded
?: false
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.utils.transformer.Transformer
internal class UpdateSearchQueryTransformer(private val newQuery: String) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
content = when (val content = prevState.content) {
is NFTCollectionsUM.Content -> content.copy(
search = content.search.copy(
query = newQuery,
),
)
is NFTCollectionsUM.Empty,
is NFTCollectionsUM.Loading,
is NFTCollectionsUM.Failed,
-> content
},
)
}

View file

@ -0,0 +1,148 @@
package com.tangem.features.nft.collections.model
import com.tangem.common.routing.AppRoute
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.decompose.navigation.Router
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.nft.FetchNFTCollectionAssetsUseCase
import com.tangem.domain.nft.GetNFTCollectionsUseCase
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.features.nft.collections.entity.transformer.ChangeCollectionExpandedStateTransformer
import com.tangem.features.nft.collections.entity.transformer.ToggleSearchBarTransformer
import com.tangem.features.nft.collections.entity.transformer.UpdateDataStateTransformer
import com.tangem.features.nft.collections.entity.transformer.UpdateSearchQueryTransformer
import com.tangem.features.nft.component.NFTCollectionsComponent
import com.tangem.features.nft.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class NFTCollectionsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val searchManager: InputManager,
private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase,
private val fetchNFTCollectionAssetsUseCase: FetchNFTCollectionAssetsUseCase,
paramsContainer: ParamsContainer,
) : Model() {
val state: StateFlow<NFTCollectionsStateUM> get() = _state
private val _state = MutableStateFlow(
value = NFTCollectionsStateUM(
onBackClick = ::navigateBack,
content = NFTCollectionsUM.Loading(::onReceiveClick),
),
)
private val params: NFTCollectionsComponent.Params = paramsContainer.require()
init {
subscribeToNFTCollections()
}
private fun subscribeToNFTCollections() {
combine(
flow = getNFTCollectionsUseCase(params.userWalletId),
flow2 = searchManager.query.distinctUntilChanged(),
) { nftCollections, query ->
_state.update {
UpdateDataStateTransformer(
nftCollections = nftCollections,
searchQuery = query,
onReceiveClick = ::onReceiveClick,
onRetryClick = ::onRetryClick,
onExpandCollectionClick = ::onExpandCollectionClick,
onRetryAssetsClick = ::onRetryAssetsClick,
onAssetClick = ::onAssetClick,
initialSearchBarFactory = ::getInitialSearchBar,
).transform(it)
}
}
.launchIn(modelScope)
}
private fun onSearchQueryChange(newQuery: String) {
modelScope.launch {
_state.update { UpdateSearchQueryTransformer(newQuery).transform(it) }
searchManager.update(newQuery)
}
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
isActive = false,
onQueryChange = ::onSearchQueryChange,
onActiveChange = ::toggleSearchBar,
)
private fun toggleSearchBar(isActive: Boolean) {
_state.update {
ToggleSearchBarTransformer(isActive).transform(it)
}
}
private fun onExpandCollectionClick(collection: NFTCollection) {
_state.update {
ChangeCollectionExpandedStateTransformer(
collectionId = collection.id,
onFirstExpanded = { onFirstExpanded(collection) },
).transform(it)
}
}
private fun onFirstExpanded(collection: NFTCollection) {
loadCollectionAssets(collection)
}
private fun onRetryAssetsClick(collection: NFTCollection) {
loadCollectionAssets(collection)
}
private fun onRetryClick() {
// TODO refresh all
}
private fun onAssetClick(asset: NFTAsset) {
router.push(
AppRoute.NFTDetails(
userWalletId = params.userWalletId,
nftAsset = asset,
),
)
}
private fun onReceiveClick() {
router.push(
AppRoute.NFTReceive(
userWalletId = params.userWalletId,
),
)
}
private fun navigateBack() {
router.pop()
}
private fun loadCollectionAssets(collection: NFTCollection) {
modelScope.launch {
fetchNFTCollectionAssetsUseCase(
userWalletId = params.userWalletId,
network = collection.network,
collectionId = collection.id,
)
}
}
}

View file

@ -0,0 +1,189 @@
package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.currency.icon.CurrencyIconTopBadge
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.collections.entity.NFTCollectionAssetsListUM
import com.tangem.features.nft.collections.entity.NFTCollectionUM
import com.tangem.features.nft.impl.R
import kotlinx.collections.immutable.persistentListOf
private const val CHEVRON_ROTATION_EXPANDED = 180f
private const val CHEVRON_ROTATION_COLLAPSED = 0f
@Composable
internal fun NFTCollection(state: NFTCollectionUM, modifier: Modifier = Modifier) {
val isExpanded = state.isExpanded
Column(
modifier = modifier,
) {
Row(
modifier = Modifier
.clickable(onClick = state.onExpandClick)
.padding(
vertical = TangemTheme.dimens.spacing16,
horizontal = TangemTheme.dimens.spacing12,
),
verticalAlignment = Alignment.CenterVertically,
) {
Logo(state)
Text(state)
val rotation by animateFloatAsState(
targetValue = if (isExpanded) {
CHEVRON_ROTATION_EXPANDED
} else {
CHEVRON_ROTATION_COLLAPSED
},
label = "chevron_rotation",
)
Icon(
modifier = Modifier
.rotate(rotation)
.size(TangemTheme.dimens.size24),
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = if (isExpanded) {
TangemTheme.colors.icon.primary1
} else {
TangemTheme.colors.icon.inactive
},
contentDescription = null,
)
}
}
}
@Composable
private fun Logo(state: NFTCollectionUM) {
val networkBadgeOffset = TangemTheme.dimens.spacing6
Box(
modifier = Modifier,
) {
SubcomposeAsyncImage(
modifier = Modifier
.align(Alignment.CenterStart)
.size(TangemTheme.dimens.size36)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(LocalContext.current)
.data(state.logoUrl)
.crossfade(true)
.build(),
loading = {
RectangleShimmer(radius = TangemTheme.dimens.radius8)
},
error = {
Box(
modifier = Modifier
.clip(shape = TangemTheme.shapes.roundedCorners8)
.background(TangemTheme.colors.field.primary),
)
},
contentScale = ContentScale.Crop,
contentDescription = null,
)
CurrencyIconTopBadge(
modifier = Modifier
.offset(x = networkBadgeOffset, y = -networkBadgeOffset)
.align(Alignment.TopEnd),
iconResId = state.networkIconId,
alpha = 1f,
colorFilter = null,
)
}
}
@Composable
private fun RowScope.Text(state: NFTCollectionUM) {
Column(
modifier = Modifier
.weight(1f)
.padding(
horizontal = TangemTheme.dimens.spacing12,
),
) {
Text(
modifier = Modifier,
text = state.name,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing2),
text = state.description.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTCollection(@PreviewParameter(NFTCollectionProvider::class) item: NFTCollectionUM) {
TangemThemePreview {
NFTCollection(
state = item,
)
}
}
private class NFTCollectionProvider : CollectionPreviewParameterProvider<NFTCollectionUM>(
collection = listOf(
NFTCollectionUM(
id = "item1",
name = "Nethers",
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Content(persistentListOf()),
isExpanded = false,
onExpandClick = { },
),
NFTCollectionUM(
id = "item2",
name = "Nethers",
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Loading(
itemsCount = 3,
),
onExpandClick = { },
isExpanded = true,
),
),
)

View file

@ -0,0 +1,123 @@
package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH2
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.collections.entity.NFTCollectionAssetUM
import com.tangem.features.nft.collections.entity.NFTSalePriceUM
import com.tangem.features.nft.impl.R
@Composable
internal fun NFTCollectionAsset(state: NFTCollectionAssetUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
) {
SubcomposeAsyncImage(
modifier = Modifier
.aspectRatio(1f)
.clip(TangemTheme.shapes.roundedCornersXMedium),
model = ImageRequest.Builder(LocalContext.current)
.data(state.imageUrl)
.crossfade(true)
.build(),
loading = {
RectangleShimmer(radius = TangemTheme.dimens.radius16)
},
error = {
Placeholder()
},
contentScale = ContentScale.Crop,
contentDescription = null,
)
SpacerH12()
Text(
text = state.name,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerH2()
NFTSalePrice(
state = state.price,
)
}
}
@Composable
private fun Placeholder(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.field.focused),
contentAlignment = Alignment.Center,
) {
Image(
modifier = Modifier.size(TangemTheme.dimens.size52),
painter = painterResource(R.drawable.ic_nft_placeholder_76),
contentDescription = null,
)
}
}
@Preview(widthDp = 180, showBackground = true)
@Preview(widthDp = 180, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTCollectionAsset(
@PreviewParameter(NFTCollectionAssetProvider::class) state: NFTCollectionAssetUM,
) {
TangemThemePreview {
NFTCollectionAsset(
state = state,
)
}
}
private class NFTCollectionAssetProvider : CollectionPreviewParameterProvider<NFTCollectionAssetUM>(
collection = listOf(
NFTCollectionAssetUM(
id = "item1",
name = "Nethers #0853",
imageUrl = "img",
price = NFTSalePriceUM.Loading,
onItemClick = { },
),
NFTCollectionAssetUM(
id = "item2",
name = "Nethers #0854",
imageUrl = "img",
price = NFTSalePriceUM.Failed,
onItemClick = { },
),
NFTCollectionAssetUM(
id = "item3",
name = "Nethers #0855",
imageUrl = "img",
price = NFTSalePriceUM.Content("0.05 ETH"),
onItemClick = { },
),
),
)

View file

@ -0,0 +1,47 @@
package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
internal fun NFTCollectionAssetLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
) {
RectangleShimmer(
modifier = Modifier
.aspectRatio(1f),
)
SpacerH12()
TextShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size86),
style = TangemTheme.typography.subtitle2,
textSizeHeight = true,
)
SpacerH2()
TextShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size48),
style = TangemTheme.typography.caption2,
textSizeHeight = true,
)
}
}
@Preview(widthDp = 180, showBackground = true)
@Preview(widthDp = 180, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTCollectionAsset() {
TangemThemePreview {
NFTCollectionAssetLoading()
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.nft.collections.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.features.nft.collections.entity.NFTCollectionsWarningUM
@Composable
internal fun NFTCollectionWarning(state: NFTCollectionsWarningUM, modifier: Modifier = Modifier) {
Notification(
modifier = modifier,
config = state.config,
)
}

View file

@ -0,0 +1,49 @@
package com.tangem.features.nft.collections.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.features.nft.impl.R
@Composable
internal fun NFTCollections(state: NFTCollectionsStateUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackClick)
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
AppBarWithBackButton(
modifier = Modifier.statusBarsPadding(),
onBackClick = state.onBackClick,
text = stringResourceSafe(id = R.string.nft_collections_title),
iconRes = R.drawable.ic_back_24,
)
},
content = { innerPadding ->
AnimatedContent(
targetState = state.content,
contentKey = { it::class },
label = "NFT Collections",
) {
val contentModifier = Modifier
.padding(innerPadding)
.fillMaxSize()
when (val content = it) {
is NFTCollectionsUM.Content -> NFTCollectionsContent(content, contentModifier)
is NFTCollectionsUM.Empty -> NFTCollectionsEmpty(content, contentModifier)
is NFTCollectionsUM.Failed -> NFTCollectionsFailed(content, contentModifier)
is NFTCollectionsUM.Loading -> Unit
}
}
},
)
}

View file

@ -0,0 +1,349 @@
package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.collections.entity.*
import com.tangem.features.nft.impl.R
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable
internal fun NFTCollectionsContent(content: NFTCollectionsUM.Content, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary)
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
Column(
modifier = Modifier
.fillMaxWidth(),
) {
SearchBar(
state = content.search,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
content.warnings.fastForEach {
key(it.id) {
NFTCollectionWarning(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16),
state = it,
)
}
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing60,
)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.primary),
state = listState,
) {
content.collections.fastForEach { collection ->
item(key = collection.id) {
NFTCollection(
modifier = Modifier.fillMaxWidth(),
state = collection,
)
}
when (val assets = collection.assets) {
is NFTCollectionAssetsListUM.Init -> Unit
is NFTCollectionAssetsListUM.Loading -> {
assetsListLoading(
collectionId = collection.id,
content = assets,
expanded = collection.isExpanded,
)
}
is NFTCollectionAssetsListUM.Failed -> {
assetsListFailed(
collectionId = collection.id,
content = assets,
expanded = collection.isExpanded,
)
}
is NFTCollectionAssetsListUM.Content -> {
assetsListContent(
collectionId = collection.id,
content = assets,
expanded = collection.isExpanded,
)
}
}
}
}
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
text = stringResourceSafe(R.string.nft_collections_receive),
onClick = content.onReceiveClick,
)
}
}
private fun LazyListScope.assetsListLoading(
collectionId: String,
content: NFTCollectionAssetsListUM.Loading,
expanded: Boolean,
) {
val itemsCount = content.itemsCount
val rowCount = (itemsCount + 1) / 2
repeat(rowCount) { rowIndex ->
item(
key = "loading_${collectionId}_$rowIndex",
) {
AnimatedVisibility(visible = expanded) {
val paddingValues = PaddingValues(
start = TangemTheme.dimens.spacing6,
top = TangemTheme.dimens.spacing6,
end = TangemTheme.dimens.spacing6,
bottom = TangemTheme.dimens.spacing20,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing6)
.animateContentSize(),
) {
NFTCollectionAssetLoading(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
if (rowIndex == rowCount - 1 && itemsCount % 2 != 0) {
Box(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
} else {
NFTCollectionAssetLoading(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
}
}
}
}
}
}
private fun LazyListScope.assetsListFailed(
collectionId: String,
content: NFTCollectionAssetsListUM.Failed,
expanded: Boolean,
) {
item(
key = "failed_$collectionId",
) {
AnimatedVisibility(visible = expanded) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size142)
.animateContentSize(),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(
onRetryClick = content.onRetryClick,
)
}
}
}
}
private fun LazyListScope.assetsListContent(
collectionId: String,
content: NFTCollectionAssetsListUM.Content,
expanded: Boolean,
) {
val items = content.items
val itemsCount = items.size
val rowCount = (itemsCount + 1) / 2
repeat(rowCount) { rowIndex ->
val item1 = items[rowIndex * 2]
val item2 = items.getOrNull(rowIndex * 2 + 1)
item(
key = "content_${collectionId}_${item1.id}_${item2?.id}",
) {
AnimatedVisibility(visible = expanded) {
val paddingValues = PaddingValues(
start = TangemTheme.dimens.spacing6,
top = TangemTheme.dimens.spacing6,
end = TangemTheme.dimens.spacing6,
bottom = TangemTheme.dimens.spacing20,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing6)
.animateContentSize(),
) {
NFTCollectionAsset(
modifier = Modifier
.weight(1f)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable { item1.onItemClick() }
.padding(paddingValues),
state = item1,
)
if (item2 == null) {
Box(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
} else {
NFTCollectionAsset(
modifier = Modifier
.weight(1f)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable { item2.onItemClick() }
.padding(paddingValues),
state = item2,
)
}
}
}
}
}
}
@Suppress("LongMethod")
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTCollectionsContent() {
TangemThemePreview {
NFTCollectionsContent(
content = NFTCollectionsUM.Content(
search = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
),
collections = persistentListOf(
NFTCollectionUM(
id = "item1",
name = "Nethers",
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Content(persistentListOf()),
isExpanded = false,
onExpandClick = { },
),
NFTCollectionUM(
id = "item2",
name = "Nethers",
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Loading(
itemsCount = 1,
),
isExpanded = true,
onExpandClick = { },
),
NFTCollectionUM(
id = "item3",
name = "Nethers",
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Failed(
onRetryClick = { },
),
isExpanded = true,
onExpandClick = { },
),
NFTCollectionUM(
id = "item4",
name = "Nethers",
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Content(
items = persistentListOf(
NFTCollectionAssetUM(
id = "item1",
name = "Nethers #0854",
imageUrl = "img",
price = NFTSalePriceUM.Content("0.05 ETH"),
onItemClick = { },
),
NFTCollectionAssetUM(
id = "item2",
name = "Nethers #0855",
imageUrl = "img",
price = NFTSalePriceUM.Loading,
onItemClick = { },
),
NFTCollectionAssetUM(
id = "item3",
name = "Nethers #0856",
imageUrl = "img",
price = NFTSalePriceUM.Failed,
onItemClick = { },
),
),
),
isExpanded = true,
onExpandClick = { },
),
),
warnings = persistentListOf(
NFTCollectionsWarningUM(
id = "loading troubles",
config = NotificationConfig(
title = TextReference.Res(R.string.nft_collections_warning_title),
subtitle = TextReference.Res(R.string.nft_collections_warning_subtitle),
iconResId = R.drawable.ic_alert_triangle_20,
),
),
),
onReceiveClick = { },
),
)
}
}

View file

@ -0,0 +1,74 @@
package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.features.nft.impl.R
@Composable
internal fun NFTCollectionsEmpty(state: NFTCollectionsUM.Empty, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.padding(TangemTheme.dimens.spacing16),
) {
Column(
modifier = Modifier
.padding(TangemTheme.dimens.spacing16)
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.ic_nft_placeholder_76),
contentDescription = null,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing24),
text = stringResource(R.string.nft_collections_empty_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
text = stringResource(R.string.nft_collections_empty_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
PrimaryButton(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing48)
.wrapContentWidth(),
text = stringResourceSafe(R.string.nft_collections_receive),
onClick = state.onReceiveClick,
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTCollectionsEmpty() {
TangemThemePreview {
NFTCollectionsEmpty(
state = NFTCollectionsUM.Empty(
onReceiveClick = { },
),
)
}
}

View file

@ -0,0 +1,54 @@
package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.features.nft.impl.R
@Composable
internal fun NFTCollectionsFailed(state: NFTCollectionsUM.Failed, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.padding(TangemTheme.dimens.spacing16),
) {
UnableToLoadData(
modifier = Modifier
.align(Alignment.Center),
onRetryClick = state.onRetryClick,
)
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
text = stringResourceSafe(R.string.nft_collections_receive),
onClick = state.onReceiveClick,
)
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTCollectionsFailed() {
TangemThemePreview {
NFTCollectionsFailed(
state = NFTCollectionsUM.Failed(
onRetryClick = { },
onReceiveClick = { },
),
)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.nft.collections.ui
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.nft.collections.entity.NFTSalePriceUM
@Composable
internal fun NFTSalePrice(state: NFTSalePriceUM, modifier: Modifier = Modifier) {
when (state) {
is NFTSalePriceUM.Content -> {
Text(
modifier = modifier,
text = state.price,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
is NFTSalePriceUM.Failed -> {
Text(
modifier = modifier,
text = "",
style = TangemTheme.typography.caption2,
)
}
is NFTSalePriceUM.Loading -> {
TextShimmer(
modifier = modifier
.width(TangemTheme.dimens.size48),
style = TangemTheme.typography.caption2,
)
}
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.nft.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.nft.component.NFTDetailsComponent
import com.tangem.features.nft.details.model.NFTDetailsModel
import com.tangem.features.nft.details.ui.NFTDetails
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultNFTDetailsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: NFTDetailsComponent.Params,
) : NFTDetailsComponent, AppComponentContext by context {
private val model: NFTDetailsModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
NFTDetails(state, modifier)
}
@AssistedFactory
interface Factory : NFTDetailsComponent.Factory {
override fun create(
context: AppComponentContext,
params: NFTDetailsComponent.Params,
): DefaultNFTDetailsComponent
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.features.nft.details.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
data class NFTAssetUM(
val name: String,
val media: Media,
val topInfo: TopInfo,
val traits: ImmutableList<BlockItem>,
val baseInfoItems: ImmutableList<BlockItem>,
) {
@Immutable
sealed class TopInfo {
data object Empty : TopInfo()
data class Content(
val title: TextReference?,
val salePrice: SalePrice,
val description: String?,
val rarity: Rarity,
) : TopInfo()
}
@Immutable
sealed class Media {
data object Empty : Media()
data class Content(
val mimetype: String?,
val url: String,
) : Media()
}
@Immutable
sealed class SalePrice {
data object Loading : SalePrice()
data object Empty : SalePrice()
data class Content(val value: String, val fiatValue: String) : SalePrice()
}
@Immutable
sealed class Rarity {
data object Empty : Rarity()
data class Content(
val rank: String,
val label: String,
val showDivider: Boolean,
) : Rarity()
}
data class BlockItem(
val title: TextReference,
val titleTextEllipsis: TextEllipsis = TextEllipsis.End,
val value: String,
val valueTextEllipsis: TextEllipsis = TextEllipsis.End,
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.nft.details.entity
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
internal data class NFTDetailsUM(
val nftAsset: NFTAssetUM,
val onBackClick: () -> Unit,
val onReadMoreClick: () -> Unit,
val onSeeAllClick: () -> Unit,
val onExploreClick: () -> Unit,
val onSendClick: () -> Unit,
val bottomSheetConfig: TangemBottomSheetConfig?,
)

View file

@ -0,0 +1,64 @@
package com.tangem.features.nft.details.model
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.decompose.navigation.Router
import com.tangem.features.nft.component.NFTDetailsComponent
import com.tangem.features.nft.details.entity.NFTAssetUM
import com.tangem.features.nft.details.entity.NFTDetailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ModelScoped
internal class NFTDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
paramsContainer: ParamsContainer,
) : Model() {
@Suppress("UnusedPrivateMember")
private val params: NFTDetailsComponent.Params = paramsContainer.require()
val state: StateFlow<NFTDetailsUM> get() = _state
private val _state = MutableStateFlow(
value = NFTDetailsUM(
nftAsset = NFTAssetUM(
name = "",
media = NFTAssetUM.Media.Empty,
topInfo = NFTAssetUM.TopInfo.Empty,
traits = persistentListOf(),
baseInfoItems = persistentListOf(),
),
onBackClick = ::navigateBack,
onReadMoreClick = ::onReadMoreClick,
onSeeAllClick = ::onSeeAllClick,
onExploreClick = ::onExploreClick,
onSendClick = ::onSendClick,
bottomSheetConfig = null,
),
)
private fun onReadMoreClick() {
// TODO implement
}
private fun onSeeAllClick() {
// TODO implement
}
private fun onExploreClick() {
// TODO implement
}
private fun onSendClick() {
// TODO implement
}
private fun navigateBack() {
router.pop()
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.features.nft.details.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.nft.details.entity.NFTDetailsUM
import com.tangem.features.nft.impl.R
@Composable
internal fun NFTDetails(state: NFTDetailsUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackClick)
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
startButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_back_24,
onIconClicked = state.onBackClick,
),
title = state.nftAsset.name,
)
},
content = { innerPadding ->
NFTDetailsAsset(
state = state.nftAsset,
onReadMoreClick = state.onReadMoreClick,
onSeeAllClick = state.onSeeAllClick,
onExploreClick = state.onExploreClick,
modifier = Modifier
.padding(innerPadding),
)
},
floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
PrimaryButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResourceSafe(id = R.string.common_send),
onClick = state.onSendClick,
)
},
)
}

View file

@ -0,0 +1,178 @@
package com.tangem.features.nft.details.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
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.atoms.text.TextEllipsis
import com.tangem.core.ui.components.bottomFade
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.details.entity.NFTAssetUM
import com.tangem.features.nft.impl.R
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun NFTDetailsAsset(
state: NFTAssetUM,
onReadMoreClick: () -> Unit,
onSeeAllClick: () -> Unit,
onExploreClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
val bottomBarHeight = with(LocalDensity.current) {
WindowInsets.systemBars.getBottom(density = this).toDp()
}
val bottomPadding = TangemTheme.dimens.spacing76 + bottomBarHeight
Box(
modifier = Modifier
.fillMaxSize()
.bottomFade(),
) {
Column(
modifier = modifier
.verticalScroll(scrollState)
.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = bottomPadding,
)
.fillMaxSize(),
) {
NFTDetailsLogo(
state = state.media,
modifier = Modifier
.aspectRatio(1f),
)
NFTDetailsInfoGroup(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12),
state = state.topInfo,
onReadMoreClick = onReadMoreClick,
)
NFTDetailsBlocksGroup(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12),
items = state.traits,
title = resourceReference(R.string.nft_details_traits),
action = {
NFTBlocksGroupAction(
text = resourceReference(R.string.common_see_all),
startIcon = { },
onClick = onSeeAllClick,
)
},
)
NFTDetailsBlocksGroup(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12),
items = state.baseInfoItems,
title = resourceReference(R.string.nft_details_base_information),
action = {
NFTBlocksGroupAction(
text = resourceReference(R.string.common_explore),
startIcon = {
NFTBlocksGroupActionIcon(iconRes = R.drawable.ic_compass_24)
},
onClick = onExploreClick,
)
},
)
}
}
}
@Preview(widthDp = 360, heightDp = 1000, showBackground = true)
@Preview(widthDp = 360, heightDp = 1000, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTDetailsAssetAsset(@PreviewParameter(NFTAssetProvider::class) state: NFTAssetUM) {
TangemThemePreview {
NFTDetailsAsset(
state = state,
onReadMoreClick = { },
onSeeAllClick = { },
onExploreClick = { },
)
}
}
@Suppress("MagicNumber")
private class NFTAssetProvider : CollectionPreviewParameterProvider<NFTAssetUM>(
collection = listOf(
NFTAssetUM(
name = "Nethers #9744",
media = NFTAssetUM.Media.Content(
url = "img1",
mimetype = "image/jpeg",
),
topInfo = NFTAssetUM.TopInfo.Content(
title = resourceReference(R.string.nft_details_last_sale_price),
salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"),
description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3),
rarity = NFTAssetUM.Rarity.Content(
rank = "Top 1% rarity",
label = "115.28",
showDivider = true,
),
),
traits = persistentListOf(
NFTAssetUM.BlockItem(
title = stringReference("Tier"),
value = "Infinite",
),
NFTAssetUM.BlockItem(
title = stringReference("Phygital Toy"),
value = "None",
),
NFTAssetUM.BlockItem(
title = stringReference("Class"),
value = "CYBER",
),
NFTAssetUM.BlockItem(
title = stringReference("Accessory"),
value = "No accessory",
),
NFTAssetUM.BlockItem(
title = stringReference("Sneakers"),
value = "Boots",
),
NFTAssetUM.BlockItem(
title = stringReference("Artist"),
value = "DJ Dragoon",
),
),
baseInfoItems = persistentListOf(
NFTAssetUM.BlockItem(
title = resourceReference(R.string.nft_details_token_standard),
value = "ERC-721",
),
NFTAssetUM.BlockItem(
title = resourceReference(R.string.nft_details_contract_address),
value = "0x6811f2fgd892ac83f719",
valueTextEllipsis = TextEllipsis.Middle,
),
NFTAssetUM.BlockItem(
title = resourceReference(R.string.nft_details_token_id),
value = "100200273",
),
NFTAssetUM.BlockItem(
title = resourceReference(R.string.nft_details_chain),
value = "Ethereum",
),
),
),
),
)

View file

@ -0,0 +1,246 @@
package com.tangem.features.nft.details.ui
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.details.entity.NFTAssetUM
import com.tangem.features.nft.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun NFTDetailsBlocksGroup(
title: TextReference,
action: (@Composable BoxScope.() -> Unit)?,
items: ImmutableList<NFTAssetUM.BlockItem>,
modifier: Modifier = Modifier,
) {
val itemsCount = items.size
if (items.size > 0) {
val rowCount = (itemsCount + 1) / 2
val paddingValues = PaddingValues(
horizontal = TangemTheme.dimens.spacing6,
vertical = TangemTheme.dimens.spacing8,
)
InformationBlock(
modifier = modifier,
title = {
NFTDetailsGroupTitle(
text = title,
)
},
action = action,
contentHorizontalPadding = TangemTheme.dimens.spacing6,
) {
Column(
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing12),
) {
repeat(rowCount) { rowIndex ->
val item1 = items[rowIndex * 2]
val item2 = items.getOrNull(rowIndex * 2 + 1)
key(rowIndex) {
Row(
modifier = Modifier.fillMaxWidth(),
) {
NFTDetailsGroupBlock(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
title = item1.title,
value = stringReference(item1.value),
titleEllipsis = item1.titleTextEllipsis,
valueEllipsis = item1.valueTextEllipsis,
)
if (item2 == null) {
Box(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
} else {
NFTDetailsGroupBlock(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
title = item2.title,
value = stringReference(item2.value),
titleEllipsis = item2.titleTextEllipsis,
valueEllipsis = item2.valueTextEllipsis,
)
}
}
}
}
}
}
}
}
@Composable
internal fun NFTDetailsGroupTitle(text: TextReference, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = text.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
internal fun NFTDetailsGroupBlock(
title: TextReference,
value: TextReference,
modifier: Modifier = Modifier,
titleEllipsis: TextEllipsis = TextEllipsis.End,
valueEllipsis: TextEllipsis = TextEllipsis.End,
) {
Column(
modifier = modifier,
) {
EllipsisText(
text = title.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = titleEllipsis,
)
EllipsisText(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing4),
text = value.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
ellipsis = valueEllipsis,
)
}
}
@Composable
internal fun NFTBlocksGroupAction(
text: TextReference,
startIcon: @Composable RowScope.() -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
Row(
modifier = modifier
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing2),
) {
startIcon()
Text(
text = text.resolveReference(),
color = TangemTheme.colors.text.accent,
style = TangemTheme.typography.subtitle2,
)
}
}
@Composable
internal fun NFTBlocksGroupActionIcon(@DrawableRes iconRes: Int, modifier: Modifier = Modifier) {
Icon(
modifier = modifier.size(size = TangemTheme.dimens.size16),
painter = painterResource(id = iconRes),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
}
@Preview(widthDp = 360)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTDetailsInfoBlock(
@PreviewParameter(NFTAssetBlocksProvider::class) state: ImmutableList<NFTAssetUM.BlockItem>,
) {
TangemThemePreview {
Column {
NFTDetailsBlocksGroup(
title = stringReference("Title"),
action = null,
items = state,
)
NFTDetailsBlocksGroup(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
title = stringReference("Title"),
action = {
NFTBlocksGroupAction(
text = resourceReference(R.string.common_see_all),
startIcon = { },
onClick = { },
)
},
items = state,
)
NFTDetailsBlocksGroup(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
title = stringReference("Title"),
action = {
NFTBlocksGroupAction(
text = resourceReference(R.string.common_explore),
startIcon = {
NFTBlocksGroupActionIcon(iconRes = R.drawable.ic_compass_24)
},
onClick = { },
)
},
items = state,
)
}
}
}
private class NFTAssetBlocksProvider : CollectionPreviewParameterProvider<ImmutableList<NFTAssetUM.BlockItem>>(
collection = listOf(
persistentListOf(
NFTAssetUM.BlockItem(
title = stringReference("Tier"),
value = "Infinite",
),
NFTAssetUM.BlockItem(
title = stringReference("Phygital Toy"),
value = "None",
),
NFTAssetUM.BlockItem(
title = stringReference("Class"),
value = "CYBER",
),
NFTAssetUM.BlockItem(
title = stringReference("Accessory"),
value = "No accessory",
),
NFTAssetUM.BlockItem(
title = stringReference("Sneakers"),
value = "Boots",
),
),
),
)

View file

@ -0,0 +1,266 @@
package com.tangem.features.nft.details.ui
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.details.entity.NFTAssetUM
import com.tangem.features.nft.impl.R
@Composable
internal fun NFTDetailsInfoGroup(
state: NFTAssetUM.TopInfo,
onReadMoreClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
is NFTAssetUM.TopInfo.Empty -> Unit
is NFTAssetUM.TopInfo.Content -> {
InformationBlock(
modifier = modifier,
title = if (state.title != null) {
{ NFTDetailsGroupTitle(state.title) }
} else {
null
},
contentHorizontalPadding = 0.dp,
) {
Column(
modifier = Modifier,
) {
SalePrice(state.salePrice)
Description(state.description, onReadMoreClick)
Rarity(state.rarity)
}
}
}
}
}
@Composable
private fun SalePrice(state: NFTAssetUM.SalePrice, modifier: Modifier = Modifier) {
AnimatedVisibility(
modifier = Modifier.fillMaxWidth().animateContentSize(),
visible = state !is NFTAssetUM.SalePrice.Empty,
enter = fadeIn(),
exit = fadeOut(),
) {
when (state) {
is NFTAssetUM.SalePrice.Empty -> Unit
is NFTAssetUM.SalePrice.Loading -> {
Column(
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
) {
TextShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size158),
style = TangemTheme.typography.head,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing4)
.width(TangemTheme.dimens.size90),
style = TangemTheme.typography.caption2,
textSizeHeight = true,
)
}
}
is NFTAssetUM.SalePrice.Content -> {
Column(
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
verticalArrangement = Arrangement.SpaceAround,
) {
Text(
text = state.value,
style = TangemTheme.typography.head,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing4),
text = state.fiatValue,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
}
}
@Composable
private fun Description(description: String?, onReadMoreClick: () -> Unit, modifier: Modifier = Modifier) {
if (!description.isNullOrEmpty()) {
// TODO configure right way to display 3 rows and "Read more" label
EllipsisText(
modifier = modifier
.clickable { onReadMoreClick() }
.padding(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing8,
),
text = description,
style = TangemTheme.typography.body2.copy(
color = TangemTheme.colors.text.tertiary,
),
)
}
}
@Composable
private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) {
when (state) {
is NFTAssetUM.Rarity.Empty -> Unit
is NFTAssetUM.Rarity.Content -> {
if (state.showDivider) {
HorizontalDivider(
modifier = Modifier.padding(
horizontal = TangemTheme.dimens.spacing12,
),
color = TangemTheme.colors.stroke.primary,
)
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing6,
vertical = TangemTheme.dimens.spacing8,
),
) {
NFTDetailsGroupBlock(
modifier = Modifier
.weight(1f)
.padding(
horizontal = TangemTheme.dimens.spacing6,
vertical = TangemTheme.dimens.spacing4,
),
title = resourceReference(R.string.nft_details_rarity_label),
value = stringReference(state.label),
)
NFTDetailsGroupBlock(
modifier = Modifier
.weight(1f)
.padding(
horizontal = TangemTheme.dimens.spacing6,
vertical = TangemTheme.dimens.spacing4,
),
title = resourceReference(R.string.nft_details_rarity_rank),
value = stringReference(state.rank),
)
}
}
}
}
@Preview(widthDp = 360)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTDetailsInfoBlock(@PreviewParameter(NFTAssetInfoProvider::class) state: NFTAssetUM.TopInfo) {
TangemThemePreview {
NFTDetailsInfoGroup(
state = state,
onReadMoreClick = { },
)
}
}
@Suppress("LongMethod", "MagicNumber")
private class NFTAssetInfoProvider : CollectionPreviewParameterProvider<NFTAssetUM.TopInfo>(
collection = listOf(
NFTAssetUM.TopInfo.Content(
title = resourceReference(R.string.nft_details_last_sale_price),
salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"),
description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3),
rarity = NFTAssetUM.Rarity.Content(
rank = "Top 1% rarity",
label = "115.28",
showDivider = true,
),
),
NFTAssetUM.TopInfo.Content(
title = resourceReference(R.string.nft_details_last_sale_price),
salePrice = NFTAssetUM.SalePrice.Loading,
description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3),
rarity = NFTAssetUM.Rarity.Content(
rank = "Top 1% rarity",
label = "115.28",
showDivider = true,
),
),
NFTAssetUM.TopInfo.Content(
title = resourceReference(R.string.nft_details_last_sale_price),
salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"),
description = null,
rarity = NFTAssetUM.Rarity.Content(
rank = "Top 1% rarity",
label = "115.28",
showDivider = true,
),
),
NFTAssetUM.TopInfo.Content(
title = resourceReference(R.string.nft_details_last_sale_price),
salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"),
description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3),
rarity = NFTAssetUM.Rarity.Empty,
),
NFTAssetUM.TopInfo.Content(
title = resourceReference(R.string.nft_details_last_sale_price),
salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"),
description = null,
rarity = NFTAssetUM.Rarity.Empty,
),
NFTAssetUM.TopInfo.Content(
title = null,
salePrice = NFTAssetUM.SalePrice.Empty,
description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3),
rarity = NFTAssetUM.Rarity.Content(
rank = "Top 1% rarity",
label = "115.28",
showDivider = true,
),
),
NFTAssetUM.TopInfo.Content(
title = null,
salePrice = NFTAssetUM.SalePrice.Empty,
description = null,
rarity = NFTAssetUM.Rarity.Content(
rank = "Top 1% rarity",
label = "115.28",
showDivider = false,
),
),
NFTAssetUM.TopInfo.Content(
title = null,
salePrice = NFTAssetUM.SalePrice.Empty,
description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3),
rarity = NFTAssetUM.Rarity.Empty,
),
),
)

View file

@ -0,0 +1,95 @@
package com.tangem.features.nft.details.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.details.entity.NFTAssetUM
import com.tangem.features.nft.impl.R
@Composable
internal fun NFTDetailsLogo(state: NFTAssetUM.Media, modifier: Modifier = Modifier) {
when (state) {
is NFTAssetUM.Media.Empty -> {
Placeholder(
modifier = modifier.fillMaxWidth(),
)
}
is NFTAssetUM.Media.Content -> {
SubcomposeAsyncImage(
modifier = modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium),
model = ImageRequest.Builder(LocalContext.current)
.data(state.url)
.crossfade(true)
.build(),
loading = {
RectangleShimmer(radius = TangemTheme.dimens.radius16)
},
error = {
Placeholder()
},
contentScale = ContentScale.Crop,
contentDescription = null,
)
}
}
}
@Composable
private fun Placeholder(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.field.focused),
contentAlignment = Alignment.Center,
) {
Image(
modifier = Modifier.size(TangemTheme.dimens.size120),
painter = painterResource(R.drawable.ic_nft_placeholder_76),
contentDescription = null,
)
}
}
@Preview(widthDp = 360)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTDetailsInfoBlock(@PreviewParameter(NFTAssetMediaProvider::class) state: NFTAssetUM.Media) {
TangemThemePreview {
NFTDetailsLogo(
state = state,
modifier = Modifier
.aspectRatio(1f),
)
}
}
private class NFTAssetMediaProvider : CollectionPreviewParameterProvider<NFTAssetUM.Media>(
collection = listOf(
NFTAssetUM.Media.Empty,
NFTAssetUM.Media.Content(
mimetype = "image/jpeg",
url = "img1",
),
),
)

View file

@ -0,0 +1,37 @@
package com.tangem.features.nft.receive
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.nft.component.NFTReceiveComponent
import com.tangem.features.nft.receive.model.NFTReceiveModel
import com.tangem.features.nft.receive.ui.NFTReceive
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultNFTReceiveComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: NFTReceiveComponent.Params,
) : NFTReceiveComponent, AppComponentContext by context {
private val model: NFTReceiveModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
NFTReceive(state, modifier)
}
@AssistedFactory
interface Factory : NFTReceiveComponent.Factory {
override fun create(
context: AppComponentContext,
params: NFTReceiveComponent.Params,
): DefaultNFTReceiveComponent
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.nft.receive.entity
import androidx.annotation.DrawableRes
internal data class NFTNetworkUM(
val id: String,
@DrawableRes val iconRes: Int,
val name: String,
val onItemClick: () -> Unit,
)

View file

@ -0,0 +1,23 @@
package com.tangem.features.nft.receive.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import kotlinx.collections.immutable.ImmutableList
internal data class NFTReceiveUM(
val onBackClick: () -> Unit,
val search: SearchBarUM,
val networks: Networks,
val bottomSheetConfig: TangemBottomSheetConfig?,
) {
@Immutable
sealed class Networks {
data object Empty : Networks()
data class Content(
val items: ImmutableList<NFTNetworkUM>,
) : Networks()
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.nft.receive.entity.transformer
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.NetworkAddress
import com.tangem.features.nft.receive.entity.NFTReceiveUM
import com.tangem.utils.transformer.Transformer
internal class ShowReceiveBottomSheetTransformer(
private val network: Network,
private val networkAddress: NetworkAddress,
private val onDismissBottomSheet: () -> Unit,
private val onCopyClick: (String) -> Unit,
private val onShareClick: (String) -> Unit,
) : Transformer<NFTReceiveUM> {
override fun transform(prevState: NFTReceiveUM): NFTReceiveUM = prevState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismissBottomSheet,
content = TokenReceiveBottomSheetConfig(
asset = TokenReceiveBottomSheetConfig.Asset.NFT,
network = network,
networkAddress = networkAddress,
showMemoDisclaimer = network.transactionExtrasType != Network.TransactionExtrasType.NONE,
onCopyClick = onCopyClick,
onShareClick = onShareClick,
),
),
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.nft.receive.entity.transformer
import com.tangem.features.nft.receive.entity.NFTReceiveUM
import com.tangem.utils.transformer.Transformer
internal class ToggleSearchBarTransformer(private val isActive: Boolean) : Transformer<NFTReceiveUM> {
override fun transform(prevState: NFTReceiveUM): NFTReceiveUM = prevState.copy(
search = prevState.search.copy(
isActive = isActive,
),
)
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.nft.receive.entity.transformer
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.domain.tokens.model.Network
import com.tangem.features.nft.receive.entity.NFTNetworkUM
import com.tangem.features.nft.receive.entity.NFTReceiveUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList
internal class UpdateDataStateTransformer(
private val networks: List<Network>,
private val onNetworkClick: (Network) -> Unit,
) : Transformer<NFTReceiveUM> {
override fun transform(prevState: NFTReceiveUM): NFTReceiveUM = prevState.copy(
networks = when {
networks.isEmpty() -> NFTReceiveUM.Networks.Empty
else -> NFTReceiveUM.Networks.Content(
items = networks
.map { it.transform() }
.toPersistentList(),
)
},
)
private fun Network.transform(): NFTNetworkUM = NFTNetworkUM(
id = id.value,
iconRes = getActiveIconRes(id.value),
name = name,
onItemClick = {
onNetworkClick(this)
},
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.nft.receive.entity.transformer
import com.tangem.features.nft.receive.entity.NFTReceiveUM
import com.tangem.utils.transformer.Transformer
internal class UpdateSearchQueryTransformer(private val newQuery: String) : Transformer<NFTReceiveUM> {
override fun transform(prevState: NFTReceiveUM): NFTReceiveUM = prevState.copy(
search = prevState.search.copy(
query = newQuery,
),
)
}

View file

@ -0,0 +1,147 @@
package com.tangem.features.nft.receive.model
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.decompose.navigation.Router
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.nft.FilterNFTAvailableNetworksUseCase
import com.tangem.domain.nft.GetNFTAvailableNetworksUseCase
import com.tangem.domain.nft.GetNFTNetworkStatusUseCase
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.features.nft.component.NFTReceiveComponent
import com.tangem.features.nft.impl.R
import com.tangem.features.nft.receive.entity.NFTReceiveUM
import com.tangem.features.nft.receive.entity.transformer.ShowReceiveBottomSheetTransformer
import com.tangem.features.nft.receive.entity.transformer.ToggleSearchBarTransformer
import com.tangem.features.nft.receive.entity.transformer.UpdateDataStateTransformer
import com.tangem.features.nft.receive.entity.transformer.UpdateSearchQueryTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class NFTReceiveModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val searchManager: InputManager,
private val getNFTAvailableNetworksUseCase: GetNFTAvailableNetworksUseCase,
private val filterNFTAvailableNetworksUseCase: FilterNFTAvailableNetworksUseCase,
private val getNFTNetworkStatusUseCase: GetNFTNetworkStatusUseCase,
private val clipboardManager: ClipboardManager,
private val shareManager: ShareManager,
paramsContainer: ParamsContainer,
) : Model() {
val state: StateFlow<NFTReceiveUM> get() = _state
private val _state = MutableStateFlow(
value = NFTReceiveUM(
onBackClick = ::navigateBack,
search = getInitialSearchBar(),
networks = NFTReceiveUM.Networks.Content(persistentListOf()),
bottomSheetConfig = null,
),
)
private val params: NFTReceiveComponent.Params = paramsContainer.require()
init {
subscribeToNFTAvailableNetworks()
}
private fun subscribeToNFTAvailableNetworks() {
combine(
flow = getNFTAvailableNetworksUseCase(params.userWalletId),
flow2 = searchManager.query.distinctUntilChanged(),
) { networks, query ->
filterNFTAvailableNetworksUseCase(networks, query)
}
.onEach { filteredNetworks ->
_state.update {
UpdateDataStateTransformer(
networks = filteredNetworks,
onNetworkClick = ::onNetworkClick,
).transform(it)
}
}
.launchIn(modelScope)
}
private fun onSearchQueryChange(newQuery: String) {
modelScope.launch {
_state.update { UpdateSearchQueryTransformer(newQuery).transform(it) }
searchManager.update(newQuery)
}
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
isActive = false,
onQueryChange = ::onSearchQueryChange,
onActiveChange = ::toggleSearchBar,
)
private fun toggleSearchBar(isActive: Boolean) {
_state.update {
ToggleSearchBarTransformer(isActive).transform(it)
}
}
private fun onReceiveBottomSheetDismiss() {
_state.update {
it.copy(
bottomSheetConfig = it.bottomSheetConfig?.copy(isShown = false),
)
}
}
private fun onNetworkClick(network: Network) {
modelScope.launch {
val networkStatus = getNFTNetworkStatusUseCase.invoke(
userWalletId = params.userWalletId,
network = network,
) ?: return@launch
when (val value = networkStatus.value) {
is NetworkStatus.Verified -> {
_state.update {
ShowReceiveBottomSheetTransformer(
network = network,
networkAddress = value.address,
onDismissBottomSheet = ::onReceiveBottomSheetDismiss,
onCopyClick = ::onCopyClick,
onShareClick = ::onShareClick,
).transform(it)
}
}
is NetworkStatus.MissedDerivation,
is NetworkStatus.NoAccount,
is NetworkStatus.Unreachable,
-> Unit
}
}
}
private fun onCopyClick(text: String) {
clipboardManager.setText(text = text, isSensitive = true)
}
private fun onShareClick(text: String) {
shareManager.shareText(text = text)
}
private fun navigateBack() {
router.pop()
}
}

View file

@ -0,0 +1,73 @@
package com.tangem.features.nft.receive.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.impl.R
import com.tangem.features.nft.receive.entity.NFTNetworkUM
@Composable
internal fun NFTNetwork(state: NFTNetworkUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { state.onItemClick() }
.padding(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
modifier = Modifier
.size(TangemTheme.dimens.size24)
.clip(CircleShape),
painter = painterResource(state.iconRes),
contentDescription = null,
)
Text(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing12),
text = state.name,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTNetwork(@PreviewParameter(NFTNetworkProvider::class) state: NFTNetworkUM) {
TangemThemePreview {
NFTNetwork(
state = state,
)
}
}
private class NFTNetworkProvider : CollectionPreviewParameterProvider<NFTNetworkUM>(
collection = listOf(
NFTNetworkUM(
id = "item1",
name = "Nethers #0853",
iconRes = R.drawable.img_eth_22,
onItemClick = { },
),
),
)

View file

@ -0,0 +1,82 @@
package com.tangem.features.nft.receive.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.nft.impl.R
import com.tangem.features.nft.receive.entity.NFTReceiveUM
@Composable
internal fun NFTReceive(state: NFTReceiveUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackClick)
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
startButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_close_24,
onIconClicked = state.onBackClick,
),
title = stringResourceSafe(id = R.string.nft_receive_title),
subtitle = stringResourceSafe(id = R.string.nft_receive_subtitle),
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
) {
SearchBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
state = state.search,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
val contentModifier = Modifier
.padding(TangemTheme.dimens.spacing16)
AnimatedContent(
targetState = state.networks,
contentKey = { it::class },
label = "NFT Receive",
) {
when (val networks = it) {
is NFTReceiveUM.Networks.Content -> NFTReceiveNetworksContent(networks, contentModifier)
is NFTReceiveUM.Networks.Empty -> NFTReceiveNetworksEmpty(contentModifier)
}
}
}
},
)
ShowBottomSheet(state.bottomSheetConfig)
}
@Composable
private fun ShowBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) {
if (bottomSheetConfig != null) {
when (bottomSheetConfig.content) {
is TokenReceiveBottomSheetConfig -> {
TokenReceiveBottomSheet(config = bottomSheetConfig)
}
}
}
}

View file

@ -0,0 +1,106 @@
package com.tangem.features.nft.receive.ui
import android.content.res.Configuration
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.impl.R
import com.tangem.features.nft.receive.entity.NFTNetworkUM
import com.tangem.features.nft.receive.entity.NFTReceiveUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun NFTReceiveNetworksContent(state: NFTReceiveUM.Networks.Content, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
LazyColumn(
modifier = modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.primary)
.animateContentSize(),
state = listState,
) {
item(
key = "title",
) {
Title()
}
items(
items = state.items,
key = NFTNetworkUM::id,
) { network ->
NFTNetwork(
state = network,
modifier = Modifier.animateItem(),
)
}
}
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
Text(
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing4,
),
text = stringResourceSafe(R.string.nft_receive_choose_network),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Suppress("LongMethod")
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTReceiveNetworksUM() {
TangemThemePreview {
NFTReceiveNetworksContent(
state = NFTReceiveUM.Networks.Content(
items = persistentListOf(
NFTNetworkUM(
id = "item1",
name = "Ethereum",
iconRes = R.drawable.img_eth_22,
onItemClick = { },
),
NFTNetworkUM(
id = "item2",
name = "Polygon",
iconRes = R.drawable.img_polygon_22,
onItemClick = { },
),
NFTNetworkUM(
id = "item3",
name = "BSC",
iconRes = R.drawable.img_bsc_22,
onItemClick = { },
),
NFTNetworkUM(
id = "item4",
name = "Avalanche",
iconRes = R.drawable.img_avalanche_22,
onItemClick = { },
),
),
),
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.nft.receive.ui
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.nft.impl.R
@Composable
internal fun NFTReceiveNetworksEmpty(modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = stringResourceSafe(id = R.string.nft_empty_search),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}

View file

@ -4,4 +4,5 @@ interface OnboardingV2FeatureToggles {
val isOnboardingV2Enabled: Boolean
val isVisaOnboardingEnabled: Boolean
val isNoteRefactoringEnabled: Boolean
val isTwinRefactoringEnabled: Boolean
}

View file

@ -8,12 +8,14 @@ interface OnboardingEntryComponent : ComposableContentComponent {
data class Params(
val scanResponse: ScanResponse,
val multiWalletMode: MultiWalletMode,
val mode: Mode,
)
enum class MultiWalletMode {
enum class Mode {
Onboarding,
AddBackup,
AddBackupWallet1,
WelcomeOnlyTwin,
RecreateWalletTwin,
ContinueFinalize,
}

View file

@ -27,6 +27,7 @@ dependencies {
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.datasource)
implementation(projects.core.error.ext)
/** Common */
implementation(projects.common.ui)
@ -43,6 +44,9 @@ dependencies {
implementation(projects.domain.settings)
implementation(projects.domain.onboarding)
implementation(projects.domain.visa)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.onramp)
/** Tangem libraries */
implementation(projects.libs.tangemSdkApi)
@ -63,6 +67,7 @@ dependencies {
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.compose.shimmer)
implementation(deps.lottie.compose)
implementation(deps.decompose.ext.compose)
implementation(deps.androidx.activity.compose)
@ -73,6 +78,7 @@ dependencies {
implementation(deps.kotlin.serialization)
implementation(deps.timber)
implementation(deps.firebase.crashlytics)
implementation(tangemDeps.card.core)
/** DI */
implementation(deps.hilt.android)

View file

@ -12,4 +12,6 @@ internal class DefaultOnboardingV2FeatureToggles @Inject constructor(
get() = featureTogglesManager.isFeatureEnabled("VISA_ONBOARDING_ENABLED")
override val isNoteRefactoringEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("NOTE_REFACTORING_ENABLED")
override val isTwinRefactoringEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("TWIN_REFACTORING_ENABLED")
}

View file

@ -1,6 +1,8 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.analytics
package com.tangem.features.onboarding.v2.common.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.tokens.model.CryptoCurrency
sealed class OnboardingEvent(
category: String,
@ -38,6 +40,21 @@ sealed class OnboardingEvent(
}
}
sealed class Topup(
event: String,
params: Map<String, String> = mapOf(),
) : OnboardingEvent("Onboarding / Top Up", event, params) {
object ScreenOpened : Topup("Activation Screen Opened")
object ButtonShowWalletAddress : Topup("Button - Show the Wallet Address")
class ButtonBuyCrypto(currency: CryptoCurrency) : Topup(
event = "Button - Buy Crypto",
params = mapOf(AnalyticsParam.CURRENCY to currency.symbol),
)
}
sealed class Backup(
event: String,
params: Map<String, String> = mapOf(),
@ -75,4 +92,22 @@ sealed class OnboardingEvent(
params = mapOf("Action" to "Cancel"),
)
}
sealed class Twins(
event: String,
params: Map<String, String> = mapOf(),
) : OnboardingEvent("Onboarding / Twins", event, params) {
data object ScreenOpened : Twins("Twinning Screen Opened")
data object SetupStarted : Twins("Twin Setup Started")
data object SetupFinished : Twins("Twin Setup Finished")
}
data class OfflineAttestationFailed(
val source: AnalyticsParam.ScreensSources,
) : OnboardingEvent(
category = "Error",
event = "Offline Attestation Failed",
params = mapOf(AnalyticsParam.SOURCE to source.value),
)
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.onboarding.v2.common.ui
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.features.onboarding.v2.impl.R
internal fun exitOnboardingDialog(onConfirm: () -> Unit): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.onboarding_exit_alert_title),
message = resourceReference(R.string.onboarding_exit_alert_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_yes),
onClick = onConfirm,
)
},
secondActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
)
},
)
}
internal fun interruptBackupDialog(onConfirm: () -> Unit) = DialogMessage(
title = resourceReference(R.string.onboarding_exit_alert_title),
message = resourceReference(R.string.onboarding_exit_alert_message),
firstAction = EventMessageAction(
title = resourceReference(R.string.common_ok),
warning = true,
onClick = onConfirm,
),
secondAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
),
)

View file

@ -1,4 +1,4 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.common.ui
package com.tangem.features.onboarding.v2.common.ui
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.extensions.TextReference

View file

@ -0,0 +1,51 @@
package com.tangem.features.onboarding.v2.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onboarding.v2.impl.R
@Composable
internal fun RefreshButton(isRefreshing: Boolean, onRefreshBalanceClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size48)
.shadow(elevation = 2.dp, shape = CircleShape)
.background(TangemTheme.colors.background.action)
.clickable(
enabled = !isRefreshing,
onClick = onRefreshBalanceClick,
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
),
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
color = TangemTheme.colors.icon.primary1,
strokeWidth = TangemTheme.dimens.size2,
)
} else {
Icon(
painter = painterResource(id = R.drawable.ic_refresh_24),
contentDescription = null,
tint = TangemTheme.colors.text.primary1,
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
)
}
}
}

View file

@ -5,7 +5,14 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
interface OnboardingDoneComponent : ComposableContentComponent {
data class Params(val onDone: () -> Unit)
data class Params(
val mode: Mode,
val onDone: () -> Unit,
)
enum class Mode {
WalletCreated, GoodToGo
}
interface Factory : ComponentFactory<Params, OnboardingDoneComponent>
}

View file

@ -1,7 +1,6 @@
package com.tangem.features.onboarding.v2.done.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
@ -18,7 +17,8 @@ internal class DefaultOnboardingDoneComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
OnboardingDone(
onContinueClick = remember(this) { params.onDone },
mode = params.mode,
onContinueClick = params.onDone,
)
}

View file

@ -20,10 +20,16 @@ import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
import com.tangem.features.onboarding.v2.impl.R
@Suppress("LongMethod")
@Composable
internal fun OnboardingDone(onContinueClick: () -> Unit, modifier: Modifier = Modifier) {
internal fun OnboardingDone(
mode: OnboardingDoneComponent.Mode,
onContinueClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val composition by rememberLottieComposition(spec = LottieCompositionSpec.RawRes(R.raw.anim_confetti))
val progress by animateLottieCompositionAsState(composition)
var showConfetti by remember { mutableStateOf(false) }
@ -57,7 +63,12 @@ internal fun OnboardingDone(onContinueClick: () -> Unit, modifier: Modifier = Mo
SpacerH12()
Text(
modifier = Modifier.padding(horizontal = 32.dp),
text = stringResourceSafe(R.string.onboarding_subtitle_success_tangem_wallet_onboarding),
text = when (mode) {
OnboardingDoneComponent.Mode.WalletCreated ->
stringResourceSafe(R.string.onboarding_subtitle_success_tangem_wallet_onboarding)
OnboardingDoneComponent.Mode.GoodToGo ->
stringResourceSafe(R.string.visa_onboarding_success_screen_description)
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
@ -100,6 +111,9 @@ internal fun OnboardingDone(onContinueClick: () -> Unit, modifier: Modifier = Mo
@Composable
private fun Preview() {
TangemThemePreview {
OnboardingDone(onContinueClick = {})
OnboardingDone(
mode = OnboardingDoneComponent.Mode.WalletCreated,
onContinueClick = {},
)
}
}

View file

@ -71,6 +71,7 @@ internal class DefaultOnboardingEntryComponent @AssistedInject constructor(
route = configuration,
childContext = childByContext(factoryContext),
onManageTokensDone = model::onManageTokensDone,
model = model,
)
},
)

View file

@ -15,15 +15,20 @@ import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.biometry.BiometryFeatureToggles
import com.tangem.features.onboarding.v2.TitleProvider
import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent.Mode
import com.tangem.features.onboarding.v2.entry.impl.analytics.OnboardingEntryEvent
import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.NonCancellable
@ -43,6 +48,7 @@ internal class OnboardingEntryModel @Inject constructor(
private val askBiometryFeatureToggles: BiometryFeatureToggles,
private val analyticsEventHandler: AnalyticsEventHandler,
private val uiMessageSender: UiMessageSender,
private val userWalletsListManager: UserWalletsListManager,
) : Model() {
private val params = paramsContainer.require<OnboardingEntryComponent.Params>()
@ -56,6 +62,7 @@ internal class OnboardingEntryModel @Inject constructor(
}
val startRoute = routeByProductType(params.scanResponse)
val onboardingTwinModelCallbacks = OnboardingTwinModelCallbacks()
fun onManageTokensDone() {
navigateToFinalScreenFlow()
@ -66,32 +73,26 @@ internal class OnboardingEntryModel @Inject constructor(
}
private fun routeByProductType(scanResponse: ScanResponse): OnboardingRoute {
val multiWalletNavigationMode = when (params.multiWalletMode) {
OnboardingEntryComponent.MultiWalletMode.Onboarding ->
OnboardingMultiWalletComponent.Mode.Onboarding
OnboardingEntryComponent.MultiWalletMode.AddBackup ->
OnboardingMultiWalletComponent.Mode.AddBackup
OnboardingEntryComponent.MultiWalletMode.ContinueFinalize ->
OnboardingMultiWalletComponent.Mode.ContinueFinalize
}
return when (scanResponse.productType) {
ProductType.Wallet -> OnboardingRoute.MultiWallet(
scanResponse = scanResponse,
withSeedPhraseFlow = false,
titleProvider = titleProvider,
onDone = ::onMultiWalletOnboardingDone,
mode = multiWalletNavigationMode,
)
ProductType.Ring,
ProductType.Wallet,
ProductType.Wallet2,
-> OnboardingRoute.MultiWallet(
scanResponse = scanResponse,
withSeedPhraseFlow = true,
titleProvider = titleProvider,
onDone = ::onMultiWalletOnboardingDone,
mode = multiWalletNavigationMode,
)
ProductType.Ring,
-> {
val multiWalletNavigationMode = when (params.mode) {
Mode.Onboarding -> OnboardingMultiWalletComponent.Mode.Onboarding
Mode.AddBackupWallet1 -> OnboardingMultiWalletComponent.Mode.AddBackup
Mode.ContinueFinalize -> OnboardingMultiWalletComponent.Mode.ContinueFinalize
else -> error("Incorrect onboarding type")
}
OnboardingRoute.MultiWallet(
scanResponse = scanResponse,
withSeedPhraseFlow = scanResponse.productType != ProductType.Wallet,
titleProvider = titleProvider,
onDone = ::onMultiWalletOnboardingDone,
mode = multiWalletNavigationMode,
)
}
ProductType.Visa -> OnboardingRoute.Visa(
scanResponse = scanResponse,
titleProvider = titleProvider,
@ -102,14 +103,33 @@ internal class OnboardingEntryModel @Inject constructor(
titleProvider = titleProvider,
onDone = ::navigateToFinalScreenFlow,
)
ProductType.Twins -> {
val mode = when (params.mode) {
Mode.Onboarding -> OnboardingTwinComponent.Params.Mode.CreateWallet
Mode.WelcomeOnlyTwin -> OnboardingTwinComponent.Params.Mode.WelcomeOnly
Mode.RecreateWalletTwin -> OnboardingTwinComponent.Params.Mode.RecreateWallet
else -> error("Incorrect onboarding type")
}
OnboardingRoute.Twins(
scanResponse = scanResponse,
titleProvider = titleProvider,
mode = mode,
)
}
else -> error("Unsupported")
}
}
private fun onMultiWalletOnboardingDone(userWallet: UserWallet) {
when {
params.multiWalletMode == OnboardingEntryComponent.MultiWalletMode.AddBackup -> {
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
params.mode == Mode.AddBackupWallet1 -> {
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = OnboardingDoneComponent.Mode.WalletCreated,
onDone = ::exitComponentScreen,
),
)
}
userWallet.scanResponse.cardTypesResolver.isMultiwalletAllowed() -> {
stackNavigation.replaceAll(OnboardingRoute.ManageTokens(userWallet))
@ -121,48 +141,104 @@ internal class OnboardingEntryModel @Inject constructor(
}
private fun onVisaOnboardingDone() {
navigateToFinalScreenFlow()
navigateToFinalScreenFlow(doneMode = OnboardingDoneComponent.Mode.GoodToGo)
}
private fun navigateToFinalScreenFlow() {
private fun navigateToFinalScreenFlow(
doneMode: OnboardingDoneComponent.Mode = OnboardingDoneComponent.Mode.WalletCreated,
) {
if (askBiometryFeatureToggles.isAskForBiometryEnabled) {
modelScope.launch {
if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) {
stackNavigation.replaceAll(
OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks()),
OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks(doneMode)),
)
} else {
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = doneMode,
onDone = ::exitComponentScreen,
),
)
}
}
} else {
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = doneMode,
onDone = ::exitComponentScreen,
),
)
}
}
inner class AskBiometryModelCallbacks : AskBiometryComponent.ModelCallbacks {
inner class AskBiometryModelCallbacks(
private val doneMode: OnboardingDoneComponent.Mode,
) : AskBiometryComponent.ModelCallbacks {
override fun onAllowed() {
analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.On))
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = doneMode,
onDone = ::exitComponentScreen,
),
)
}
override fun onDenied() {
analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.Off))
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = doneMode,
onDone = ::exitComponentScreen,
),
)
}
}
private fun navigateToWalletScreen() {
private fun exitComponentScreen() {
if (askBiometryFeatureToggles.isAskForBiometryEnabled) {
router.replaceAll(AppRoute.Wallet)
} else {
modelScope.launch(NonCancellable) {
router.replaceAll(AppRoute.Wallet)
if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) {
delay(timeMillis = 1_800)
router.push(AppRoute.SaveWallet)
if (userWalletsListManager.hasUserWallets) {
val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync!! }.getOrElse { false }
if (isLocked) {
router.replaceAll(AppRoute.Welcome())
} else {
router.replaceAll(AppRoute.Wallet)
}
} else {
router.replaceAll(AppRoute.Home)
}
} else {
if (userWalletsListManager.hasUserWallets) {
val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync!! }.getOrElse { false }
if (isLocked) {
router.replaceAll(AppRoute.Welcome())
} else {
modelScope.launch(NonCancellable) {
router.replaceAll(AppRoute.Wallet)
if (tangemSdkManager.checkCanUseBiometry() &&
settingsRepository.shouldShowSaveUserWalletScreen()
) {
delay(timeMillis = 1_800)
router.push(AppRoute.SaveWallet)
}
}
}
} else {
router.replaceAll(AppRoute.Home)
}
}
}
inner class OnboardingTwinModelCallbacks : OnboardingTwinComponent.ModelCallbacks {
override fun onDone() {
navigateToFinalScreenFlow()
}
override fun onBack() {
router.pop()
}
}
}

View file

@ -4,11 +4,14 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
import com.tangem.features.onboarding.v2.entry.impl.model.OnboardingEntryModel
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent
import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent
import com.tangem.features.onboarding.v2.visa.api.OnboardingVisaComponent
import javax.inject.Inject
@Suppress("LongParameterList")
internal class OnboardingChildFactory @Inject constructor(
private val multiWalletComponentFactory: OnboardingMultiWalletComponent.Factory,
private val onboardingManageTokensComponentFactory: OnboardingManageTokensComponent.Factory,
@ -16,9 +19,15 @@ internal class OnboardingChildFactory @Inject constructor(
private val onBoardingVisaComponentFactory: OnboardingVisaComponent.Factory,
private val askBiometryComponentFactory: AskBiometryComponent.Factory,
private val onboardingNoteComponentFactory: OnboardingNoteComponent.Factory,
private val onboardingTwinComponentFactory: OnboardingTwinComponent.Factory,
) {
fun createChild(route: OnboardingRoute, childContext: AppComponentContext, onManageTokensDone: () -> Unit): Any {
fun createChild(
route: OnboardingRoute,
childContext: AppComponentContext,
model: OnboardingEntryModel,
onManageTokensDone: () -> Unit,
): Any {
return when (route) {
is OnboardingRoute.MultiWallet -> multiWalletComponentFactory.create(
context = childContext,
@ -48,6 +57,7 @@ internal class OnboardingChildFactory @Inject constructor(
is OnboardingRoute.Done -> onboardingDoneComponentFactory.create(
context = childContext,
params = OnboardingDoneComponent.Params(
mode = route.mode,
onDone = route.onDone,
),
)
@ -59,6 +69,15 @@ internal class OnboardingChildFactory @Inject constructor(
onDone = route.onDone,
),
)
is OnboardingRoute.Twins -> onboardingTwinComponentFactory.create(
context = childContext,
params = OnboardingTwinComponent.Params(
scanResponse = route.scanResponse,
modelCallbacks = model.onboardingTwinModelCallbacks,
titleProvider = route.titleProvider,
mode = route.mode,
),
)
is OnboardingRoute.AskBiometry -> askBiometryComponentFactory.create(
context = childContext,
params = AskBiometryComponent.Params(

View file

@ -5,7 +5,9 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.onboarding.v2.TitleProvider
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent
sealed class OnboardingRoute : Route {
@ -31,6 +33,12 @@ sealed class OnboardingRoute : Route {
val onDone: () -> Unit,
) : OnboardingRoute()
data class Twins(
val titleProvider: TitleProvider,
val scanResponse: ScanResponse,
val mode: OnboardingTwinComponent.Params.Mode,
) : OnboardingRoute()
data class ManageTokens(
val userWallet: UserWallet,
) : OnboardingRoute()
@ -40,6 +48,7 @@ sealed class OnboardingRoute : Route {
) : OnboardingRoute()
data class Done(
val mode: OnboardingDoneComponent.Mode,
val onDone: () -> Unit,
) : OnboardingRoute()
}

View file

@ -26,7 +26,7 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigation
import com.tangem.core.decompose.navigation.inner.InnerNavigationState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.MultiWalletAccessCodeComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent

View file

@ -6,7 +6,7 @@ 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.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.ui.state.MultiWalletAccessCodeUM
import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState

View file

@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.stringResourceSafe
@ -36,7 +36,7 @@ private fun Content(state: MultiWalletAccessCodeUM, modifier: Modifier = Modifie
modifier = Modifier.weight(1f),
targetState = state.step,
transitionSpec = TangemAnimations.AnimatedContent
.slide { initial, target -> initial.ordinal > target.ordinal },
.slide { initial, target -> target.ordinal > initial.ordinal },
label = "AnimatedContent",
) { step ->
when (step) {

View file

@ -31,7 +31,7 @@ internal fun MultiWalletAccessCodeIntro(modifier: Modifier = Modifier) {
color = TangemTheme.colors.text.primary1,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_access_code),
imageVector = ImageVector.vectorResource(R.drawable.ic_access_code_119),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)

View file

@ -1,11 +1,12 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.model
import androidx.compose.runtime.Stable
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -19,8 +20,8 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ProductType
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.ui.backupCardAttestationFailedDialog
@ -48,6 +49,7 @@ class MultiWalletBackupModel @Inject constructor(
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val tangemSdkManager: TangemSdkManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val uiMessageSender: UiMessageSender,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val cardRepository: CardRepository,
@ -241,7 +243,7 @@ class MultiWalletBackupModel @Inject constructor(
)
}
}
else -> FirebaseCrashlytics.getInstance().recordException(result.error)
else -> analyticsExceptionHandler.sendException(ExceptionAnalyticsEvent(result.error))
}
}
}

View file

@ -10,9 +10,8 @@ import com.tangem.domain.common.TapWorkarounds.canSkipBackup
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableSharedFlow
@ -25,7 +24,7 @@ import javax.inject.Inject
internal class Wallet1ChooseOptionModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val cardRepository: CardRepository,
private val saveWalletUseCase: SaveWalletUseCase,
private val analyticsHandler: AnalyticsEventHandler,
@ -67,7 +66,7 @@ internal class Wallet1ChooseOptionModel @Inject constructor(
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = UserWalletBuilder(scanResponse, generateWalletNameUseCase).build(),
value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(),
lazyMessage = { "User wallet not created" },
)
}

View file

@ -15,10 +15,9 @@ import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.state.MultiWalletCreateWalletUM
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog
@ -43,7 +42,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
private val getCardInfoUseCase: GetCardInfoUseCase,
private val cardRepository: CardRepository,
private val analyticsHandler: AnalyticsEventHandler,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
) : Model() {
@ -146,7 +145,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = UserWalletBuilder(scanResponse, generateWalletNameUseCase).build(),
value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(),
lazyMessage = { "User wallet not created" },
)
}

View file

@ -1,7 +1,7 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
internal data class MultiWalletCreateWalletUM(
val title: TextReference,

View file

@ -20,7 +20,6 @@ import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
@ -49,7 +48,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private val tangemSdkManager: TangemSdkManager,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
@ -281,7 +280,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = UserWalletBuilder(scanResponse, generateWalletNameUseCase)
value = userWalletBuilderFactory.create(scanResponse = scanResponse)
.backupCardsIds(backupCardIds.toSet())
.hasBackupError(walletHasBackupError)
.build(),

View file

@ -1,6 +1,6 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.state
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
internal data class MultiWalletFinalizeUM(
val step: Step = Step.Primary,

View file

@ -13,13 +13,13 @@ import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.GenerateSeedPhraseUiStateBuilder
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.ImportSeedPhraseUiStateBuilder
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.SeedPhraseCheckUiStateBuilder
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider

View file

@ -12,7 +12,7 @@ internal fun MultiWalletSeedPhrase(state: MultiWalletSeedPhraseUM, modifier: Mod
AnimatedContent(
modifier = modifier.navigationBarsPadding(),
targetState = state,
transitionSpec = TangemAnimations.AnimatedContent.slide { from, to -> to.order < from.order },
transitionSpec = TangemAnimations.AnimatedContent.slide { from, to -> to.order > from.order },
contentKey = { st -> st::class.java },
label = "animatedContent",
) { st ->

View file

@ -14,7 +14,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.stringResourceSafe

View file

@ -4,8 +4,8 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -1,6 +1,7 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.common.ui
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.impl.R
internal fun resetCardDialog(onConfirm: () -> Unit, dismiss: () -> Unit, onDismissButtonClick: () -> Unit) =
@ -19,20 +20,4 @@ internal fun resetCardDialog(onConfirm: () -> Unit, dismiss: () -> Unit, onDismi
onDismissButtonClick()
dismiss()
},
)
internal fun interruptBackupDialog(onConfirm: () -> Unit, dismiss: () -> Unit) = OnboardingDialogUM(
title = resourceReference(R.string.onboarding_exit_alert_title),
message = resourceReference(R.string.onboarding_exit_alert_message),
confirmButtonText = resourceReference(R.string.common_ok),
dismissButtonText = resourceReference(R.string.common_cancel),
dismissWarningColor = true,
onDismiss = dismiss,
onConfirmClick = {
onConfirm()
dismiss()
},
onDismissButtonClick = {
dismiss()
},
)
)

View file

@ -5,34 +5,40 @@ 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.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.ui.interruptBackupDialog
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.interruptBackupDialog
import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.FinalizeStage
import com.tangem.features.onboarding.v2.multiwallet.impl.ui.state.OnboardingMultiWalletUM
import com.tangem.operations.backup.BackupService
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class OnboardingMultiWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
analyticsHandler: AnalyticsEventHandler,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val analyticsHandler: AnalyticsEventHandler,
private val backupServiceHolder: BackupServiceHolder,
private val onboardingRepository: OnboardingRepository,
private val getCardImageUseCase: GetCardImageUseCase,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<OnboardingMultiWalletComponent.Params>()
private val getCardImageUseCase = GetCardImageUseCase()
private val _uiState = MutableStateFlow(OnboardingMultiWalletUM())
val state = MutableStateFlow(
@ -56,19 +62,16 @@ internal class OnboardingMultiWalletModel @Inject constructor(
}
fun onBack() {
_uiState.update { st ->
st.copy(
dialog = interruptBackupDialog(
onConfirm = {
modelScope.launch {
onboardingRepository.clearUnfinishedFinalizeOnboarding()
router.pop()
}
},
dismiss = { _uiState.update { it.copy(dialog = null) } },
),
)
}
uiMessageSender.send(
interruptBackupDialog(
onConfirm = {
modelScope.launch {
onboardingRepository.clearUnfinishedFinalizeOnboarding()
router.pop()
}
},
),
)
}
private fun subscribeToBackups() {

View file

@ -8,9 +8,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.multiwallet.impl.ui.state.OnboardingMultiWalletUM
@ -23,23 +20,6 @@ internal fun OnboardingMultiWallet(
modifier: Modifier = Modifier,
childContent: @Composable (Modifier) -> Unit = {},
) {
if (state.dialog != null) {
BasicDialog(
title = state.dialog.title.resolveReference(),
message = state.dialog.message.resolveReference(),
confirmButton = DialogButtonUM(
title = state.dialog.confirmButtonText.resolveReference(),
onClick = state.dialog.onConfirmClick,
),
dismissButton = DialogButtonUM(
title = state.dialog.dismissButtonText.resolveReference(),
warning = state.dialog.dismissWarningColor,
onClick = state.dialog.onDismissButtonClick,
),
onDismissDialog = state.dialog.onDismiss,
)
}
Column(
modifier = modifier.fillMaxSize(),
) {

View file

@ -1,10 +1,7 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.ui.state
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
internal data class OnboardingMultiWalletUM(
val artwork1Url: String? = null,
val artwork2Url: String? = null,
val artwork3Url: String? = null,
val dialog: OnboardingDialogUM? = null,
)

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