Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-22 10:09:11 +02:00
commit e4754fed3b
483 changed files with 9315 additions and 4084 deletions

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)
}

View file

@ -20,6 +20,7 @@ dependencies {
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)

View file

@ -0,0 +1,68 @@
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.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
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 = stringReference(account.accountName.value),
message = TextReference.EMPTY,
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

@ -1,18 +0,0 @@
package com.tangem.features.account.common
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.CryptoPortfolioIcon.Color
import com.tangem.domain.models.account.CryptoPortfolioIcon.Icon
data class CryptoPortfolioIconUM(
val value: Icon,
val color: Color,
) {
constructor(domainModel: CryptoPortfolioIcon) : this(
value = domainModel.value,
color = domainModel.color,
)
}
fun CryptoPortfolioIcon.toUM() = CryptoPortfolioIconUM(this)
fun CryptoPortfolioIconUM.toDomain() = CryptoPortfolioIcon.ofCustomAccount(this.value, this.color)

View file

@ -1,5 +1,8 @@
package com.tangem.features.account.createedit
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.common.ui.account.toDomain
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -9,27 +12,34 @@ 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.common.toDomain
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,
@ -37,13 +47,21 @@ internal class AccountCreateEditModel @Inject constructor(
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> get() = _uiState
private val _uiState = MutableStateFlow(value = getInitialState())
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(
@ -74,13 +92,16 @@ internal class AccountCreateEditModel @Inject constructor(
private suspend fun createNewCryptoPortfolio(params: AccountCreateEditComponent.Params.Create) {
val state = uiState.value
val name = AccountName(state.account.name).getOrNull() ?: return
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.Main, // todo account
derivationIndex = derivationIndex,
)
}
@ -88,7 +109,7 @@ internal class AccountCreateEditModel @Inject constructor(
val state = uiState.value
val name = AccountName(state.account.name).getOrNull() ?: return
val icon = state.account.portfolioIcon.toDomain()
val isNewName = name != params.account.name
val isNewName = name != params.account.accountName
val isNewIcon = icon != params.account.portfolioIcon
updateCryptoPortfolioUseCase(
icon = if (isNewIcon) icon else null,
@ -100,19 +121,19 @@ internal class AccountCreateEditModel @Inject constructor(
private fun onCloseClick() = unsaveChangeDialog()
private fun onIconSelect(icon: CryptoPortfolioIcon.Icon) {
_uiState.value = uiState.value
uiState.value = uiState.value
.updateIconSelect(icon)
.validateNewState()
}
private fun onColorSelect(color: CryptoPortfolioIcon.Color) {
_uiState.value = uiState.value
uiState.value = uiState.value
.updateColorSelect(color)
.validateNewState()
}
private fun onNameChange(name: String) {
_uiState.value = uiState.value
uiState.value = uiState.value
.updateName(name)
.validateNewState()
}
@ -122,7 +143,7 @@ internal class AccountCreateEditModel @Inject constructor(
val isAvailableForConfirm = when (params) {
is AccountCreateEditComponent.Params.Create -> isValidName
is AccountCreateEditComponent.Params.Edit -> {
val isNewName = this.account.name != params.account.name.value
val isNewName = this.account.name != params.account.accountName.value
val isNewIcon = this.account.portfolioIcon != params.account.portfolioIcon
isValidName && (isNewName || isNewIcon)
}
@ -140,4 +161,35 @@ internal class AccountCreateEditModel @Inject constructor(
onCloseClick = ::onCloseClick,
)
}
private fun updateDerivationInfo(userWalletId: UserWalletId) {
modelScope.launch(dispatchers.default) {
getUnoccupiedAccountIndexUseCase(userWalletId = userWalletId)
.onRight { derivationIndex ->
uiState.update {
it.updateDerivationIndex(derivationIndex = derivationIndex.value)
}
}
.onLeft {
handleError(
error = AccountFeatureError.CreateAccount.UnableToGetDerivationIndex,
params = mapOf("userWalletId" to userWalletId.stringValue),
)
return@launch
}
}
}
private fun handleError(error: AccountFeatureError, params: Map<String, String> = mapOf()) {
val exception = IllegalStateException(error.toString())
Timber.e(exception)
analyticsExceptionHandler.sendException(
event = ExceptionAnalyticsEvent(exception = exception, params = params),
)
messageSender.showErrorDialog(universalError = error, onDismiss = router::pop)
}
}

View file

@ -1,8 +1,8 @@
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 com.tangem.features.account.common.CryptoPortfolioIconUM
import kotlinx.collections.immutable.ImmutableList
data class AccountCreateEditUM(
@ -17,11 +17,23 @@ data class AccountCreateEditUM(
data class Account(
val name: String,
val portfolioIcon: CryptoPortfolioIconUM,
val derivationInfo: TextReference,
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>,

View file

@ -1,17 +1,17 @@
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 com.tangem.features.account.common.toUM
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
internal class AccountCreateEditUMBuilder @Inject constructor(
val params: AccountCreateEditComponent.Params,
internal class AccountCreateEditUMBuilder(
private val params: AccountCreateEditComponent.Params,
) {
private val accountColors = CryptoPortfolioIcon.Color.entries.toImmutableList()
@ -29,14 +29,16 @@ internal class AccountCreateEditUMBuilder @Inject constructor(
is AccountCreateEditComponent.Params.Create -> AccountCreateEditUM.Account(
name = "",
portfolioIcon = createIcon,
derivationInfo = TextReference.EMPTY,
derivationInfo = AccountCreateEditUM.DerivationInfo.Empty,
inputPlaceholder = resourceReference(R.string.account_form_placeholder_new_account),
onNameChange = onNameChange,
)
is AccountCreateEditComponent.Params.Edit -> AccountCreateEditUM.Account(
name = params.account.name.value,
name = params.account.accountName.value,
portfolioIcon = params.account.portfolioIcon.toUM(),
derivationInfo = TextReference.EMPTY, // todo account use Account.CryptoPortfolio.derivationIndex ?
derivationInfo = createAccountDerivationInfo(
index = (params.account as Account.CryptoPortfolio).derivationIndex.value,
),
inputPlaceholder = resourceReference(R.string.account_form_placeholder_edit_account),
onNameChange = onNameChange,
)
@ -113,5 +115,25 @@ internal class AccountCreateEditUMBuilder @Inject constructor(
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

@ -24,6 +24,9 @@ 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
@ -32,14 +35,10 @@ 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.resolveReference
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.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.common.toUM
import com.tangem.features.account.createedit.entity.AccountCreateEditUM
import com.tangem.features.account.createedit.entity.AccountCreateEditUM.Account
import kotlinx.collections.immutable.toImmutableList
@ -72,11 +71,11 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi
SpacerH24()
AccountColor(state.colorsState)
SpacerH24()
AccountIcon(state.iconsState)
AccountIcons(state.iconsState)
SpacerH8()
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = state.account.derivationInfo.resolveReference(),
text = state.account.derivationInfo.text.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
@ -93,7 +92,7 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi
}
@Composable
private fun AccountSummary(account: AccountCreateEditUM.Account) {
private fun AccountSummary(account: Account) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
@ -103,7 +102,11 @@ private fun AccountSummary(account: AccountCreateEditUM.Account) {
) {
Spacer(modifier = Modifier.height(24.dp))
AccountIcon(account)
AccountIcon(
name = stringReference(account.name),
icon = account.portfolioIcon,
size = AccountIconSize.Large,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
@ -125,34 +128,6 @@ private fun AccountSummary(account: AccountCreateEditUM.Account) {
}
}
@Composable
private fun AccountIcon(account: AccountCreateEditUM.Account) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(88.dp)
.clip(RoundedCornerShape(TangemTheme.dimens.radius24))
.background(account.portfolioIcon.color.getUiColor()),
) {
val icon = account.portfolioIcon.value
val letter = account.name.firstOrNull()
?: account.inputPlaceholder.resolveReference().first()
when {
icon == CryptoPortfolioIcon.Icon.Letter -> Text(
text = letter.uppercase(),
style = TangemTheme.typography.head,
color = TangemTheme.colors.text.constantWhite,
)
else -> Icon(
modifier = Modifier.size(44.dp),
tint = TangemTheme.colors.text.constantWhite,
imageVector = ImageVector.vectorResource(id = icon.getResId()),
contentDescription = null,
)
}
}
}
@Suppress("LongMethod", "MagicNumber")
@Composable
private fun AccountColor(colorsState: AccountCreateEditUM.Colors) {
@ -204,7 +179,7 @@ private fun AccountColor(colorsState: AccountCreateEditUM.Colors) {
@Suppress("LongMethod", "MagicNumber")
@Composable
private fun AccountIcon(iconsState: AccountCreateEditUM.Icons) {
private fun AccountIcons(iconsState: AccountCreateEditUM.Icons) {
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
@ -299,7 +274,7 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountC
buildList {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val icons = CryptoPortfolioIcon.Icon.entries.toImmutableList()
var portfolioIcon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM()
var portfolioIcon = AccountIconPreviewData.randomAccountIcon()
val first = AccountCreateEditUM(
title = stringReference("Add account"),
onCloseClick = {},
@ -308,7 +283,10 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountC
portfolioIcon = portfolioIcon,
inputPlaceholder = resourceReference(R.string.account_form_placeholder_new_account),
onNameChange = {},
derivationInfo = stringReference("Account #03 — used for address derivation."),
derivationInfo = AccountCreateEditUM.DerivationInfo.Content(
text = resourceReference(id = R.string.account_form_account_index, formatArgs = wrappedList(1)),
index = 1,
),
),
colorsState = AccountCreateEditUM.Colors(
selected = portfolioIcon.color,
@ -328,19 +306,20 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountC
)
add(first)
portfolioIcon = portfolioIcon.copy(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.random(),
)
portfolioIcon = AccountIconPreviewData.randomAccountIcon(letter = true)
val accountName = "Main account"
val second = AccountCreateEditUM(
title = stringReference("Edit account"),
onCloseClick = {},
account = Account(
portfolioIcon = portfolioIcon,
name = "Main account",
name = accountName,
inputPlaceholder = resourceReference(R.string.account_form_placeholder_edit_account),
onNameChange = {},
derivationInfo = stringReference("Account #03 — used for address derivation."),
derivationInfo = AccountCreateEditUM.DerivationInfo.Content(
text = resourceReference(id = R.string.account_form_account_index, formatArgs = wrappedList(1)),
index = 1,
),
),
colorsState = AccountCreateEditUM.Colors(
selected = portfolioIcon.color,

View file

@ -1,6 +1,7 @@
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
@ -12,7 +13,6 @@ 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.common.toUM
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.portfolioIcon
import com.tangem.features.account.details.entity.AccountDetailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -74,7 +74,7 @@ internal class AccountDetailsModel @Inject constructor(
private fun getInitialState(): AccountDetailsUM {
return AccountDetailsUM(
accountName = params.account.name.value,
accountName = params.account.accountName.value,
accountIcon = params.account.portfolioIcon.toUM(),
onCloseClick = { router.pop() },
onAccountEditClick = ::onEditAccountClick,

View file

@ -1,6 +1,6 @@
package com.tangem.features.account.details.entity
import com.tangem.features.account.common.CryptoPortfolioIconUM
import com.tangem.common.ui.account.CryptoPortfolioIconUM
data class AccountDetailsUM(
val accountName: String,

View file

@ -19,21 +19,18 @@ 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.getResId
import com.tangem.common.ui.account.getUiColor
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.components.fields.AutoSizeTextField
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.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.account.common.CryptoPortfolioIconUM
import com.tangem.features.account.common.toUM
import com.tangem.features.account.details.entity.AccountDetailsUM
@Composable
@ -145,32 +142,13 @@ private fun AccountRow(state: AccountDetailsUM) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
AccountIcon(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(9.dp)),
accountName = state.accountName,
accountIcon = state.accountIcon,
AccountRow(
title = stringReference(state.accountName),
subtitle = resourceReference(R.string.account_form_name),
icon = state.accountIcon,
modifier = Modifier.weight(1f),
isReverse = true,
)
Column(
modifier = Modifier
.weight(1f),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
Text(
text = stringResourceSafe(R.string.account_form_name),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
)
AutoSizeTextField(
textStyle = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
value = state.accountName,
singleLine = true,
readOnly = true,
onValueChange = {},
)
}
SecondarySmallButton(
config = SmallButtonConfig(
@ -181,31 +159,6 @@ private fun AccountRow(state: AccountDetailsUM) {
}
}
// todo account make reusable
@Composable
private fun AccountIcon(accountName: String, accountIcon: CryptoPortfolioIconUM, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier.background(accountIcon.color.getUiColor()),
) {
val icon = accountIcon.value
val letter = accountName.first()
when {
icon == CryptoPortfolioIcon.Icon.Letter -> Text(
text = letter.uppercase(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.constantWhite,
)
else -> Icon(
modifier = Modifier.size(20.dp),
tint = TangemTheme.colors.text.constantWhite,
imageVector = ImageVector.vectorResource(id = icon.getResId()),
contentDescription = null,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -217,20 +170,18 @@ private fun WcConnectionsContentPreview(@PreviewParameter(PreviewStateProvider::
private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountDetailsUM>(
buildList {
var portfolioIcon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM()
val accountName = "Main"
var portfolioIcon = AccountIconPreviewData.randomAccountIcon()
val first = AccountDetailsUM(
onCloseClick = {},
onAccountEditClick = {},
onManageTokensClick = {},
onArchiveAccountClick = {},
accountName = "Main",
accountName = accountName,
accountIcon = portfolioIcon,
)
add(first)
portfolioIcon = portfolioIcon.copy(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.random(),
)
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")),
@ -109,10 +113,18 @@ internal class AskBiometryModel @Inject constructor(
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 {
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)

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

@ -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

@ -53,7 +53,7 @@ internal class DisclaimerModel @Inject constructor(
val shouldAskPushPermission = notificationsRepository.shouldShowSubscribeOnNotificationsAfterUpdate()
val isHuaweiDevice = getIsHuaweiDeviceWithoutGoogleServicesUseCase()
if (shouldAskPushPermission && !isHuaweiDevice) {
router.push(AppRoute.PushNotification)
router.push(AppRoute.PushNotification(AppRoute.PushNotification.Source.Stories))
} else {
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
neverRequestPermissionUseCase(PUSH_PERMISSION)

View file

@ -51,9 +51,6 @@ dependencies {
implementation(deps.compose.coil)
implementation(deps.decompose.ext.compose)
/** Firebase */
implementation(deps.firebase.analytics)
/** Tangem libraries */
implementation(tangemDeps.card.android)
implementation(tangemDeps.card.core)

View file

@ -1,9 +0,0 @@
package com.tangem.features.home.impl.analytics
internal sealed class AnalyticsParam {
sealed class CurrencyType(val value: String) {
class Blockchain(blockchain: com.tangem.blockchain.common.Blockchain) : CurrencyType(blockchain.currency)
class Token(token: com.tangem.blockchain.common.Token) : CurrencyType(token.symbol)
}
}

View file

@ -1,14 +0,0 @@
package com.tangem.features.home.impl.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
sealed class IntroductionProcess(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Introduction Process", event, params) {
object ScreenOpened : IntroductionProcess("Introduction Process Screen Opened")
object ButtonTokensList : IntroductionProcess("Button - Tokens List")
object ButtonBuyCards : IntroductionProcess("Button - Buy Cards")
object ButtonScanCard : IntroductionProcess("Button - Scan Card")
}

View file

@ -1,23 +0,0 @@
package com.tangem.features.home.impl.analytics
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.card.CardTypesResolver
import com.tangem.utils.converter.Converter
import com.tangem.core.analytics.models.AnalyticsParam as CoreAnalyticsParam
internal class ParamCardCurrencyConverter : Converter<CardTypesResolver, CoreAnalyticsParam.WalletType?> {
override fun convert(value: CardTypesResolver): CoreAnalyticsParam.WalletType? {
if (value.isMultiwalletAllowed()) return CoreAnalyticsParam.WalletType.MultiCurrency
val type = when {
value.isTangemNote() -> AnalyticsParam.CurrencyType.Blockchain(value.getBlockchain())
value.isTangemTwins() -> AnalyticsParam.CurrencyType.Blockchain(Blockchain.Bitcoin)
value.getBlockchain() != Blockchain.Unknown -> AnalyticsParam.CurrencyType.Blockchain(value.getBlockchain())
value.getPrimaryToken() != null -> AnalyticsParam.CurrencyType.Token(value.getPrimaryToken()!!)
else -> null
} ?: return null
return CoreAnalyticsParam.WalletType.SingleCurrency(type.value)
}
}

View file

@ -1,11 +0,0 @@
package com.tangem.features.home.impl.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
internal sealed class Shop(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Shop", event, params) {
object ScreenOpened : Shop("Shop Screen Opened")
}

View file

@ -1,7 +1,5 @@
package com.tangem.features.home.impl.model
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.ktx.Firebase
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.routing.AppRoute
@ -23,20 +21,22 @@ 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.domain.wallets.usecase.SelectWalletUseCase
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.home.impl.analytics.IntroductionProcess
import com.tangem.features.home.impl.analytics.ParamCardCurrencyConverter
import com.tangem.features.home.impl.analytics.Shop
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
@ -64,16 +64,17 @@ internal class HomeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val scanCardProcessor: ScanCardProcessor,
private val saveWalletUseCase: SaveWalletUseCase,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsRepository: SettingsRepository,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val router: Router,
private val selectWalletUseCase: SelectWalletUseCase,
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() {
@ -135,10 +136,9 @@ internal class HomeModel @Inject constructor(
private fun onShopClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards)
analyticsEventHandler.send(Shop.ScreenOpened)
Firebase.analytics.appInstanceId
.addOnSuccessListener { urlOpener.openUrl(url = "$NEW_BUY_WALLET_URL&app_instance_id=$it") }
.addOnFailureListener { urlOpener.openUrl(url = NEW_BUY_WALLET_URL) }
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onSearchTokensClick() {
@ -198,24 +198,17 @@ internal class HomeModel @Inject constructor(
saveWalletUseCase(userWallet).fold(
ifLeft = {
Timber.e(it.toString(), "Unable to save user wallet")
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)
// Select the wallet using new mechanism
selectWalletUseCase(userWallet.walletId).fold(
ifLeft = {
Timber.e("Unable to select user wallet: $it")
setLoading(false)
},
ifRight = {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
appRouter.replaceAll(AppRoute.Wallet)
},
)
appRouter.replaceAll(AppRoute.Wallet)
},
)
}
@ -228,7 +221,7 @@ internal class HomeModel @Inject constructor(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = "1",
walletsCount = userWalletsListManager.walletsCount.toString(),
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
@ -241,15 +234,9 @@ internal class HomeModel @Inject constructor(
fun handleScanError(error: TangemError) {
when (error) {
is TangemSdkError.NfcFeatureIsUnavailable -> {
handleNfcFeatureUnavailable()
}
is TangemSdkError -> {
Timber.e(error, "Scan error occurred")
}
else -> {
Timber.e(error, "Error happened")
}
is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable()
is TangemSdkError -> Timber.e(error, "Scan error occurred")
else -> Timber.e(error, "Error happened")
}
}
@ -261,8 +248,4 @@ internal class HomeModel @Inject constructor(
),
)
}
companion object {
const val NEW_BUY_WALLET_URL = "https://buy.tangem.com/?utm_source=tangem-app&utm_medium=app"
}
}

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

@ -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)

View file

@ -5,10 +5,11 @@ 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.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotAuth
@ -27,7 +28,8 @@ internal class AccessCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val saveWalletUseCase: SaveWalletUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val walletsRepository: WalletsRepository,
private val tangemHotSdk: TangemHotSdk,
) : Model() {
@ -77,15 +79,45 @@ internal class AccessCodeModel @Inject constructor(
runCatching {
val userWallet = getUserWalletUseCase(userWalletId)
.getOrElse { error("User wallet with id $userWalletId not found") }
if (userWallet is UserWallet.Hot) {
val unlockHotWallet = UnlockHotWallet(userWallet.hotWalletId, HotAuth.NoAuth)
val updatedHotWalletId = tangemHotSdk.changeAuth(
unlockHotWallet = unlockHotWallet,
auth = HotAuth.Password(accessCode.toCharArray()),
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,
)
saveWalletUseCase(userWallet.copy(hotWalletId = updatedHotWalletId), canOverride = true)
params.callbacks.onAccessCodeConfirmed(params.userWalletId)
}
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)

View file

@ -12,6 +12,7 @@ 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
@ -73,8 +74,9 @@ internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) {
) {
PinTextField(
length = state.accessCodeLength,
isPasswordVisual = true,
isPasswordVisual = !state.isConfirmMode,
value = state.accessCode,
pinTextColor = PinTextColor.Primary,
onValueChange = state.onAccessCodeChange,
)
}

View file

@ -26,12 +26,13 @@ internal class DefaultHotAccessCodeRequestComponent @AssistedInject constructor(
}
override suspend fun successfulAuthentication() {
// TODO handle successful authentication
// TODO add delay
model.successfulAuthentication()
}
override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result {
model.show(hasBiometry)
override suspend fun requestPassword(
attemptRequest: HotWalletPasswordRequester.AttemptRequest,
): HotWalletPasswordRequester.Result {
model.show(attemptRequest)
return model.waitResult()
}

View file

@ -2,37 +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.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,
)
}
@ -42,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,
@ -66,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) {
@ -78,6 +128,68 @@ 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)

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

@ -13,20 +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 successfulAuthentication() {
call { successfulAuthentication() }
}
override suspend fun successfulAuthentication() = call { successfulAuthentication() }
override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result =
call { requestPassword(hasBiometry) }
override suspend fun requestPassword(
attemptRequest: HotWalletPasswordRequester.AttemptRequest,
): HotWalletPasswordRequester.Result = call { requestPassword(attemptRequest) }
override suspend fun dismiss() {
call { dismiss() }
}
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

@ -1,5 +1,6 @@
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
@ -63,6 +64,7 @@ internal class AddExistingWalletChildFactory @Inject constructor(
context = childContext,
params = PushNotificationsParams(
modelCallbacks = model.pushNotificationsCallbacks,
source = AppRoute.PushNotification.Source.Onboarding,
),
)
is AddExistingWalletRoute.SetupFinished -> MobileWalletSetupFinishedComponent(

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
@ -18,6 +17,5 @@ internal data class AddExistingWalletImportUM(
val importWalletClick: () -> Unit,
val suggestionsList: ImmutableList<String>,
val onSuggestionClick: (String) -> Unit,
val infoBottomSheetConfig: TangemBottomSheetConfig,
val readyToImport: Boolean,
)

View file

@ -1,9 +1,21 @@
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
@ -19,6 +31,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class AddExistingWalletImportModel @Inject constructor(
paramsContainer: ParamsContainer,
@ -27,12 +40,29 @@ internal class AddExistingWalletImportModel @Inject constructor(
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,
@ -45,6 +75,7 @@ internal class AddExistingWalletImportModel @Inject constructor(
passphrase = passphrase,
)
},
onPassphraseInfoClick = ::onPassphraseInfoClick,
)
}
@ -54,23 +85,42 @@ internal class AddExistingWalletImportModel @Inject constructor(
@Suppress("UnusedPrivateMember")
private fun importWallet(mnemonic: Mnemonic, passphrase: String?) {
modelScope.launch {
uiState.update {
it.copy(importWalletProgress = true)
}
setImportProgress(true)
runCatching {
val hotWalletId = tangemHotSdk.importWallet(mnemonic, passphrase?.toCharArray(), HotAuth.NoAuth)
val hotUserWalletBuilder = hotUserWalletBuilderFactory.create(hotWalletId)
val userWallet = hotUserWalletBuilder.build()
saveUserWalletUseCase(userWallet)
params.callbacks.onWalletImported(userWallet.walletId)
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)
uiState.update {
it.copy(importWalletProgress = false)
}
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
@ -49,11 +49,10 @@ internal class ImportSeedPhraseUiStateBuilder(
passphrase = it.text
updateUiState { state -> state.copy(passPhrase = it) }
},
onPassphraseInfoClick = ::showInfoBS,
onPassphraseInfoClick = onPassphraseInfoClick,
importWalletClick = ::onCreateWallet,
onSuggestionClick = { word -> addSuggestedWord(word) },
readyToImport = false,
infoBottomSheetConfig = TangemBottomSheetConfig.Empty,
)
}
@ -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

@ -37,7 +37,6 @@ import com.tangem.core.ui.components.Notifier
import com.tangem.core.ui.components.OutlineTextFieldWithIcon
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
@ -113,8 +112,6 @@ internal fun AddExistingWalletImportContent(state: AddExistingWalletImportUM, mo
)
}
}
PassphraseInfoBottomSheet(state.infoBottomSheetConfig)
}
@Composable
@ -233,7 +230,6 @@ private fun PreviewAddExistingWalletImportContent() {
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,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
@ -74,14 +75,25 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi
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,
@ -160,6 +172,7 @@ private fun PreviewCreateWalletContent() {
TangemThemePreview {
AddExistingWalletStartContent(
state = AddExistingWalletStartUM(
isScanInProgress = true,
onBackClick = {},
onImportPhraseClick = {},
onScanCardClick = {},

View file

@ -45,9 +45,8 @@ internal class CreateMobileWalletModel @Inject constructor(
runCatching {
val hotWalletId = tangemHotSdk.generateWallet(HotAuth.NoAuth, mnemonicType = MnemonicType.Words12)
val hotUserWalletBuilder = hotUserWalletBuilderFactory.create(hotWalletId)
saveUserWalletUseCase(
hotUserWalletBuilder.build(),
)
val userWallet = hotUserWalletBuilder.build()
saveUserWalletUseCase(userWallet)
router.replaceAll(AppRoute.Wallet)
}.onFailure {
Timber.e(it)

View file

@ -0,0 +1,97 @@
package com.tangem.features.hotwallet.createwalletbackup
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.push
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.domain.models.wallet.UserWalletId
import com.tangem.features.hotwallet.CreateWalletBackupComponent
import com.tangem.features.hotwallet.createwalletbackup.routing.CreateWalletBackupRoute
import com.tangem.features.hotwallet.manualbackup.check.ManualBackupCheckComponent
import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent
import com.tangem.features.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent
import com.tangem.features.hotwallet.manualbackup.start.ManualBackupStartComponent
import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@ModelScoped
internal class CreateWalletBackupModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
) : Model() {
val params = paramsContainer.require<CreateWalletBackupComponent.Params>()
val hotWalletStepperComponentModelCallback = HotWalletStepperComponentModelCallback()
val manualBackupStartModelCallbacks = ManualBackupStartModelCallbacks()
val manualBackupPhraseModelCallbacks = ManualBackupPhraseModelCallbacks()
val manualBackupCheckModelCallbacks = ManualBackupCheckModelCallbacks()
val manualBackupCompletedModelCallbacks = ManualBackupCompletedModelCallbacks()
val stackNavigation = StackNavigation<CreateWalletBackupRoute>()
val startRoute = CreateWalletBackupRoute.RecoveryPhraseStart
val currentRoute: MutableStateFlow<CreateWalletBackupRoute> = MutableStateFlow(startRoute)
fun onBack() {
when (currentRoute.value) {
is CreateWalletBackupRoute.RecoveryPhraseStart -> router.pop()
is CreateWalletBackupRoute.RecoveryPhrase -> stackNavigation.pop()
is CreateWalletBackupRoute.ConfirmBackup -> stackNavigation.pop()
is CreateWalletBackupRoute.BackupCompleted -> router.pop()
}
}
fun onManualBackupStarted() {
stackNavigation.push(CreateWalletBackupRoute.RecoveryPhrase)
}
fun onManualBackupPhraseShown() {
stackNavigation.push(CreateWalletBackupRoute.ConfirmBackup)
}
fun onManualBackupChecked() {
stackNavigation.push(CreateWalletBackupRoute.BackupCompleted)
}
fun onManualBackupCompleted() {
router.pop()
}
inner class HotWalletStepperComponentModelCallback : HotWalletStepperComponent.ModelCallback {
override fun onBackClick() {
onBack()
}
override fun onSkipClick() = Unit
}
inner class ManualBackupStartModelCallbacks : ManualBackupStartComponent.ModelCallbacks {
override fun onContinueClick() {
onManualBackupStarted()
}
}
inner class ManualBackupPhraseModelCallbacks : ManualBackupPhraseComponent.ModelCallbacks {
override fun onContinueClick() {
onManualBackupPhraseShown()
}
}
inner class ManualBackupCheckModelCallbacks : ManualBackupCheckComponent.ModelCallbacks {
override fun onCompleteClick() {
onManualBackupChecked()
}
}
inner class ManualBackupCompletedModelCallbacks : ManualBackupCompletedComponent.ModelCallbacks {
override fun onContinueClick(userWalletId: UserWalletId) {
onManualBackupCompleted()
}
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.features.hotwallet.createwalletbackup
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.hotwallet.createwalletbackup.routing.CreateWalletBackupRoute
import com.tangem.features.hotwallet.impl.R
import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
import javax.inject.Inject
internal class CreateWalletBackupStepperStateManager @Inject constructor() {
fun getStepperState(route: CreateWalletBackupRoute): HotWalletStepperComponent.StepperUM? {
return when (route) {
is CreateWalletBackupRoute.RecoveryPhraseStart -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_START,
steps = STEPS_COUNT,
title = resourceReference(R.string.common_backup),
showBackButton = true,
showSkipButton = false,
showFeedbackButton = true,
)
is CreateWalletBackupRoute.RecoveryPhrase -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_PHRASE,
steps = STEPS_COUNT,
title = resourceReference(R.string.common_backup),
showBackButton = true,
showSkipButton = false,
showFeedbackButton = true,
)
is CreateWalletBackupRoute.ConfirmBackup -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_CONFIRM,
steps = STEPS_COUNT,
title = resourceReference(R.string.common_backup),
showBackButton = true,
showSkipButton = false,
showFeedbackButton = true,
)
is CreateWalletBackupRoute.BackupCompleted -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_COMPLETED,
steps = STEPS_COUNT,
title = resourceReference(R.string.common_done),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
}
}
companion object {
private const val STEPS_COUNT = 4
private const val STEP_START = 1
private const val STEP_PHRASE = 2
private const val STEP_CONFIRM = 3
private const val STEP_COMPLETED = 4
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.hotwallet.createwalletbackup
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
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.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.CreateWalletBackupComponent
import com.tangem.features.hotwallet.createwalletbackup.routing.CreateWalletBackupChildFactory
import com.tangem.features.hotwallet.createwalletbackup.ui.CreateWalletBackupContent
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 DefaultCreateWalletBackupComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: CreateWalletBackupComponent.Params,
private val stepperStateManager: CreateWalletBackupStepperStateManager,
createWalletBackupChildFactory: CreateWalletBackupChildFactory,
stepperComponentFactory: DefaultHotWalletStepperComponent.Factory,
) : CreateWalletBackupComponent, AppComponentContext by appComponentContext {
private val model: CreateWalletBackupModel = getOrCreateModel(params)
private val innerStack = childStack(
key = "createWalletBackupInnerStack",
source = model.stackNavigation,
serializer = null,
initialConfiguration = model.startRoute,
handleBackButton = true,
childFactory = { configuration, factoryContext ->
createWalletBackupChildFactory.createChild(
route = configuration,
childContext = childByContext(factoryContext),
model = model,
)
},
)
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::onBack)
val stepperState = stepperStateManager.getStepperState(currentRoute)
stepperState?.let { stepperComponent.updateState(it) }
CreateWalletBackupContent(
stackState = stackState,
stepperComponent = stepperComponent.takeIf { stepperState != null },
modifier = modifier,
)
}
@AssistedFactory
interface Factory : CreateWalletBackupComponent.Factory {
override fun create(
context: AppComponentContext,
params: CreateWalletBackupComponent.Params,
): DefaultCreateWalletBackupComponent
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.features.hotwallet.createwalletbackup.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.CreateWalletBackupComponent
import com.tangem.features.hotwallet.createwalletbackup.CreateWalletBackupModel
import com.tangem.features.hotwallet.createwalletbackup.CreateWalletBackupStepperStateManager
import com.tangem.features.hotwallet.createwalletbackup.DefaultCreateWalletBackupComponent
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 CreateWalletBackupModuleBinds {
@Binds
@Singleton
fun bindCreateWalletBackupComponentFactory(
impl: DefaultCreateWalletBackupComponent.Factory,
): CreateWalletBackupComponent.Factory
@Binds
@IntoMap
@ClassKey(CreateWalletBackupModel::class)
fun bindCreateWalletBackupModel(model: CreateWalletBackupModel): Model
}
@Module
@InstallIn(SingletonComponent::class)
internal object CreateWalletBackupModule {
@Provides
@Singleton
fun provideCreateWalletBackupStepperStateManager(): CreateWalletBackupStepperStateManager {
return CreateWalletBackupStepperStateManager()
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.hotwallet.createwalletbackup.routing
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.hotwallet.createwalletbackup.CreateWalletBackupModel
import com.tangem.features.hotwallet.manualbackup.check.ManualBackupCheckComponent
import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent
import com.tangem.features.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent
import com.tangem.features.hotwallet.manualbackup.start.ManualBackupStartComponent
import javax.inject.Inject
internal class CreateWalletBackupChildFactory @Inject constructor() {
fun createChild(
route: CreateWalletBackupRoute,
childContext: AppComponentContext,
model: CreateWalletBackupModel,
): ComposableContentComponent = when (route) {
CreateWalletBackupRoute.RecoveryPhraseStart -> ManualBackupStartComponent(
context = childContext,
params = ManualBackupStartComponent.Params(
callbacks = model.manualBackupStartModelCallbacks,
),
)
CreateWalletBackupRoute.RecoveryPhrase -> ManualBackupPhraseComponent(
context = childContext,
params = ManualBackupPhraseComponent.Params(
userWalletId = model.params.userWalletId,
callbacks = model.manualBackupPhraseModelCallbacks,
),
)
CreateWalletBackupRoute.ConfirmBackup -> ManualBackupCheckComponent(
context = childContext,
params = ManualBackupCheckComponent.Params(
userWalletId = model.params.userWalletId,
callbacks = model.manualBackupCheckModelCallbacks,
),
)
CreateWalletBackupRoute.BackupCompleted -> ManualBackupCompletedComponent(
context = childContext,
params = ManualBackupCompletedComponent.Params(
userWalletId = model.params.userWalletId,
callbacks = model.manualBackupCompletedModelCallbacks,
),
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.hotwallet.createwalletbackup.routing
import kotlinx.serialization.Serializable
@Serializable
internal sealed interface CreateWalletBackupRoute {
@Serializable
data object RecoveryPhraseStart : CreateWalletBackupRoute
@Serializable
data object RecoveryPhrase : CreateWalletBackupRoute
@Serializable
data object ConfirmBackup : CreateWalletBackupRoute
@Serializable
data object BackupCompleted : CreateWalletBackupRoute
}

View file

@ -0,0 +1,42 @@
package com.tangem.features.hotwallet.createwalletbackup.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
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
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.createwalletbackup.routing.CreateWalletBackupRoute
import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
@Composable
internal fun CreateWalletBackupContent(
stackState: ChildStack<CreateWalletBackupRoute, ComposableContentComponent>,
stepperComponent: HotWalletStepperComponent?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.primary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
) {
stepperComponent?.Content(Modifier)
Children(
stack = stackState,
animation = stackAnimation(slide()),
modifier = Modifier.fillMaxSize(),
) {
it.instance.Content(Modifier.fillMaxSize())
}
}
}

View file

@ -1,12 +1,13 @@
package com.tangem.features.hotwallet.walletactivation.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.accesscode.AccessCodeComponent
import com.tangem.features.hotwallet.manualbackup.check.ManualBackupCheckComponent
import com.tangem.features.hotwallet.manualbackup.completed.ManualBackupCompletedComponent
import com.tangem.features.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent
import com.tangem.features.hotwallet.manualbackup.start.ManualBackupStartComponent
import com.tangem.features.hotwallet.accesscode.AccessCodeComponent
import com.tangem.features.hotwallet.setupfinished.MobileWalletSetupFinishedComponent
import com.tangem.features.hotwallet.walletactivation.entry.WalletActivationModel
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
@ -72,6 +73,7 @@ internal class WalletActivationChildFactory @Inject constructor(
context = childContext,
params = PushNotificationsParams(
modelCallbacks = model.pushNotificationsCallbacks,
source = AppRoute.PushNotification.Source.Onboarding,
),
)
is WalletActivationRoute.SetupFinished -> MobileWalletSetupFinishedComponent(

View file

@ -8,6 +8,7 @@ internal data class WalletBackupUM(
val googleDriveStatus: LabelUM?,
val onRecoveryPhraseClick: () -> Unit,
val onGoogleDriveClick: () -> Unit,
val backedUp: Boolean,
)
internal sealed class BackupStatus {

View file

@ -1,15 +1,24 @@
package com.tangem.features.hotwallet.walletbackup.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.navigation.Router
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.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.common.routing.AppRoute
import com.tangem.features.hotwallet.WalletBackupComponent
import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -22,6 +31,7 @@ internal class WalletBackupModel @Inject constructor(
getWalletUseCase: GetUserWalletUseCase,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
private val params: WalletBackupComponent.Params = paramsContainer.require()
@ -38,11 +48,31 @@ internal class WalletBackupModel @Inject constructor(
text = resourceReference(R.string.common_coming_soon),
style = LabelStyle.REGULAR,
),
onRecoveryPhraseClick = { },
onRecoveryPhraseClick = ::onRecoveryPhraseClick,
onGoogleDriveClick = { },
backedUp = false,
),
)
private val makeBackupAtFirstAlertBS
get() = bottomSheetMessage {
infoBlock {
icon(R.drawable.ic_passcode_lock_32) {
type = MessageBottomSheetUMV2.Icon.Type.Accent
backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint
}
title = resourceReference(R.string.hw_backup_need_title)
body = resourceReference(R.string.hw_backup_need_description)
}
secondaryButton {
text = resourceReference(R.string.hw_backup_need_action)
onClick {
router.push(AppRoute.CreateWalletBackup(params.userWalletId))
closeBs()
}
}
}
init {
getWalletUseCase.invokeFlow(params.userWalletId)
.map { it.getOrNull() }
@ -80,5 +110,14 @@ internal class WalletBackupModel @Inject constructor(
text = resourceReference(R.string.common_coming_soon),
style = LabelStyle.REGULAR,
),
backedUp = userWallet.backedUp,
)
private fun onRecoveryPhraseClick() {
if (uiState.value.backedUp) {
// TODO [REDACTED_TASK_KEY]
} else {
uiMessageSender.send(makeBackupAtFirstAlertBS)
}
}
}

View file

@ -96,6 +96,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onBackClick = {},
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
backedUp = false,
),
WalletBackupUM(
recoveryPhraseStatus = LabelUM(
@ -109,6 +110,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onBackClick = {},
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
backedUp = false,
),
WalletBackupUM(
recoveryPhraseStatus = LabelUM(
@ -122,6 +124,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onBackClick = {},
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
backedUp = false,
),
),
)

View file

@ -4,9 +4,14 @@ import com.tangem.core.decompose.context.AppComponentContext
interface KycComponent {
fun launch()
fun launch(params: Params)
interface Factory {
fun create(appComponentContext: AppComponentContext): KycComponent
}
data class Params(
val targetAddress: String,
val cardId: String,
)
}

View file

@ -13,8 +13,7 @@ android {
dependencies {
/** Api */
//TODO disable for release because of the permissions
// implementation(projects.features.kyc.api)
implementation(projects.features.kyc.api)
/** Domain */
implementation(projects.domain.visa)

View file

@ -1,57 +1,41 @@
package com.tangem.features.kyc
import com.sumsub.sns.core.SNSMobileSDK
import com.sumsub.sns.core.data.listener.SNSCompleteHandler
import com.sumsub.sns.core.data.listener.TokenExpirationHandler
import com.sumsub.sns.core.data.model.SNSCompletionResult
import com.sumsub.sns.core.data.model.SNSInitConfig
import com.sumsub.sns.core.data.model.SNSSDKState
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.kyc.theme.TangemSNSTheme
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.kyc.theme.TangemSNSIconHandler
import com.tangem.features.kyc.theme.TangemSNSTheme
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.Locale
class DefaultKycComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
private val kycRepositoryFactory: KycRepository.Factory,
) : KycComponent, AppComponentContext by appComponentContext {
private val kycRepository = kycRepositoryFactory.create(UserWalletId("0FFFFF"))
private val model: DefaultKycModel = getOrCreateModel()
override fun launch() {
override fun launch(params: KycComponent.Params) {
componentScope.launch {
val startInfo = kycRepository.getKycStartInfo().getOrNull() ?: return@launch
val tokenExpirationHandler = object : TokenExpirationHandler {
override fun onTokenExpired(): String? {
val newToken = runBlocking { kycRepository.getKycStartInfo().getOrNull()?.token }
return newToken
model.uiState.collect {
it?.let { startInfo ->
val tokenExpirationHandler = object : TokenExpirationHandler {
override fun onTokenExpired() = ""
}
val snsSdk = SNSMobileSDK.Builder(activity)
.withAccessToken(accessToken = startInfo.token, onTokenExpiration = tokenExpirationHandler)
.withTheme(TangemSNSTheme.theme(activity))
.withIconHandler(TangemSNSIconHandler())
.withLocale(Locale("en"))
.build()
snsSdk.launch()
}
}
val snsSdk = SNSMobileSDK.Builder(activity)
.withAccessToken(accessToken = startInfo.token, onTokenExpiration = tokenExpirationHandler)
.withConf(SNSInitConfig(strings = mapOf()))
.withTheme(TangemSNSTheme.theme(activity))
.withIconHandler(TangemSNSIconHandler())
.withLocale(Locale("en"))
.withCompleteHandler(
object : SNSCompleteHandler {
override fun onComplete(result: SNSCompletionResult, state: SNSSDKState) {
}
},
)
.build()
snsSdk.launch()
}
model.getKycToken(params)
}
@AssistedFactory

View file

@ -0,0 +1,32 @@
package com.tangem.features.kyc
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.domain.pay.KycStartInfo
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
class DefaultKycModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
kycRepositoryFactory: KycRepository.Factory,
) : Model() {
private val kycRepository = kycRepositoryFactory.create()
private val _uiState: MutableStateFlow<KycStartInfo?> = MutableStateFlow(null)
val uiState = _uiState.asStateFlow()
fun getKycToken(params: KycComponent.Params) {
modelScope.launch {
kycRepository.getKycStartInfo(address = params.targetAddress, cardId = params.cardId).getOrNull()
?.let { _uiState.emit(it) }
}
}
}

View file

@ -1,11 +1,16 @@
package com.tangem.features.kyc.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.kyc.DefaultKycComponent
import com.tangem.features.kyc.DefaultKycModel
import com.tangem.features.kyc.KycComponent
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)
@ -13,4 +18,13 @@ internal interface FeatureModule {
@Binds
fun bindComponentFactory(impl: DefaultKycComponent.Factory): KycComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(DefaultKycModel::class)
fun provideModel(model: DefaultKycModel): Model
}

View file

@ -93,6 +93,7 @@ internal class ManageTokensListManager @AssistedInject constructor(
actionsFlow = actionsFlow,
coroutineScope = this,
),
// only for onboarding case, change carefully and check repository implementation
loadUserTokensFromRemote = userWalletId != null && source == ManageTokensSource.ONBOARDING,
)

View file

@ -18,9 +18,10 @@ import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
@ -164,11 +165,12 @@ internal class MarketsTokenDetailsModel @Inject constructor(
type = percentChangeType.toChartType(),
xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24),
yAxisFormatter = { value ->
BigDecimalFormatter.formatFiatPriceUncapped(
fiatAmount = value,
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
)
value.format {
fiat(
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
}
},
)
}
@ -196,11 +198,12 @@ internal class MarketsTokenDetailsModel @Inject constructor(
val state = MutableStateFlow(
MarketsTokenDetailsUM(
tokenName = params.token.name,
priceText = BigDecimalFormatter.formatFiatPriceUncapped(
fiatAmount = params.token.tokenQuotes.currentPrice,
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
),
priceText = params.token.tokenQuotes.currentPrice.format {
fiat(
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
},
dateTimeText = resourceReference(R.string.common_today),
priceChangePercentText = params.token.tokenQuotes.h24Percent?.format { percent() },
priceChangeType = params.token.tokenQuotes.h24Percent.percentChangeType(),
@ -403,7 +406,12 @@ internal class MarketsTokenDetailsModel @Inject constructor(
state.update {
it.copy(
priceText = newInfo.quotes.currentPrice.formatAsPrice(currentAppCurrency.value),
priceText = newInfo.quotes.currentPrice.format {
fiat(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
fiatCurrencyCode = currentAppCurrency.value.code,
).price()
},
priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval(
interval = it.selectedInterval,
),
@ -490,7 +498,12 @@ internal class MarketsTokenDetailsModel @Inject constructor(
)
} ?: getDefaultDateTimeString(currentState.selectedInterval)
val priceText = (price ?: currentQuotes.value.currentPrice).formatAsPrice(currentAppCurrency.value)
val priceText = (price ?: currentQuotes.value.currentPrice).format {
fiat(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
fiatCurrencyCode = currentAppCurrency.value.code,
).price()
}
val percent = price?.let {
getChangePercentBetween(

View file

@ -5,7 +5,9 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.markets.TokenMarketExchange
import com.tangem.domain.markets.TokenMarketExchange.TrustScore
import com.tangem.features.markets.impl.R
@ -29,11 +31,12 @@ internal object ExchangeItemStateConverter : Converter<TokenMarketExchange, Toke
),
titleState = TokenItemState.TitleState.Content(text = stringReference(value.name)),
fiatAmountState = TokenItemState.FiatAmountState.Content(
text = BigDecimalFormatter.formatFiatPriceUncapped(
fiatAmount = value.volumeInUsd,
fiatCurrencyCode = "USD",
fiatCurrencySymbol = "$",
),
text = value.volumeInUsd.format {
fiat(
fiatCurrencyCode = "USD",
fiatCurrencySymbol = "$",
).price()
},
),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference(value = if (value.isCentralized) "CEX" else "DEX"),

View file

@ -1,7 +1,9 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
@ -45,11 +47,12 @@ internal class PricePerformanceConverter(
private fun BigDecimal?.convert(): String {
val currency = appCurrency()
return BigDecimalFormatter.formatFiatPriceUncapped(
fiatAmount = this,
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
)
return format {
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).price()
}
}
private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float {

View file

@ -3,22 +3,13 @@ package com.tangem.features.markets.details.impl.model.formatter
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getFiatPriceAmountWithScale
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenQuotes
import java.math.BigDecimal
import java.math.RoundingMode
internal fun BigDecimal.formatAsPrice(currency: AppCurrency): String {
return BigDecimalFormatter.formatFiatPriceUncapped(
fiatAmount = this,
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
)
}
internal fun TokenQuotes.getFormattedPercentByInterval(interval: PriceChangeInterval): String {
val percent = when (interval) {
PriceChangeInterval.H24 -> h24ChangePercent
@ -66,8 +57,8 @@ internal fun getChangePercentBetween(currentPrice: BigDecimal, previousPrice: Bi
}
internal fun getFormattedPriceChange(currentPrice: BigDecimal, updatedPrice: BigDecimal): PriceChangeType {
val current = BigDecimalFormatter.getFiatPriceUncappedWithScale(value = currentPrice).first
val updated = BigDecimalFormatter.getFiatPriceUncappedWithScale(value = updatedPrice).first
val current = getFiatPriceAmountWithScale(value = currentPrice).first
val updated = getFiatPriceAmountWithScale(value = updatedPrice).first
return when {
updated > current -> PriceChangeType.UP

View file

@ -3,6 +3,9 @@ package com.tangem.features.markets.details.impl.model.state
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
@ -57,7 +60,12 @@ internal class QuotesStateUpdater(
state.update { stateToUpdate ->
stateToUpdate.copy(
priceText = newQuotes.currentPrice.formatAsPrice(currentAppCurrency()),
priceText = newQuotes.currentPrice.format {
fiat(
fiatCurrencySymbol = currentAppCurrency().symbol,
fiatCurrencyCode = currentAppCurrency().code,
).price()
},
priceChangePercentText = newQuotes.getFormattedPercentByInterval(
interval = stateToUpdate.selectedInterval,
),

View file

@ -11,9 +11,10 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetCurrencyQuotesUseCase
@ -86,13 +87,14 @@ internal class TokenMarketBlockModel @Inject constructor(
)
state.value = state.value.copy(
currentPrice = BigDecimalFormatter.formatFiatPriceUncapped(
fiatAmount = res.fiatRate,
// TODO get currency from quotes use case [REDACTED_TASK_KEY]
fiatCurrencyCode = currentAppCurrency.value.code,
// TODO get currency from quotes use case [REDACTED_TASK_KEY]
fiatCurrencySymbol = currentAppCurrency.value.symbol,
),
currentPrice = res.fiatRate.format {
fiat(
// TODO get currency from quotes use case [REDACTED_TASK_KEY]
fiatCurrencyCode = currentAppCurrency.value.code,
// TODO get currency from quotes use case [REDACTED_TASK_KEY]
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
},
h24Percent = res.priceChange.format { percent() },
priceChangeType = PriceChangeType.fromBigDecimal(res.priceChange),
)

View file

@ -7,11 +7,7 @@ import com.tangem.common.ui.charts.state.sorted
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.compact
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.impl.R
@ -94,11 +90,12 @@ internal class MarketsTokenItemConverter(
private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {
val prevPrice = prev?.tokenQuotesShort?.currentPrice
val priceText = BigDecimalFormatter.formatFiatPriceUncapped(
fiatAmount = tokenQuotesShort.currentPrice,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
val priceText = tokenQuotesShort.currentPrice.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).price()
}
val changeType = if (prevPrice != null) {
if (tokenQuotesShort.currentPrice > prevPrice) {

View file

@ -2,8 +2,9 @@ package com.tangem.features.nft.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.nft.models.NFTAsset
interface NFTDetailsBlockComponent : ComposableContentComponent {
@ -11,6 +12,8 @@ interface NFTDetailsBlockComponent : ComposableContentComponent {
val userWalletId: UserWalletId,
val nftAsset: NFTAsset,
val nftCollectionName: String,
val title: TextReference,
val isSuccessScreen: Boolean,
)
interface Factory : ComponentFactory<Params, NFTDetailsBlockComponent>

View file

@ -22,6 +22,8 @@ class DefaultNFTDetailsBlockComponent @AssistedInject constructor(
assetName = stringReference(params.nftAsset.name.orEmpty()),
collectionName = stringReference(params.nftCollectionName),
assetImage = params.nftAsset.media?.imageUrl,
title = params.title,
isSuccessScreen = params.isSuccessScreen,
networkIconRes = getActiveIconRes(params.nftAsset.network.rawId),
)
}

View file

@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
@ -19,12 +20,15 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.common.ui.NFTLogo
import com.tangem.features.nft.impl.R
@Suppress("LongParameterList")
@Composable
internal fun NFTDetailsBlock(
title: TextReference,
assetName: TextReference,
collectionName: TextReference,
assetImage: String?,
networkIconRes: Int,
isSuccessScreen: Boolean,
) {
Column(
modifier = Modifier
@ -35,20 +39,21 @@ internal fun NFTDetailsBlock(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "NFT Asset",
text = title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.secondary,
color = TangemTheme.colors.text.tertiary,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NFTLogo(
assetImage,
networkIconRes,
background = TangemTheme.colors.background.action,
)
if (isSuccessScreen) {
NFTLogo(
assetImage,
networkIconRes,
background = TangemTheme.colors.background.action,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
@ -63,6 +68,14 @@ internal fun NFTDetailsBlock(
color = TangemTheme.colors.text.tertiary,
)
}
if (!isSuccessScreen) {
SpacerWMax()
NFTLogo(
assetImage,
networkIconRes,
background = TangemTheme.colors.background.action,
)
}
}
}
}
@ -78,6 +91,8 @@ private fun NFTDetailsBlock_Preview() {
collectionName = stringReference("NFT Collection"),
assetImage = null,
networkIconRes = R.drawable.img_polygon_22,
title = stringReference("From My Wallet"),
isSuccessScreen = false,
)
}
}

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -12,6 +13,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -73,6 +75,7 @@ internal fun MultiWalletAccessCodeEnter(
label = stringResourceSafe(id = R.string.onboarding_wallet_info_title_third),
isError = state.codesNotMatchError,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
caption = when {
state.codesNotMatchError && reEnterAccessCodeState ->
stringResourceSafe(R.string.onboarding_access_codes_doesnt_match)

View file

@ -21,6 +21,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
@ -51,6 +52,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
private val saveWalletUseCase: SaveWalletUseCase,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
private val walletsRepository: WalletsRepository,
@ -231,7 +233,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
OnboardingMultiWalletComponent.Mode.Onboarding,
OnboardingMultiWalletComponent.Mode.ContinueFinalize,
-> {
userWalletsListManager.save(
saveWalletUseCase(
userWallet = userWalletCreated.copy(
scanResponse = scanResponse.updateScanResponseAfterBackup(),
),
@ -247,13 +249,11 @@ internal class MultiWalletFinalizeModel @Inject constructor(
}
?: userWalletCreated
userWalletsListManager.update(
userWalletId = userWallet.walletId,
update = { wallet ->
wallet.requireColdWallet().copy(
scanResponse = scanResponse.updateScanResponseAfterBackup(),
)
},
saveWalletUseCase(
userWallet = userWallet.requireColdWallet().copy(
scanResponse = scanResponse.updateScanResponseAfterBackup(),
),
canOverride = true,
)
userWallet

View file

@ -20,10 +20,10 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigation
import com.tangem.core.decompose.navigation.inner.InnerNavigationState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent
import com.tangem.features.onboarding.v2.note.impl.child.create.OnboardingNoteCreateWalletComponent
import com.tangem.features.onboarding.v2.note.impl.child.topup.OnboardingNoteTopUpComponent
import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteModel
import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteCommonState
import com.tangem.features.onboarding.v2.note.impl.route.ONBOARDING_NOTE_STEPS_COUNT
@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow
internal class DefaultOnboardingNoteComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted val params: OnboardingNoteComponent.Params,
val onboardingDoneComponentFactory: OnboardingDoneComponent.Factory,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : OnboardingNoteComponent, AppComponentContext by context {
@ -100,14 +101,14 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor(
childParams = childParams,
onWalletCreated = { userWallet ->
model.onWalletCreated(userWallet)
model.stackNavigation.push(OnboardingNoteRoute.TopUp)
model.stackNavigation.push(OnboardingNoteRoute.Done)
},
),
)
OnboardingNoteRoute.TopUp -> OnboardingNoteTopUpComponent(
appComponentContext = factoryContext,
params = OnboardingNoteTopUpComponent.Params(
childParams = childParams,
OnboardingNoteRoute.Done -> onboardingDoneComponentFactory.create(
context = factoryContext,
params = OnboardingDoneComponent.Params(
mode = OnboardingDoneComponent.Mode.WalletCreated,
onDone = { params.onDone() },
),
tokenReceiveComponentFactory = tokenReceiveComponentFactory,

View file

@ -1,68 +0,0 @@
package com.tangem.features.onboarding.v2.note.impl.child.topup
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.features.onboarding.v2.note.impl.DefaultOnboardingNoteComponent
import com.tangem.features.onboarding.v2.note.impl.child.topup.model.OnboardingNoteTopUpModel
import com.tangem.features.onboarding.v2.note.impl.child.topup.ui.OnboardingNoteTopUp
import com.tangem.features.tokenreceive.TokenReceiveComponent
internal class OnboardingNoteTopUpComponent(
appComponentContext: AppComponentContext,
private val params: Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: OnboardingNoteTopUpModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TokenReceiveConfig.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
BackHandler(onBack = remember(this) { { params.childParams.onBack() } })
OnboardingNoteTopUp(
modifier = modifier,
state = state,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: TokenReceiveConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
data class Params(
val childParams: DefaultOnboardingNoteComponent.ChildParams,
val onDone: () -> Unit,
)
}

View file

@ -1,319 +0,0 @@
package com.tangem.features.onboarding.v2.note.impl.child.topup.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig
import com.tangem.core.analytics.Analytics
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.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.GetLegacyTopUpUrlUseCase
import com.tangem.domain.tokens.FetchCurrencyStatusUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.transaction.usecase.GetEnsNameUseCase
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.note.impl.child.topup.OnboardingNoteTopUpComponent
import com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state.OnboardingNoteTopUpUM
import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.isPositive
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class OnboardingNoteTopUpModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val getLegacyTopUpUrlUseCase: GetLegacyTopUpUrlUseCase,
private val urlOpener: UrlOpener,
private val clipboardManager: ClipboardManager,
private val shareManager: ShareManager,
private val rampStateManager: RampStateManager,
private val cardRepository: CardRepository,
private val saveWalletUseCase: SaveWalletUseCase,
private val walletBalanceFetcher: WalletBalanceFetcher,
private val tokensFeatureToggles: TokensFeatureToggles,
private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle,
private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase,
private val getEnsNameUseCase: GetEnsNameUseCase,
) : Model() {
private val params = paramsContainer.require<OnboardingNoteTopUpComponent.Params>()
private val commonState = params.childParams.commonState
private val scanResponse = params.childParams.commonState.value.scanResponse
private var userWallet = params.childParams.commonState.value.userWallet
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
private val _uiState = MutableStateFlow(
OnboardingNoteTopUpUM(
onRefreshBalanceClick = ::refreshBalance,
onBuyCryptoClick = ::onBuyCryptoClick,
onShowWalletAddressClick = ::onShowWalletAddressClick,
onDismissBottomSheet = ::onDismissBottomSheet,
),
)
val uiState: StateFlow<OnboardingNoteTopUpUM> = _uiState
init {
Analytics.send(OnboardingEvent.Topup.ScreenOpened)
observeArtwork()
modelScope.launch {
createUserWalletIfNull()
cardRepository.finishCardActivation(scanResponse.card.cardId)
observeCryptoCurrencyStatus()
refreshBalance()
}
}
private fun refreshBalance() {
modelScope.launch {
showBalanceLoadingProgress(true)
createUserWalletIfNull()
val userWalletId = requireNotNull(userWallet?.walletId)
if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWalletId))
.onLeft(Timber::e)
} else {
fetchCurrencyStatusUseCase(userWalletId = userWalletId, refresh = true)
}
showBalanceLoadingProgress(false)
}
}
private fun onBuyCryptoClick() {
val cryptoCurrencyStatus = params.childParams.commonState.value.cryptoCurrencyStatus ?: return
modelScope.launch {
getLegacyTopUpUrlUseCase(cryptoCurrencyStatus).onRight {
urlOpener.openUrl(it)
}
}
Analytics.send(OnboardingEvent.Topup.ButtonBuyCrypto(cryptoCurrencyStatus.currency))
}
private fun onShowWalletAddressClick() {
val currencyStatus = params.childParams.commonState.value.cryptoCurrencyStatus ?: return
val networkAddress = currencyStatus.value.networkAddress ?: return
if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) {
val userWalletId = userWallet?.walletId ?: return
modelScope.launch {
configureReceiveAddresses(
cryptoCurrencyStatus = currencyStatus,
userWalletId = userWalletId,
)?.let { bottomSheetNavigation.activate(it) }
}
} else {
_uiState.update {
it.copy(addressBottomSheetConfig = createReceiveBS(currencyStatus, networkAddress))
}
}
Analytics.send(OnboardingEvent.Topup.ButtonShowWalletAddress)
}
private fun onDismissBottomSheet() {
_uiState.update {
it.copy(addressBottomSheetConfig = null)
}
}
private suspend fun createUserWalletIfNull() {
if (userWallet != null) {
return
}
val commonState = params.childParams.commonState.value
userWallet = commonState.userWallet ?: createAndSaveUserWallet(scanResponse)
}
private fun observeArtwork() {
modelScope.launch {
params.childParams.commonState.collect {
_uiState.value = _uiState.value.copy(
cardArtwork = it.cardArtwork,
)
}
}
}
private fun observeCryptoCurrencyStatus() {
val userWalletId = userWallet?.walletId ?: return
getSingleCryptoCurrencyStatusUseCase.invokeSingleWallet(userWalletId = userWalletId)
.map { it.getOrNull() }
.filterNotNull()
.onEach(::applyCryptoCurrencyStatusToState)
.launchIn(modelScope)
}
private fun applyCryptoCurrencyStatusToState(status: CryptoCurrencyStatus) {
if (commonState.value.cryptoCurrencyStatus == null) {
loadAvailableForBuy(status)
}
commonState.update {
it.copy(cryptoCurrencyStatus = status)
}
val amount = when (status.value) {
is CryptoCurrencyStatus.Loaded -> status.value.amount
is CryptoCurrencyStatus.NoAccount -> status.value.amount
is CryptoCurrencyStatus.NoQuote -> status.value.amount
else -> null
}
val hasCurrentNetworkTransactions = when (status.value) {
is CryptoCurrencyStatus.Loaded -> status.value.hasCurrentNetworkTransactions
is CryptoCurrencyStatus.NoAccount -> status.value.hasCurrentNetworkTransactions
else -> false
}
val amountToCreateAccount = (status.value as? CryptoCurrencyStatus.NoAccount)?.amountToCreateAccount
if (amount?.isPositive() == true || hasCurrentNetworkTransactions) {
params.onDone()
}
_uiState.update {
it.copy(
amountToCreateAccount = amountToCreateAccount
?.format {
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
},
balance = amount?.format {
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
}.orEmpty(),
isTopUpDataLoading = status.value.networkAddress == null,
)
}
}
private fun showBalanceLoadingProgress(value: Boolean) {
_uiState.update {
it.copy(isRefreshing = value)
}
}
private fun loadAvailableForBuy(cryptoCurrencyStatus: CryptoCurrencyStatus) {
modelScope.launch {
val availableForBuy = rampStateManager.availableForBuy(
userWallet = userWallet ?: return@launch,
cryptoCurrency = cryptoCurrencyStatus.currency,
)
_uiState.update {
it.copy(
availableForBuy = availableForBuy == ScenarioUnavailabilityReason.None,
availableForBuyLoading = false,
)
}
}
}
private fun createReceiveBS(currencyStatus: CryptoCurrencyStatus, networkAddress: NetworkAddress) =
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = uiState.value.onDismissBottomSheet,
content = TokenReceiveBottomSheetConfig(
asset = TokenReceiveBottomSheetConfig.Asset.Currency(
name = currencyStatus.currency.name,
symbol = currencyStatus.currency.symbol,
),
network = currencyStatus.currency.network,
networkAddress = networkAddress,
showMemoDisclaimer =
currencyStatus.currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE,
onCopyClick = {
Analytics.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(currencyStatus.currency.symbol))
clipboardManager.setText(text = it, isSensitive = true)
},
onShareClick = {
Analytics.send(TokenReceiveAnalyticsEvent.ButtonShareAddress(currencyStatus.currency.symbol))
shareManager.shareText(text = it)
},
),
)
private suspend fun createAndSaveUserWallet(scanResponse: ScanResponse): UserWallet {
val wallet = requireNotNull(
value = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build(),
lazyMessage = { "User wallet not created" },
)
saveWalletUseCase(wallet, false)
return wallet
}
private suspend fun configureReceiveAddresses(
cryptoCurrencyStatus: CryptoCurrencyStatus,
userWalletId: UserWalletId,
): TokenReceiveConfig? {
val addresses = cryptoCurrencyStatus.value.networkAddress ?: return null
val ensName = getEnsNameUseCase.invoke(
userWalletId = userWalletId,
network = cryptoCurrencyStatus.currency.network,
address = addresses.defaultAddress.value,
)
val receiveAddresses = buildList {
ensName?.let { ens ->
add(
ReceiveAddressModel(
nameService = ReceiveAddressModel.NameService.Ens,
value = ens,
displayName = ens,
),
)
}
addresses.availableAddresses.map { address ->
add(
ReceiveAddressModel(
nameService = ReceiveAddressModel.NameService.Default,
value = address.value,
displayName = "${cryptoCurrencyStatus.currency.name} (${cryptoCurrencyStatus.currency.symbol})",
),
)
}
}
return TokenReceiveConfig(
shouldShowWarning = cryptoCurrencyStatus.currency.name !in getViewedTokenReceiveWarningUseCase(),
cryptoCurrency = cryptoCurrencyStatus.currency,
userWalletId = userWalletId,
showMemoDisclaimer = cryptoCurrencyStatus.currency.network.transactionExtrasType != Network
.TransactionExtrasType.NONE,
receiveAddress = receiveAddresses,
)
}
}

View file

@ -1,102 +0,0 @@
package com.tangem.features.onboarding.v2.note.impl.child.topup.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.artwork.ArtworkUM
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalTangemShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.common.ui.RefreshButton
import com.tangem.features.onboarding.v2.common.ui.WalletCard
import com.tangem.features.onboarding.v2.impl.R
import com.valentinilk.shimmer.shimmer
@Composable
fun OnboardingNoteTopUpHeader(
balance: String,
cardArtwork: ArtworkUM?,
isRefreshing: Boolean,
onRefreshBalanceClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.heightIn(min = 180.dp)
.widthIn(max = 450.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.padding(vertical = 24.dp, horizontal = 16.dp)
.fillMaxSize()
.background(
TangemTheme.colors.button.secondary,
shape = TangemTheme.shapes.roundedCornersMedium,
),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 32.dp),
) {
SpacerHMax()
Text(
text = stringResourceSafe(R.string.common_balance_title),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerH8()
Text(
modifier = if (balance.isEmpty()) {
Modifier
.width(120.dp)
.clip(RoundedCornerShape(size = TangemTheme.dimens.radius3))
.shimmer(LocalTangemShimmer.current)
} else {
Modifier
},
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
text = balance,
)
SpacerHMax()
}
}
WalletCard(
modifier = Modifier.width(120.dp).align(Alignment.TopCenter),
artwork = cardArtwork,
)
RefreshButton(
modifier = Modifier.align(Alignment.BottomCenter),
isRefreshing = isRefreshing,
onRefreshBalanceClick = onRefreshBalanceClick,
)
}
}
@Preview(showBackground = true)
@Composable
private fun OnboardinNoteTopUpHeaderPreview() {
TangemThemePreview {
OnboardingNoteTopUpHeader(
balance = "0.00000001 BTC",
cardArtwork = ArtworkUM(null, ""),
onRefreshBalanceClick = {},
isRefreshing = false,
)
}
}

View file

@ -1,130 +0,0 @@
package com.tangem.features.onboarding.v2.note.impl.child.topup.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.note.impl.ALL_STEPS_TOP_CONTAINER_WEIGHT
import com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state.OnboardingNoteTopUpUM
@Composable
fun OnboardingNoteTopUp(state: OnboardingNoteTopUpUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.CenterHorizontally,
) {
OnboardingNoteTopUpHeader(
balance = state.balance,
cardArtwork = state.cardArtwork,
onRefreshBalanceClick = state.onRefreshBalanceClick,
isRefreshing = state.isRefreshing,
modifier = Modifier
.padding(top = 64.dp)
.padding(horizontal = 24.dp)
.weight(ALL_STEPS_TOP_CONTAINER_WEIGHT)
.fillMaxWidth(),
)
Column(
modifier = Modifier.weight(1 - ALL_STEPS_TOP_CONTAINER_WEIGHT)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SpacerHMax()
Text(
text = stringResourceSafe(R.string.onboarding_topup_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp),
)
val text = if (state.amountToCreateAccount != null) {
stringResourceSafe(
R.string.onboarding_top_up_min_create_account_amount,
state.amountToCreateAccount,
)
} else {
stringResourceSafe(R.string.onboarding_top_up_body)
}
SpacerH16()
Text(
text = text,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerHMax()
}
BottomButtons(state)
state.addressBottomSheetConfig?.let { config ->
TokenReceiveBottomSheet(config = config)
}
}
}
@Composable
private fun BottomButtons(state: OnboardingNoteTopUpUM) {
if (state.availableForBuy) {
PrimaryButton(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.onboarding_top_up_button_but_crypto),
onClick = state.onBuyCryptoClick,
)
} else {
PrimaryButton(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.onboarding_button_receive_crypto),
onClick = state.onShowWalletAddressClick,
)
}
AnimatedVisibility(visible = !state.availableForBuyLoading) {
if (state.availableForBuy) {
SecondaryButton(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.onboarding_top_up_button_show_wallet_address),
onClick = state.onShowWalletAddressClick,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun OnboardingNoteTopUpPreview() {
TangemThemePreview {
OnboardingNoteTopUp(
state = OnboardingNoteTopUpUM(
availableForBuy = true,
),
)
}
}

View file

@ -1,19 +0,0 @@
package com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state
import com.tangem.core.ui.components.artwork.ArtworkUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
data class OnboardingNoteTopUpUM(
val cardArtwork: ArtworkUM? = null,
val availableForBuy: Boolean = false,
val availableForBuyLoading: Boolean = true,
val balance: String = "",
val isRefreshing: Boolean = false,
val isTopUpDataLoading: Boolean = true,
val amountToCreateAccount: String? = null,
val addressBottomSheetConfig: TangemBottomSheetConfig? = null,
val onBuyCryptoClick: () -> Unit = {},
val onShowWalletAddressClick: () -> Unit = {},
val onRefreshBalanceClick: () -> Unit = {},
val onDismissBottomSheet: () -> Unit = {},
)

View file

@ -5,7 +5,6 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent
import com.tangem.features.onboarding.v2.note.impl.DefaultOnboardingNoteComponent
import com.tangem.features.onboarding.v2.note.impl.child.create.model.OnboardingNoteCreateWalletModel
import com.tangem.features.onboarding.v2.note.impl.child.topup.model.OnboardingNoteTopUpModel
import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteModel
import dagger.Binds
import dagger.Module
@ -37,9 +36,4 @@ internal interface ModelModule {
@IntoMap
@ClassKey(OnboardingNoteCreateWalletModel::class)
fun provideNoteCreateWalletModel(model: OnboardingNoteCreateWalletModel): Model
@Binds
@IntoMap
@ClassKey(OnboardingNoteTopUpModel::class)
fun provideNoteTopUpModel(model: OnboardingNoteTopUpModel): Model
}

View file

@ -84,7 +84,7 @@ internal class OnboardingNoteModel @Inject constructor(
return if (card.wallets.isEmpty()) {
OnboardingNoteRoute.CreateWallet
} else {
OnboardingNoteRoute.TopUp
OnboardingNoteRoute.Done
}
}

View file

@ -9,7 +9,7 @@ internal sealed class OnboardingNoteRoute {
data object CreateWallet : OnboardingNoteRoute()
@Serializable
data object TopUp : OnboardingNoteRoute()
data object Done : OnboardingNoteRoute()
}
internal const val ONBOARDING_NOTE_STEPS_COUNT = 3
internal const val ONBOARDING_NOTE_STEPS_COUNT = 2

View file

@ -2,5 +2,5 @@ package com.tangem.features.onboarding.v2.note.impl.route
internal fun OnboardingNoteRoute.stepNum() = when (this) {
OnboardingNoteRoute.CreateWallet -> 1
OnboardingNoteRoute.TopUp -> 2
OnboardingNoteRoute.Done -> 2
}

View file

@ -1,7 +1,5 @@
package com.tangem.features.onboarding.v2.twin.impl.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.Message
import com.tangem.common.CompletionResult
import com.tangem.common.KeyPair
@ -9,47 +7,26 @@ import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
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.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.toWrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.domain.card.common.util.twinsIsTwinned
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.common.getTwinCardNumber
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase
import com.tangem.domain.onramp.GetLegacyTopUpUrlUseCase
import com.tangem.domain.tokens.FetchCurrencyStatusUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.transaction.usecase.GetEnsNameUseCase
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.ui.interruptBackupDialog
import com.tangem.features.onboarding.v2.impl.R
@ -58,7 +35,6 @@ import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent.Params
import com.tangem.features.onboarding.v2.twin.impl.DefaultOnboardingTwinComponent
import com.tangem.features.onboarding.v2.twin.impl.ui.TwinWalletArtworkUM
import com.tangem.features.onboarding.v2.twin.impl.ui.state.OnboardingTwinUM
import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.extensions.localizedDescriptionRes
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -67,11 +43,9 @@ import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -80,33 +54,21 @@ internal class OnboardingTwinModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
private val saveWalletUseCase: SaveWalletUseCase,
private val deleteWalletUseCase: DeleteWalletUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase,
private val tangemSdkManager: TangemSdkManager,
private val issuersConfigStorage: IssuersConfigStorage,
private val cardRepository: CardRepository,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase,
private val getLegacyTopUpUrlUseCase: GetLegacyTopUpUrlUseCase,
private val urlOpener: UrlOpener,
private val uiMessageSender: UiMessageSender,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val clipboardManager: ClipboardManager,
private val shareManager: ShareManager,
private val tokensFeatureToggles: TokensFeatureToggles,
private val walletBalanceFetcher: WalletBalanceFetcher,
private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle,
private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase,
private val getEnsNameUseCase: GetEnsNameUseCase,
) : Model() {
private val params = paramsContainer.require<OnboardingTwinComponent.Params>()
private val firstCardTwinNumber = params.scanResponse.card.getTwinCardNumber() ?: error("Not twin")
private val cryptoCurrencyStatusJobHolder = JobHolder()
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
private val _uiState = MutableStateFlow(
when (params.mode) {
Mode.WelcomeOnly -> {
@ -126,14 +88,10 @@ internal class OnboardingTwinModel @Inject constructor(
)
}
Mode.CreateWallet -> {
if (params.scanResponse.twinsIsTwinned()) {
OnboardingTwinUM.TopUpPrepare
} else {
OnboardingTwinUM.Welcome(
pairCardNumber = firstCardTwinNumber.pairNumber().number,
onContinueClick = ::navigateToFirstScan,
)
}
OnboardingTwinUM.Welcome(
pairCardNumber = firstCardTwinNumber.pairNumber().number,
onContinueClick = ::navigateToFirstScan,
)
}
},
)
@ -149,11 +107,6 @@ internal class OnboardingTwinModel @Inject constructor(
saveTwinsOnboardingShownUseCase()
}
}
OnboardingTwinUM.TopUpPrepare -> {
modelScope.launch {
setTopUpState(params.scanResponse)
}
}
else -> {}
}
}
@ -211,9 +164,9 @@ internal class OnboardingTwinModel @Inject constructor(
// remove wallet only after first step of retwin
if (params.mode == Mode.RecreateWallet) {
userWalletsListManager.delete(
listOfNotNull(UserWalletIdBuilder.scanResponse(params.scanResponse).build()),
)
UserWalletIdBuilder.scanResponse(params.scanResponse).build()?.let {
deleteWalletUseCase(it)
}
}
analyticsEventHandler.send(OnboardingEvent.CreateWallet.WalletCreatedSuccessfully())
@ -228,10 +181,7 @@ internal class OnboardingTwinModel @Inject constructor(
},
)
}
innerNavigationState.update {
it.copy(stackSize = 2)
}
innerNavigationState.update { it.copy(stackSize = 2) }
}
}
}
@ -239,7 +189,6 @@ internal class OnboardingTwinModel @Inject constructor(
private fun createSecondWallet(firstPublicKey: String) {
setLoading(true)
modelScope.launch {
val secondCardNumber = firstCardTwinNumber.pairNumber().number
val result = tangemSdkManager.createSecondTwinWallet(
@ -328,142 +277,31 @@ internal class OnboardingTwinModel @Inject constructor(
Mode.CreateWallet -> {
modelScope.launch {
setLoading(true)
setTopUpState(scanResponse)
finishActivation(scanResponse)
}.saveIn(cryptoCurrencyStatusJobHolder)
}
}
}
private suspend fun setTopUpState(scanResponse: ScanResponse) = coroutineScope {
private suspend fun finishActivation(scanResponse: ScanResponse) = coroutineScope {
val userWallet = coldUserWalletBuilderFactory.create(scanResponse).build() ?: run {
Timber.e("User wallet not created")
setLoading(false)
return@coroutineScope
}
userWalletsListManager.save(userWallet, canOverride = true)
saveWalletUseCase(
userWallet = userWallet,
canOverride = true,
).onLeft {
Timber.e("Unable to save user wallet: $it")
setLoading(false)
return@coroutineScope
}
cardRepository.finishCardActivation(params.scanResponse.card.cardId)
if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId))
} else {
fetchCurrencyStatusUseCase.invoke(userWalletId = userWallet.walletId, refresh = true)
}
.onLeft {
Timber.e("Unable to fetch currency status: $it")
setLoading(false)
}
val cryptoCurrencyStatus = getSingleCryptoCurrencyStatusUseCase.invokeSingleWallet(userWallet.walletId)
.firstOrNull()?.getOrNull()
?: run {
setLoading(false)
Timber.e("Unable to get currency status")
return@coroutineScope
}
launch {
getSingleCryptoCurrencyStatusUseCase.invokeSingleWallet(userWallet.walletId)
.collect {
it.onRight { status ->
applyCryptoCurrencyStatusToState(status)
}
}
}
_uiState.value = OnboardingTwinUM.TopUp(
onBuyCryptoClick = { onBuyCryptoClick(cryptoCurrencyStatus) },
onRefreshClick = { onRefreshBalanceClick(userWallet) },
onShowAddressClick = { onShowAddressClick(cryptoCurrencyStatus) },
isLoading = true,
)
innerNavigationState.update {
it.copy(stackSize = 4)
}
}
private fun applyCryptoCurrencyStatusToState(status: CryptoCurrencyStatus) {
val amount = (status.value as? CryptoCurrencyStatus.Loaded)?.amount ?: return
if (amount > BigDecimal.ZERO) {
params.modelCallbacks.onDone()
} else {
update<OnboardingTwinUM.TopUp> {
it.copy(
balance = BigDecimal.ZERO.format { crypto(status.currency) },
onBuyCryptoClick = { onBuyCryptoClick(status) },
onShowAddressClick = { onShowAddressClick(status) },
isLoading = false,
)
}
}
}
private fun onBuyCryptoClick(status: CryptoCurrencyStatus) {
modelScope.launch {
getLegacyTopUpUrlUseCase(status).onRight {
urlOpener.openUrl(it)
}
}
}
private fun onShowAddressClick(status: CryptoCurrencyStatus) {
val currency = status.currency
val networkAddress = status.value.networkAddress ?: return
if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) {
modelScope.launch {
configureReceiveAddresses(cryptoCurrencyStatus = status)?.let {
bottomSheetNavigation.activate(it)
}
}
} else {
update<OnboardingTwinUM.TopUp> {
it.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {
update<OnboardingTwinUM.TopUp> {
it.copy(bottomSheetConfig = TangemBottomSheetConfig.Empty)
}
},
content = TokenReceiveBottomSheetConfig(
asset = TokenReceiveBottomSheetConfig.Asset.Currency(
name = currency.name,
symbol = currency.symbol,
),
network = currency.network,
networkAddress = networkAddress,
showMemoDisclaimer =
currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE,
onCopyClick = {
Analytics.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(currency.symbol))
clipboardManager.setText(text = it, isSensitive = true)
},
onShareClick = {
Analytics.send(TokenReceiveAnalyticsEvent.ButtonShareAddress(currency.symbol))
shareManager.shareText(text = it)
},
),
),
)
}
}
}
private fun onRefreshBalanceClick(userWallet: UserWallet) {
update<OnboardingTwinUM.TopUp> {
it.copy(isLoading = true)
}
modelScope.launch {
if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId))
.onLeft(Timber::e)
} else {
fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, refresh = true)
}
}
params.modelCallbacks.onDone()
}
private fun saveWalletAndDone() {
@ -476,7 +314,15 @@ internal class OnboardingTwinModel @Inject constructor(
return@launch
}
userWalletsListManager.save(userWallet, canOverride = true)
saveWalletUseCase(
userWallet = userWallet,
canOverride = true,
).onLeft {
Timber.e("Unable to save user wallet: $it")
setLoading(false)
return@launch
}
params.modelCallbacks.onDone()
}
}
@ -526,45 +372,4 @@ internal class OnboardingTwinModel @Inject constructor(
TwinWalletArtworkUM.Leapfrog.Step.FirstCard -> TwinWalletArtworkUM.Leapfrog.Step.SecondCard
TwinWalletArtworkUM.Leapfrog.Step.SecondCard -> TwinWalletArtworkUM.Leapfrog.Step.FirstCard
}
private suspend fun configureReceiveAddresses(cryptoCurrencyStatus: CryptoCurrencyStatus): TokenReceiveConfig? {
val userWallet = coldUserWalletBuilderFactory.create(params.scanResponse).build() ?: return null
val addresses = cryptoCurrencyStatus.value.networkAddress ?: return null
val ensName = getEnsNameUseCase.invoke(
userWalletId = userWallet.walletId,
network = cryptoCurrencyStatus.currency.network,
address = addresses.defaultAddress.value,
)
val receiveAddresses = buildList {
ensName?.let { ens ->
add(
ReceiveAddressModel(
nameService = ReceiveAddressModel.NameService.Ens,
value = ens,
displayName = ens,
),
)
}
addresses.availableAddresses.map { address ->
add(
ReceiveAddressModel(
nameService = ReceiveAddressModel.NameService.Default,
value = address.value,
displayName = "${cryptoCurrencyStatus.currency.name} (${cryptoCurrencyStatus.currency.symbol})",
),
)
}
}
return TokenReceiveConfig(
shouldShowWarning = cryptoCurrencyStatus.currency.name !in getViewedTokenReceiveWarningUseCase(),
cryptoCurrency = cryptoCurrencyStatus.currency,
userWalletId = userWallet.walletId,
showMemoDisclaimer = cryptoCurrencyStatus.currency.network.transactionExtrasType != Network
.TransactionExtrasType.NONE,
receiveAddress = receiveAddresses,
)
}
}

View file

@ -18,9 +18,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH16
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemAnimations
import com.tangem.core.ui.res.TangemTheme
@ -43,11 +41,6 @@ internal fun OnboardingTwin(state: OnboardingTwinUM, modifier: Modifier = Modifi
.weight(.48f)
.fillMaxWidth(),
state = state.artwork,
balance = (state as? OnboardingTwinUM.TopUp)?.balance ?: "",
isRefreshing = state.isLoading,
onRefreshBalanceClick = {
(state as? OnboardingTwinUM.TopUp)?.onRefreshClick()
},
)
AnimatedContent(
@ -60,16 +53,10 @@ internal fun OnboardingTwin(state: OnboardingTwinUM, modifier: Modifier = Modifi
when (st) {
is OnboardingTwinUM.ResetWarning -> ResetWarning(st)
is OnboardingTwinUM.ScanCard -> ScanCard(st)
is OnboardingTwinUM.TopUp -> TopUp(st)
is OnboardingTwinUM.Welcome -> Welcome(st)
OnboardingTwinUM.TopUpPrepare -> {}
}
}
}
if (state is OnboardingTwinUM.TopUp) {
TokenReceiveBottomSheet(config = state.bottomSheetConfig)
}
}
@Suppress("LongMethod")
@ -154,55 +141,6 @@ private fun ResetWarning(state: OnboardingTwinUM.ResetWarning, modifier: Modifie
}
}
@Composable
private fun TopUp(state: OnboardingTwinUM.TopUp, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier
.padding(start = 32.dp, end = 32.dp, bottom = 16.dp)
.weight(1f)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResourceSafe(R.string.onboarding_topup_title),
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
style = TangemTheme.typography.h2,
)
SpacerH16()
Text(
text = stringResourceSafe(R.string.onboarding_top_up_body),
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
style = TangemTheme.typography.body1,
)
}
PrimaryButton(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.onboarding_top_up_button_but_crypto),
onClick = state.onBuyCryptoClick,
)
SecondaryButton(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.onboarding_top_up_button_show_wallet_address),
onClick = state.onShowAddressClick,
)
}
}
@Composable
private fun ScanCard(state: OnboardingTwinUM.ScanCard, modifier: Modifier = Modifier) {
Column(
@ -287,14 +225,6 @@ private fun Welcome(state: OnboardingTwinUM.Welcome, modifier: Modifier = Modifi
}
}
@Preview(showBackground = true)
@Composable
private fun PreviewTopUp() {
TangemThemePreview {
OnboardingTwin(OnboardingTwinUM.TopUp())
}
}
@Preview(showBackground = true)
@Composable
private fun PreviewWelcome() {

View file

@ -1,12 +1,10 @@
package com.tangem.features.onboarding.v2.twin.impl.ui
import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
@ -16,21 +14,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.zIndex
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.artwork.ArtworkUM
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.wallets.models.Artwork
import com.tangem.features.onboarding.v2.common.ui.RefreshButton
import com.tangem.features.onboarding.v2.common.ui.WalletCard
import com.tangem.features.onboarding.v2.impl.R
import kotlinx.coroutines.delay
import java.util.concurrent.TimeUnit
@ -45,8 +37,6 @@ internal sealed class TwinWalletArtworkUM {
FirstCard, SecondCard
}
}
data object TopUp : TwinWalletArtworkUM()
}
private data class CardsTransitionState(
@ -64,15 +54,10 @@ private data class WalletCardTransitionState(
val zIndex: Float = 0f,
)
@SuppressLint("UnusedBoxWithConstraintsScope")
@Suppress("LongMethod")
@Composable
internal fun TwinWalletArtworks(
state: TwinWalletArtworkUM,
balance: String,
isRefreshing: Boolean,
onRefreshBalanceClick: () -> Unit,
modifier: Modifier = Modifier,
) {
internal fun TwinWalletArtworks(state: TwinWalletArtworkUM, modifier: Modifier = Modifier) {
BoxWithConstraints(
modifier
.heightIn(min = 180.dp)
@ -110,22 +95,6 @@ internal fun TwinWalletArtworks(
}
}
AnimatedVisibility(
visible = state == TwinWalletArtworkUM.TopUp,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier
.padding(vertical = 24.dp, horizontal = 16.dp)
.fillMaxSize()
.background(
TangemTheme.colors.button.secondary,
shape = TangemTheme.shapes.roundedCornersMedium,
),
)
}
AnimatedTwinCards(
transition1 = transition1,
transition2 = transition2,
@ -133,46 +102,6 @@ internal fun TwinWalletArtworks(
.widthIn(max = 450.dp)
.matchParentSize(),
)
AnimatedVisibility(
modifier = Modifier.align(Alignment.Center),
visible = state == TwinWalletArtworkUM.TopUp,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SpacerHMax()
Text(
text = stringResourceSafe(R.string.common_balance_title),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerH8()
Text(
text = balance,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
SpacerHMax()
}
}
AnimatedVisibility(
modifier = Modifier.align(Alignment.BottomCenter),
visible = state == TwinWalletArtworkUM.TopUp,
enter = fadeIn(),
exit = fadeOut(),
) {
RefreshButton(
isRefreshing = isRefreshing,
onRefreshBalanceClick = onRefreshBalanceClick,
)
}
}
}
@ -308,26 +237,6 @@ private fun TwinWalletArtworkUM.toTransitionSetState(
)
}
}
TwinWalletArtworkUM.TopUp -> {
val scale = 0.4f
val yTranslation = -maxHeightDp * density - 24 * density
listOf(
CardsTransitionState(
walletCard1 = WalletCardTransitionState(
yTranslation = yTranslation,
xScale = scale,
yScale = scale,
zIndex = 2f,
),
walletCard2 = WalletCardTransitionState(
yTranslation = yTranslation * 0.35f,
xScale = scale * 0.8f,
yScale = scale * 0.8f,
zIndex = 1f,
),
),
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@ -341,16 +250,13 @@ private fun Preview() {
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
var state: TwinWalletArtworkUM by remember { mutableStateOf(TwinWalletArtworkUM.TopUp) }
var state: TwinWalletArtworkUM by remember { mutableStateOf(TwinWalletArtworkUM.Spread) }
TwinWalletArtworks(
state = state,
modifier = Modifier
.padding(top = 250.dp)
.fillMaxWidth(),
balance = "1 USD",
isRefreshing = false,
onRefreshBalanceClick = {},
)
var index by remember { mutableIntStateOf(0) }
@ -366,7 +272,6 @@ private fun Preview() {
TwinWalletArtworkUM.Leapfrog(step = TwinWalletArtworkUM.Leapfrog.Step.SecondCard),
TwinWalletArtworkUM.Leapfrog(step = TwinWalletArtworkUM.Leapfrog.Step.FirstCard),
TwinWalletArtworkUM.Leapfrog(step = TwinWalletArtworkUM.Leapfrog.Step.SecondCard),
TwinWalletArtworkUM.TopUp,
)
state = list[index % list.size]

View file

@ -1,7 +1,6 @@
package com.tangem.features.onboarding.v2.twin.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.features.onboarding.v2.twin.impl.ui.TwinWalletArtworkUM
@Immutable
@ -11,12 +10,6 @@ internal sealed class OnboardingTwinUM {
abstract val isLoading: Boolean
abstract val artwork: TwinWalletArtworkUM
data object TopUpPrepare : OnboardingTwinUM() {
override val stepIndex: Int = 0
override val isLoading: Boolean = false
override val artwork: TwinWalletArtworkUM = TwinWalletArtworkUM.Spread
}
data class Welcome(
override val isLoading: Boolean = false,
val pairCardNumber: Int = 2,
@ -56,23 +49,9 @@ internal sealed class OnboardingTwinUM {
override val artwork: TwinWalletArtworkUM = TwinWalletArtworkUM.Leapfrog(artworkStep)
}
data class TopUp(
override val isLoading: Boolean = false,
val balance: String = "",
val bottomSheetConfig: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty,
val onBuyCryptoClick: () -> Unit = {},
val onShowAddressClick: () -> Unit = {},
val onRefreshClick: () -> Unit = {},
) : OnboardingTwinUM() {
override val stepIndex: Int = 2
override val artwork: TwinWalletArtworkUM = TwinWalletArtworkUM.TopUp
}
fun copySealed(isLoading: Boolean = this.isLoading): OnboardingTwinUM = when (this) {
is Welcome -> copy(isLoading = isLoading)
is ResetWarning -> copy()
is ScanCard -> copy(isLoading = isLoading)
is TopUp -> copy(isLoading = isLoading)
TopUpPrepare -> this
}
}

View file

@ -1,15 +1,11 @@
package com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.ui
import androidx.compose.foundation.border
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.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
@ -19,9 +15,9 @@ import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.rows.RowContentContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.outsetBorder
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.selectedBorder
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -112,17 +108,7 @@ private fun SelectableChainRow(
RowContentContainer(
modifier = modifier
.heightIn(min = 48.dp)
.outsetBorder(
color = if (selected) TangemTheme.colors.icon.accent.copy(alpha = 0.15f) else Color.Transparent,
width = 5.dp,
shape = RoundedCornerShape(size = 18.dp),
)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.border(
width = 1.dp,
color = if (selected) TangemTheme.colors.icon.accent else Color.Transparent,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.selectedBorder(selected)
.clickable(onClick = onClick)
.padding(12.dp),
icon = {
@ -163,7 +149,7 @@ private fun Preview() {
),
),
selectedOption = SelectableChainRowUM(
event = OnboardingVisaChooseWalletComponent.Params.Event.OtherWallet,
event = OnboardingVisaChooseWalletComponent.Params.Event.TangemWallet,
icon = R.drawable.ic_tangem_24,
text = TextReference.Str("Tangem Wallet"),
),

View file

@ -20,8 +20,8 @@ import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Config
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Params
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent
@ -46,7 +46,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val otpStorage: VisaOTPStorage,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
private val saveWalletUseCase: SaveWalletUseCase,
private val uiMessageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
@ -173,7 +173,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
}
val userWallet = createUserWallet(params.scanResponse, newTokens)
userWalletsListManager.save(userWallet)
saveWalletUseCase(userWallet)
visaAuthTokenStorage.remove(params.scanResponse.card.cardId)
otpStorage.removeOTP(params.scanResponse.card.cardId)

View file

@ -153,7 +153,7 @@ private fun PinCode(
}
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(

View file

@ -21,7 +21,6 @@ import com.tangem.domain.tokens.GetAssetRequirementsUseCase
import com.tangem.domain.tokens.GetTokenListUseCase
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent
@ -210,21 +209,22 @@ internal class OnrampTokenListModel @Inject constructor(
val isOperationAvailable = checkAvailabilityByOperation(status = status)
val isNotMissedDerivation = status.value !is CryptoCurrencyStatus.MissedDerivation
val isNotLoading = status.value !is CryptoCurrencyStatus.Loading
val requirements = getAssetRequirementsUseCase(
userWalletId = userWallet.walletId,
currency = status.currency,
).getOrNull()
val isNotTrustlineRequired = requirements !is AssetRequirementsCondition.RequiredTrustline
val isAvailableForBuy = rampStateManager.checkAssetRequirements(requirements)
val isNotUnreachable = status.value !is CryptoCurrencyStatus.Unreachable
val isAvailable = when (params.filterOperation) {
OnrampOperation.BUY -> {
isNotTrustlineRequired
isAvailableForBuy
} // unreachable state is available for Buy operation
OnrampOperation.SELL -> isNotUnreachable
OnrampOperation.SWAP -> {
isNotUnreachable && isNotTrustlineRequired
isNotUnreachable && isAvailableForBuy
}
}

View file

@ -6,4 +6,5 @@ data class PushNotificationsParams(
val isBottomSheet: Boolean = false,
val nextRoute: AppRoute? = null,
val modelCallbacks: PushNotificationsModelCallbacks,
val source: AppRoute.PushNotification.Source,
)

View file

@ -53,6 +53,15 @@ sealed class PushNotificationAnalyticEvents(
),
)
data class NotificationsScreenOpened(
val source: AnalyticsParam.ScreensSources,
) : PushNotificationAnalyticEvents(
event = "Push Notification Screen Opened",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
),
)
data class NotificationsEnabled(
val isEnabled: Boolean,
) : PushNotificationAnalyticEvents(

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