Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-29 14:47:57 +03:00
commit 24963f1e5d
1288 changed files with 33316 additions and 9405 deletions

1
features/account/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,19 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.account.api"
}
dependencies {
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/* Project - Domain */
implementation(projects.domain.models)
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.account
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWalletId
interface AccountCreateEditComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AccountCreateEditComponent>
sealed interface Params {
data class Create(
val userWalletId: UserWalletId,
) : Params
data class Edit(
val account: Account,
) : Params
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.account
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.account.Account
interface AccountDetailsComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AccountDetailsComponent>
data class Params(val account: Account)
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.account
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
interface ArchivedAccountListComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, ArchivedAccountListComponent>
data class Params(val userWalletId: UserWalletId)
}

1
features/account/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,61 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.account.impl"
}
dependencies {
/** Api */
implementation(projects.features.account.api)
/** Core modules */
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.error)
implementation(projects.core.res)
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.datasource)
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.account)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)
implementation(deps.lifecycle.runtime.ktx)
/** Compose libraries */
implementation(deps.compose.material3)
implementation(deps.compose.animation)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.decompose.ext.compose)
implementation(deps.androidx.activity.compose)
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
implementation(deps.firebase.crashlytics)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,70 @@
package com.tangem.features.account.archived
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.account.archived.entity.AccountArchivedUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("UnusedPrivateMember") // todo account
internal class ArchivedAccountListModel @Inject constructor(
paramsContainer: ParamsContainer,
private val messageSender: UiMessageSender,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
private val recoverCryptoPortfolioUseCase: RecoverCryptoPortfolioUseCase,
) : Model() {
private val params = paramsContainer.require<ArchivedAccountListComponent.Params>()
val uiState: StateFlow<AccountArchivedUM> get() = _uiState
private val _uiState: MutableStateFlow<AccountArchivedUM> = MutableStateFlow(getInitialState())
private fun confirmRecoverDialog(accountId: AccountId) {
val account: Account? = null // todo account find
account ?: return
val secondAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
)
val firstAction = EventMessageAction(
title = resourceReference(R.string.account_archived_recover),
onClick = { recoverCryptoPortfolio(account.accountId) },
)
messageSender.send(
DialogMessage(
title = resourceReference(R.string.account_archived_recover_dialog_title),
message = resourceReference(
id = R.string.account_archived_recover_dialog_description,
formatArgs = wrappedList(account.accountName.value),
),
firstActionBuilder = { firstAction },
secondActionBuilder = { secondAction },
),
)
}
private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch {
recoverCryptoPortfolioUseCase(accountId)
}
private fun getInitialState(): AccountArchivedUM {
return AccountArchivedUM.Loading(
onCloseClick = { router.pop() },
)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.account.archived
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.account.archived.ui.ArchivedAccountListContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultArchivedAccountListComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: ArchivedAccountListComponent.Params,
) : AppComponentContext by appComponentContext, ArchivedAccountListComponent {
private val model: ArchivedAccountListModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ArchivedAccountListContent(
modifier = modifier,
state = state,
)
BackHandler(onBack = state.onCloseClick)
}
@AssistedFactory
interface Factory : ArchivedAccountListComponent.Factory {
override fun create(
context: AppComponentContext,
params: ArchivedAccountListComponent.Params,
): DefaultArchivedAccountListComponent
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.account.archived.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.account.archived.ArchivedAccountListModel
import com.tangem.features.account.archived.DefaultArchivedAccountListComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
internal interface AccountArchivedModule {
@Binds
fun bindArchivedAccountListComponentFactory(
impl: DefaultArchivedAccountListComponent.Factory,
): ArchivedAccountListComponent.Factory
@Binds
@IntoMap
@ClassKey(ArchivedAccountListModel::class)
fun bindArchivedAccountListModel(model: ArchivedAccountListModel): Model
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.account.archived.entity
import com.tangem.common.ui.account.CryptoPortfolioIconUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal sealed interface AccountArchivedUM {
val onCloseClick: () -> Unit
data class Loading(override val onCloseClick: () -> Unit) : AccountArchivedUM
data class Error(
override val onCloseClick: () -> Unit,
val onRetryClick: () -> Unit,
) : AccountArchivedUM
data class Content(
override val onCloseClick: () -> Unit,
val accounts: ImmutableList<ArchivedAccountUM>,
) : AccountArchivedUM
}
internal data class ArchivedAccountUM(
val accountId: String,
val accountName: TextReference,
val accountIconUM: CryptoPortfolioIconUM,
val tokensInfo: TextReference,
val onClick: (accountId: String) -> Unit,
)

View file

@ -0,0 +1,182 @@
package com.tangem.features.account.archived.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
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 androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.AccountRow
import com.tangem.core.res.R
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
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.account.archived.entity.AccountArchivedUM
import com.tangem.features.account.archived.entity.ArchivedAccountUM
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun ArchivedAccountListContent(state: AccountArchivedUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarWithBackButton(
text = stringResourceSafe(R.string.account_archived_title),
onBackClick = state.onCloseClick,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
Column(
modifier = Modifier
.fillMaxSize()
.weight(1f),
) {
when (state) {
is AccountArchivedUM.Content -> ArchiveAccountContent(state)
is AccountArchivedUM.Error -> ArchiveAccountError(state)
is AccountArchivedUM.Loading -> ArchiveAccountLoading()
}
}
}
}
@Composable
private fun ArchiveAccountLoading(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = TangemTheme.colors.icon.primary1,
modifier = Modifier,
)
}
}
@Composable
private fun ArchiveAccountError(state: AccountArchivedUM.Error, modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
text = stringResourceSafe(R.string.common_unable_to_load),
)
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.try_to_load_data_again_button_title),
onClick = state.onRetryClick,
),
)
}
}
}
@Composable
private fun ArchiveAccountContent(state: AccountArchivedUM.Content, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
itemsIndexed(
items = state.accounts,
key = { index, item -> item.accountId },
) { index, account ->
ArchivedAccountRow(
item = account,
modifier = Modifier.roundedShapeItemDecoration(
backgroundColor = TangemTheme.colors.background.primary,
radius = TangemTheme.dimens.radius20,
currentIndex = index,
addDefaultPadding = true,
lastIndex = state.accounts.lastIndex,
),
)
}
}
}
@Composable
private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = { item.onClick(item.accountId) })
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
AccountRow(
title = item.accountName,
subtitle = item.tokensInfo,
icon = item.accountIconUM,
modifier = Modifier.weight(1f),
)
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.account_archived_recover),
onClick = { item.onClick(item.accountId) },
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun WcConnectionsContentPreview(@PreviewParameter(PreviewStateProvider::class) params: AccountArchivedUM) {
TangemThemePreview {
ArchivedAccountListContent(state = params)
}
}
@Suppress("MagicNumber")
private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountArchivedUM>(
buildList {
fun portfolioIcon() = AccountIconPreviewData.randomAccountIcon()
val accountName = stringReference("Account name")
val firstList = List(10) {
ArchivedAccountUM(
accountId = it.toString(),
accountName = accountName,
accountIconUM = portfolioIcon(),
tokensInfo = stringReference("10 tokens in 2 networks"),
onClick = {},
)
}.toImmutableList()
val first = AccountArchivedUM.Content(
onCloseClick = {},
accounts = firstList,
)
add(first)
add(AccountArchivedUM.Loading {})
add(AccountArchivedUM.Error({}, {}))
},
)

View file

