Updated on 2026-08-14

This commit is contained in:
Tangem 2024-06-06 18:12:38 +05:00
commit 800328ff80
27 changed files with 774 additions and 135 deletions

View file

@ -0,0 +1,13 @@
package com.tangem.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
@Stable
fun interface ComposableContentProvider {
@Composable
@Suppress("TopLevelComposableFunctions") // TODO: Remove this check
fun Content(modifier: Modifier)
}

View file

@ -11,6 +11,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import org.intellij.markdown.MarkdownElementTypes
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
/**
* Utility class for creating text as [String] or [StringRes].
@ -197,6 +199,16 @@ operator fun TextReference.plus(ref: TextReference): TextReference {
}
}
@Suppress("NOTHING_TO_INLINE")
@OptIn(ExperimentalContracts::class)
inline fun TextReference?.isNullOrEmpty(): Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}
return this == null || this == TextReference.EMPTY
}
@Composable
private fun formatAnnotated(rawString: String): AnnotatedString {
val markdownDescriptor = rememberMarkdownParser()

View file

@ -93,4 +93,15 @@ sealed class Lce<out E : Any, out C : Any> {
ifContent = ::identity,
ifError = { null },
)
/**
* Returns the error of this [Lce] if it's a [Lce.Error], `null` otherwise.
*
* @return The error of this [Lce] or `null`.
*/
fun errorOrNull(): E? = fold(
ifLoading = { null },
ifContent = { null },
ifError = ::identity,
)
}

View file