@ -0,0 +1,203 @@
package com.tangem.features.account.createedit
import com.tangem.common.ui.account.toDomain
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
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.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.account.usecase.AddCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase
import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.createedit.entity.AccountCreateEditUM
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.portfolioIcon
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateButton
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateColorSelect
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateDerivationIndex
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateIconSelect
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateName
import com.tangem.features.account.createedit.error.AccountFeatureError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class AccountCreateEditModel @Inject constructor(
paramsContainer: ParamsContainer,
private val messageSender: UiMessageSender,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
private val updateCryptoPortfolioUseCase: UpdateCryptoPortfolioUseCase,
private val addCryptoPortfolioUseCase: AddCryptoPortfolioUseCase,
private val getUnoccupiedAccountIndexUseCase: GetUnoccupiedAccountIndexUseCase,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : Model() {
private val params = paramsContainer.require<AccountCreateEditComponent.Params>()
private val umBuilder = AccountCreateEditUMBuilder(params)
val uiState: StateFlow<AccountCreateEditUM>
field = MutableStateFlow(value = getInitialState())
init {
if (params is AccountCreateEditComponent.Params.Create) {
updateDerivationInfo(userWalletId = params.userWalletId)
}
}
private fun unsaveChangeDialog() {
val secondAction = EventMessageAction(
title = resourceReference(R.string.account_unsaved_dialog_action_first),
onClick = {},
)
val firstAction = EventMessageAction(
title = resourceReference(R.string.account_unsaved_dialog_action_second),
warning = true,
onClick = { router.pop() },
)
messageSender.send(
DialogMessage(
title = resourceReference(R.string.account_unsaved_dialog_title),
message = resourceReference(R.string.account_unsaved_dialog_message_create),
firstActionBuilder = { firstAction },
secondActionBuilder = { secondAction },
),
)
}
private fun onConfirmClick() = modelScope.launch {
when (params) {
is AccountCreateEditComponent.Params.Create -> createNewCryptoPortfolio(params)
is AccountCreateEditComponent.Params.Edit -> editCryptoPortfolio(params)
}
}
private suspend fun createNewCryptoPortfolio(params: AccountCreateEditComponent.Params.Create) {
val state = uiState.value
val name = AccountName(value = state.account.name).getOrNull() ?: return
val icon = state.account.portfolioIcon.toDomain()
val index = state.account.derivationInfo.index ?: return
val derivationIndex = DerivationIndex(value = index).getOrNull() ?: return
addCryptoPortfolioUseCase(
userWalletId = params.userWalletId,
accountName = name,
icon = icon,
derivationIndex = derivationIndex,
)
}
private suspend fun editCryptoPortfolio(params: AccountCreateEditComponent.Params.Edit) {
val state = uiState.value
val name = AccountName(state.account.name).getOrNull() ?: return
val icon = state.account.portfolioIcon.toDomain()
val isNewName = name != params.account.accountName
val isNewIcon = icon != params.account.portfolioIcon
updateCryptoPortfolioUseCase(
icon = if (isNewIcon) icon else null,
accountName = if (isNewName) name else null,
accountId = params.account.accountId,
)
}
private fun onCloseClick() = unsaveChangeDialog()
private fun onIconSelect(icon: CryptoPortfolioIcon.Icon) {
uiState.value = uiState.value
.updateIconSelect(icon)
.validateNewState()
}
private fun onColorSelect(color: CryptoPortfolioIcon.Color) {
uiState.value = uiState.value
.updateColorSelect(color)
.validateNewState()
}
private fun onNameChange(name: String) {
uiState.value = uiState.value
.updateName(name)
.validateNewState()
}
private fun AccountCreateEditUM.validateNewState(): AccountCreateEditUM {
val isValidName = AccountName(this.account.name).isRight()
val isAvailableForConfirm = when (params) {
is AccountCreateEditComponent.Params.Create -> isValidName
is AccountCreateEditComponent.Params.Edit -> {
val isNewName = this.account.name != params.account.accountName.value
val isNewIcon = this.account.portfolioIcon != params.account.portfolioIcon
isValidName && (isNewName || isNewIcon)
}
}
return this.updateButton(isButtonEnabled = isAvailableForConfirm)
}
private fun getInitialState(): AccountCreateEditUM {
return AccountCreateEditUM(
title = umBuilder.toolbarTitle,
account = umBuilder.initAccountUM(::onNameChange),
colorsState = umBuilder.initColorsUM(::onColorSelect),
iconsState = umBuilder.initIconsUM(::onIconSelect),
buttonState = umBuilder.initButtonUM(::onConfirmClick),
onCloseClick = ::onCloseClick,
)
}
private fun updateDerivationInfo(userWalletId: UserWalletId) {
modelScope.launch(dispatchers.default) {
getUnoccupiedAccountIndexUseCase(userWalletId = userWalletId)
.onRight { derivationIndex ->
uiState.update {
it.updateDerivationIndex(derivationIndex = derivationIndex.value)
}
}
.onLeft { cause ->
handleError(
error = AccountFeatureError.CreateAccount.UnableToGetDerivationIndex,
message = cause.toString(),
params = mapOf(
"userWalletId" to userWalletId.stringValue,
"cause" to cause.toString(),
),
)
return@launch
}
}
}
private fun handleError(
error: AccountFeatureError,
message: String? = null,
params: Map<String, String> = mapOf(),
) {
val exception = IllegalStateException("$error. Cause: $message")
Timber.e(exception)
analyticsExceptionHandler.sendException(
event = ExceptionAnalyticsEvent(exception = exception, params = params),
)
messageSender.showErrorDialog(universalError = error, onDismiss = router::pop)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.account.createedit
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.createedit.ui.AccountCreateEditContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAccountCreateEditComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: AccountCreateEditComponent.Params,
) : AppComponentContext by appComponentContext, AccountCreateEditComponent {
private val model: AccountCreateEditModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
AccountCreateEditContent(
modifier = modifier,
state = state,
)
BackHandler(onBack = state.onCloseClick)
}
@AssistedFactory
interface Factory : AccountCreateEditComponent.Factory {
override fun create(
context: AppComponentContext,
params: AccountCreateEditComponent.Params,
): DefaultAccountCreateEditComponent
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.account.createedit.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.createedit.AccountCreateEditModel
import com.tangem.features.account.createedit.DefaultAccountCreateEditComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
internal interface AccountCreateEditModule {
@Binds
fun bindAccountCreateEditComponentFactory(
impl: DefaultAccountCreateEditComponent.Factory,
): AccountCreateEditComponent.Factory
@Binds
@IntoMap
@ClassKey(AccountCreateEditModel::class)
fun bindAccountCreateEditModel(model: AccountCreateEditModel): Model
}

View file

@ -0,0 +1,54 @@
package com.tangem.features.account.createedit.entity
import com.tangem.common.ui.account.CryptoPortfolioIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList
internal data class AccountCreateEditUM(
val title: TextReference,
val account: Account,
val colorsState: Colors,
val iconsState: Icons,
val buttonState: Button,
val onCloseClick: () -> Unit,
) {
data class Account(
val name: String,
val portfolioIcon: CryptoPortfolioIconUM,
val derivationInfo: DerivationInfo,
val inputPlaceholder: TextReference,
val onNameChange: (String) -> Unit,
)
sealed interface DerivationInfo {
val text: TextReference
val index: Int?
data class Content(override val text: TextReference, override val index: Int) : DerivationInfo
data object Empty : DerivationInfo {
override val text: TextReference = TextReference.EMPTY
override val index: Int? = null
}
}
data class Colors(
val selected: CryptoPortfolioIcon.Color,
val list: ImmutableList<CryptoPortfolioIcon.Color>,
val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
)
data class Icons(
val selected: CryptoPortfolioIcon.Icon,
val list: ImmutableList<CryptoPortfolioIcon.Icon>,
val onIconSelect: (CryptoPortfolioIcon.Icon) -> Unit,
)
data class Button(
val isButtonEnabled: Boolean,
val onConfirmClick: () -> Unit,
val text: TextReference,
)
}

View file

@ -0,0 +1,139 @@
package com.tangem.features.account.createedit.entity
import com.tangem.common.ui.account.toUM
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.account.AccountCreateEditComponent
import kotlinx.collections.immutable.toImmutableList
internal class AccountCreateEditUMBuilder(
private val params: AccountCreateEditComponent.Params,
) {
private val accountColors = CryptoPortfolioIcon.Color.entries.toImmutableList()
private val accountIcons = CryptoPortfolioIcon.Icon.entries.toImmutableList()
private val createIcon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM()
val toolbarTitle: TextReference
get() = when (params) {
is AccountCreateEditComponent.Params.Create -> resourceReference(R.string.account_form_title_create)
is AccountCreateEditComponent.Params.Edit -> resourceReference(R.string.account_form_title_edit)
}
fun initAccountUM(onNameChange: (String) -> Unit): AccountCreateEditUM.Account {
return when (params) {
is AccountCreateEditComponent.Params.Create -> AccountCreateEditUM.Account(
name = "",
portfolioIcon = createIcon,
derivationInfo = AccountCreateEditUM.DerivationInfo.Empty,
inputPlaceholder = resourceReference(R.string.account_form_placeholder_new_account),
onNameChange = onNameChange,
)
is AccountCreateEditComponent.Params.Edit -> AccountCreateEditUM.Account(
name = params.account.accountName.value,
portfolioIcon = params.account.portfolioIcon.toUM(),
derivationInfo = createAccountDerivationInfo(
index = (params.account as Account.CryptoPortfolio).derivationIndex.value,
),
inputPlaceholder = resourceReference(R.string.account_form_placeholder_edit_account),
onNameChange = onNameChange,
)
}
}
fun initColorsUM(onColorSelect: (CryptoPortfolioIcon.Color) -> Unit): AccountCreateEditUM.Colors {
val selected: CryptoPortfolioIcon.Color = when (params) {
is AccountCreateEditComponent.Params.Create -> createIcon.color
is AccountCreateEditComponent.Params.Edit -> params.account.portfolioIcon.color
}
return AccountCreateEditUM.Colors(
selected = selected,
list = accountColors,
onColorSelect = onColorSelect,
)
}
fun initIconsUM(onIconSelect: (CryptoPortfolioIcon.Icon) -> Unit): AccountCreateEditUM.Icons {
val selected: CryptoPortfolioIcon.Icon = when (params) {
is AccountCreateEditComponent.Params.Create -> createIcon.value
is AccountCreateEditComponent.Params.Edit -> params.account.portfolioIcon.value
}
return AccountCreateEditUM.Icons(
selected = selected,
list = accountIcons,
onIconSelect = onIconSelect,
)
}
fun initButtonUM(onConfirmClick: () -> Unit): AccountCreateEditUM.Button {
val text: TextReference = when (params) {
is AccountCreateEditComponent.Params.Create -> resourceReference(R.string.account_form_create_button)
is AccountCreateEditComponent.Params.Edit -> resourceReference(R.string.account_form_edit_button)
}
return AccountCreateEditUM.Button(
isButtonEnabled = false,
onConfirmClick = onConfirmClick,
text = text,
)
}
internal companion object {
val Account.portfolioIcon: CryptoPortfolioIcon
get() = when (this) {
is Account.CryptoPortfolio -> this.icon
}
fun AccountCreateEditUM.updateColorSelect(color: CryptoPortfolioIcon.Color): AccountCreateEditUM {
val newIcon = this.account.portfolioIcon.copy(
color = color,
)
return this.copy(
account = this.account.copy(portfolioIcon = newIcon),
colorsState = this.colorsState.copy(selected = color),
)
}
fun AccountCreateEditUM.updateIconSelect(icon: CryptoPortfolioIcon.Icon): AccountCreateEditUM {
val newIcon = this.account.portfolioIcon.copy(
value = icon,
)
return this.copy(
account = this.account.copy(portfolioIcon = newIcon),
iconsState = this.iconsState.copy(selected = icon),
)
}
fun AccountCreateEditUM.updateName(name: String): AccountCreateEditUM {
return this.copy(account = this.account.copy(name = name))
}
fun AccountCreateEditUM.updateButton(isButtonEnabled: Boolean): AccountCreateEditUM {
return this.copy(buttonState = this.buttonState.copy(isButtonEnabled = isButtonEnabled))
}
fun AccountCreateEditUM.updateDerivationIndex(derivationIndex: Int): AccountCreateEditUM {
return this.copy(
account = this.account.copy(
derivationInfo = createAccountDerivationInfo(index = derivationIndex),
),
)
}
private fun createAccountDerivationInfo(index: Int): AccountCreateEditUM.DerivationInfo {
val derivationIndexText = if (index.toString().length == 1) "0$index" else "$index"
return AccountCreateEditUM.DerivationInfo.Content(
text = resourceReference(
id = R.string.account_form_account_index,
formatArgs = wrappedList(derivationIndexText),
),
index = index,
)
}
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.account.createedit.error
import com.tangem.core.error.UniversalError
sealed interface AccountFeatureError : UniversalError {
val subsystemCode: String
val specificErrorCode: String
override val errorCode: Int
get() = "108$subsystemCode$specificErrorCode".toInt()
sealed interface CreateAccount : AccountFeatureError {
override val subsystemCode: String get() = "001"
data object UnableToGetDerivationIndex : CreateAccount {
override val specificErrorCode: String = "001"
}
}
sealed interface EditAccount : AccountFeatureError {
override val subsystemCode: String get() = "002"
data object RequiredCryptoPortfolio : EditAccount {
override val specificErrorCode: String = "001"
}
}
}

View file

@ -0,0 +1,342 @@
package com.tangem.features.account.createedit.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
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.common.ui.R
import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.AccountIconSize
import com.tangem.common.ui.account.getResId
import com.tangem.common.ui.account.getUiColor
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.fields.AutoSizeTextField
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.account.createedit.entity.AccountCreateEditUM
import com.tangem.features.account.createedit.entity.AccountCreateEditUM.Account
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongMethod", "MagicNumber")
@Composable
internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.tertiary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarWithBackButton(
text = state.title.resolveReference(),
onBackClick = state.onCloseClick,
iconRes = R.drawable.ic_close_24,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.weight(1f),
) {
AccountSummary(state.account)
SpacerH24()
AccountColor(state.colorsState)
SpacerH24()
AccountIcons(state.iconsState)
SpacerH8()
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = state.account.derivationInfo.text.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
enabled = state.buttonState.isButtonEnabled,
text = state.buttonState.text.resolveReference(),
onClick = state.buttonState.onConfirmClick,
)
}
}
@Composable
private fun AccountSummary(account: Account) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors.background.action),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
AccountIcon(
name = stringReference(account.name),
icon = account.portfolioIcon,
size = AccountIconSize.Large,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResourceSafe(R.string.account_form_name),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
Spacer(modifier = Modifier.height(2.dp))
AutoSizeTextField(
centered = true,
textStyle = TangemTheme.typography.head,
placeholder = account.inputPlaceholder,
value = account.name,
singleLine = true,
onValueChange = account.onNameChange,
)
SpacerH(20.dp)
}
}
@Suppress("LongMethod", "MagicNumber")
@Composable
private fun AccountColor(colorsState: AccountCreateEditUM.Colors) {
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors.background.action),
) {
val columns = GridCells.Fixed(6)
val contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp)
LazyVerticalGrid(
columns = columns,
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(colorsState.list) { index, color ->
val isSelected = color == colorsState.selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.wrapContentSize()
.clickable(onClick = { colorsState.onColorSelect(color) })
.size(48.dp),
) {
if (isSelected) {
Box(
modifier = Modifier
.size(47.dp)
.border(2.dp, color.getUiColor(), shape = CircleShape),
)
Box(
modifier = Modifier
.size(36.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
} else {
Box(
modifier = Modifier
.size(40.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
}
}
}
}
}
}
@Suppress("LongMethod", "MagicNumber")
@Composable
private fun AccountIcons(iconsState: AccountCreateEditUM.Icons) {
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors.background.action)
.padding(8.dp),
) {
val columns = GridCells.Fixed(6)
LazyVerticalGrid(
columns = columns,
) {
itemsIndexed(iconsState.list) { index, icon ->
val isSelected = icon == iconsState.selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.wrapContentSize()
.clickable(onClick = { iconsState.onIconSelect(icon) })
.size(52.dp),
) {
if (isSelected) {
val borderColor: Color
val iconTint: Color
val backgroundTint: Color
if (index == 0) {
borderColor = TangemTheme.colors.icon.accent
iconTint = TangemTheme.colors.icon.accent
backgroundTint = TangemTheme.colors.icon.accent.copy(alpha = 0.1f)
} else {
borderColor = TangemTheme.colors.icon.informative
iconTint = TangemTheme.colors.icon.secondary
backgroundTint = TangemTheme.colors.field.focused
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(44.dp)
.border(2.dp, borderColor, shape = CircleShape),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.background(color = backgroundTint, shape = CircleShape),
) {
Icon(
imageVector = ImageVector.vectorResource(id = icon.getResId()),
contentDescription = null,
tint = iconTint,
)
}
}
} else {
val iconTint: Color
val backgroundTint: Color
if (index == 0) {
iconTint = TangemTheme.colors.icon.accent
backgroundTint = TangemTheme.colors.icon.accent.copy(alpha = 0.1f)
} else {
iconTint = TangemTheme.colors.text.tertiary
backgroundTint = TangemTheme.colors.field.focused
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(40.dp)
.background(color = backgroundTint, shape = CircleShape),
) {
Icon(
imageVector = ImageVector.vectorResource(id = icon.getResId()),
contentDescription = null,
tint = iconTint,
)
}
}
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun WcConnectionsContentPreview(@PreviewParameter(PreviewStateProvider::class) params: AccountCreateEditUM) {
TangemThemePreview {
AccountCreateEditContent(state = params)
}
}
private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountCreateEditUM>(
buildList {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val icons = CryptoPortfolioIcon.Icon.entries.toImmutableList()
var portfolioIcon = AccountIconPreviewData.randomAccountIcon()
val first = AccountCreateEditUM(
title = stringReference("Add account"),
onCloseClick = {},
account = Account(
name = "",
portfolioIcon = portfolioIcon,
inputPlaceholder = resourceReference(R.string.account_form_placeholder_new_account),
onNameChange = {},
derivationInfo = AccountCreateEditUM.DerivationInfo.Content(
text = resourceReference(id = R.string.account_form_account_index, formatArgs = wrappedList(1)),
index = 1,
),
),
colorsState = AccountCreateEditUM.Colors(
selected = portfolioIcon.color,
onColorSelect = {},
list = colors.toImmutableList(),
),
iconsState = AccountCreateEditUM.Icons(
selected = portfolioIcon.value,
onIconSelect = {},
list = icons.toImmutableList(),
),
buttonState = AccountCreateEditUM.Button(
isButtonEnabled = false,
onConfirmClick = {},
text = stringReference("Add account"),
),
)
add(first)
portfolioIcon = AccountIconPreviewData.randomAccountIcon(letter = true)
val accountName = "Main account"
val second = AccountCreateEditUM(
title = stringReference("Edit account"),
onCloseClick = {},
account = Account(
portfolioIcon = portfolioIcon,
name = accountName,
inputPlaceholder = resourceReference(R.string.account_form_placeholder_edit_account),
onNameChange = {},
derivationInfo = AccountCreateEditUM.DerivationInfo.Content(
text = resourceReference(id = R.string.account_form_account_index, formatArgs = wrappedList(1)),
index = 1,
),
),
colorsState = AccountCreateEditUM.Colors(
selected = portfolioIcon.color,
onColorSelect = {},
list = colors.toImmutableList(),
),
iconsState = AccountCreateEditUM.Icons(
selected = portfolioIcon.value,
onIconSelect = {},
list = icons.toImmutableList(),
),
buttonState = AccountCreateEditUM.Button(
isButtonEnabled = false,
onConfirmClick = {},
text = stringReference("Save"),
),
)
add(second)
},
)

View file

@ -0,0 +1,85 @@
package com.tangem.features.account.details
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.account.toUM
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.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.portfolioIcon
import com.tangem.features.account.details.entity.AccountDetailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
internal class AccountDetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
private val messageSender: UiMessageSender,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
private val archiveCryptoPortfolioUseCase: ArchiveCryptoPortfolioUseCase,
) : Model() {
private val params = paramsContainer.require<AccountDetailsComponent.Params>()
val uiState: StateFlow<AccountDetailsUM> get() = _uiState
private val _uiState: MutableStateFlow<AccountDetailsUM> = MutableStateFlow(getInitialState())
private fun onEditAccountClick() {
router.push(AppRoute.EditAccount(params.account))
}
private fun onManageTokensClick() {
// todo account add account param
router.push(AppRoute.ManageTokens(source = AppRoute.ManageTokens.Source.SETTINGS))
}
private fun onArchiveAccountClick() {
confirmArchiveDialog()
}
private fun confirmArchiveDialog() {
val secondAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
)
val firstAction = EventMessageAction(
title = resourceReference(R.string.account_details_archive_action),
warning = true,
onClick = ::archiveCryptoPortfolio,
)
messageSender.send(
DialogMessage(
title = resourceReference(R.string.account_details_archive),
message = resourceReference(R.string.account_details_archive_description),
firstActionBuilder = { firstAction },
secondActionBuilder = { secondAction },
),
)
}
private fun archiveCryptoPortfolio() = modelScope.launch {
archiveCryptoPortfolioUseCase(params.account.accountId)
}
private fun getInitialState(): AccountDetailsUM {
return AccountDetailsUM(
accountName = params.account.accountName.value,
accountIcon = params.account.portfolioIcon.toUM(),
onCloseClick = { router.pop() },
onAccountEditClick = ::onEditAccountClick,
onManageTokensClick = ::onManageTokensClick,
onArchiveAccountClick = ::onArchiveAccountClick,
)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.account.details
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.details.ui.AccountDetailsContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAccountDetailsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: AccountDetailsComponent.Params,
) : AppComponentContext by appComponentContext, AccountDetailsComponent {
private val model: AccountDetailsModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
AccountDetailsContent(
modifier = modifier,
state = state,
)
BackHandler(onBack = state.onCloseClick)
}
@AssistedFactory
interface Factory : AccountDetailsComponent.Factory {
override fun create(
context: AppComponentContext,
params: AccountDetailsComponent.Params,
): DefaultAccountDetailsComponent
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.account.details.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.details.AccountDetailsModel
import com.tangem.features.account.details.DefaultAccountDetailsComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
internal interface AccountDetailsModule {
@Binds
fun bindAccountDetailsComponentFactory(
impl: DefaultAccountDetailsComponent.Factory,
): AccountDetailsComponent.Factory
@Binds
@IntoMap
@ClassKey(AccountDetailsModel::class)
fun bindAccountDetailsModel(model: AccountDetailsModel): Model
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.account.details.entity
import com.tangem.common.ui.account.CryptoPortfolioIconUM
internal data class AccountDetailsUM(
val accountName: String,
val accountIcon: CryptoPortfolioIconUM,
val onCloseClick: () -> Unit,
val onAccountEditClick: () -> Unit,
val onManageTokensClick: () -> Unit,
val onArchiveAccountClick: () -> Unit,
)

View file

@ -0,0 +1,187 @@
package com.tangem.features.account.details.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
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 androidx.compose.ui.unit.dp
import com.tangem.common.ui.R
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.AccountRow
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
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.stringReference
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.account.details.entity.AccountDetailsUM
@Composable
internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarWithBackButton(
onBackClick = state.onCloseClick,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = TangemTheme.dimens.spacing16)
.weight(1f),
) {
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
text = stringResourceSafe(R.string.account_details_title),
style = TangemTheme.typography.h1,
color = TangemTheme.colors.text.primary1,
)
SpacerH16()
AccountRow(state)
SpacerH16()
ManageTokensRow(state)
SpacerH16()
ArchiveAccountRow(state)
SpacerH(8.dp)
Text(
modifier = Modifier.padding(horizontal = 12.dp),
text = stringResourceSafe(R.string.account_details_archive_description),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable
private fun ArchiveAccountRow(state: AccountDetailsUM) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius12))
.background(TangemTheme.colors.background.primary)
.clickable(onClick = state.onArchiveAccountClick)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Icon(
tint = TangemTheme.colors.icon.warning,
imageVector = ImageVector.vectorResource(id = R.drawable.ic_archive_24),
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.account_details_archive),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.subtitle1,
)
}
}
@Composable
private fun ManageTokensRow(state: AccountDetailsUM) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius12))
.background(TangemTheme.colors.background.primary)
.clickable(onClick = state.onManageTokensClick)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_group_24),
tint = TangemTheme.colors.icon.secondary,
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.main_manage_tokens),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun AccountRow(state: AccountDetailsUM) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius12))
.background(TangemTheme.colors.background.primary)
.clickable(onClick = state.onAccountEditClick)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
AccountRow(
title = stringReference(state.accountName),
subtitle = resourceReference(R.string.account_form_name),
icon = state.accountIcon,
modifier = Modifier.weight(1f),
isReverse = true,
)
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.common_edit),
onClick = state.onAccountEditClick,
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun WcConnectionsContentPreview(@PreviewParameter(PreviewStateProvider::class) params: AccountDetailsUM) {
TangemThemePreview {
AccountDetailsContent(state = params)
}
}
private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountDetailsUM>(
buildList {
val accountName = "Main"
var portfolioIcon = AccountIconPreviewData.randomAccountIcon()
val first = AccountDetailsUM(
onCloseClick = {},
onAccountEditClick = {},
onManageTokensClick = {},
onArchiveAccountClick = {},
accountName = accountName,
accountIcon = portfolioIcon,
)
add(first)
portfolioIcon = AccountIconPreviewData.randomAccountIcon(letter = true)
add(first.copy(accountIcon = portfolioIcon))
},
)

View file