@ -2,8 +2,10 @@ package com.tangem.domain.core.lce
import arrow.atomic.Atomic
import arrow.core.raise.Raise
import arrow.core.raise.RaiseDSL
import arrow.core.raise.recover
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading
import kotlin.experimental.ExperimentalTypeInference
@ -18,18 +20,55 @@ class LceRaise<E : Any> @PublishedApi internal constructor(
private val raise: Raise<Lce<E, Nothing>>,
) : Raise<Lce<E, Nothing>> by raise {
/**
* An [Atomic] boolean flag indicating whether a loading operation is in progress.
*/
val isLoading: Atomic<Boolean> = Atomic(false)
/**
* Helper function to raise an [Lce.Error] state with the given error object.
* */
@RaiseDSL
@JvmName(name = "raiseError")
fun raise(r: E): Nothing = raise(r = r.lceError())
/**
* Helper function to raise an [Lce.Loading] state.
*/
@RaiseDSL
fun raiseLoading(): Nothing = raise(r = lceLoading())
/**
* Execute the [Raise] context function resulting in [C] or any _logical error_ of type [OtherError],
* and transform any raised [OtherError] into [E], which is raised to the outer [Raise].
*
* @see arrow.core.raise.withError
* */
@RaiseDSL
@OptIn(ExperimentalTypeInference::class)
inline fun <OtherError : Any, C : Any> withError(
transform: (OtherError) -> E,
@BuilderInference block: LceRaise<OtherError>.() -> C,
): C = recover(
block = { block(LceRaise(raise = this@recover)) },
recover = { error ->
error.fold<Nothing>(
ifLoading = { raiseLoading() },
ifError = { raise(transform(it)) },
ifContent = { it },
)
},
)
/**
* Binds the content of this [Lce] instance and handles its state.
* If this is a [Lce.Loading] state, sets the [isLoading] flag to true and calls the [ifLoading] function.
* If this is a [Lce.Content] state, returns the content.
* If this is a [Lce.Error] state, raises the error.
*
* @param ifLoading The function to call if this is a [Lce.Loading] state.
* By default, it raises a new [Lce.Loading] state.
* @return The content of this [Lce] instance.
*/
@RaiseDSL
fun <C : Any> Lce<E, C>.bind(): C = when (this) {
is Lce.Loading -> {
isLoading.set(true)
@ -48,6 +87,7 @@ class LceRaise<E : Any> @PublishedApi internal constructor(
*
* @return The content of this [Lce] instance.
*/
@RaiseDSL
fun <C : Any> Lce<E, C>.bindOrNull(): C? = when (this) {
is Lce.Loading -> {
isLoading.set(true)

View file

@ -25,9 +25,21 @@ dependencies {
implementation(projects.core.analytics.models)
/* Project - Domain */
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.card)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.walletConnect)
implementation(projects.domain.legacy)
/* SDK */
// TODO: For TangemError model, should be removed after card domain scanning refactoring
implementation(deps.tangem.card.core)
/* AndroidX */
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.activity.compose)
@ -47,4 +59,5 @@ dependencies {
/* Other */
implementation(deps.kotlin.immutable.collections)
implementation(deps.timber)
}

View file

@ -3,14 +3,16 @@ package com.tangem.features.details
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.Fragment
import com.arkivanov.decompose.defaultComponentContext
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.di.RootAppComponentContext
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.message.EventMessageEffect
import com.tangem.core.ui.message.EventMessageHandler
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.component.preview.PreviewDetailsComponent
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@ -21,8 +23,8 @@ internal class DetailsFragment : ComposeFragment() {
@Inject
override lateinit var uiDependencies: UiDependencies
// @Inject
// internal lateinit var componentFactory: DetailsComponent.Factory
@Inject
internal lateinit var componentFactory: DetailsComponent.Factory
@Inject
internal lateinit var detailsRouter: DetailsRouter
@ -37,7 +39,7 @@ internal class DetailsFragment : ComposeFragment() {
@Composable
override fun ScreenContent(modifier: Modifier) {
component.View(modifier = modifier)
component.Content(modifier = modifier)
EventMessageEffect(
messageHandler = messageHandler,
@ -46,27 +48,23 @@ internal class DetailsFragment : ComposeFragment() {
}
private fun initComponent(): DetailsComponent {
// TODO: Uncomment in [REDACTED_JIRA]
// val selectedUserWalletId = arguments?.getString(DetailsEntryPoint.USER_WALLET_ID_KEY)
// ?.let(::UserWalletId)
//
//
// requireNotNull(selectedUserWalletId) { "UserWalletId must be provided" }
//
// val context = rootContext.childByContext(
// componentContext = defaultComponentContext(requireActivity().onBackPressedDispatcher),
// messageHandler = messageHandler,
// router = detailsRouter,
// )
//
// return componentFactory.create(
// context = context,
// params = DetailsComponent.Params(
// selectedUserWalletId = selectedUserWalletId,
// ),
// )
val selectedUserWalletId = arguments?.getString(DetailsEntryPoint.USER_WALLET_ID_KEY)
?.let(::UserWalletId)
return PreviewDetailsComponent()
requireNotNull(selectedUserWalletId) { "UserWalletId must be provided" }
val context = rootContext.childByContext(
componentContext = defaultComponentContext(requireActivity().onBackPressedDispatcher),
messageHandler = messageHandler,
router = detailsRouter,
)
return componentFactory.create(
context = context,
params = DetailsComponent.Params(
selectedUserWalletId = selectedUserWalletId,
),
)
}
companion object : DetailsEntryPoint {

View file

@ -1,19 +1,14 @@
package com.tangem.features.details.component
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.ComposableContentProvider
import com.tangem.domain.wallets.models.UserWalletId
interface DetailsComponent {
interface DetailsComponent : ComposableContentProvider {
val snackbarHostState: SnackbarHostState
@Composable
@Suppress("TopLevelComposableFunctions") // TODO: Remove this check
fun View(modifier: Modifier)
interface Factory {
fun create(context: AppComponentContext, params: Params): DetailsComponent

View file

@ -1,14 +1,9 @@
package com.tangem.features.details.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.ComposableContentProvider
interface UserWalletListComponent {
@Composable
@Suppress("TopLevelComposableFunctions") // TODO: Remove this check
fun View(modifier: Modifier)
interface UserWalletListComponent : ComposableContentProvider {
interface Factory {
fun create(context: AppComponentContext): UserWalletListComponent

View file

@ -1,23 +0,0 @@
package com.tangem.features.details.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.wallets.models.UserWalletId
interface WalletConnectComponent {
suspend fun checkIsAvailable(): Boolean
@Composable
@Suppress("TopLevelComposableFunctions") // TODO: Remove this check
fun View(modifier: Modifier)
interface Factory {
fun create(context: AppComponentContext, params: Params): WalletConnectComponent
}
data class Params(
val userWalletId: UserWalletId,
)
}

View file

@ -0,0 +1,53 @@
package com.tangem.features.details.component.impl
import androidx.compose.material3.SnackbarHostState
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.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.component.UserWalletListComponent
import com.tangem.features.details.model.DetailsModel
import com.tangem.features.details.ui.DetailsScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultDetailsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: DetailsComponent.Params,
userWalletListComponentFactory: UserWalletListComponent.Factory,
) : DetailsComponent, AppComponentContext by context {
private val model: DetailsModel = getOrCreateModel()
private val userWalletListComponent = userWalletListComponentFactory.create(
context = child(key = "user_wallet_list"),
)
override val snackbarHostState: SnackbarHostState = SnackbarHostState()
init {
model.provideUserWalletId(params.selectedUserWalletId)
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
DetailsScreen(
modifier = modifier,
state = state,
snackbarHostState = snackbarHostState,
userWalletListBlockContent = userWalletListComponent,
)
}
@AssistedFactory
interface Factory : DetailsComponent.Factory {
override fun create(context: AppComponentContext, params: DetailsComponent.Params): DefaultDetailsComponent
}
}

View file

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

View file

@ -17,10 +17,8 @@ internal class PreviewDetailsComponent : DetailsComponent {
private val previewBlocks = runBlocking {
ItemsBuilder(
walletConnectComponent = PreviewWalletConnectComponent(),
userWalletListComponent = PreviewUserWalletListComponent(),
router = PreviewRouter(),
).buldAll()
).buldAll(isWalletConnectAvailable = true)
}
private val previewFooter = DetailsFooterUM(
@ -36,11 +34,12 @@ internal class PreviewDetailsComponent : DetailsComponent {
@Composable
@Suppress("TopLevelComposableFunctions") // TODO: Remove this check
override fun View(modifier: Modifier) {
override fun Content(modifier: Modifier) {
DetailsScreen(
modifier = modifier,
state = previewState,
snackbarHostState = snackbarHostState,
userWalletListBlockContent = PreviewUserWalletListComponent(),
)
}
}

View file

@ -46,7 +46,7 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent {
@Composable
@Suppress("TopLevelComposableFunctions") // TODO: Remove this check
override fun View(modifier: Modifier) {
override fun Content(modifier: Modifier) {
UserWalletListBlock(state = previewState, modifier = modifier)
}

View file

@ -1,17 +0,0 @@
package com.tangem.features.details.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.features.details.component.WalletConnectComponent
import com.tangem.features.details.ui.WalletConnectBlock
internal class PreviewWalletConnectComponent : WalletConnectComponent {
override suspend fun checkIsAvailable(): Boolean = true
@Composable
@Suppress("TopLevelComposableFunctions") // TODO: Remove this check
override fun View(modifier: Modifier) {
WalletConnectBlock(onClick = { /* no-op */ }, modifier = modifier)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.details.di
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.component.UserWalletListComponent
import com.tangem.features.details.component.impl.DefaultDetailsComponent
import com.tangem.features.details.component.impl.DefaultUserWalletListComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindDetailsComponentFactory(factory: DefaultDetailsComponent.Factory): DetailsComponent.Factory
@Binds
@Singleton
fun bindUserWalletListComponentFactory(
factory: DefaultUserWalletListComponent.Factory,
): UserWalletListComponent.Factory
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.details.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.details.model.DetailsModel
import com.tangem.features.details.model.UserWalletListModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -17,4 +18,9 @@ internal interface ModelModule {
@IntoMap
@ClassKey(DetailsModel::class)
fun provideDetailsModel(model: DetailsModel): Model
@Binds
@IntoMap
@ClassKey(UserWalletListModel::class)
fun provideUserWalletListModel(model: UserWalletListModel): Model
}

View file

@ -1,8 +1,10 @@
package com.tangem.features.details.entity
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class DetailsFooterUM(
val appVersion: String,
val socials: ImmutableList<Social>,

View file

@ -1,9 +1,7 @@
package com.tangem.features.details.entity
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@ -26,16 +24,11 @@ internal sealed class DetailsItemUM {
)
}
data class Component(
override val id: String,
val content: Content,
) : DetailsItemUM() {
data class WalletConnect(val onClick: () -> Unit) : DetailsItemUM() {
override val id: String = "wallet_connect"
}
fun interface Content {
@Composable
@Suppress("TopLevelComposableFunctions", "ComposableFunctionName")
operator fun invoke(modifier: Modifier)
}
data object UserWalletList : DetailsItemUM() {
override val id: String = "user_wallet_list"
}
}

View file

@ -1,10 +1,74 @@
package com.tangem.features.details.model
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.entity.DetailsUM
import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.version.AppVersionProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
// TODO: Will be implemented later
@ComponentScoped
internal class DetailsModel @Inject constructor(
private val socialsBuilder: SocialsBuilder,
private val itemsBuilder: ItemsBuilder,
private val appVersionProvider: AppVersionProvider,
private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
) : Model()
) : Model() {
private val items: MutableStateFlow<ImmutableList<DetailsItemUM>> = MutableStateFlow(value = persistentListOf())
val state: MutableStateFlow<DetailsUM> = MutableStateFlow(
value = DetailsUM(
items = items.value,
footer = DetailsFooterUM(
socials = socialsBuilder.buildAll(),
appVersion = getAppVersion(),
),
popBack = router::pop,
),
)
init {
items
.onEach(::updateState)
.launchIn(modelScope)
}
fun provideUserWalletId(userWalletId: UserWalletId) = modelScope.launch {
val isWalletConnectAvailable = checkIsWalletConnectAvailableUseCase(userWalletId).getOrElse {
Timber.w("Unable to check WalletConnect availability: $it")
false
}
items.value = itemsBuilder.buldAll(isWalletConnectAvailable)
}
private suspend fun updateState(items: ImmutableList<DetailsItemUM>) {
state.update { prevState ->
prevState.copy(
items = items,
)
}
}
private fun getAppVersion(): String = "${appVersionProvider.versionName} (${appVersionProvider.versionCode})"
}

View file

@ -0,0 +1,74 @@
package com.tangem.features.details.model
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.features.details.entity.UserWalletListUM
import com.tangem.features.details.entity.UserWalletListUM.UserWalletUM
import com.tangem.features.details.impl.R
import com.tangem.features.details.utils.UserWalletSaver
import com.tangem.features.details.utils.UserWalletsFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ComponentScoped
internal class UserWalletListModel @Inject constructor(
private val shouldSaveUserWalletsUseCase: ShouldSaveUserWalletsUseCase,
private val userWalletSaver: UserWalletSaver,
private val userWalletsFetcher: UserWalletsFetcher,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val isWalletSavingInProgress: MutableStateFlow<Boolean> = MutableStateFlow(value = false)
private val userWallets: SharedFlow<ImmutableList<UserWalletUM>> = userWalletsFetcher
.userWallets
.share()
private val shouldSaveUserWallets: SharedFlow<Boolean> = shouldSaveUserWalletsUseCase()
.distinctUntilChanged()
.share()
val state: MutableStateFlow<UserWalletListUM> = MutableStateFlow(
value = UserWalletListUM(
userWallets = persistentListOf(),
isWalletSavingInProgress = false,
addNewWalletText = TextReference.EMPTY,
onAddNewWalletClick = ::addUserWallet,
),
)
init {
combine(
userWallets,
shouldSaveUserWallets,
isWalletSavingInProgress,
transform = ::updateState,
).launchIn(modelScope)
}
private suspend fun updateState(
userWallets: ImmutableList<UserWalletUM>,
shouldSaveUserWallets: Boolean,
isWalletSavingInProgress: Boolean,
) = state.update { value ->
value.copy(
userWallets = userWallets,
isWalletSavingInProgress = isWalletSavingInProgress,
addNewWalletText = if (shouldSaveUserWallets) {
resourceReference(R.string.user_wallet_list_add_button)
} else {
resourceReference(R.string.scan_card_settings_button)
},
)
}
private fun addUserWallet() = withProgress(isWalletSavingInProgress) {
userWalletSaver.scanAndSaveUserWallet()
}
}

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.ComposableContentProvider
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.res.TangemTheme
@ -32,7 +33,12 @@ private const val COLLAPSED_APP_BAR_THRESHOLD = 0.4f
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DetailsScreen(state: DetailsUM, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier) {
internal fun DetailsScreen(
state: DetailsUM,
snackbarHostState: SnackbarHostState,
userWalletListBlockContent: ComposableContentProvider,
modifier: Modifier = Modifier,
) {
val backgroundColor = TangemTheme.colors.background.secondary
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@ -56,6 +62,7 @@ internal fun DetailsScreen(state: DetailsUM, snackbarHostState: SnackbarHostStat
Content(
modifier = Modifier.padding(paddingValues),
state = state,
userWalletListBlockContent = userWalletListBlockContent,
)
}
}
@ -109,7 +116,11 @@ private fun TopBar(state: DetailsUM, scrollBehavior: TopAppBarScrollBehavior, mo
}
@Composable
private fun Content(state: DetailsUM, modifier: Modifier = Modifier) {
private fun Content(
state: DetailsUM,
userWalletListBlockContent: ComposableContentProvider,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
@ -125,6 +136,7 @@ private fun Content(state: DetailsUM, modifier: Modifier = Modifier) {
Block(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
model = block,
userWalletListBlockContent = userWalletListBlockContent,
)
}
@ -138,7 +150,11 @@ private fun Content(state: DetailsUM, modifier: Modifier = Modifier) {
}
@Composable
private fun Block(model: DetailsItemUM, modifier: Modifier = Modifier) {
private fun Block(
model: DetailsItemUM,
userWalletListBlockContent: ComposableContentProvider,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
@ -149,22 +165,28 @@ private fun Block(model: DetailsItemUM, modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
) {
val itemModifier = Modifier.fillMaxWidth()
when (model) {
is DetailsItemUM.Basic -> {
model.items.forEach { item ->
key(item.id) {
BlockItem(
modifier = Modifier.fillMaxWidth(),
modifier = itemModifier,
model = item,
)
}
}
}
is DetailsItemUM.Component -> {
model.content(
modifier = Modifier.fillMaxWidth(),
is DetailsItemUM.WalletConnect -> {
WalletConnectBlock(
modifier = itemModifier,
onClick = model.onClick,
)
}
is DetailsItemUM.UserWalletList -> {
userWalletListBlockContent.Content(modifier = itemModifier)
}
}
}
}
@ -222,7 +244,7 @@ private fun Footer(model: DetailsFooterUM, modifier: Modifier = Modifier) {
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_DetailsScreen() {
TangemThemePreview {
PreviewDetailsComponent().View(modifier = Modifier.fillMaxSize())
PreviewDetailsComponent().Content(modifier = Modifier.fillMaxSize())
}
}
// endregion Preview

View file

@ -1,7 +1,9 @@
package com.tangem.features.details.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -104,12 +106,24 @@ private fun AddWalletButton(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Icon(
AnimatedContent(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = R.drawable.ic_plus_24),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
targetState = isInProgress,
) { isInProgress ->
if (isInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(TangemTheme.dimens.size24),
color = TangemTheme.colors.icon.accent,
)
} else {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = R.drawable.ic_plus_24),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
}
}
Text(
text = text.resolveReference(),

View file

@ -1,11 +1,10 @@
package com.tangem.features.details.utils
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.AppScreen
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.details.component.UserWalletListComponent
import com.tangem.features.details.component.WalletConnectComponent
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.impl.BuildConfig
import com.tangem.features.details.impl.R
@ -13,47 +12,39 @@ import com.tangem.features.details.routing.DetailsRoute
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
internal class ItemsBuilder(
private val walletConnectComponent: WalletConnectComponent,
private val userWalletListComponent: UserWalletListComponent,
@ComponentScoped
internal class ItemsBuilder @Inject constructor(
private val router: Router,
) {
suspend fun buldAll(): ImmutableList<DetailsItemUM> = buildList {
buildWalletConnectBlock()?.let(::add)
suspend fun buldAll(isWalletConnectAvailable: Boolean): ImmutableList<DetailsItemUM> = buildList {
buildWalletConnectBlock(isWalletConnectAvailable)?.let(::add)
buildUserWalletListBlock().let(::add)
buildShopBlock().let(::add)
buildSettingsBlock().let(::add)
buildSupportBlock().let(::add)
}.toImmutableList()
private suspend fun buildWalletConnectBlock(): DetailsItemUM? {
return if (walletConnectComponent.checkIsAvailable()) {
DetailsItemUM.Component(
id = "wallet_connect",
content = {
walletConnectComponent.View(modifier = it)
},
private suspend fun buildWalletConnectBlock(isWalletConnectAvailable: Boolean): DetailsItemUM? {
return if (isWalletConnectAvailable) {
DetailsItemUM.WalletConnect(
onClick = { router.push(DetailsRoute.Screen(AppScreen.WalletConnectSessions)) },
)
} else {
null
}
}
private fun buildUserWalletListBlock(): DetailsItemUM = DetailsItemUM.Component(
id = "user_wallet_list",
content = {
userWalletListComponent.View(modifier = it)
},
)
private fun buildUserWalletListBlock(): DetailsItemUM = DetailsItemUM.UserWalletList
private fun buildShopBlock(): DetailsItemUM = DetailsItemUM.Basic(
id = "shop",
items = persistentListOf(
DetailsItemUM.Basic.Item(
id = "buy_tangem_wallet",
title = stringReference("Buy Tangem Wallet"), // TODO: Move to resources in [REDACTED_TASK_KEY]
title = resourceReference(R.string.details_buy_wallet),
iconRes = R.drawable.ic_tangem_24,
onClick = { router.push(DetailsRoute.Url(BUY_TANGEM_URL)) },
),
@ -86,7 +77,7 @@ internal class ItemsBuilder(
items = persistentListOf(
DetailsItemUM.Basic.Item(
id = "send_feedback",
title = stringReference("Send feedback"), // TODO: Move to resources in [REDACTED_TASK_KEY]
title = resourceReference(R.string.details_send_feedback),
iconRes = R.drawable.ic_comment_24,
onClick = { router.push(DetailsRoute.Feedback) },
),

View file

@ -1,14 +1,17 @@
package com.tangem.features.details.utils
import androidx.compose.ui.text.intl.Locale
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.impl.R
import com.tangem.features.details.routing.DetailsRoute
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
internal class SocialsBuilder(
@ComponentScoped
internal class SocialsBuilder @Inject constructor(
private val router: Router,
) {

View file

@ -0,0 +1,84 @@
package com.tangem.features.details.utils
import androidx.annotation.DrawableRes
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.tokens.model.TotalFiatBalance
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.details.entity.UserWalletListUM.UserWalletUM
import com.tangem.features.details.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
internal fun List<UserWallet>.toUiModels(
onClick: (UserWalletId) -> Unit,
appCurrency: AppCurrency? = null,
balances: Map<UserWalletId, TotalFiatBalance> = emptyMap(),
): ImmutableList<UserWalletUM> = this.map { model ->
val balance = balances[model.walletId]
model.mapToUiModel(
balance = balance,
appCurrency = appCurrency,
onClick = { onClick(model.walletId) },
)
}.toImmutableList()
private fun UserWallet.mapToUiModel(
balance: TotalFiatBalance?,
appCurrency: AppCurrency?,
onClick: () -> Unit,
): UserWalletUM = UserWalletUM(
id = walletId,
name = name,
information = getInfo(appCurrency, balance),
imageResId = resolveImage(),
onClick = onClick,
)
private fun UserWallet.getInfo(appCurrency: AppCurrency?, balance: TotalFiatBalance?): TextReference {
val cardCount = getCardCount()
val cardCountRef = TextReference.PluralRes(
id = R.plurals.card_label_card_count,
count = cardCount,
formatArgs = wrappedList(cardCount),
)
val amount = when (balance) {
is TotalFiatBalance.Loaded -> balance.amount.takeIf { balance.isAllAmountsSummarized }
is TotalFiatBalance.Failed,
is TotalFiatBalance.Loading,
null,
-> null
}
return if (amount != null && appCurrency != null) {
val divider = stringReference(value = "")
val formattedAmount = BigDecimalFormatter.formatFiatAmount(
fiatAmount = amount,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
val amountRef = stringReference(formattedAmount)
TextReference.Combined(wrappedList(cardCountRef, divider, amountRef))
} else {
cardCountRef
}
}
private fun UserWallet.getCardCount() = when (val status = scanResponse.card.backupStatus) {
is CardDTO.BackupStatus.Active -> status.cardCount.inc()
is CardDTO.BackupStatus.CardLinked -> status.cardCount.inc()
is CardDTO.BackupStatus.NoBackup,
null,
-> 1
}
@DrawableRes
private fun UserWallet.resolveImage(): Int {
// TODO: Implement image resolving [REDACTED_JIRA]
return R.drawable.ill_card_wallet_2_211_343
}

View file

@ -0,0 +1,139 @@
package com.tangem.features.details.utils
import arrow.core.raise.*
import arrow.core.recover
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.AppScreen
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.isNullOrEmpty
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.card.ScanCardProcessor
import com.tangem.domain.models.scan.ScanResponse
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.SelectWalletUseCase
import com.tangem.features.details.impl.R
import com.tangem.features.details.routing.DetailsRoute
import javax.inject.Inject
@ComponentScoped
@Suppress("LongParameterList")
internal class UserWalletSaver @Inject constructor(
private val scanCardProcessor: ScanCardProcessor,
private val saveWalletUseCase: SaveWalletUseCase,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val selectWalletUseCase: SelectWalletUseCase,
private val reduxStateHolder: ReduxStateHolder,
private val messageSender: UiMessageSender,
private val router: Router,
) {
suspend fun scanAndSaveUserWallet() = recover(
block = {
val response = scanCard()
val userWallet = createUserWallet(response)
saveWallet(userWallet)
router.popTo(DetailsRoute.Screen(AppScreen.Wallet))
},
recover = { error ->
val message = error.message
if (!message.isNullOrEmpty()) {
messageSender.send(SnackbarMessage(message))
}
},
)
private suspend fun Raise<Error>.saveWallet(userWallet: UserWallet) {
saveWalletUseCase(userWallet).recover { error ->
when (error) {
is SaveWalletError.WalletAlreadySaved -> selectUserWallet(userWallet)
is SaveWalletError.DataError -> {
val messageRef = ensureNotNull(error.messageId?.let(::resourceReference)) {
Error.Unkonwn
}
raise(Error.Message(messageRef))
}
}
}.bind()
reduxStateHolder.onUserWalletSelected(userWallet)
}
private suspend fun Raise<Error>.selectUserWallet(userWallet: UserWallet) {
withError({ Error.Unkonwn }) {
selectWalletUseCase(userWallet.walletId).bind()
}
router.popTo(DetailsRoute.Screen(AppScreen.Wallet))
}
private suspend fun Raise<Error>.createUserWallet(response: ScanResponse): UserWallet {
val userWallet = UserWalletBuilder(response, generateWalletNameUseCase).build()
return ensureNotNull(userWallet) { Error.Unkonwn }
}
private suspend fun Raise<Error>.scanCard(): ScanResponse {
var response: ScanResponse? = null
scanCardProcessor.scan(
analyticsSource = AnalyticsParam.ScreensSources.Settings,
onWalletNotCreated = {
raise(Error.WalletNotCreated)
},
disclaimerWillShow = {
router.pop()
raise(Error.DisclaimerWillShow)
},
onSuccess = {
response = it
},
onFailure = { tangemError ->
val error = if (!tangemError.silent) {
val message = tangemError.messageResId
?.let(::resourceReference)
?: stringReference(tangemError.customMessage)
Error.Message(message)
} else {
Error.Silent
}
raise(error)
},
)
return response!!
}
sealed class Error {
open val message: TextReference? = null
data object WalletNotCreated : Error()
data object DisclaimerWillShow : Error()
data object Silent : Error()
data class Message(override val message: TextReference) : Error()
data object Unkonwn : Error() {
override val message: TextReference = resourceReference(R.string.common_unknown_error)
}
}
}

View file

@ -0,0 +1,95 @@
package com.tangem.features.details.utils
import arrow.core.Either
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.error.SelectedAppCurrencyError
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.lce.lce
import com.tangem.domain.core.utils.getOrElse
import com.tangem.domain.core.utils.toLce
import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.TotalFiatBalance
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.details.entity.UserWalletListUM.UserWalletUM
import com.tangem.features.details.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ComponentScoped
internal class UserWalletsFetcher @Inject constructor(
private val getWalletsUseCase: GetWalletsUseCase,
private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val messageSender: UiMessageSender,
) {
val userWallets: Flow<ImmutableList<UserWalletUM>> = getWalletsUseCase()
.distinctUntilChanged()
.transform { wallets ->
if (wallets.isEmpty()) {
error("Wallets must not be empty")
} else {
emit(wallets.toUiModels(onClick = ::navigateToWalletSettings))
}
combine(
getSelectedAppCurrencyUseCase(),
getWalletTotalBalanceUseCase(wallets.map(UserWallet::walletId)),
) { maybeAppCurrency, maybeBalances ->
val models = createUiModels(wallets, maybeAppCurrency, maybeBalances).getOrElse(
ifLoading = { return@combine },
ifError = {
val message = resourceReference(R.string.common_unknown_error)
messageSender.send(SnackbarMessage(message))
return@combine
},
)
emit(models)
}.collect()
}
private fun createUiModels(
wallets: List<UserWallet>,
maybeAppCurrency: Either<SelectedAppCurrencyError, AppCurrency>,
maybeBalances: Lce<TokenListError, Map<UserWalletId, TotalFiatBalance>>,
): Lce<Error, ImmutableList<UserWalletUM>> = lce {
val balances = withError(
transform = { Error.UnableToGetBalances },
block = { maybeBalances.bind() },
)
val appCurrency = withError(
transform = { Error.UnableToGetAppCurrency },
block = { maybeAppCurrency.toLce().bind() },
)
wallets.toUiModels(
appCurrency = appCurrency,
balances = balances,
onClick = ::navigateToWalletSettings,
)
}
private fun navigateToWalletSettings(userWalletId: UserWalletId) {
val message = stringReference("Wallet settings have not yet been implemented: $userWalletId")
messageSender.send(SnackbarMessage(message))
}
sealed class Error {
data object UnableToGetAppCurrency : Error()
data object UnableToGetBalances : Error()
}
}