@ -13,6 +13,7 @@ android {
dependencies {
api(projects.features.biometry.api)
implementation(projects.features.hotWallet.api)
/** Core modules */
implementation(projects.core.ui)

View file

@ -13,13 +13,15 @@ import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.SetSaveWalletScreenShownUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.biometry.impl.ui.state.AskBiometryUM
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
@ -40,11 +42,13 @@ internal class AskBiometryModel @Inject constructor(
private val setSaveWalletScreenShownUseCase: SetSaveWalletScreenShownUseCase,
private val settingsRepository: SettingsRepository,
private val tangemSdkManager: TangemSdkManager,
private val userWalletsListManager: UserWalletsListManager,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
private val walletsRepository: WalletsRepository,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsManager: SettingsManager,
private val uiMessageSender: UiMessageSender,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : Model() {
private val params = paramsContainer.require<AskBiometryComponent.Params>()
@ -87,7 +91,7 @@ internal class AskBiometryModel @Inject constructor(
* because it will be automatically saved on UserWalletsListManager switch
*/
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync ?: run {
val selectedUserWallet = getSelectedWalletUseCase.sync().getOrNull() ?: run {
Timber.e("Unable to save user wallet")
uiMessageSender.send(
SnackbarMessage(stringReference("No selected user wallet")),
@ -107,12 +111,20 @@ internal class AskBiometryModel @Inject constructor(
private suspend fun handleSuccessAllowing(userWallet: UserWallet) {
walletsRepository.saveShouldSaveUserWallets(item = true)
settingsRepository.setShouldSaveAccessCodes(value = true)
if (userWallet is UserWallet.Cold) {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
walletsRepository.setUseBiometricAuthentication(value = true)
setBiometryLockForAllWallets()
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = userWallet.hasAccessCode,
isBiometricsRequestPolicy = walletsRepository.requireAccessCode().not(),
)
} else {
settingsRepository.setShouldSaveAccessCodes(value = true)
if (userWallet is UserWallet.Cold) {
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = userWallet.hasAccessCode,
)
}
}
if (_uiState.value.bottomSheetVariant) {
@ -123,6 +135,18 @@ internal class AskBiometryModel @Inject constructor(
params.modelCallbacks.onAllowed()
}
private fun setBiometryLockForAllWallets() {
modelScope.launch {
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
userWalletsListRepository.setLock(
userWalletId = userWallet.walletId,
lockMethod = UserWalletsListRepository.LockMethod.Biometric,
changeUnsecured = false,
)
}
}
}
private fun showEnrollBiometricsDialog() {
uiMessageSender.send(
DialogMessage(

View file

@ -18,6 +18,12 @@ dependencies {
/** Hot Wallet Feature */
implementation(projects.features.hotWallet.api)
/** Project - Domain */
implementation(projects.domain.card)
implementation(projects.domain.settings)
implementation(projects.domain.wallets)
implementation(projects.domain.models)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.analytics)
@ -45,7 +51,6 @@ dependencies {
implementation(deps.lifecycle.runtime.ktx)
/** Compose libraries */
implementation(deps.compose.material)
implementation(deps.compose.material3)
implementation(deps.compose.animation)
implementation(deps.compose.foundation)

View file

@ -1,19 +1,63 @@
package com.tangem.features.createwalletselection
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic.SignedIn
import com.tangem.core.analytics.models.Basic.SignedIn.SignInType
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.analytics.ParamCardCurrencyConverter
import com.tangem.domain.card.analytics.Shop
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.core.wallets.error.SaveWalletError
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val HIDE_PROGRESS_DELAY = 400L
@Suppress("LongParameterList")
@ModelScoped
internal class CreateWalletSelectionModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val scanCardProcessor: ScanCardProcessor,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsRepository: SettingsRepository,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val userWalletsListManager: UserWalletsListManager,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
internal val uiState: StateFlow<CreateWalletSelectionUM>
@ -31,10 +75,110 @@ internal class CreateWalletSelectionModel @Inject constructor(
}
private fun onHardwareWalletClick() {
// TODO open card order web page
analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards)
analyticsEventHandler.send(Shop.ScreenOpened)
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onScanClick() {
// TODO open card scanning
analyticsEventHandler.send(IntroductionProcess.ButtonScanCard)
scanCard()
}
private fun scanCard() {
modelScope.launch {
setLoading(true)
val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes()
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = shouldSaveAccessCodes,
)
val analyticsSource = AnalyticsParam.ScreensSources.Intro
scanCardProcessor.scan(
analyticsSource = analyticsSource,
onProgressStateChange = { showProgress ->
if (!showProgress) {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
} else {
setLoading(true)
}
},
onFailure = { error ->
handleScanError(error)
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
},
onSuccess = { scanResponse ->
proceedWithScanResponse(scanResponse)
},
)
}
}
private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) {
val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build()
if (userWallet == null) {
Timber.e("User wallet not created")
setLoading(false)
return
}
saveWalletUseCase(userWallet).fold(
ifLeft = {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
when (it) {
is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet")
is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet)
}
},
ifRight = {
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse)
appRouter.replaceAll(AppRoute.Wallet)
},
)
}
private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
SignedIn(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListManager.walletsCount.toString(),
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
}
private fun setLoading(isLoading: Boolean) {
uiState.update { it.copy(isScanInProgress = isLoading) }
}
fun handleScanError(error: TangemError) {
when (error) {
is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable()
is TangemSdkError -> Timber.e(error, "Scan error occurred")
else -> Timber.e(error, "Error happened")
}
}
private fun handleNfcFeatureUnavailable() {
uiMessageSender.send(
message = DialogMessage(
message = resourceReference(R.string.nfc_error_unavailable),
title = resourceReference(id = R.string.common_error),
),
)
}
}

View file

@ -5,10 +5,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -18,6 +20,7 @@ import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -179,6 +182,9 @@ private fun AlreadyHaveTangemWalletBlock(
isScanInProgress: Boolean,
modifier: Modifier = Modifier,
) {
var buttonWidth by remember { mutableStateOf(0) }
val density = LocalDensity.current
Row(
modifier = modifier
.fillMaxWidth()
@ -201,9 +207,17 @@ private fun AlreadyHaveTangemWalletBlock(
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
TangemButton(
modifier = Modifier
.wrapContentWidth(),
.conditional(buttonWidth > 0) {
width(with(density) { buttonWidth.toDp() })
}
.onGloballyPositioned { coordinates ->
if (buttonWidth == 0) {
buttonWidth = coordinates.size.width
}
},
text = stringResourceSafe(R.string.wallet_create_scan_title),
onClick = onScanClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24),

View file

@ -18,6 +18,8 @@ dependencies {
implementation(projects.features.wallet.api)
implementation(projects.features.disclaimer.api)
implementation(projects.features.tester.api)
implementation(projects.features.createWalletSelection.api)
implementation(projects.features.hotWallet.api)
/* Project - Core */
implementation(projects.core.decompose)

View file

@ -2,6 +2,7 @@ package com.tangem.features.details.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@ -11,4 +12,5 @@ internal data class UserWalletListUM(
val isWalletSavingInProgress: Boolean,
val addNewWalletText: TextReference,
val onAddNewWalletClick: () -> Unit,
val addWalletBottomSheet: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty,
)

View file

@ -118,10 +118,14 @@ internal class DetailsModel @Inject constructor(
modelScope.launch {
val userWallets = getWalletsUseCase.invokeSync()
val scanResponse =
getSelectedWalletSyncUseCase().getOrNull()?.requireColdWallet()?.scanResponse // TODO [REDACTED_TASK_KEY]
?: error("Selected wallet is null")
val selectedUserWallet = getSelectedWalletSyncUseCase().getOrNull()
?: error("Selected wallet is null")
if (selectedUserWallet is UserWallet.Hot) {
return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Send feedback
}
val scanResponse = selectedUserWallet.requireColdWallet().scanResponse
val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch
val feedbackType = when {
@ -140,11 +144,13 @@ internal class DetailsModel @Inject constructor(
}
private fun openUseDesk() {
val scanResponse =
getSelectedWalletSyncUseCase().getOrNull()?.requireColdWallet()?.scanResponse // TODO [REDACTED_TASK_KEY]
?: error("Selected wallet is null")
val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null")
val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return
if (userWallet is UserWallet.Hot) {
return // TODO [REDACTED_TASK_KEY] [Hot Wallet] UseDesk
}
val cardInfo = getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return
router.push(AppRoute.Usedesk(cardInfo))
}

View file

@ -6,12 +6,19 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.R.*
import com.tangem.core.ui.components.bottomsheets.BottomSheetOption
import com.tangem.core.ui.components.bottomsheets.OptionsBottomSheetContent
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.features.details.entity.UserWalletListUM
import com.tangem.features.details.impl.R
import com.tangem.features.details.utils.UserWalletSaver
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.wallet.utils.UserWalletsFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
@ -20,22 +27,28 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class UserWalletListModel @Inject constructor(
userWalletsFetcherFactory: UserWalletsFetcher.Factory,
shouldSaveUserWalletsUseCase: ShouldSaveUserWalletsUseCase,
private val router: Router,
private val messageSender: UiMessageSender,
private val userWalletSaver: UserWalletSaver,
override val dispatchers: CoroutineDispatcherProvider,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val userWalletSaver: UserWalletSaver,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : Model() {
private val isWalletSavingInProgress: MutableStateFlow<Boolean> = MutableStateFlow(value = false)
private val userWalletsFetcher = userWalletsFetcherFactory.create(
messageSender = messageSender,
onlyMultiCurrency = false,
authMode = false,
onWalletClick = { userWalletId -> router.push(AppRoute.WalletSettings(userWalletId)) },
)
@ -44,7 +57,8 @@ internal class UserWalletListModel @Inject constructor(
userWallets = persistentListOf(),
isWalletSavingInProgress = false,
addNewWalletText = TextReference.EMPTY,
onAddNewWalletClick = ::addUserWallet,
onAddNewWalletClick = ::onAddNewWalletClick,
addWalletBottomSheet = TangemBottomSheetConfig.Empty,
),
)
@ -53,8 +67,9 @@ internal class UserWalletListModel @Inject constructor(
flow = userWalletsFetcher.userWallets,
flow2 = shouldSaveUserWalletsUseCase(),
flow3 = isWalletSavingInProgress,
transform = ::updateState,
).launchIn(modelScope)
) { userWallets, shouldSaveUserWallets, isWalletSavingInProgress ->
updateState(userWallets, shouldSaveUserWallets, isWalletSavingInProgress)
}.launchIn(modelScope)
}
private fun updateState(
@ -65,7 +80,7 @@ internal class UserWalletListModel @Inject constructor(
value.copy(
userWallets = userWallets,
isWalletSavingInProgress = isWalletSavingInProgress,
addNewWalletText = if (shouldSaveUserWallets) {
addNewWalletText = if (shouldSaveUserWallets || hotWalletFeatureToggles.isHotWalletEnabled) {
resourceReference(R.string.user_wallet_list_add_button)
} else {
resourceReference(R.string.scan_card_settings_button)
@ -73,7 +88,64 @@ internal class UserWalletListModel @Inject constructor(
)
}
private fun addUserWallet() = withProgress(isWalletSavingInProgress) {
userWalletSaver.scanAndSaveUserWallet(modelScope)
private fun onAddNewWalletClick() {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
state.update { currentState ->
currentState.copy(
addWalletBottomSheet = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismissAddWalletBottomSheet,
content = createAddWalletBottomSheetContent(),
),
)
}
} else {
withProgress(isWalletSavingInProgress) {
userWalletSaver.scanAndSaveUserWallet(modelScope)
}
}
}
private fun dismissAddWalletBottomSheet() {
state.update { currentState ->
currentState.copy(
addWalletBottomSheet = currentState.addWalletBottomSheet.copy(isShown = false),
)
}
}
private fun createAddWalletBottomSheetContent(): OptionsBottomSheetContent {
return OptionsBottomSheetContent(
options = persistentListOf(
BottomSheetOption(
key = ADD_WALLET_KEY_CREATE,
label = resourceReference(string.home_button_create_new_wallet),
),
BottomSheetOption(
key = ADD_WALLET_KEY_ADD,
label = resourceReference(string.home_button_add_existing_wallet),
),
BottomSheetOption(
key = ADD_WALLET_KEY_BUY,
label = resourceReference(string.details_buy_wallet),
),
),
onOptionClick = { optionKey ->
dismissAddWalletBottomSheet()
when (optionKey) {
ADD_WALLET_KEY_CREATE -> router.push(AppRoute.CreateWalletSelection)
ADD_WALLET_KEY_ADD -> router.push(AppRoute.AddExistingWallet)
ADD_WALLET_KEY_BUY -> modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
},
)
}
companion object {
private const val ADD_WALLET_KEY_CREATE = "create"
private const val ADD_WALLET_KEY_ADD = "add"
private const val ADD_WALLET_KEY_BUY = "buy"
}
}

View file

@ -15,9 +15,13 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.core.ui.R.*
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.bottomsheets.OptionsBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.details.component.UserWalletListComponent
@ -44,6 +48,8 @@ internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = M
onClick = state.onAddNewWalletClick,
)
}
AddWalletBottomSheet(state.addWalletBottomSheet)
}
@Composable
@ -94,6 +100,15 @@ private fun AddWalletButton(
}
}
@Composable
private fun AddWalletBottomSheet(config: TangemBottomSheetConfig) {
OptionsBottomSheet(
config = config,
title = resourceReference(string.auth_info_add_wallet_title),
containerColor = TangemTheme.colors.background.tertiary,
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)

View file

@ -21,7 +21,7 @@ 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.ColdUserWalletBuilder
import com.tangem.domain.wallets.models.SaveWalletError
import com.tangem.domain.core.wallets.error.SaveWalletError
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase

View file

@ -23,7 +23,6 @@ dependencies {
implementation(deps.compose.accompanist.permission)
implementation(deps.compose.accompanist.webView)
implementation(deps.compose.material3)
implementation(deps.compose.material)
/** Core modules */
implementation(projects.core.ui)

View file

@ -57,7 +57,7 @@ internal class DisclaimerModel @Inject constructor(
} else {
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
neverRequestPermissionUseCase(PUSH_PERMISSION)
router.replaceAll(AppRoute.Home)
router.replaceAll(AppRoute.Home())
}
}
}

View file

@ -14,6 +14,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import com.google.accompanist.web.WebView
import com.google.accompanist.web.WebViewNavigator
@ -62,9 +64,8 @@ internal fun DisclaimerScreen(state: DisclaimerUM) {
) {
TangemTopAppBar(
title = resourceReference(R.string.disclaimer_title),
startButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_back_24,
onIconClicked = state.popBack,
startButton = TopAppBarButtonUM.Back(
onBackClicked = state.popBack,
).takeIf { state.isTosAccepted },
titleAlignment = Alignment.CenterHorizontally,
textColor = textColor,
@ -108,7 +109,11 @@ private fun DisclaimerContent(url: String) {
state = webViewState,
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary),
.background(TangemTheme.colors.background.primary)
.testTag(DisclaimerScreenTestTags.WEB_VIEW)
.semantics {
contentDescription = "WebView URL: ${webViewState.content.getCurrentUrl() ?: url}"
},
captureBackPresses = false,
navigator = webViewNavigator,
onCreated = WebView::applySafeSettings,

1
features/home/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,17 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.home.api"
}
dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
/** Common */
implementation(projects.common.routing)
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.home.api
import com.tangem.common.routing.entity.InitScreenLaunchMode
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface HomeComponent : ComposableContentComponent {
data class Params(
val launchMode: InitScreenLaunchMode = InitScreenLaunchMode.Standard,
)
interface Factory : ComponentFactory<Params, HomeComponent> {
override fun create(context: AppComponentContext, params: Params): HomeComponent
}
}

1
features/home/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,66 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.home.impl"
}
dependencies {
/** Api */
implementation(projects.features.home.api)
implementation(projects.features.hotWallet.api)
/** Core modules */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.res)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.navigation)
/** Common */
implementation(projects.common.routing)
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.core)
implementation(projects.domain.card)
implementation(projects.domain.settings)
implementation(projects.domain.tokens)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.legacy)
implementation(projects.domain.feedback)
implementation(projects.domain.feedback.models)
/** AndroidX libraries */
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.runtime.ktx)
/** Compose libraries */
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.animation)
implementation(deps.compose.coil)
implementation(deps.decompose.ext.compose)
/** Tangem libraries */
implementation(tangemDeps.card.android)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
/** Other libraries */
implementation(deps.kotlin.immutable.collections)
implementation(deps.timber)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.home.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.home.api.HomeComponent
import com.tangem.features.home.impl.model.HomeModel
import com.tangem.features.home.impl.ui.Home
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultHomeComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: HomeComponent.Params,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : HomeComponent, AppComponentContext by appComponentContext {
private val model: HomeModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
Home(
state = state,
modifier = modifier,
isV2StoriesEnabled = hotWalletFeatureToggles.isHotWalletEnabled,
)
}
@AssistedFactory
interface Factory : HomeComponent.Factory {
override fun create(context: AppComponentContext, params: HomeComponent.Params): DefaultHomeComponent
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.home.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.home.impl.DefaultHomeComponent
import com.tangem.features.home.impl.model.HomeModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindComponent(factory: DefaultHomeComponent.Factory): HomeComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(HomeModel::class)
fun provideModel(model: HomeModel): Model
}

View file

@ -0,0 +1,251 @@
package com.tangem.features.home.impl.model
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRoute.ManageTokens.Source
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.entity.InitScreenLaunchMode
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic.SignedIn
import com.tangem.core.analytics.models.Basic.SignedIn.SignInType
import com.tangem.core.decompose.di.GlobalUiMessageSender
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.core.navigation.url.UrlOpener
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.analytics.ParamCardCurrencyConverter
import com.tangem.domain.card.analytics.Shop
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.core.wallets.error.SaveWalletError
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.home.impl.ui.state.HomeUM
import com.tangem.features.home.impl.ui.state.Stories
import com.tangem.features.home.impl.ui.state.getRestrictedStories
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
private const val HIDE_PROGRESS_DELAY = 400L
@Suppress("LongParameterList")
@ModelScoped
internal class HomeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val scanCardProcessor: ScanCardProcessor,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsRepository: SettingsRepository,
private val analyticsEventHandler: AnalyticsEventHandler,
private val router: Router,
private val appRouter: AppRouter,
private val getUserCountryUseCase: GetUserCountryUseCase,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val userWalletsListManager: UserWalletsListManager,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
val params = paramsContainer.require<HomeComponent.Params>()
private val _uiState = MutableStateFlow(
HomeUM(
scanInProgress = false,
stories = getRestrictedStories().toImmutableList(),
onScanClick = ::onScanClick,
onShopClick = ::onShopClick,
onSearchTokensClick = ::onSearchTokensClick,
onCreateNewWalletClick = ::onCreateNewWalletClick,
onAddExistingWalletClick = ::onAddExistingWalletClick,
),
)
val uiState = _uiState.asStateFlow()
init {
analyticsEventHandler.send(IntroductionProcess.ScreenOpened)
observeUserCountryChanges()
when (params.launchMode) {
InitScreenLaunchMode.Standard -> Unit
InitScreenLaunchMode.WithCardScan -> scanCard()
}
}
private fun observeUserCountryChanges() {
getUserCountryUseCase.invoke()
.distinctUntilChanged()
.filterNotNull()
.onEach { result ->
val userCountry = result.getOrNull() ?: UserCountry.Other(Locale.getDefault().country)
updateStoriesForCountry(userCountry)
}
.flowOn(dispatchers.io)
.launchIn(modelScope)
}
private fun updateStoriesForCountry(userCountry: UserCountry) {
val stories = if (userCountry.needApplyFCARestrictions()) {
getRestrictedStories()
} else {
Stories.entries
}
_uiState.update {
it.copy(stories = stories.toImmutableList())
}
}
private fun onScanClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonScanCard)
scanCard()
}
private fun onShopClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards)
analyticsEventHandler.send(Shop.ScreenOpened)
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onSearchTokensClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonTokensList)
router.push(AppRoute.ManageTokens(Source.STORIES))
}
private fun onCreateNewWalletClick() {
router.push(AppRoute.CreateWalletSelection)
}
private fun onAddExistingWalletClick() {
router.push(AppRoute.AddExistingWallet)
}
private fun scanCard() {
modelScope.launch {
setLoading(true)
val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes()
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = shouldSaveAccessCodes,
)
val analyticsSource = AnalyticsParam.ScreensSources.Intro
scanCardProcessor.scan(
analyticsSource = analyticsSource,
onProgressStateChange = { showProgress ->
if (!showProgress) {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
} else {
setLoading(true)
}
},
onFailure = { error ->
handleScanError(error)
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
},
onSuccess = { scanResponse ->
proceedWithScanResponse(scanResponse)
},
)
}
}
private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) {
val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build()
if (userWallet == null) {
Timber.e("User wallet not created")
setLoading(false)
return
}
saveWalletUseCase(userWallet).fold(
ifLeft = {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
when (it) {
is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet")
is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet)
}
},
ifRight = {
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse)
appRouter.replaceAll(AppRoute.Wallet)
},
)
}
private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
SignedIn(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListManager.walletsCount.toString(),
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
}
private fun setLoading(isLoading: Boolean) {
_uiState.update { it.copy(scanInProgress = isLoading) }
}
fun handleScanError(error: TangemError) {
when (error) {
is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable()
is TangemSdkError -> Timber.e(error, "Scan error occurred")
else -> Timber.e(error, "Error happened")
}
}
private fun handleNfcFeatureUnavailable() {
uiMessageSender.send(
message = DialogMessage(
message = resourceReference(R.string.nfc_error_unavailable),
title = resourceReference(id = R.string.common_error),
),
)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.home.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.SystemBarsIconsDisposable
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.utils.ChangeRootBackgroundColorEffect
import com.tangem.features.home.impl.ui.compose.StoriesScreen
import com.tangem.features.home.impl.ui.compose.StoriesScreenV2
import com.tangem.features.home.impl.ui.state.HomeUM
@Composable
internal fun Home(state: HomeUM, isV2StoriesEnabled: Boolean, modifier: Modifier = Modifier) {
SystemBarsIconsDisposable(darkIcons = false)
if (isV2StoriesEnabled) {
StoriesScreenV2(
modifier = modifier,
state = state,
onCreateNewWalletButtonClick = state.onCreateNewWalletClick,
onAddExistingWalletButtonClick = state.onAddExistingWalletClick,
onScanButtonClick = state.onScanClick,
)
} else {
StoriesScreen(
modifier = modifier,
state = state,
onScanButtonClick = state.onScanClick,
onShopButtonClick = state.onShopClick,
onSearchTokensClick = state.onSearchTokensClick,
)
}
ChangeRootBackgroundColorEffect(TangemColorPalette.Black)
}

View file

@ -0,0 +1,156 @@
package com.tangem.features.home.impl.ui.compose
import androidx.compose.animation.core.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.utils.AnimatedValue
import com.tangem.core.ui.utils.toAnimatable
private const val SCALE_SWITCH_BARRIER = 1.15f
@Suppress("LongParameterList")
@Composable
fun HorizontalSlidingImage(
painter: Painter,
paused: Boolean,
duration: Int,
itemSize: DpSize,
startOffset: Float,
targetOffset: Float,
contentDescription: String,
) {
val translateX = AnimatedValue(startOffset * -1f, (startOffset + targetOffset) * -1f)
Image(
modifier = Modifier
.requiredWidth(itemSize.width)
.requiredHeight(itemSize.height)
.graphicsLayer(
translationX = translateX.toAnimatable(isPaused = paused, duration = duration).value,
),
alignment = Alignment.TopStart,
contentScale = ContentScale.FillBounds,
painter = painter,
contentDescription = contentDescription,
)
}
@Composable
fun StoriesTextAnimation(
slideInDuration: Int = 500,
slideInDelay: Int = 200,
slideDistance: Dp = 60.dp,
label: String = "",
content: @Composable (Modifier) -> Unit,
) {
val isLaunched = remember { mutableStateOf(false) }
val transition = updateTransition(targetState = isLaunched.value, label = label)
val offsetY = transition.animateDp(
transitionSpec = {
tween(
durationMillis = slideInDuration,
delayMillis = slideInDelay,
easing = FastOutSlowInEasing,
)
},
label = "Slide in",
) { value -> if (value) 0.dp else slideDistance }
val alpha = transition.animateFloat(
transitionSpec = {
tween(
durationMillis = slideInDuration * 2,
delayMillis = slideInDelay,
easing = FastOutSlowInEasing,
)
},
label = "Visibility",
) { value -> if (value) 1f else 0f }
content(
Modifier
.absoluteOffset(y = offsetY.value)
.alpha(alpha.value),
)
LaunchedEffect(Unit) { isLaunched.value = true }
}
@Composable
fun StoriesBottomImageAnimation(
initialScale: Float = 2.5f,
secondStageScale: Float = SCALE_SWITCH_BARRIER,
targetScale: Float = 1.0f,
firstStepDuration: Int,
totalDuration: Int,
content: @Composable (Modifier) -> Unit,
) {
val secondStepDuration = totalDuration - firstStepDuration
val isFirstStepLaunched = remember { mutableStateOf(false) }
val isSecondStepLaunched = remember { mutableStateOf(false) }
val firstTransition = updateTransition(
targetState = isFirstStepLaunched.value,
label = "Image appearing",
)
val firstScaleStep = firstTransition.animateFloat(
transitionSpec = {
tween(
durationMillis = firstStepDuration,
easing = FastOutLinearInEasing,
)
},
label = "Appearing scale",
) { value -> if (value) secondStageScale else initialScale }
val secondTransition = updateTransition(
targetState = isSecondStepLaunched.value,
label = "Image slow outgoing",
)
val secondScaleStep = secondTransition.animateFloat(
transitionSpec = {
tween(
durationMillis = secondStepDuration,
easing = LinearEasing,
)
},
label = "Outgoing scale",
) { value -> if (value) targetScale else secondStageScale }
val fadeIn = firstTransition.animateFloat(
transitionSpec = { tween(durationMillis = 400) },
label = "Fade in on start",
) { value -> if (value) 1f else 0f }
if (firstScaleStep.value == secondStageScale) {
isSecondStepLaunched.value = true
}
val modifier = if (!isSecondStepLaunched.value) {
Modifier.scale(firstScaleStep.value)
} else {
Modifier.scale(secondScaleStep.value)
}.alpha(fadeIn.value)
content(modifier)
LaunchedEffect(Unit) { isFirstStepLaunched.value = true }
}

View file

@ -0,0 +1,265 @@
@file:Suppress("MagicNumber")
package com.tangem.features.home.impl.ui.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.StoriesScreenTestTags
import com.tangem.features.home.impl.ui.compose.content.*
import com.tangem.features.home.impl.ui.compose.views.HomeButtons
import com.tangem.features.home.impl.ui.compose.views.SearchCurrenciesButton
import com.tangem.features.home.impl.ui.compose.views.StoriesProgressBar
import com.tangem.features.home.impl.ui.state.Stories
import com.tangem.core.ui.R
import com.tangem.features.home.impl.ui.state.HomeUM
import kotlin.math.max
@Composable
internal fun StoriesScreen(
state: HomeUM,
onScanButtonClick: () -> Unit,
onShopButtonClick: () -> Unit,
onSearchTokensClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var currentStory by remember { mutableStateOf(state.firstStory) }
val currentStoryIndex by rememberUpdatedState(newValue = state.stepOf(currentStory))
val goToPreviousStory = remember(currentStory, currentStoryIndex) {
{ currentStory = state.stories[max(0, currentStoryIndex - 1)] }
}
val goToNextStory = remember(currentStory, currentStoryIndex) {
{
currentStory = if (currentStoryIndex < state.stories.lastIndex) {
state.stories[currentStoryIndex + 1]
} else {
state.firstStory
}
}
}
// todo refactor [REDACTED_TASK_KEY]
StoriesScreenContent(
modifier = modifier
.fillMaxSize()
.testTag(StoriesScreenTestTags.SCREEN_CONTAINER),
config = StoriesScreenContentConfig(
storiesSize = state.stories.lastIndex,
currentStoryIndex = currentStoryIndex,
currentStory = currentStory,
isScanInProgress = state.scanInProgress,
onGoToPreviousStory = goToPreviousStory,
onGoToNextStory = goToNextStory,
onSearchTokensClick = onSearchTokensClick,
onScanButtonClick = onScanButtonClick,
onShopButtonClick = onShopButtonClick,
),
)
}
@Deprecated("Use StoriesContainer from core/ui")
@Suppress("LongMethod")
@Composable
private fun StoriesScreenContent(config: StoriesScreenContentConfig, modifier: Modifier = Modifier) {
var isPressed by remember { mutableStateOf(value = false) }
val isPaused = isPressed || config.isScanInProgress
val currentStoryDuration = config.currentStory.duration
Box(
modifier = modifier.background(Color(0xFF010101)),
) {
Row(
modifier = Modifier.fillMaxSize(),
) {
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.pointerInput(Unit) {
detectTapGestures(
onPress = {
val pressStartTime = System.currentTimeMillis()
isPressed = true
this.tryAwaitRelease()
val pressEndTime = System.currentTimeMillis()
val totalPressTime = pressEndTime - pressStartTime
if (totalPressTime < 200) config.onGoToPreviousStory()
isPressed = false
},
)
},
)
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.pointerInput(Unit) {
detectTapGestures(
onPress = {
val pressStartTime = System.currentTimeMillis()
isPressed = true
this.tryAwaitRelease()
val pressEndTime = System.currentTimeMillis()
val totalPressTime = pressEndTime - pressStartTime
if (totalPressTime < 200) config.onGoToNextStory()
isPressed = false
},
)
},
)
}
Column(
modifier = Modifier
.statusBarsPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
StoriesProgressBar(
steps = config.storiesSize,
currentStep = config.currentStoryIndex,
stepDuration = currentStoryDuration,
paused = isPaused,
onStepFinish = config.onGoToNextStory,
)
Image(
painter = painterResource(id = R.drawable.ic_tangem_logo),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing16,
)
.height(TangemTheme.dimens.size18)
.align(Alignment.Start),
)
when (config.currentStory) {
Stories.TangemIntro -> FirstStoriesContent(
isPaused = isPaused,
duration = currentStoryDuration,
)
Stories.RevolutionaryWallet -> StoriesRevolutionaryWallet()
Stories.UltraSecureBackup -> StoriesUltraSecureBackup(
isPaused = isPaused,
stepDuration = currentStoryDuration,
)
Stories.Currencies -> StoriesCurrencies(isPaused, currentStoryDuration)
Stories.Web3 -> StoriesWeb3(isPaused, currentStoryDuration)
Stories.WalletForEveryone -> StoriesWalletForEveryone(currentStoryDuration)
}
}
Column(
modifier = Modifier
.navigationBarsPadding()
.padding(bottom = TangemTheme.dimens.spacing16)
.padding(horizontal = TangemTheme.dimens.spacing16)
.align(Alignment.BottomCenter)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
AnimatedVisibility(
visible = config.currentStory == Stories.Currencies,
enter = fadeIn(),
exit = fadeOut(),
) {
SearchCurrenciesButton(
modifier = Modifier.fillMaxWidth(),
onClick = config.onSearchTokensClick,
)
}
HomeButtons(
modifier = Modifier.fillMaxWidth(),
btnScanStateInProgress = config.isScanInProgress,
onScanButtonClick = config.onScanButtonClick,
onShopButtonClick = config.onShopButtonClick,
)
}
}
}
private data class StoriesScreenContentConfig(
val storiesSize: Int,
val currentStoryIndex: Int,
val currentStory: Stories,
val isScanInProgress: Boolean,
val onGoToPreviousStory: () -> Unit = {},
val onGoToNextStory: () -> Unit = {},
val onSearchTokensClick: () -> Unit = {},
val onScanButtonClick: () -> Unit = {},
val onShopButtonClick: () -> Unit = {},
)
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun StoriesScreenContentPreview(
@PreviewParameter(StoriesScreenContentConfigProvider::class) config: StoriesScreenContentConfig,
) {
TangemThemePreview {
StoriesScreenContent(config = config)
}
}
private class StoriesScreenContentConfigProvider : CollectionPreviewParameterProvider<StoriesScreenContentConfig>(
collection = listOf(
StoriesScreenContentConfig(
storiesSize = 6,
currentStoryIndex = 0,
currentStory = Stories.TangemIntro,
isScanInProgress = true,
),
StoriesScreenContentConfig(
storiesSize = 6,
currentStoryIndex = 1,
currentStory = Stories.RevolutionaryWallet,
isScanInProgress = false,
),
StoriesScreenContentConfig(
storiesSize = 6,
currentStoryIndex = 2,
currentStory = Stories.UltraSecureBackup,
isScanInProgress = false,
),
StoriesScreenContentConfig(
storiesSize = 6,
currentStoryIndex = 3,
currentStory = Stories.Currencies,
isScanInProgress = false,
),
StoriesScreenContentConfig(
storiesSize = 6,
currentStoryIndex = 4,
currentStory = Stories.Web3,
isScanInProgress = false,
),
StoriesScreenContentConfig(
storiesSize = 6,
currentStoryIndex = 5,
currentStory = Stories.WalletForEveryone,
isScanInProgress = false,
),
),
)
// endregion Preview

View file

@ -0,0 +1,251 @@
@file:Suppress("MagicNumber")
package com.tangem.features.home.impl.ui.compose
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.StoriesScreenTestTags
import com.tangem.features.home.impl.ui.compose.content.*
import com.tangem.features.home.impl.ui.compose.views.HomeButtonsV2
import com.tangem.features.home.impl.ui.compose.views.StoriesProgressBar
import com.tangem.features.home.impl.ui.state.Stories
import kotlin.math.max
import com.tangem.core.ui.R
import com.tangem.features.home.impl.ui.state.HomeUM
@Composable
internal fun StoriesScreenV2(
state: HomeUM,
onCreateNewWalletButtonClick: () -> Unit,
onAddExistingWalletButtonClick: () -> Unit,
onScanButtonClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var currentStory by remember { mutableStateOf(state.firstStory) }
val currentStoryIndex by rememberUpdatedState(newValue = state.stepOf(currentStory))
val goToPreviousStory = remember(currentStory, currentStoryIndex) {
{ currentStory = state.stories[max(0, currentStoryIndex - 1)] }
}
val goToNextStory = remember(currentStory, currentStoryIndex) {
{
currentStory = if (currentStoryIndex < state.stories.lastIndex) {
state.stories[currentStoryIndex + 1]
} else {
state.firstStory
}
}
}
// todo refactor [REDACTED_TASK_KEY]
StoriesScreenContentV2(
modifier = modifier
.fillMaxSize()
.testTag(StoriesScreenTestTags.SCREEN_CONTAINER),
config = StoriesScreenContentV2Config(
storiesSize = state.stories.lastIndex,
currentStoryIndex = currentStoryIndex,
currentStory = currentStory,
isScanInProgress = state.scanInProgress,
onGoToPreviousStory = goToPreviousStory,
onGoToNextStory = goToNextStory,
onCreateNewWalletButtonClick = onCreateNewWalletButtonClick,
onAddExistingWalletButtonClick = onAddExistingWalletButtonClick,
onScanButtonClick = onScanButtonClick,
),
)
}
@Deprecated("Use StoriesContainer from core/ui")
@Suppress("LongMethod")
@Composable
private fun StoriesScreenContentV2(config: StoriesScreenContentV2Config, modifier: Modifier = Modifier) {
var isPressed by remember { mutableStateOf(value = false) }
val isPaused = isPressed || config.isScanInProgress
val currentStoryDuration = config.currentStory.duration
Box(
modifier = modifier.background(Color(0xFF010101)),
) {
Row(
modifier = Modifier.fillMaxSize(),
) {
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.pointerInput(Unit) {
detectTapGestures(
onPress = {
val pressStartTime = System.currentTimeMillis()
isPressed = true
this.tryAwaitRelease()
val pressEndTime = System.currentTimeMillis()
val totalPressTime = pressEndTime - pressStartTime
if (totalPressTime < 200) config.onGoToPreviousStory()
isPressed = false
},
)
},
)
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.pointerInput(Unit) {
detectTapGestures(
onPress = {
val pressStartTime = System.currentTimeMillis()
isPressed = true
this.tryAwaitRelease()
val pressEndTime = System.currentTimeMillis()
val totalPressTime = pressEndTime - pressStartTime
if (totalPressTime < 200) config.onGoToNextStory()
isPressed = false
},
)
},
)
}
Column(
modifier = Modifier
.statusBarsPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
StoriesProgressBar(
steps = config.storiesSize,
currentStep = config.currentStoryIndex,
stepDuration = currentStoryDuration,
paused = isPaused,
onStepFinish = config.onGoToNextStory,
)
Image(
painter = painterResource(id = R.drawable.ic_tangem_logo),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing16,
)
.height(TangemTheme.dimens.size18)
.align(Alignment.Start),
)
when (config.currentStory) {
Stories.TangemIntro -> FirstStoriesContent(
isPaused = isPaused,
duration = currentStoryDuration,
)
Stories.RevolutionaryWallet -> StoriesRevolutionaryWallet()
Stories.UltraSecureBackup -> StoriesUltraSecureBackup(
isPaused = isPaused,
stepDuration = currentStoryDuration,
)
Stories.Currencies -> StoriesCurrencies(isPaused, currentStoryDuration)
Stories.Web3 -> StoriesWeb3(isPaused, currentStoryDuration)
Stories.WalletForEveryone -> StoriesWalletForEveryone(currentStoryDuration)
}
}
Column(
modifier = Modifier
.navigationBarsPadding()
.padding(bottom = TangemTheme.dimens.spacing16)
.padding(horizontal = TangemTheme.dimens.spacing16)
.align(Alignment.BottomCenter)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
HomeButtonsV2(
modifier = Modifier.fillMaxWidth(),
btnScanStateInProgress = config.isScanInProgress,
onScanButtonClick = config.onScanButtonClick,
onCreateNewWalletButtonClick = config.onCreateNewWalletButtonClick,
onAddExistingWalletButtonClick = config.onAddExistingWalletButtonClick,
)
}
}
}
private data class StoriesScreenContentV2Config(
val storiesSize: Int,
val currentStoryIndex: Int,
val currentStory: Stories,
val isScanInProgress: Boolean,
val onGoToPreviousStory: () -> Unit = {},
val onGoToNextStory: () -> Unit = {},
val onCreateNewWalletButtonClick: () -> Unit = {},
val onAddExistingWalletButtonClick: () -> Unit = {},
val onScanButtonClick: () -> Unit = {},
)
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun StoriesScreenContentV2Preview(
@PreviewParameter(StoriesScreenContentV2ConfigProvider::class) config: StoriesScreenContentV2Config,
) {
TangemThemePreview {
StoriesScreenContentV2(config = config)
}
}
private class StoriesScreenContentV2ConfigProvider : CollectionPreviewParameterProvider<StoriesScreenContentV2Config>(
collection = listOf(
StoriesScreenContentV2Config(
storiesSize = 6,
currentStoryIndex = 0,
currentStory = Stories.TangemIntro,
isScanInProgress = true,
),
StoriesScreenContentV2Config(
storiesSize = 6,
currentStoryIndex = 1,
currentStory = Stories.RevolutionaryWallet,
isScanInProgress = false,
),
StoriesScreenContentV2Config(
storiesSize = 6,
currentStoryIndex = 2,
currentStory = Stories.UltraSecureBackup,
isScanInProgress = false,
),
StoriesScreenContentV2Config(
storiesSize = 6,
currentStoryIndex = 3,
currentStory = Stories.Currencies,
isScanInProgress = false,
),
StoriesScreenContentV2Config(
storiesSize = 6,
currentStoryIndex = 4,
currentStory = Stories.Web3,
isScanInProgress = false,
),
StoriesScreenContentV2Config(
storiesSize = 6,
currentStoryIndex = 5,
currentStory = Stories.WalletForEveryone,
isScanInProgress = false,
),
),
)
// endregion Preview

View file

@ -0,0 +1,229 @@
package com.tangem.features.home.impl.ui.compose.content
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.home.impl.ui.compose.StoriesBottomImageAnimation
import com.tangem.features.home.impl.ui.compose.StoriesTextAnimation
import com.tangem.core.ui.R
@Composable
fun StoriesRevolutionaryWallet() {
SplitContent(
topContent = {
TopContent(
titleText = stringResourceSafe(id = R.string.story_awe_title),
subtitleText = stringResourceSafe(id = R.string.story_awe_description),
)
},
bottomContent = {
SpacerH32()
StoriesImage(
modifier = Modifier,
drawableResId = R.drawable.img_revolutionary_wallet,
)
},
)
}
@Composable
fun StoriesUltraSecureBackup(isPaused: Boolean, stepDuration: Int) {
SplitContent(
topContent = {
TopContent(
titleText = stringResourceSafe(id = R.string.story_backup_title),
subtitleText = stringResourceSafe(id = R.string.story_backup_description),
)
},
bottomContent = {
SpacerH32()
FloatingCardsContent(
isPaused = isPaused,
stepDuration = stepDuration,
)
},
)
}
@Composable
fun StoriesCurrencies(isPaused: Boolean, stepDuration: Int) {
SplitContent(
topContent = {
TopContent(
titleText = stringResourceSafe(id = R.string.story_currencies_title),
subtitleText = stringResourceSafe(id = R.string.story_currencies_description),
)
},
bottomContent = {
SpacerH32()
StoriesCurrenciesContent(paused = isPaused, duration = stepDuration)
},
)
}
@Composable
fun StoriesWeb3(isPaused: Boolean, stepDuration: Int) {
SplitContent(
topContent = {
TopContent(
titleText = stringResourceSafe(id = R.string.story_web3_title),
subtitleText = stringResourceSafe(id = R.string.story_web3_description),
)
},
bottomContent = {
SpacerH(TangemTheme.dimens.spacing70)
StoriesWeb3Content(paused = isPaused, duration = stepDuration)
},
)
}
@Composable
fun StoriesWalletForEveryone(stepDuration: Int) {
SplitContent(
topContent = {
TopContent(
titleText = stringResourceSafe(id = R.string.story_finish_title),
subtitleText = stringResourceSafe(id = R.string.story_finish_description),
)
},
bottomContent = {
SpacerH32()
BoxWithGradient {
StoriesBottomImageAnimation(
initialScale = 2.6f,
secondStageScale = 1.2f,
targetScale = 1.1f,
totalDuration = stepDuration,
firstStepDuration = 500,
) { modifier ->
StoriesImage(
modifier = modifier,
drawableResId = R.drawable.img_tangem_for_everyone,
)
}
}
},
)
}
@Composable
private fun SplitContent(topContent: @Composable () -> Unit, bottomContent: @Composable () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
topContent()
bottomContent()
}
}
@Composable
private fun TopContent(titleText: String, subtitleText: String) {
SpacerH(TangemTheme.dimens.spacing36)
StoriesTitleText(
text = titleText,
)
SpacerH16()
StoriesSubtitleText(
subtitleText = subtitleText,
)
}
@Suppress("MagicNumber")
@Composable
private fun StoriesTitleText(text: String) {
StoriesTextAnimation(
slideInDuration = 500,
slideInDelay = 150,
) { modifier ->
Text(
modifier = modifier
.padding(start = 40.dp, end = 40.dp),
text = text,
style = TangemTheme.typography.head,
color = TangemColorPalette.White,
textAlign = TextAlign.Center,
)
}
}
@Suppress("MagicNumber")
@Composable
private fun StoriesSubtitleText(subtitleText: String) {
StoriesTextAnimation(
slideInDuration = 500,
slideInDelay = 400,
) { modifier ->
Text(
modifier = modifier
.padding(start = 40.dp, end = 40.dp),
text = subtitleText,
style = TangemTheme.typography.body1,
color = TangemColorPalette.Dark1,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun StoriesImage(@DrawableRes drawableResId: Int, modifier: Modifier = Modifier) {
Image(
painter = painterResource(id = drawableResId),
contentDescription = null,
contentScale = ContentScale.Inside,
modifier = modifier.fillMaxSize(),
)
}
@Preview
@Composable
private fun RevolutionaryWalletPreview() {
StoriesRevolutionaryWallet()
}
@Preview
@Composable
private fun UltraSecureBackupPreview() {
StoriesUltraSecureBackup(
false,
6000,
)
}
@Preview
@Composable
private fun CurrenciesPreview() {
StoriesCurrencies(false, 6000)
}
@Preview
@Composable
private fun Web3Preview() {
StoriesWeb3(false, 6000)
}
@Preview
@Composable
private fun WalletForEveryonePreview() {
StoriesWalletForEveryone(6000)
}

View file

@ -0,0 +1,143 @@
package com.tangem.features.home.impl.ui.compose.content
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.home.impl.ui.compose.HorizontalSlidingImage
import com.tangem.core.ui.R
import com.tangem.core.ui.utils.dpSize
import com.tangem.core.ui.utils.toPx
@Composable
fun StoriesCurrenciesContent(paused: Boolean, duration: Int) {
val currencyDrawableList = remember {
listOf(
R.drawable.currency0,
R.drawable.currency1,
R.drawable.currency2,
R.drawable.currency3,
R.drawable.currency4,
)
}
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val decreaseRate = remember { 1f / currencyDrawableList.size }
val designItemHeight = remember { 82.dp }
BoxWithGradient {
Column(modifier = Modifier.graphicsLayer(clip = false)) {
currencyDrawableList.forEachIndexed { index, drawableResId ->
val painter = painterResource(id = drawableResId)
val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = designItemHeight)
val itemOversizedScreenWidthBy = scaledItemSize.width - screenWidth
val moveItemToStartOfScreen = itemOversizedScreenWidthBy / 2
val chessOffset = if (index.isEven()) 0.dp else scaledItemSize.halfHeight()
val animateFrom = chessOffset - moveItemToStartOfScreen
val animateTo = 50.dp - 50.dp * index * decreaseRate
HorizontalSlidingImage(
paused = paused,
duration = duration,
painter = painter,
itemSize = scaledItemSize,
startOffset = animateFrom.toPx(),
targetOffset = animateTo.toPx(),
contentDescription = "Currency row",
)
SpacerH12()
}
}
}
}
@Suppress("MagicNumber")
@Composable
fun StoriesWeb3Content(paused: Boolean, duration: Int) {
val dappsItemList = remember {
listOf(
R.drawable.dapps1,
R.drawable.dapps1,
R.drawable.dapps2,
R.drawable.dapps3,
R.drawable.dapps4,
R.drawable.dapps5,
)
}
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val decreaseRate = remember { 1f / dappsItemList.size }
val designItemHeight = 75.dp
BoxWithGradient {
Column(modifier = Modifier.graphicsLayer(clip = false)) {
dappsItemList.forEachIndexed { index, drawableResId ->
val painter = painterResource(id = drawableResId)
val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = designItemHeight)
val itemOversizedScreenWidthBy = scaledItemSize.width - screenWidth
val moveItemToStartOfScreen = itemOversizedScreenWidthBy / 2
val chessOffset = if (index.isEven()) 0.dp else scaledItemSize.width / 3
val animateFrom = chessOffset - moveItemToStartOfScreen
val animateTo = 70.dp - 70.dp * index * decreaseRate
HorizontalSlidingImage(
paused = paused,
duration = duration,
painter = painter,
itemSize = scaledItemSize,
startOffset = animateFrom.toPx(),
targetOffset = animateTo.toPx(),
contentDescription = "Web3 row",
)
}
}
}
}
@Composable
internal fun BoxWithGradient(content: @Composable () -> Unit) {
val bottomInsetsPx = WindowInsets.navigationBars.getBottom(LocalDensity.current)
Box(modifier = Modifier.fillMaxSize()) {
content()
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(TangemTheme.dimens.size164 + bottomInsetsPx.dp)
.background(BottomGradient),
)
}
}
private fun scaleToDesignSize(itemSize: DpSize, designItemHeight: Dp): DpSize {
val scaleRate = itemSize.height / designItemHeight
return itemSize / scaleRate
}
private val BottomGradient: Brush = Brush.verticalGradient(
colors = listOf(
TangemColorPalette.Black.copy(alpha = 0f),
TangemColorPalette.Black.copy(alpha = 0.75f),
TangemColorPalette.Black.copy(alpha = 0.95f),
TangemColorPalette.Black,
),
)
fun DpSize.halfHeight(): Dp = this.height / 2
fun Int.isEven() = this and 1 == 0

View file

@ -0,0 +1,93 @@
package com.tangem.features.home.impl.ui.compose.content
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.home.impl.ui.compose.StoriesTextAnimation
import com.tangem.core.ui.R
@Suppress("LongMethod", "ComplexMethod", "MagicNumber")
@Composable
fun FirstStoriesContent(isPaused: Boolean, duration: Int) {
val progress = remember { Animatable(0f) }
LaunchedEffect(isPaused) {
if (isPaused) {
progress.stop()
} else {
progress.animateTo(
targetValue = 2f,
animationSpec = tween(
durationMillis = duration,
easing = LinearEasing,
),
)
}
}
val style = TextStyle(
fontSize = 46.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Center,
)
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SpacerH(TangemTheme.dimens.spacing94)
StoriesTextAnimation(
slideInDuration = 500,
slideInDelay = 150,
) { modifier ->
Text(
modifier = modifier,
text = stringResourceSafe(R.string.story_meet_title),
style = style,
color = TangemColorPalette.White,
textAlign = TextAlign.Center,
)
}
SpacerH(TangemTheme.dimens.spacing46)
Image(
modifier = Modifier
.fillMaxWidth(),
painter = painterResource(R.drawable.img_meet_tangem),
contentScale = ContentScale.Inside,
contentDescription = "Tangem Wallet card",
)
}
}
@Preview
@Composable
private fun FirstStoriesPreview() {
FirstStoriesContent(
false,
8000,
)
}

View file

@ -0,0 +1,97 @@
package com.tangem.features.home.impl.ui.compose.content
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import com.tangem.core.ui.R
import com.tangem.core.ui.utils.AnimatedValue
import com.tangem.core.ui.utils.asImageBitmap
import com.tangem.core.ui.utils.toAnimatable
/**
[REDACTED_AUTHOR]
*/
@Composable
fun FloatingCardsContent(isPaused: Boolean, stepDuration: Int) {
val imageBitmap = asImageBitmap(R.drawable.img_card_placeholder_wallet_2)
val cards = listOf(
FloatingCard.first(),
FloatingCard.second(),
FloatingCard.third(),
)
Box(modifier = Modifier.fillMaxSize()) {
cards.forEach { floatingCard ->
FloatingCard.Item(
isPaused = isPaused,
imageBitmap = imageBitmap,
cardValues = floatingCard,
stepDuration = stepDuration,
)
}
}
}
private data class CardValues(
val translateX: AnimatedValue = AnimatedValue(0f, 0f),
val translateY: AnimatedValue = AnimatedValue(0f, 0f),
val rotationX: AnimatedValue = AnimatedValue(0f, 0f),
val rotationY: AnimatedValue = AnimatedValue(0f, 0f),
val rotationZ: AnimatedValue = AnimatedValue(0f, 0f),
val scale: AnimatedValue = AnimatedValue(1f, 1f),
)
private object FloatingCard {
@Suppress("TopLevelComposableFunctions")
@Composable
fun Item(isPaused: Boolean, stepDuration: Int, imageBitmap: ImageBitmap, cardValues: CardValues) {
Image(
bitmap = imageBitmap,
contentDescription = "Floating Tangem card",
modifier = Modifier
.graphicsLayer(
translationX = cardValues.translateX.toAnimatable(isPaused, stepDuration).value,
translationY = cardValues.translateY.toAnimatable(isPaused, stepDuration).value,
rotationX = cardValues.rotationX.toAnimatable(isPaused, stepDuration).value,
rotationY = cardValues.rotationY.toAnimatable(isPaused, stepDuration).value,
rotationZ = cardValues.rotationZ.toAnimatable(isPaused, stepDuration).value,
scaleX = cardValues.scale.toAnimatable(isPaused, stepDuration).value,
scaleY = cardValues.scale.toAnimatable(isPaused, stepDuration).value,
),
)
}
@Suppress("MagicNumber")
fun first(): CardValues = CardValues(
translateX = -400f to -350f,
translateY = 30f to 32f,
rotationX = 10f to 15f,
rotationY = 15f to 15f,
rotationZ = 40f to 27f,
scale = 0.6f to 0.6f,
)
@Suppress("MagicNumber")
fun second(): CardValues = CardValues(
translateX = 350f to 300f,
translateY = -70f to 0f,
rotationX = 30f to 48f,
rotationY = 0f to 5f,
rotationZ = -34f to -42f,
scale = 0.47f to 0.35f,
)
@Suppress("MagicNumber")
fun third(): CardValues = CardValues(
translateX = 320f to 250f,
translateY = 500f to 500f,
rotationX = 0f to 3f,
rotationY = 10f to 10f,
rotationZ = -45f to -30f,
scale = 0.6f to 0.75f,
)
}

View file

@ -0,0 +1,105 @@
package com.tangem.features.home.impl.ui.compose.views
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
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.SpacerW12
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.StoriesScreenTestTags
import com.tangem.core.ui.R
@Composable
internal fun HomeButtons(
btnScanStateInProgress: Boolean,
onScanButtonClick: () -> Unit,
onShopButtonClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = modifier,
) {
ScanCardButton(
modifier = Modifier
.weight(weight = 1f)
.testTag(StoriesScreenTestTags.SCAN_BUTTON),
showProgress = btnScanStateInProgress,
onClick = onScanButtonClick,
)
SpacerW12()
OrderCardButton(
modifier = Modifier
.weight(weight = 1f)
.testTag(StoriesScreenTestTags.ORDER_BUTTON),
onClick = onShopButtonClick,
)
}
}
@Composable
private fun ScanCardButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.home_button_scan),
useDarkerColors = false,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24),
onClick = onClick,
showProgress = showProgress,
)
}
@Composable
private fun OrderCardButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.home_button_order),
useDarkerColors = true,
onClick = onClick,
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun HomeButtonsPreview(@PreviewParameter(HomeButtonsParameterProvider::class) state: HomeButtonsState) {
TangemThemePreview {
Box(
modifier = Modifier.background(Color.Black),
) {
HomeButtons(
btnScanStateInProgress = state.btnScanStateInProgress,
onScanButtonClick = {},
onShopButtonClick = {},
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
)
}
}
}
private class HomeButtonsParameterProvider : CollectionPreviewParameterProvider<HomeButtonsState>(
collection = listOf(
HomeButtonsState(
btnScanStateInProgress = false,
),
HomeButtonsState(
btnScanStateInProgress = true,
),
),
)
private data class HomeButtonsState(
val btnScanStateInProgress: Boolean,
)
// endregion Preview

View file

@ -0,0 +1,124 @@
package com.tangem.features.home.impl.ui.compose.views
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
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.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.StoriesScreenTestTags
import com.tangem.core.ui.R
@Composable
internal fun HomeButtonsV2(
btnScanStateInProgress: Boolean,
onScanButtonClick: () -> Unit,
onCreateNewWalletButtonClick: () -> Unit,
onAddExistingWalletButtonClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
CreateNewWalletButton(
modifier = Modifier
.fillMaxWidth()
.testTag(StoriesScreenTestTags.CREATE_NEW_WALLET_BUTTON),
onClick = onCreateNewWalletButtonClick,
)
AddExistingWalletButton(
modifier = Modifier
.fillMaxWidth()
.testTag(StoriesScreenTestTags.ADD_EXISTING_WALLET_BUTTON),
onClick = onAddExistingWalletButtonClick,
)
ScanCardButton(
modifier = Modifier
.fillMaxWidth()
.testTag(StoriesScreenTestTags.SCAN_BUTTON),
showProgress = btnScanStateInProgress,
onClick = onScanButtonClick,
)
}
}
@Composable
private fun CreateNewWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.home_button_create_new_wallet),
useDarkerColors = false,
onClick = onClick,
)
}
@Composable
private fun AddExistingWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.home_button_add_existing_wallet),
useDarkerColors = true,
onClick = onClick,
)
}
@Composable
private fun ScanCardButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.home_button_scan),
useDarkerColors = true,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24),
onClick = onClick,
showProgress = showProgress,
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun HomeButtonsV2Preview(@PreviewParameter(HomeButtonsV2ParameterProvider::class) state: HomeButtonsV2State) {
TangemThemePreview {
Box(
modifier = Modifier.background(Color.Black),
) {
HomeButtonsV2(
btnScanStateInProgress = state.btnScanStateInProgress,
onCreateNewWalletButtonClick = {},
onAddExistingWalletButtonClick = {},
onScanButtonClick = {},
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
)
}
}
}
private class HomeButtonsV2ParameterProvider : CollectionPreviewParameterProvider<HomeButtonsV2State>(
collection = listOf(
HomeButtonsV2State(
btnScanStateInProgress = false,
),
HomeButtonsV2State(
btnScanStateInProgress = true,
),
),
)
private data class HomeButtonsV2State(
val btnScanStateInProgress: Boolean,
)
// endregion Preview

View file

@ -0,0 +1,43 @@
package com.tangem.features.home.impl.ui.compose.views
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.R
@Composable
internal fun SearchCurrenciesButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.common_search_tokens),
icon = TangemButtonIconPosition.Start(R.drawable.ic_search_24),
showProgress = false,
useDarkerColors = true,
onClick = onClick,
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun SearchCurrenciesButtonPreview() {
TangemThemePreview {
Box(
modifier = Modifier
.background(color = Color.Black)
.padding(all = TangemTheme.dimens.spacing16),
) {
SearchCurrenciesButton(modifier = Modifier.fillMaxWidth(), onClick = {})
}
}
}
// endregion Preview

View file

@ -0,0 +1,51 @@
package com.tangem.features.home.impl.ui.compose.views
import androidx.compose.material3.ButtonColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun StoriesButton(
text: String,
useDarkerColors: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: TangemButtonIconPosition = TangemButtonIconPosition.None,
showProgress: Boolean = false,
) {
TangemButton(
modifier = modifier,
text = text,
icon = icon,
colors = if (useDarkerColors) DarkerButtonColors else LighterButtonColors,
showProgress = showProgress,
enabled = true,
shape = TangemTheme.shapes.roundedCornersXMedium,
textStyle = TangemTheme.typography.subtitle1,
iconPadding = when (icon) {
is TangemButtonIconPosition.Start -> TangemTheme.dimens.spacing4
is TangemButtonIconPosition.End,
is TangemButtonIconPosition.None,
-> TangemTheme.dimens.spacing8
},
onClick = onClick,
)
}
private val LighterButtonColors: ButtonColors = ButtonColors(
containerColor = TangemColorPalette.Light4,
contentColor = TangemColorPalette.Dark6,
disabledContainerColor = TangemColorPalette.Dark5,
disabledContentColor = TangemColorPalette.Dark6,
)
private val DarkerButtonColors: ButtonColors = ButtonColors(
containerColor = TangemColorPalette.Dark4,
contentColor = TangemColorPalette.White,
disabledContainerColor = TangemColorPalette.Dark4,
disabledContentColor = TangemColorPalette.White,
)

View file

@ -0,0 +1,112 @@
package com.tangem.features.home.impl.ui.compose.views
import android.provider.Settings
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import kotlinx.coroutines.delay
private const val STORIES_ANIMATION_SPEED_ZERO_DURATION = 3000L
@Composable
fun StoriesProgressBar(
steps: Int,
currentStep: Int,
paused: Boolean = false,
stepDuration: Int = 8_000,
onStepFinish: () -> Unit = {},
) {
val progress = remember(currentStep) { Animatable(initialValue = 0f) }
val context = LocalContext.current
val animatorSpeed = Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
LaunchedEffect(paused, currentStep, animatorSpeed) {
if (paused) {
progress.stop()
} else {
if (animatorSpeed == 0f) {
progress.snapTo(1f)
delay(STORIES_ANIMATION_SPEED_ZERO_DURATION)
} else {
progress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = (stepDuration * (1f - progress.value)).toInt(),
easing = LinearEasing,
),
)
progress.snapTo(0f)
}
onStepFinish()
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing16,
),
) {
for (index in 0..steps) {
Row(
modifier = Modifier
.height(TangemTheme.dimens.size2)
.weight(1f)
.clip(RoundedCornerShape(TangemTheme.dimens.radius2))
.background(TangemColorPalette.White.copy(alpha = .2f)),
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius2))
.background(TangemColorPalette.White)
.fillMaxHeight()
.let {
when (index) {
currentStep -> it.fillMaxWidth(progress.value)
in 0..currentStep -> it.fillMaxWidth(fraction = 1f)
else -> it
}
},
)
}
if (index != steps) {
SpacerW4()
}
}
}
}
@Preview
@Composable
private fun StoriesProgressBarPreview() {
Box(
modifier = Modifier
.wrapContentSize()
.background(TangemColorPalette.Black)
.padding(vertical = TangemTheme.dimens.spacing16),
) {
StoriesProgressBar(steps = 5, currentStep = 3, paused = false)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.home.impl.ui.state
import kotlinx.collections.immutable.ImmutableList
data class HomeUM(
val scanInProgress: Boolean,
val stories: ImmutableList<Stories>,
val onScanClick: () -> Unit,
val onShopClick: () -> Unit,
val onSearchTokensClick: () -> Unit,
val onCreateNewWalletClick: () -> Unit,
val onAddExistingWalletClick: () -> Unit,
) {
val firstStory: Stories get() = stories[0]
fun stepOf(story: Stories): Int = stories.indexOf(story)
}
enum class Stories(val duration: Int = 6000) {
TangemIntro,
RevolutionaryWallet,
UltraSecureBackup,
Currencies,
Web3,
WalletForEveryone,
}
/**
* For FCA restriction stories
*/
fun getRestrictedStories(): List<Stories> {
return Stories.entries.filterNot { it == Stories.Currencies }
}

View file

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

View file

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

View file

@ -2,6 +2,7 @@ package com.tangem.features.hotwallet
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
interface HotAccessCodeRequestComponent : ComposableContentComponent, HotWalletPasswordRequester {

View file

@ -1,18 +0,0 @@
package com.tangem.features.hotwallet
import com.tangem.hot.sdk.model.HotAuth
interface HotWalletPasswordRequester {
suspend fun wrongPassword()
suspend fun requestPassword(hasBiometry: Boolean): Result
suspend fun dismiss()
sealed class Result {
data object UseBiometry : Result()
data object Dismiss : Result()
data class EnteredPassword(val password: HotAuth.Password) : Result()
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.hotwallet
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
interface UpdateAccessCodeComponent : ComposableContentComponent {
data class Params(val userWalletId: UserWalletId)
interface Factory : ComponentFactory<Params, UpdateAccessCodeComponent>
}

View file

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

View file

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

View file

@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.datasource)
/** Domain */
implementation(projects.domain.card)
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
@ -52,7 +53,6 @@ dependencies {
implementation(deps.lifecycle.runtime.ktx)
/** Compose libraries */
implementation(deps.compose.material) // to use buttons and text field in MultiWalletSeedPhraseImport.kt
implementation(deps.compose.material3)
implementation(deps.compose.animation)
implementation(deps.compose.foundation)

View file

@ -1,4 +1,4 @@
package com.tangem.features.hotwallet.setaccesscode
package com.tangem.features.hotwallet.accesscode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -8,36 +8,45 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.security.DisableScreenshotsDisposableEffect
import com.tangem.features.hotwallet.setaccesscode.ui.SetAccessCodeContent
import com.tangem.features.hotwallet.accesscode.ui.AccessCode
import com.tangem.domain.models.wallet.UserWalletId
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class SetAccessCodeComponent @AssistedInject constructor(
internal class AccessCodeComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by context {
private val model: SetAccessCodeModel = getOrCreateModel(params)
private val model: AccessCodeModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
SetAccessCodeContent(
state = state,
onBack = { model.onBack() },
modifier = modifier,
)
DisableScreenshotsDisposableEffect()
AccessCode(
modifier = modifier,
state = state,
)
}
interface ModelCallbacks {
fun onBackClick()
fun onAccessCodeSet()
fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String)
fun onAccessCodeConfirmed(userWalletId: UserWalletId)
}
data class Params(
val isConfirmMode: Boolean,
val accessCodeToConfirm: String? = null,
val userWalletId: UserWalletId,
val callbacks: ModelCallbacks,
)
@AssistedFactory
interface Factory {
fun create(context: AppComponentContext, params: Params): AccessCodeComponent
}
}

View file

@ -0,0 +1,130 @@
package com.tangem.features.hotwallet.accesscode
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.UnlockHotWallet
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Stable
@ModelScoped
internal class AccessCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val walletsRepository: WalletsRepository,
private val tangemHotSdk: TangemHotSdk,
) : Model() {
private val params = paramsContainer.require<AccessCodeComponent.Params>()
internal val uiState: StateFlow<AccessCodeUM>
field = MutableStateFlow(getInitialState())
private fun getInitialState() = AccessCodeUM(
accessCode = "",
onAccessCodeChange = ::onAccessCodeChange,
isConfirmMode = params.isConfirmMode,
buttonEnabled = false,
buttonInProgress = false,
onButtonClick = ::onButtonClick,
)
private fun onAccessCodeChange(value: String) {
uiState.update {
it.copy(
accessCode = value,
buttonEnabled = if (params.isConfirmMode) {
value == params.accessCodeToConfirm
} else {
value.length == uiState.value.accessCodeLength
},
)
}
}
private fun onButtonClick() {
if (!params.isConfirmMode) {
params.callbacks.onAccessCodeSet(params.userWalletId, uiState.value.accessCode)
} else {
params.accessCodeToConfirm?.let {
setCode(params.userWalletId, it)
}
}
}
private fun setCode(userWalletId: UserWalletId, accessCode: String) {
modelScope.launch {
uiState.update {
it.copy(buttonInProgress = true)
}
runCatching {
val userWallet = getUserWalletUseCase(userWalletId)
.getOrElse { error("User wallet with id $userWalletId not found") }
if (userWallet !is UserWallet.Hot) return@launch
val unlockHotWallet = UnlockHotWallet(userWallet.hotWalletId, HotAuth.NoAuth)
var updatedHotWalletId = tangemHotSdk.changeAuth(
unlockHotWallet = unlockHotWallet,
auth = HotAuth.Password(accessCode.toCharArray()),
)
if (walletsRepository.requireAccessCode().not()) {
updatedHotWalletId = tangemHotSdk.changeAuth(
unlockHotWallet = UnlockHotWallet(
walletId = updatedHotWalletId,
auth = HotAuth.Password(accessCode.toCharArray()),
),
auth = HotAuth.Biometry,
)
}
userWalletsListRepository.saveWithoutLock(
userWallet.copy(
hotWalletId = updatedHotWalletId,
backedUp = true,
),
canOverride = true,
)
userWalletsListRepository.setLock(
userWallet.walletId,
UserWalletsListRepository.LockMethod.AccessCode(accessCode.toCharArray()),
)
if (walletsRepository.useBiometricAuthentication()) {
userWalletsListRepository.setLock(
userWallet.walletId,
UserWalletsListRepository.LockMethod.Biometric,
)
}
params.callbacks.onAccessCodeConfirmed(params.userWalletId)
}.onFailure {
Timber.e(it)
uiState.update {
it.copy(buttonInProgress = false)
}
}
}
}
}

View file

@ -0,0 +1,3 @@
package com.tangem.features.hotwallet.accesscode
const val ACCESS_CODE_LENGTH = 6

View file

@ -1,7 +1,7 @@
package com.tangem.features.hotwallet.setaccesscode.di
package com.tangem.features.hotwallet.accesscode.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.setaccesscode.SetAccessCodeModel
import com.tangem.features.hotwallet.accesscode.AccessCodeModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -11,10 +11,10 @@ import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
internal interface SetAccessCodeModule {
internal interface AccessCodeModule {
@Binds
@IntoMap
@ClassKey(SetAccessCodeModel::class)
fun bindSetAccessCodeModel(model: SetAccessCodeModel): Model
@ClassKey(AccessCodeModel::class)
fun bindAccessCodeModel(model: AccessCodeModel): Model
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.hotwallet.accesscode.entity
import com.tangem.features.hotwallet.accesscode.ACCESS_CODE_LENGTH
internal data class AccessCodeUM(
val accessCode: String,
val onAccessCodeChange: (String) -> Unit,
val isConfirmMode: Boolean,
val buttonEnabled: Boolean,
val buttonInProgress: Boolean,
val onButtonClick: () -> Unit,
) {
val accessCodeLength: Int = ACCESS_CODE_LENGTH
}

View file

@ -0,0 +1,138 @@
package com.tangem.features.hotwallet.accesscode.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.res.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.fields.PinTextColor
import com.tangem.core.ui.components.fields.PinTextField
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.hotwallet.accesscode.entity.AccessCodeUM
@Suppress("LongParameterList", "LongMethod")
@Composable
internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
Column(
modifier = Modifier
.padding(top = 16.dp)
.weight(1f)
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier
.padding(top = 56.dp)
.align(Alignment.CenterHorizontally),
text = if (state.isConfirmMode) {
stringResourceSafe(R.string.access_code_confirm_title)
} else {
stringResourceSafe(R.string.access_code_create_title)
},
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.padding(16.dp)
.align(Alignment.CenterHorizontally),
text = if (state.isConfirmMode) {
stringResourceSafe(R.string.access_code_confirm_description)
} else {
stringResourceSafe(
R.string.access_code_create_description,
state.accessCodeLength,
)
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
contentAlignment = Alignment.Center,
) {
PinTextField(
length = state.accessCodeLength,
isPasswordVisual = state.isConfirmMode,
value = state.accessCode,
pinTextColor = PinTextColor.Primary,
onValueChange = state.onAccessCodeChange,
)
}
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.imePadding(),
text = stringResourceSafe(
if (state.isConfirmMode) {
R.string.common_confirm
} else {
R.string.common_continue
},
),
onClick = state.onButtonClick,
enabled = state.buttonEnabled,
showProgress = state.buttonInProgress,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewSet() {
TangemThemePreview {
AccessCode(
state = AccessCodeUM(
accessCode = "",
onAccessCodeChange = {},
isConfirmMode = false,
buttonEnabled = false,
buttonInProgress = false,
onButtonClick = {},
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewConfirm() {
TangemThemePreview {
AccessCode(
state = AccessCodeUM(
accessCode = "123456",
onAccessCodeChange = {},
isConfirmMode = true,
buttonEnabled = true,
buttonInProgress = false,
onButtonClick = {},
),
)
}
}

View file

@ -1,14 +1,13 @@
package com.tangem.features.hotwallet.accesscoderequest
import androidx.compose.foundation.focusable
import androidx.compose.runtime.*
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.core.ui.components.FullScreen
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
import com.tangem.features.hotwallet.HotWalletPasswordRequester
import com.tangem.features.hotwallet.accesscoderequest.ui.HotAccessCodeRequestFullScreenContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -26,8 +25,14 @@ internal class DefaultHotAccessCodeRequestComponent @AssistedInject constructor(
model.wrongAccessCode()
}
override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result {
model.show(hasBiometry)
override suspend fun successfulAuthentication() {
model.successfulAuthentication()
}
override suspend fun requestPassword(
attemptRequest: HotWalletPasswordRequester.AttemptRequest,
): HotWalletPasswordRequester.Result {
model.show(attemptRequest)
return model.waitResult()
}

View file

@ -2,36 +2,64 @@ package com.tangem.features.hotwallet.accesscoderequest
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.HotWalletPasswordRequester
import com.tangem.core.ui.components.fields.PinTextColor
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.features.hotwallet.accesscode.ACCESS_CODE_LENGTH
import com.tangem.features.hotwallet.accesscoderequest.entity.HotAccessCodeRequestUM
import com.tangem.features.hotwallet.impl.R
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
internal class HotAccessCodeRequestModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository,
private val userWalletsListRepository: UserWalletsListRepository,
) : Model() {
private val result = MutableStateFlow<HotWalletPasswordRequester.Result?>(null)
private val currentRequest = MutableStateFlow<HotWalletPasswordRequester.AttemptRequest?>(null)
private val attemptsRequestJobHolder = JobHolder()
private val HotWalletPasswordRequester.AttemptRequest.attemptId
get() = HotWalletAccessCodeAttemptsRepository.AttemptId(
hotWalletId = hotWalletId,
auth = authMode,
)
val uiState: StateFlow<HotAccessCodeRequestUM>
field = MutableStateFlow(getInitialState())
fun dismiss() {
result.value = HotWalletPasswordRequester.Result.Dismiss
dismissState()
}
suspend fun show(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
if (userWalletExists(attemptRequest.hotWalletId).not()) {
Timber.e("User wallet with id ${attemptRequest.hotWalletId} does not exist")
result.value = HotWalletPasswordRequester.Result.Dismiss
return
}
fun show(hasBiometry: Boolean) {
currentRequest.value = attemptRequest
result.value = null // Reset the result when showing the dialog
subscribeToAttempts(id = attemptRequest.attemptId)
uiState.update {
it.copy(
isShown = true,
accessCode = "",
useBiometricVisible = hasBiometry,
useBiometricVisible = attemptRequest.hasBiometry,
onAccessCodeChange = ::onAccessCodeChange,
)
}
@ -41,16 +69,36 @@ internal class HotAccessCodeRequestModel @Inject constructor(
return result.filterNotNull().first().also { result.value = null }
}
fun dismiss() {
result.value = HotWalletPasswordRequester.Result.Dismiss
attemptsRequestJobHolder.cancel()
dismissState()
}
suspend fun wrongAccessCode() {
val currentRequest = currentRequest.value ?: return
hotAccessCodeAttemptsRepository.incrementAttempts(currentRequest.attemptId)
uiState.update {
it.copy(
wrongAccessCode = true,
accessCodeColor = PinTextColor.WrongCode,
onAccessCodeChange = {},
)
}
delay(timeMillis = 500) // Delay to show the wrong access code state
}
suspend fun successfulAuthentication() {
val currentRequest = currentRequest.value ?: return
hotAccessCodeAttemptsRepository.resetAttempts(currentRequest.hotWalletId)
uiState.update {
it.copy(
accessCodeColor = PinTextColor.Success,
onAccessCodeChange = {},
)
}
delay(timeMillis = 200) // Delay to show the success state
}
private fun getInitialState() = HotAccessCodeRequestUM(
onDismiss = ::dismiss,
onAccessCodeChange = ::onAccessCodeChange,
@ -65,7 +113,10 @@ internal class HotAccessCodeRequestModel @Inject constructor(
if (accessCode.length > ACCESS_CODE_LENGTH) return
uiState.update {
it.copy(accessCode = accessCode, wrongAccessCode = false)
it.copy(
accessCode = accessCode,
accessCodeColor = PinTextColor.Primary,
)
}
if (accessCode.length == ACCESS_CODE_LENGTH) {
@ -77,13 +128,71 @@ internal class HotAccessCodeRequestModel @Inject constructor(
}
}
private fun subscribeToAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId) {
fun remainingSecondsToText(remainingSeconds: Int): TextReference? {
return if (remainingSeconds > 0) {
resourceReference(
R.string.access_code_check_warining_wait,
wrappedList(remainingSeconds),
)
} else {
null
}
}
suspend fun collectAttempts(attempts: Attempts) {
when (attempts) {
is Attempts.FastForward -> {
/** ignore */
}
is Attempts.WithDelay -> {
uiState.update {
it.copy(
wrongAccessCodeText = remainingSecondsToText(attempts.remainingSeconds),
onAccessCodeChange = ::onAccessCodeChange.takeIf { attempts.remainingSeconds <= 0 }
?: {},
)
}
}
is Attempts.BeforeDeletion -> {
uiState.update {
it.copy(
wrongAccessCodeText = remainingSecondsToText(attempts.remainingSeconds)
?: resourceReference(
R.string.access_code_check_warining_delete,
wrappedList(attempts.remainingAttemptsCountBeforeDeletion),
),
onAccessCodeChange = ::onAccessCodeChange.takeIf { attempts.remainingSeconds <= 0 }
?: {},
)
}
}
Attempts.Deletion -> deleteUserWallet()
}
}
modelScope.launch {
hotAccessCodeAttemptsRepository.getAttempts(id)
.collectLatest { attempts -> collectAttempts(attempts) }
}.saveIn(attemptsRequestJobHolder)
}
private suspend fun userWalletExists(id: HotWalletId): Boolean {
return userWalletsListRepository.userWalletsSync()
.any { it is UserWallet.Hot && it.hotWalletId == id }
}
private suspend fun deleteUserWallet() {
val currentRequest = currentRequest.value ?: return
val userWallet = userWalletsListRepository.userWalletsSync()
.firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return
userWalletsListRepository.delete(listOf(userWallet.walletId))
dismiss()
}
private fun dismissState() {
uiState.update {
it.copy(isShown = false)
}
}
private companion object {
const val ACCESS_CODE_LENGTH = 6
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.features.hotwallet.accesscoderequest.di
import com.tangem.core.decompose.model.Model
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
import com.tangem.features.hotwallet.HotWalletPasswordRequester
import com.tangem.features.hotwallet.accesscoderequest.DefaultHotAccessCodeRequestComponent
import com.tangem.features.hotwallet.accesscoderequest.HotAccessCodeRequestModel
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy

View file

@ -1,9 +1,13 @@
package com.tangem.features.hotwallet.accesscoderequest.entity
import com.tangem.core.ui.components.fields.PinTextColor
import com.tangem.core.ui.extensions.TextReference
internal data class HotAccessCodeRequestUM(
val isShown: Boolean = false,
val accessCode: String = "",
val wrongAccessCode: Boolean = false,
val accessCodeColor: PinTextColor = PinTextColor.Primary,
val wrongAccessCodeText: TextReference? = null,
val useBiometricVisible: Boolean = true,
val useBiometricClick: () -> Unit = {},
val onAccessCodeChange: (String) -> Unit = {},

View file

@ -1,6 +1,6 @@
package com.tangem.features.hotwallet.accesscoderequest.proxy
import com.tangem.features.hotwallet.HotWalletPasswordRequester
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
@ -13,16 +13,15 @@ class HotWalletPasswordRequesterProxy @Inject constructor() : HotWalletPasswordR
val componentRequester = MutableStateFlow<HotWalletPasswordRequester?>(null)
override suspend fun wrongPassword() {
call { wrongPassword() }
}
override suspend fun wrongPassword() = call { wrongPassword() }
override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result =
call { requestPassword(hasBiometry) }
override suspend fun successfulAuthentication() = call { successfulAuthentication() }
override suspend fun dismiss() {
call { dismiss() }
}
override suspend fun requestPassword(
attemptRequest: HotWalletPasswordRequester.AttemptRequest,
): HotWalletPasswordRequester.Result = call { requestPassword(attemptRequest) }
override suspend fun dismiss() = call { dismiss() }
private suspend fun <T> call(block: suspend HotWalletPasswordRequester.() -> T): T {
return withTimeout(timeMillis = 1000) {

View file

@ -13,6 +13,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SecondaryButton
@ -20,7 +22,10 @@ import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.fields.PinTextColor
import com.tangem.core.ui.components.fields.PinTextField
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
@ -85,9 +90,36 @@ internal fun HotAccessCodeRequestFullScreenContent(state: HotAccessCodeRequestUM
length = 6,
isPasswordVisual = true,
value = state.accessCode,
wrongCode = state.wrongAccessCode,
pinTextColor = state.accessCodeColor,
onValueChange = state.onAccessCodeChange,
)
SpacerH(20.dp)
AnimatedVisibility(
modifier = Modifier.animateEnterExit(
enter = slideInVertically(
tween(),
initialOffsetY = { it + 200 },
) + fadeIn(tween()),
exit = slideOutVertically(tween(300)) { it - 200 } + fadeOut(tween()),
),
visible = state.wrongAccessCodeText != null,
enter = fadeIn(),
exit = fadeOut(),
) {
val wrongAccessCodeText =
state.wrongAccessCodeText ?: return@AnimatedVisibility
Text(
text = wrongAccessCodeText.resolveReference(),
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption2.copy(
lineBreak = LineBreak.Heading,
),
color = TangemTheme.colors.text.warning,
)
}
}
if (state.useBiometricVisible) {
@ -97,7 +129,10 @@ internal fun HotAccessCodeRequestFullScreenContent(state: HotAccessCodeRequestUM
.fillMaxWidth()
.navigationBarsPadding()
.imePadding(),
text = "Use biometric",
text = stringResourceSafe(
id = R.string.welcome_unlock,
stringResourceSafe(R.string.common_biometrics),
),
onClick = state.useBiometricClick,
)
}
@ -106,9 +141,15 @@ internal fun HotAccessCodeRequestFullScreenContent(state: HotAccessCodeRequestUM
val hapticManager = LocalHapticManager.current
LaunchedEffect(state.wrongAccessCode) {
if (state.wrongAccessCode) {
hapticManager.perform(TangemHapticEffect.View.Reject)
LaunchedEffect(state.accessCodeColor) {
when (state.accessCodeColor) {
PinTextColor.WrongCode -> {
hapticManager.perform(TangemHapticEffect.View.Reject)
}
PinTextColor.Success -> {
hapticManager.perform(TangemHapticEffect.View.Confirm)
}
else -> Unit
}
}
}
@ -125,7 +166,10 @@ private fun Preview() {
var isShown by remember { mutableStateOf(true) }
HotAccessCodeRequestFullScreenContent(
state = HotAccessCodeRequestUM(isShown = isShown),
state = HotAccessCodeRequestUM(
isShown = isShown,
wrongAccessCodeText = stringReference("Wrong access code"),
),
modifier = Modifier,
)

View file

@ -0,0 +1,134 @@
package com.tangem.features.hotwallet.addexistingwallet.entry
import com.arkivanov.decompose.router.stack.*
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.navigation.Router
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.ShouldAskPermissionUseCase
import com.tangem.features.hotwallet.addexistingwallet.entry.routing.AddExistingWalletRoute
import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent
import com.tangem.features.hotwallet.addexistingwallet.start.AddExistingWalletStartComponent
import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent
import com.tangem.features.hotwallet.accesscode.AccessCodeComponent
import com.tangem.features.hotwallet.setupfinished.MobileWalletSetupFinishedComponent
import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
internal class AddExistingWalletModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase,
) : Model() {
val hotWalletStepperComponentModelCallback = HotWalletStepperComponentModelCallback()
val addExistingWalletStartModelCallbacks = AddExistingWalletStartModelCallbacks()
val addExistingWalletImportModelCallbacks = AddExistingWalletImportModelCallbacks()
val manualBackupCompletedComponentModelCallbacks = ManualBackupCompletedComponentModelCallbacks()
val accessCodeModelCallbacks = AccessCodeModelCallbacks()
val pushNotificationsCallbacks = PushNotificationsCallbacks()
val mobileWalletSetupFinishedComponentModelCallbacks = MobileWalletSetupFinishedComponentModelCallbacks()
val stackNavigation = StackNavigation<AddExistingWalletRoute>()
val startRoute = AddExistingWalletRoute.Start
val currentRoute: MutableStateFlow<AddExistingWalletRoute> = MutableStateFlow(startRoute)
fun onChildBack() {
when (currentRoute.value) {
is AddExistingWalletRoute.Start -> router.pop()
is AddExistingWalletRoute.Import -> stackNavigation.pop()
is AddExistingWalletRoute.BackupCompleted -> Unit
is AddExistingWalletRoute.SetAccessCode -> Unit
is AddExistingWalletRoute.ConfirmAccessCode -> stackNavigation.pop()
is AddExistingWalletRoute.PushNotifications -> Unit
is AddExistingWalletRoute.SetupFinished -> Unit
}
}
private fun navigateToPushNotificationsOrNext() {
modelScope.launch {
val shouldRequestPush = shouldAskPermissionUseCase(PUSH_PERMISSION)
if (shouldRequestPush) {
// is yet blocked by [REDACTED_TASK_KEY]
// stackNavigation.replaceAll(AddExistingWalletRoute.PushNotifications)
stackNavigation.replaceAll(AddExistingWalletRoute.SetupFinished)
} else {
stackNavigation.replaceAll(AddExistingWalletRoute.SetupFinished)
}
}
}
private fun navigateToSetupFinished() {
stackNavigation.replaceAll(AddExistingWalletRoute.SetupFinished)
}
inner class HotWalletStepperComponentModelCallback : HotWalletStepperComponent.ModelCallback {
override fun onBackClick() {
onChildBack()
}
override fun onSkipClick() {
navigateToPushNotificationsOrNext()
}
}
inner class AddExistingWalletStartModelCallbacks : AddExistingWalletStartComponent.ModelCallbacks {
override fun onBackClick() {
router.pop()
}
override fun onImportPhraseClick() {
stackNavigation.push(AddExistingWalletRoute.Import)
}
}
inner class AddExistingWalletImportModelCallbacks : AddExistingWalletImportComponent.ModelCallbacks {
override fun onWalletImported(userWalletId: UserWalletId) {
stackNavigation.replaceAll(AddExistingWalletRoute.BackupCompleted(userWalletId))
}
}
inner class ManualBackupCompletedComponentModelCallbacks : ManualBackupCompletedComponent.ModelCallbacks {
override fun onContinueClick(userWalletId: UserWalletId) {
stackNavigation.replaceAll(AddExistingWalletRoute.SetAccessCode(userWalletId))
}
}
inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks {
override fun onAccessCodeSet(userWalletId: UserWalletId, accessCode: String) {
stackNavigation.push(AddExistingWalletRoute.ConfirmAccessCode(userWalletId, accessCode))
}
override fun onAccessCodeConfirmed(userWalletId: UserWalletId) {
navigateToPushNotificationsOrNext()
}
}
inner class PushNotificationsCallbacks : PushNotificationsModelCallbacks {
override fun onAllowSystemPermission() {
navigateToSetupFinished()
}
override fun onDenySystemPermission() {
navigateToSetupFinished()
}
override fun onDismiss() {
navigateToSetupFinished()
}
}
inner class MobileWalletSetupFinishedComponentModelCallbacks :
MobileWalletSetupFinishedComponent.ModelCallbacks {
override fun onContinueClick() {
router.replaceAll(AppRoute.Wallet)
}
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.hotwallet.addexistingwallet.entry
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.hotwallet.addexistingwallet.entry.routing.AddExistingWalletRoute
import com.tangem.features.hotwallet.impl.R
import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
internal class AddExistingWalletStepperStateManager {
fun getStepperState(route: AddExistingWalletRoute): HotWalletStepperComponent.StepperUM? {
return when (route) {
is AddExistingWalletRoute.Start -> null
is AddExistingWalletRoute.Import -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_IMPORT,
steps = STEPS_COUNT,
title = resourceReference(R.string.wallet_import_seed_navtitle),
showBackButton = true,
showSkipButton = false,
showFeedbackButton = true,
)
is AddExistingWalletRoute.BackupCompleted -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_BACKUP,
steps = STEPS_COUNT,
title = resourceReference(R.string.common_backup),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
is AddExistingWalletRoute.SetAccessCode -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_ACCESS_CODE,
steps = STEPS_COUNT,
title = resourceReference(R.string.access_code_navtitle),
showBackButton = false,
showSkipButton = true,
showFeedbackButton = false,
)
is AddExistingWalletRoute.ConfirmAccessCode -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_ACCESS_CODE,
steps = STEPS_COUNT,
title = resourceReference(R.string.access_code_navtitle),
showBackButton = true,
showSkipButton = true,
showFeedbackButton = false,
)
is AddExistingWalletRoute.PushNotifications -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_NOTIFICATIONS,
steps = STEPS_COUNT,
title = resourceReference(R.string.onboarding_title_notifications),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
is AddExistingWalletRoute.SetupFinished -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_DONE,
steps = STEPS_COUNT,
title = resourceReference(R.string.common_done),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
}
}
companion object {
private const val STEPS_COUNT = 5
private const val STEP_IMPORT = 1
private const val STEP_BACKUP = 2
private const val STEP_ACCESS_CODE = 3
private const val STEP_NOTIFICATIONS = 4
private const val STEP_DONE = 5
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.hotwallet.addexistingwallet.root
package com.tangem.features.hotwallet.addexistingwallet.entry
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
@ -6,33 +6,36 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.value.ObserveLifecycleMode
import com.arkivanov.decompose.value.subscribe
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.hotwallet.AddExistingWalletComponent
import com.tangem.features.hotwallet.addexistingwallet.root.routing.AddExistingWalletChildFactory
import com.tangem.features.hotwallet.addexistingwallet.root.routing.AddExistingWalletRoute
import com.tangem.features.hotwallet.addexistingwallet.root.ui.AddExistingWalletContent
import com.tangem.features.hotwallet.addexistingwallet.entry.routing.AddExistingWalletChildFactory
import com.tangem.features.hotwallet.addexistingwallet.entry.ui.AddExistingWalletContent
import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
import com.tangem.features.hotwallet.stepper.impl.DefaultHotWalletStepperComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
internal class DefaultAddExistingWalletComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: Unit,
private val stepperStateManager: AddExistingWalletStepperStateManager,
addExistingWalletChildFactory: AddExistingWalletChildFactory,
stepperComponentFactory: DefaultHotWalletStepperComponent.Factory,
) : AddExistingWalletComponent, AppComponentContext by appComponentContext {
private val model: AddExistingWalletModel = getOrCreateModel(params)
private val startRoute = AddExistingWalletRoute.Start
private val innerStack = childStack(
key = "addExistingWalletInnerStack",
source = model.stackNavigation,
serializer = null,
initialConfiguration = startRoute,
initialConfiguration = model.startRoute,
handleBackButton = true,
childFactory = { configuration, factoryContext ->
addExistingWalletChildFactory.createChild(
@ -43,27 +46,41 @@ internal class DefaultAddExistingWalletComponent @AssistedInject constructor(
},
)
private val stepperComponent = stepperComponentFactory.create(
context = this,
params = HotWalletStepperComponent.Params(
initState = HotWalletStepperComponent.StepperUM.initialState(),
callback = model.hotWalletStepperComponentModelCallback,
),
)
init {
innerStack.subscribe(
lifecycle = lifecycle,
mode = ObserveLifecycleMode.CREATE_DESTROY,
) { stack ->
componentScope.launch {
model.currentRoute.emit(stack.active.configuration)
}
}
}
@Composable
override fun Content(modifier: Modifier) {
val stackState by innerStack.subscribeAsState()
val currentRoute = stackState.active.configuration
BackHandler(onBack = model::onChildBack)
val stepperState = stepperStateManager.getStepperState(currentRoute)
stepperState?.let { stepperComponent.updateState(it) }
BackHandler(onBack = ::onChildBack)
AddExistingWalletContent(
stackState = stackState,
stepperComponent = stepperComponent.takeIf { stepperState != null },
)
}
private fun onChildBack() {
val isEmptyStack = innerStack.value.backStack.isEmpty()
if (isEmptyStack) {
router.pop()
} else {
val currentRoute = innerStack.value.active.configuration
model.onChildBack(currentRoute)
}
}
@AssistedFactory
interface Factory : AddExistingWalletComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultAddExistingWalletComponent

View file

@ -0,0 +1,53 @@
package com.tangem.features.hotwallet.addexistingwallet.entry.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.AddExistingWalletComponent
import com.tangem.features.hotwallet.addexistingwallet.entry.AddExistingWalletModel
import com.tangem.features.hotwallet.addexistingwallet.entry.AddExistingWalletStepperStateManager
import com.tangem.features.hotwallet.addexistingwallet.entry.DefaultAddExistingWalletComponent
import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
import com.tangem.features.hotwallet.stepper.impl.DefaultHotWalletStepperComponent
import com.tangem.features.hotwallet.stepper.impl.HotWalletStepperModel
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
@InstallIn(SingletonComponent::class)
internal interface AddExistingWalletModuleBinds {
@Binds
@Singleton
fun bindAddExistingWalletComponentFactory(
impl: DefaultAddExistingWalletComponent.Factory,
): AddExistingWalletComponent.Factory
@Binds
@IntoMap
@ClassKey(AddExistingWalletModel::class)
fun bindAddExistingWalletModel(model: AddExistingWalletModel): Model
@Binds
fun bindFactory(impl: DefaultHotWalletStepperComponent.Factory): HotWalletStepperComponent.Factory
@Binds
@IntoMap
@ClassKey(HotWalletStepperModel::class)
fun bindHotWalletStepperModel(model: HotWalletStepperModel): Model
}
@Module
@InstallIn(SingletonComponent::class)
internal object AddExistingWalletModule {
@Provides
@Singleton
fun provideAddExistingWalletStepperStateManager(): AddExistingWalletStepperStateManager {
return AddExistingWalletStepperStateManager()
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.hotwallet.addexistingwallet.root.entity
package com.tangem.features.hotwallet.addexistingwallet.entry.entity
internal data class AddExistingWalletUM(
val onBackClick: () -> Unit,

View file

@ -1,21 +1,21 @@
package com.tangem.features.hotwallet.addexistingwallet.root.routing
package com.tangem.features.hotwallet.addexistingwallet.entry.routing
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.hotwallet.addexistingwallet.entry.AddExistingWalletModel
import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent
import com.tangem.features.hotwallet.addexistingwallet.root.AddExistingWalletModel
import com.tangem.features.hotwallet.addexistingwallet.start.AddExistingWalletStartComponent
import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent
import com.tangem.features.hotwallet.setaccesscode.SetAccessCodeComponent
import com.tangem.features.hotwallet.accesscode.AccessCodeComponent
import com.tangem.features.hotwallet.setupfinished.MobileWalletSetupFinishedComponent
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacksStub
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import javax.inject.Inject
internal class AddExistingWalletChildFactory @Inject constructor(
private val pushNotificationsComponent: PushNotificationsComponent.Factory,
private val accessCodeComponentFactory: AccessCodeComponent.Factory,
) {
fun createChild(
@ -39,23 +39,35 @@ internal class AddExistingWalletChildFactory @Inject constructor(
is AddExistingWalletRoute.BackupCompleted -> ManualBackupCompletedComponent(
context = childContext,
params = ManualBackupCompletedComponent.Params(
userWalletId = route.userWalletId,
callbacks = model.manualBackupCompletedComponentModelCallbacks,
),
)
is AddExistingWalletRoute.AccessCode -> SetAccessCodeComponent(
is AddExistingWalletRoute.SetAccessCode -> accessCodeComponentFactory.create(
context = childContext,
params = SetAccessCodeComponent.Params(
params = AccessCodeComponent.Params(
isConfirmMode = false,
userWalletId = route.userWalletId,
callbacks = model.accessCodeModelCallbacks,
),
)
is AddExistingWalletRoute.ConfirmAccessCode -> accessCodeComponentFactory.create(
context = childContext,
params = AccessCodeComponent.Params(
isConfirmMode = true,
accessCodeToConfirm = route.accessCode,
userWalletId = route.userWalletId,
callbacks = model.accessCodeModelCallbacks,
),
)
is AddExistingWalletRoute.PushNotifications -> pushNotificationsComponent.create(
context = childContext,
params = PushNotificationsParams(
modelCallbacks = PushNotificationsModelCallbacksStub(),
modelCallbacks = model.pushNotificationsCallbacks,
source = AppRoute.PushNotification.Source.Onboarding,
),
)
AddExistingWalletRoute.SetupFinished -> MobileWalletSetupFinishedComponent(
is AddExistingWalletRoute.SetupFinished -> MobileWalletSetupFinishedComponent(
context = childContext,
params = MobileWalletSetupFinishedComponent.Params(
callbacks = model.mobileWalletSetupFinishedComponentModelCallbacks,

View file

@ -1,6 +1,7 @@
package com.tangem.features.hotwallet.addexistingwallet.root.routing
package com.tangem.features.hotwallet.addexistingwallet.entry.routing
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
internal sealed class AddExistingWalletRoute : Route {
@ -12,10 +13,13 @@ internal sealed class AddExistingWalletRoute : Route {
object Import : AddExistingWalletRoute()
@Serializable
object BackupCompleted : AddExistingWalletRoute()
data class BackupCompleted(val userWalletId: UserWalletId) : AddExistingWalletRoute()
@Serializable
object AccessCode : AddExistingWalletRoute()
data class SetAccessCode(val userWalletId: UserWalletId) : AddExistingWalletRoute()
@Serializable
data class ConfirmAccessCode(val userWalletId: UserWalletId, val accessCode: String) : AddExistingWalletRoute()
@Serializable
object PushNotifications : AddExistingWalletRoute()

View file

@ -1,6 +1,7 @@
package com.tangem.features.hotwallet.addexistingwallet.root.ui
package com.tangem.features.hotwallet.addexistingwallet.entry.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.systemBarsPadding
@ -12,19 +13,29 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.hotwallet.addexistingwallet.root.routing.AddExistingWalletRoute
import com.tangem.features.hotwallet.addexistingwallet.entry.routing.AddExistingWalletRoute
import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
@Composable
internal fun AddExistingWalletContent(stackState: ChildStack<AddExistingWalletRoute, ComposableContentComponent>) {
Children(
stack = stackState,
animation = stackAnimation(slide()),
internal fun AddExistingWalletContent(
stackState: ChildStack<AddExistingWalletRoute, ComposableContentComponent>,
stepperComponent: HotWalletStepperComponent?,
) {
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.primary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
) {
it.instance.Content(Modifier.fillMaxSize())
stepperComponent?.Content(Modifier)
Children(
stack = stackState,
animation = stackAnimation(slide()),
modifier = Modifier.fillMaxSize(),
) {
it.instance.Content(Modifier.fillMaxSize())
}
}
}

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.hotwallet.addexistingwallet.im.port.model.AddExistingWalletImportModel
import com.tangem.features.hotwallet.addexistingwallet.im.port.ui.AddExistingWalletImportContent
import dagger.assisted.Assisted
@ -28,7 +29,7 @@ internal class AddExistingWalletImportComponent @AssistedInject constructor(
}
interface ModelCallbacks {
fun onWalletImported()
fun onWalletImported(userWalletId: UserWalletId)
}
data class Params(

View file

@ -1,7 +1,6 @@
package com.tangem.features.hotwallet.addexistingwallet.im.port.entity
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@ -13,11 +12,10 @@ internal data class AddExistingWalletImportUM(
val onPassphraseInfoClick: () -> Unit,
val wordsErrorText: TextReference?,
val invalidWords: ImmutableList<String>,
val createWalletEnabled: Boolean,
val createWalletProgress: Boolean,
val createWalletClick: () -> Unit,
val importWalletEnabled: Boolean,
val importWalletProgress: Boolean,
val importWalletClick: () -> Unit,
val suggestionsList: ImmutableList<String>,
val onSuggestionClick: (String) -> Unit,
val infoBottomSheetConfig: TangemBottomSheetConfig,
val readyToImport: Boolean,
)

View file

@ -1,29 +1,68 @@
package com.tangem.features.hotwallet.addexistingwallet.im.port.model
import com.tangem.core.decompose.di.GlobalUiMessageSender
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.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2
import com.tangem.core.ui.components.bottomsheets.message.icon
import com.tangem.core.ui.components.bottomsheets.message.infoBlock
import com.tangem.core.ui.components.bottomsheets.message.onClick
import com.tangem.core.ui.components.bottomsheets.message.secondaryButton
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.crypto.bip39.Mnemonic
import com.tangem.domain.core.wallets.error.SaveWalletError
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.hotwallet.MnemonicRepository
import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent
import com.tangem.features.hotwallet.addexistingwallet.im.port.entity.AddExistingWalletImportUM
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class AddExistingWalletImportModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val mnemonicRepository: MnemonicRepository,
private val tangemHotSdk: TangemHotSdk,
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
private val saveUserWalletUseCase: SaveWalletUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
private val params: AddExistingWalletImportComponent.Params = paramsContainer.require()
private val importSeedPhraseUiStateBuilder: ImportSeedPhraseUiStateBuilder
private val passphraseInfoAlertBS
get() = bottomSheetMessage {
infoBlock {
icon(R.drawable.ic_passcode_lock_56) {
type = MessageBottomSheetUMV2.Icon.Type.Accent
backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint
}
title = resourceReference(R.string.common_passphrase)
body = resourceReference(R.string.onboarding_bottom_sheet_passphrase_description)
}
secondaryButton {
text = resourceReference(R.string.common_got_it)
onClick { closeBs() }
}
}
init {
importSeedPhraseUiStateBuilder = ImportSeedPhraseUiStateBuilder(
modelScope = modelScope,
@ -36,6 +75,7 @@ internal class AddExistingWalletImportModel @Inject constructor(
passphrase = passphrase,
)
},
onPassphraseInfoClick = ::onPassphraseInfoClick,
)
}
@ -44,7 +84,43 @@ internal class AddExistingWalletImportModel @Inject constructor(
@Suppress("UnusedPrivateMember")
private fun importWallet(mnemonic: Mnemonic, passphrase: String?) {
// TODO implement importing seed phrase
params.callbacks.onWalletImported()
modelScope.launch {
setImportProgress(true)
runCatching {
val hotWalletId = tangemHotSdk.importWallet(mnemonic, passphrase?.toCharArray(), HotAuth.NoAuth)
val hotUserWalletBuilder = hotUserWalletBuilderFactory.create(hotWalletId)
val userWallet = hotUserWalletBuilder.build()
saveUserWalletUseCase.invoke(userWallet.copy(backedUp = true))
.onLeft {
setImportProgress(false)
when (it) {
is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet")
is SaveWalletError.WalletAlreadySaved -> {
uiMessageSender.send(
SnackbarMessage(resourceReference(R.string.hw_import_seed_phrase_already_imported)),
)
}
}
}
.onRight {
setImportProgress(false)
params.callbacks.onWalletImported(userWallet.walletId)
}
}.onFailure {
Timber.e(it)
setImportProgress(false)
}
}
}
private fun setImportProgress(progress: Boolean) {
uiState.update {
it.copy(importWalletProgress = progress)
}
}
private fun onPassphraseInfoClick() {
uiMessageSender.send(passphraseInfoAlertBS)
}
}

View file

@ -4,7 +4,6 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.core.TangemSdkError
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.crypto.bip39.Mnemonic
import com.tangem.crypto.bip39.MnemonicErrorResult
@ -24,6 +23,7 @@ internal class ImportSeedPhraseUiStateBuilder(
private val readyToImport: (Boolean) -> Unit,
private val updateUiState: ((AddExistingWalletImportUM) -> AddExistingWalletImportUM) -> Unit,
private val importWallet: (mnemonic: Mnemonic, passphrase: String?) -> Unit,
private val onPassphraseInfoClick: () -> Unit,
) {
private val wordsCheckJobHolder = JobHolder()
private var importedMnemonic: Mnemonic? = null
@ -35,8 +35,8 @@ internal class ImportSeedPhraseUiStateBuilder(
passPhrase = TextFieldValue(""),
wordsErrorText = null,
invalidWords = persistentListOf(),
createWalletEnabled = false,
createWalletProgress = false,
importWalletEnabled = false,
importWalletProgress = false,
suggestionsList = persistentListOf(),
wordsChange = {
launchInterceptWords(wordsField = it)
@ -49,11 +49,10 @@ internal class ImportSeedPhraseUiStateBuilder(
passphrase = it.text
updateUiState { state -> state.copy(passPhrase = it) }
},
onPassphraseInfoClick = ::showInfoBS,
createWalletClick = ::onCreateWallet,
onPassphraseInfoClick = onPassphraseInfoClick,
importWalletClick = ::onCreateWallet,
onSuggestionClick = { word -> addSuggestedWord(word) },
readyToImport = false,
infoBottomSheetConfig = TangemBottomSheetConfig.Empty,
)
}
@ -115,7 +114,7 @@ internal class ImportSeedPhraseUiStateBuilder(
updateUiState {
it.copy(
createWalletEnabled = false,
importWalletEnabled = false,
wordsErrorText = null,
)
}
@ -130,7 +129,7 @@ internal class ImportSeedPhraseUiStateBuilder(
it.copy(
invalidWords = invalidWords.toImmutableList(),
wordsErrorText = resourceReference(R.string.onboarding_seed_mnemonic_wrong_words),
createWalletEnabled = false,
importWalletEnabled = false,
)
}
return
@ -143,7 +142,7 @@ internal class ImportSeedPhraseUiStateBuilder(
it.copy(
invalidWords = emptyList<String>().toImmutableList(),
wordsErrorText = null,
createWalletEnabled = true,
importWalletEnabled = true,
)
}
readyToImport(true)
@ -154,13 +153,13 @@ internal class ImportSeedPhraseUiStateBuilder(
updateUiState {
it.copy(
wordsErrorText = resourceReference(R.string.onboarding_seed_mnemonic_invalid_checksum),
createWalletEnabled = false,
importWalletEnabled = false,
)
}
} else {
updateUiState {
it.copy(
createWalletEnabled = false,
importWalletEnabled = false,
wordsErrorText = null,
)
}
@ -168,19 +167,6 @@ internal class ImportSeedPhraseUiStateBuilder(
}
}
private fun showInfoBS() {
updateUiState { state ->
state.copy(
infoBottomSheetConfig = TangemBottomSheetConfig.Companion.Empty.copy(
isShown = true,
onDismissRequest = {
updateUiState { it.copy(infoBottomSheetConfig = TangemBottomSheetConfig.Companion.Empty) }
},
),
)
}
}
companion object {
private const val MINIMUM_WORD_LENGTH = 2
private const val WORDS_INTERCEPT_DELAY_MS = 500L

View file

@ -35,9 +35,8 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.Notifier
import com.tangem.core.ui.components.OutlineTextFieldWithIcon
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.TangemTextFieldsDefault
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.features.hotwallet.addexistingwallet.im.port.ui.utils.InvalidWordsColorTransformation
@ -91,15 +90,14 @@ internal fun AddExistingWalletImportContent(state: AddExistingWalletImportUM, mo
)
}
PrimaryButtonIconEnd(
PrimaryButton(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
text = stringResourceSafe(id = R.string.common_import),
iconResId = R.drawable.ic_tangem_24,
enabled = state.createWalletEnabled,
showProgress = state.createWalletProgress,
onClick = state.createWalletClick,
enabled = state.importWalletEnabled,
showProgress = state.importWalletProgress,
onClick = state.importWalletClick,
)
}
@ -114,8 +112,6 @@ internal fun AddExistingWalletImportContent(state: AddExistingWalletImportUM, mo
)
}
}
PassphraseInfoBottomSheet(state.infoBottomSheetConfig)
}
@Composable
@ -229,12 +225,11 @@ private fun PreviewAddExistingWalletImportContent() {
onPassphraseInfoClick = {},
wordsErrorText = null,
invalidWords = persistentListOf(),
createWalletEnabled = false,
createWalletProgress = false,
createWalletClick = {},
importWalletEnabled = false,
importWalletProgress = false,
importWalletClick = {},
suggestionsList = persistentListOf(),
onSuggestionClick = {},
infoBottomSheetConfig = TangemBottomSheetConfig.Empty,
readyToImport = false,
),
)

View file

@ -1,90 +0,0 @@
package com.tangem.features.hotwallet.addexistingwallet.im.port.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
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
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
fun PassphraseInfoBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(
config = config,
containerColor = TangemTheme.colors.background.primary,
) { _: TangemBottomSheetConfigContent.Empty ->
PassphraseInfoBottomSheetContent(config.onDismissRequest)
}
}
@Composable
fun PassphraseInfoBottomSheetContent(onDismiss: () -> Unit) {
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.primary)
.fillMaxWidth(),
) {
Icon(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = TangemTheme.dimens.size40)
.size(TangemTheme.dimens.size48),
painter = painterResource(id = R.drawable.ic_information_24),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
Text(
text = stringResourceSafe(id = R.string.common_passphrase),
modifier = Modifier
.padding(top = TangemTheme.dimens.size40)
.align(Alignment.CenterHorizontally),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h2,
)
Text(
text = stringResourceSafe(id = R.string.onboarding_bottom_sheet_passphrase_description),
modifier = Modifier
.padding(top = TangemTheme.dimens.size16)
.padding(horizontal = TangemTheme.dimens.size24)
.align(Alignment.CenterHorizontally),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
)
PrimaryButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.size16)
.padding(top = TangemTheme.dimens.size40)
.padding(bottom = TangemTheme.dimens.size32)
.fillMaxWidth(),
text = stringResourceSafe(id = R.string.common_ok),
onClick = onDismiss,
)
}
}
@Preview
@Composable
private fun PassphraseInfoBottomSheetContentPreview() {
TangemThemePreview {
PassphraseInfoBottomSheetContent({ })
}
}

View file

@ -1,95 +0,0 @@
package com.tangem.features.hotwallet.addexistingwallet.root
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.push
import com.arkivanov.decompose.router.stack.replaceCurrent
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.navigation.Router
import com.tangem.domain.settings.ShouldAskPermissionUseCase
import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent
import com.tangem.features.hotwallet.addexistingwallet.root.routing.AddExistingWalletRoute
import com.tangem.features.hotwallet.addexistingwallet.start.AddExistingWalletStartComponent
import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent
import com.tangem.features.hotwallet.setaccesscode.SetAccessCodeComponent
import com.tangem.features.hotwallet.setupfinished.MobileWalletSetupFinishedComponent
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
internal class AddExistingWalletModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase,
) : Model() {
val addExistingWalletStartModelCallbacks = AddExistingWalletStartModelCallbacks()
val addExistingWalletImportModelCallbacks = AddExistingWalletImportModelCallbacks()
val manualBackupCompletedComponentModelCallbacks = ManualBackupCompletedComponentModelCallbacks()
val accessCodeModelCallbacks = AccessCodeModelCallbacks()
val mobileWalletSetupFinishedComponentModelCallbacks = MobileWalletSetupFinishedComponentModelCallbacks()
val stackNavigation = StackNavigation<AddExistingWalletRoute>()
fun onChildBack(currentRoute: AddExistingWalletRoute) {
when (currentRoute) {
AddExistingWalletRoute.Import -> stackNavigation.pop()
AddExistingWalletRoute.BackupCompleted -> Unit
AddExistingWalletRoute.AccessCode -> stackNavigation.pop()
AddExistingWalletRoute.PushNotifications -> Unit
AddExistingWalletRoute.SetupFinished -> Unit
AddExistingWalletRoute.Start -> Unit
}
}
inner class AddExistingWalletStartModelCallbacks : AddExistingWalletStartComponent.ModelCallbacks {
override fun onBackClick() {
router.pop()
}
override fun onImportPhraseClick() {
stackNavigation.push(AddExistingWalletRoute.Import)
}
}
inner class AddExistingWalletImportModelCallbacks : AddExistingWalletImportComponent.ModelCallbacks {
override fun onWalletImported() {
stackNavigation.replaceCurrent(AddExistingWalletRoute.BackupCompleted)
}
}
inner class ManualBackupCompletedComponentModelCallbacks : ManualBackupCompletedComponent.ModelCallbacks {
override fun onContinueClick() {
stackNavigation.push(AddExistingWalletRoute.AccessCode)
}
}
inner class AccessCodeModelCallbacks : SetAccessCodeComponent.ModelCallbacks {
override fun onBackClick() {
stackNavigation.pop()
}
override fun onAccessCodeSet() {
modelScope.launch {
val shouldRequestPush = shouldAskPermissionUseCase(PUSH_PERMISSION)
if (shouldRequestPush) {
// is yet blocked by [REDACTED_TASK_KEY]
// stackNavigation.replaceCurrent(AddExistingWalletRoute.PushNotifications)
stackNavigation.replaceCurrent(AddExistingWalletRoute.SetupFinished)
} else {
stackNavigation.replaceCurrent(AddExistingWalletRoute.SetupFinished)
}
}
}
}
inner class MobileWalletSetupFinishedComponentModelCallbacks : MobileWalletSetupFinishedComponent.ModelCallbacks {
override fun onContinueClick() {
router.replaceAll(AppRoute.Wallet)
}
}
}

View file

@ -1,29 +0,0 @@
package com.tangem.features.hotwallet.addexistingwallet.root.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.AddExistingWalletComponent
import com.tangem.features.hotwallet.addexistingwallet.root.AddExistingWalletModel
import com.tangem.features.hotwallet.addexistingwallet.root.DefaultAddExistingWalletComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface AddExistingWalletModule {
@Binds
@Singleton
fun bindAddExistingWalletComponentFactory(
impl: DefaultAddExistingWalletComponent.Factory,
): AddExistingWalletComponent.Factory
@Binds
@IntoMap
@ClassKey(AddExistingWalletModel::class)
fun bindAddExistingWalletModel(model: AddExistingWalletModel): Model
}

View file

@ -1,18 +1,63 @@
package com.tangem.features.hotwallet.addexistingwallet.start
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic.SignedIn
import com.tangem.core.analytics.models.Basic.SignedIn.SignInType
import com.tangem.core.decompose.di.GlobalUiMessageSender
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.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.analytics.ParamCardCurrencyConverter
import com.tangem.domain.card.analytics.Shop
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.core.wallets.error.SaveWalletError
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.hotwallet.addexistingwallet.start.entity.AddExistingWalletStartUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val HIDE_PROGRESS_DELAY = 400L
@Suppress("LongParameterList")
@ModelScoped
internal class AddExistingWalletStartModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val saveWalletUseCase: SaveWalletUseCase,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val scanCardProcessor: ScanCardProcessor,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsRepository: SettingsRepository,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val urlOpener: UrlOpener,
private val userWalletsListManager: UserWalletsListManager,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
private val params: AddExistingWalletStartComponent.Params = paramsContainer.require()
@ -20,10 +65,119 @@ internal class AddExistingWalletStartModel @Inject constructor(
internal val uiState: StateFlow<AddExistingWalletStartUM>
field = MutableStateFlow(
AddExistingWalletStartUM(
isScanInProgress = false,
onBackClick = params.callbacks::onBackClick,
onImportPhraseClick = params.callbacks::onImportPhraseClick,
onScanCardClick = { /* [REDACTED_TODO_COMMENT] */ },
onBuyCardClick = { /* [REDACTED_TODO_COMMENT] */ },
onScanCardClick = ::onScanClick,
onBuyCardClick = ::onShopClick,
),
)
private fun onShopClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards)
analyticsEventHandler.send(Shop.ScreenOpened)
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onScanClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonScanCard)
scanCard()
}
private fun scanCard() {
modelScope.launch {
setLoading(true)
val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes()
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = shouldSaveAccessCodes,
)
val analyticsSource = AnalyticsParam.ScreensSources.Intro
scanCardProcessor.scan(
analyticsSource = analyticsSource,
onProgressStateChange = { showProgress ->
if (!showProgress) {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
} else {
setLoading(true)
}
},
onFailure = { error ->
handleScanError(error)
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
},
onSuccess = { scanResponse ->
proceedWithScanResponse(scanResponse)
},
)
}
}
private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) {
val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build()
if (userWallet == null) {
Timber.e("User wallet not created")
setLoading(false)
return
}
saveWalletUseCase(userWallet).fold(
ifLeft = {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
when (it) {
is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet")
is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet)
}
},
ifRight = {
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse)
appRouter.replaceAll(AppRoute.Wallet)
},
)
}
private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
SignedIn(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListManager.walletsCount.toString(),
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
}
private fun setLoading(isLoading: Boolean) {
uiState.update { it.copy(isScanInProgress = isLoading) }
}
fun handleScanError(error: TangemError) {
when (error) {
is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable()
is TangemSdkError -> Timber.e(error, "Scan error occurred")
else -> Timber.e(error, "Error happened")
}
}
private fun handleNfcFeatureUnavailable() {
uiMessageSender.send(
message = DialogMessage(
message = resourceReference(R.string.nfc_error_unavailable),
title = resourceReference(id = R.string.common_error),
),
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.hotwallet.addexistingwallet.start.entity
internal data class AddExistingWalletStartUM(
val isScanInProgress: Boolean,
val onBackClick: () -> Unit,
val onImportPhraseClick: () -> Unit,
val onScanCardClick: () -> Unit,

View file

@ -3,6 +3,7 @@ package com.tangem.features.hotwallet.addexistingwallet.start.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@ -61,7 +62,7 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi
)
OptionBlock(
modifier = Modifier
.padding(top = 24.dp),
.padding(top = 32.dp),
backgroundColor = TangemTheme.colors.background.secondary,
title = stringResourceSafe(R.string.wallet_import_seed_title),
description = stringResourceSafe(R.string.wallet_import_seed_description),
@ -70,23 +71,38 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi
enabled = true,
)
OptionBlock(
modifier = Modifier
.padding(top = 8.dp),
backgroundColor = TangemTheme.colors.background.secondary,
title = stringResourceSafe(R.string.wallet_import_scan_title),
description = stringResourceSafe(R.string.wallet_import_scan_description),
badge = {
Icon(
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp),
painter = painterResource(R.drawable.ic_tangem_24),
contentDescription = null,
tint = TangemTheme.colors.icon.secondary,
)
if (state.isScanInProgress) {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
.padding(2.dp),
color = TangemTheme.colors.text.primary1,
strokeWidth = TangemTheme.dimens.size2,
)
} else {
Icon(
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp),
painter = painterResource(R.drawable.ic_tangem_24),
contentDescription = null,
tint = TangemTheme.colors.icon.secondary,
)
}
},
onClick = state.onScanCardClick,
enabled = true,
)
OptionBlock(
modifier = Modifier
.padding(top = 8.dp),
backgroundColor = TangemTheme.colors.background.secondary,
title = stringResourceSafe(R.string.wallet_import_google_drive_title),
description = stringResourceSafe(R.string.wallet_import_google_drive_description),
@ -160,6 +176,7 @@ private fun PreviewCreateWalletContent() {
TangemThemePreview {
AddExistingWalletStartContent(
state = AddExistingWalletStartUM(
isScanInProgress = true,
onBackClick = {},
onImportPhraseClick = {},
onScanCardClick = {},

View file

@ -1,6 +1,5 @@
package com.tangem.features.hotwallet.common.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -8,8 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@ -17,7 +16,7 @@ import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.res.TangemTheme
internal const val DISABLED_COLORS_ALPHA = 0.5f
private const val DISABLED_COLORS_ALPHA = 0.5f
@Suppress("LongParameterList")
@Composable
@ -30,38 +29,16 @@ internal fun OptionBlock(
backgroundColor: Color,
modifier: Modifier = Modifier,
) {
val backgroundColor by animateColorAsState(
targetValue = if (enabled) {
backgroundColor
} else {
backgroundColor.copy(alpha = DISABLED_COLORS_ALPHA)
},
)
val titleColor by animateColorAsState(
targetValue = if (enabled) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.primary1.copy(alpha = DISABLED_COLORS_ALPHA)
},
)
val descriptionColor by animateColorAsState(
targetValue = if (enabled) {
TangemTheme.colors.text.tertiary
} else {
TangemTheme.colors.text.tertiary.copy(alpha = DISABLED_COLORS_ALPHA)
},
)
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 8.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.alpha(if (enabled) 1f else DISABLED_COLORS_ALPHA)
.background(
color = backgroundColor,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.conditional(onClick != null) {
.conditional(onClick != null && enabled) {
onClick?.let { clickableSingle(onClick = it) } ?: Modifier
}
.padding(16.dp),
@ -73,7 +50,7 @@ internal fun OptionBlock(
.padding(end = 4.dp),
text = title,
style = TangemTheme.typography.subtitle1,
color = titleColor,
color = TangemTheme.colors.text.primary1,
)
badge?.invoke()
}
@ -82,7 +59,7 @@ internal fun OptionBlock(
.padding(top = 4.dp),
text = description,
style = TangemTheme.typography.body2,
color = descriptionColor,
color = TangemTheme.colors.text.tertiary,
)
}
}

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