Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-17 14:05:58 +03:00
commit 3cbc2dadfb
822 changed files with 12838 additions and 5366 deletions

View file

@ -1,10 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:PortfolioFetcher.kt$PortfolioFetcher.Mode.All$val onlyMultiCurrency: Boolean</ID>
<ID>NonBooleanPropertyPrefixedWithIs:PortfolioSelectorComponent.kt$PortfolioSelectorController$/** * for some Feature specific filtering * combine and update with your Feature data and [PortfolioFetcher.data] */ val isEnabled: MutableStateFlow&lt;(UserWallet, AccountStatus) -&gt; Boolean&gt;</ID>
<ID>NonBooleanPropertyPrefixedWithIs:PortfolioSelectorComponent.kt$PortfolioSelectorController$val isAccountMode: Flow&lt;Boolean&gt;</ID>
<ID>UseSumOfInsteadOfFlatMapSize:PortfolioFetcher.kt$PortfolioFetcher.Data$flatten()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -26,7 +26,7 @@ interface PortfolioFetcher {
val isSingleChoice: Boolean = balances.values
.map { it.accountsBalance.accountStatuses }
.flatten().size == 1
.sumOf { it.size } == 1
fun isSingleChoice(walletId: UserWalletId): Boolean = balances[walletId]
?.accountsBalance
@ -43,7 +43,7 @@ interface PortfolioFetcher {
}
sealed interface Mode {
data class All(val onlyMultiCurrency: Boolean) : Mode
data class All(val isOnlyMultiCurrency: Boolean) : Mode
data class Wallet(val walletId: UserWalletId) : Mode
}

View file

@ -57,6 +57,7 @@ interface PortfolioSelectorController {
* combine and update with your Feature data and [PortfolioFetcher.data]
*/
val isEnabled: MutableStateFlow<(UserWallet, AccountStatus) -> Boolean>
suspend fun isAccountModeSync(): Boolean
fun selectAccount(accountId: AccountId?)
fun selectedAccountWithData(portfolioFetcher: PortfolioFetcher): Flow<Pair<UserWallet, AccountStatus>?>

View file

@ -1,10 +1,5 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>NonBooleanPropertyPrefixedWithIs:DefaultPortfolioSelectorController.kt$DefaultPortfolioSelectorController$override val isAccountMode: Flow&lt;Boolean&gt; by lazy { isAccountsModeEnabledUseCase() }</ID>
<ID>NonBooleanPropertyPrefixedWithIs:DefaultPortfolioSelectorController.kt$DefaultPortfolioSelectorController$override val isEnabled: MutableStateFlow&lt;(UserWallet, AccountStatus) -&gt; Boolean&gt; = MutableStateFlow { _, _ -&gt; true }</ID>
<ID>NonBooleanPropertyPrefixedWithIs:DefaultPortfolioSelectorController.kt$DefaultPortfolioSelectorController$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase</ID>
<ID>NonBooleanPropertyPrefixedWithIs:PortfolioSelectorModel.kt$PortfolioSelectorModel$private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase</ID>
</CurrentIssues>
<CurrentIssues/>
</SmellBaseline>

View file

@ -0,0 +1,100 @@
package com.tangem.features.account.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.account.AccountCreateEditComponent
sealed class AccountSettingsAnalyticEvents(
category: String = "Settings / Account",
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category, event, params) {
class AccountSettingsScreenOpened : AccountSettingsAnalyticEvents(
event = "Account Settings Screen Opened",
)
class ButtonManageTokens : AccountSettingsAnalyticEvents(
event = "Button - Manage Tokens",
)
class ButtonArchiveAccount : AccountSettingsAnalyticEvents(
event = "Button - Archive Account",
)
class ButtonArchiveAccountConfirmation : AccountSettingsAnalyticEvents(
event = "Button - Archive Account Confirmation",
)
class ButtonCancelAccountArchivation : AccountSettingsAnalyticEvents(
event = "Button - Cancel Account Archivation",
)
class AccountArchived : AccountSettingsAnalyticEvents(
event = "Account Archived",
)
class ButtonEdit : AccountSettingsAnalyticEvents(
event = "Button - Edit",
)
class AccountEditScreenOpened : AccountSettingsAnalyticEvents(
event = "Account Edit Screen Opened",
)
class ButtonSave(
val name: AccountName,
val icon: CryptoPortfolioIcon,
) : AccountSettingsAnalyticEvents(
event = "Button - Save",
params = buildMap {
val accountName = when (name) {
is AccountName.Custom -> name.value
AccountName.DefaultMain -> "DefaultMain"
}
put("Name", accountName)
put("Color", icon.color.name)
put("Icon", icon.value.name)
},
)
class ButtonAddNewAccount(
val name: AccountName,
val icon: CryptoPortfolioIcon,
val derivationIndex: Int,
) : AccountSettingsAnalyticEvents(
event = "Button - Add New Account",
params = buildMap {
val accountName = when (name) {
is AccountName.Custom -> name.value
AccountName.DefaultMain -> "DefaultMain"
}
put("Name", accountName)
put("Color", icon.color.name)
put("Icon", icon.value.name)
put("Derivation", derivationIndex.toString())
},
)
class AccountError(
val source: Source,
val error: String,
) : AccountSettingsAnalyticEvents(
event = "Account Error",
params = buildMap {
put("Error", error)
},
)
enum class Source(val value: String) {
NEW_ACCOUNT("New Account"), EDIT("Edit"), ARCHIVE("Archive")
}
companion object {
fun AccountCreateEditComponent.Params.toAnalyticSource() = when (this) {
is AccountCreateEditComponent.Params.Create -> Source.NEW_ACCOUNT
is AccountCreateEditComponent.Params.Edit -> Source.EDIT
}
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.account.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
sealed class WalletSettingsAccountAnalyticEvents(
category: String = "Settings / Wallet Settings",
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category, event, params) {
class AccountCreated : WalletSettingsAccountAnalyticEvents(
event = "Account Created",
)
class AccountRecovered : WalletSettingsAccountAnalyticEvents(
event = "Account Recovered",
)
class ArchivedAccountsScreenOpened : WalletSettingsAccountAnalyticEvents(
event = "Archived Accounts Screen Opened",
)
class ButtonRecoverAccount : WalletSettingsAccountAnalyticEvents(
event = "Button - Recover Account",
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.account.archived
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.model.Model
@ -18,6 +19,7 @@ import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase
import com.tangem.domain.models.account.AccountId
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.account.analytics.WalletSettingsAccountAnalyticEvents
import com.tangem.features.account.archived.entity.AccountArchivedUM
import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder
import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder.Companion.toggleProgress
@ -40,6 +42,7 @@ internal class ArchivedAccountListModel @Inject constructor(
private val recoverCryptoPortfolioUseCase: RecoverCryptoPortfolioUseCase,
private val getArchivedAccountsUseCase: GetArchivedAccountsUseCase,
private val umBuilder: AccountArchivedUMBuilder,
private val analyticsEventHandler: AnalyticsEventHandler,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : Model() {
@ -52,6 +55,7 @@ internal class ArchivedAccountListModel @Inject constructor(
private val getArchivedAccountsJob = JobHolder()
init {
analyticsEventHandler.send(WalletSettingsAccountAnalyticEvents.ArchivedAccountsScreenOpened())
getArchivedAccounts()
}
@ -96,6 +100,7 @@ internal class ArchivedAccountListModel @Inject constructor(
}
private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch {
analyticsEventHandler.send(WalletSettingsAccountAnalyticEvents.ButtonRecoverAccount())
uiState.update { it.toggleProgress(accountId, isLoading = true) }
val result = withContext(dispatchers.default) {
recoverCryptoPortfolioUseCase(accountId)
@ -120,7 +125,7 @@ internal class ArchivedAccountListModel @Inject constructor(
messageSender.send(
DialogMessage(
title = resourceReference(R.string.account_recover_limit_dialog_title),
title = resourceReference(R.string.common_something_went_wrong),
message = resourceReference(
id = R.string.account_recover_limit_dialog_description,
formatArgs = wrappedList(AccountList.MAX_ACCOUNTS_COUNT.toString()),
@ -148,6 +153,7 @@ internal class ArchivedAccountListModel @Inject constructor(
}
private fun showSuccessRecoverMessage() {
analyticsEventHandler.send(WalletSettingsAccountAnalyticEvents.AccountRecovered())
val message = resourceReference(R.string.account_recover_success_message)
messageSender.send(ToastMessage(message = message))
}

View file

@ -4,6 +4,7 @@ import androidx.annotation.StringRes
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.toDomain
import com.tangem.common.ui.account.toUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
@ -25,6 +26,9 @@ 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.analytics.AccountSettingsAnalyticEvents
import com.tangem.features.account.analytics.AccountSettingsAnalyticEvents.Companion.toAnalyticSource
import com.tangem.features.account.analytics.WalletSettingsAccountAnalyticEvents
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
@ -54,6 +58,7 @@ internal class AccountCreateEditModel @Inject constructor(
private val addCryptoPortfolioUseCase: AddCryptoPortfolioUseCase,
private val getUnoccupiedAccountIndexUseCase: GetUnoccupiedAccountIndexUseCase,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val params = paramsContainer.require<AccountCreateEditComponent.Params>()
@ -65,6 +70,8 @@ internal class AccountCreateEditModel @Inject constructor(
init {
if (params is AccountCreateEditComponent.Params.Create) {
updateDerivationInfo(userWalletId = params.userWalletId)
} else {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.AccountEditScreenOpened())
}
}
@ -105,6 +112,12 @@ internal class AccountCreateEditModel @Inject constructor(
val icon = state.account.portfolioIcon.toDomain()
val index = state.account.derivationInfo.index ?: return
val derivationIndex = DerivationIndex(value = index).getOrNull() ?: return
val event = AccountSettingsAnalyticEvents.ButtonAddNewAccount(
name = name,
icon = icon,
derivationIndex = derivationIndex.value,
)
analyticsEventHandler.send(event)
uiState.value = uiState.value.toggleProgress(showProgress = true)
val result = addCryptoPortfolioUseCase(
@ -118,12 +131,22 @@ internal class AccountCreateEditModel @Inject constructor(
result
.onLeft(::handleAddAccountError)
.onRight {
analyticsEventHandler.send(WalletSettingsAccountAnalyticEvents.AccountCreated())
showMessage(R.string.account_create_success_message)
router.pop()
}
}
private fun handleAddAccountError(error: AddCryptoPortfolioUseCase.Error) {
val event = AccountSettingsAnalyticEvents.AccountError(
source = params.toAnalyticSource(),
error = when (error) {
is AddCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet -> error.cause.tag
is AddCryptoPortfolioUseCase.Error.DataOperationFailed -> error.cause.message.orEmpty()
},
)
analyticsEventHandler.send(event)
val isDuplicateAccountNamesError = (error as? AddCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet)
?.cause is AccountList.Error.DuplicateAccountNames
when {
@ -141,6 +164,7 @@ internal class AccountCreateEditModel @Inject constructor(
val icon = state.account.portfolioIcon.toDomain()
val isNewName = name != params.account.accountName
val isNewIcon = icon != params.account.portfolioIcon
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonSave(name, icon))
uiState.value = uiState.value.toggleProgress(showProgress = true)
val result = updateCryptoPortfolioUseCase(
@ -159,6 +183,15 @@ internal class AccountCreateEditModel @Inject constructor(
}
private fun handleEditAccountError(error: UpdateCryptoPortfolioUseCase.Error) {
val event = AccountSettingsAnalyticEvents.AccountError(
source = params.toAnalyticSource(),
error = when (error) {
is UpdateCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet -> error.cause.tag
is UpdateCryptoPortfolioUseCase.Error.DataOperationFailed -> error.cause.message.orEmpty()
UpdateCryptoPortfolioUseCase.Error.NothingToUpdate -> error::class.simpleName.orEmpty()
},
)
analyticsEventHandler.send(event)
val isDuplicateAccountNamesError = (error as? UpdateCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet)
?.cause is AccountList.Error.DuplicateAccountNames
when {
@ -274,7 +307,7 @@ internal class AccountCreateEditModel @Inject constructor(
private fun showSomethingWrong() {
val dialogMessage = DialogMessage(
title = resourceReference(R.string.common_something_went_wrong),
message = resourceReference(R.string.account_could_not_create),
message = resourceReference(R.string.account_generic_error_dialog_message),
)
messageSender.send(dialogMessage)
}

View file

@ -1,15 +1,10 @@
package com.tangem.features.account.createedit.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -30,6 +25,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.common.ui.R
import com.tangem.common.ui.account.*
import com.tangem.core.ui.components.PrimaryButton
@ -186,9 +183,9 @@ private fun AccountColor(colorsState: AccountCreateEditUM.Colors) {
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding),
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
) {
colorsState.list.forEach { color ->
colorsState.list.fastForEach { color ->
val isSelected = color == colorsState.selected
Box(
contentAlignment = Alignment.Center,
@ -236,8 +233,9 @@ private fun AccountIcons(iconsState: AccountCreateEditUM.Icons) {
FlowRow(
maxItemsInEachRow = 6,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
iconsState.list.forEachIndexed { index, icon ->
iconsState.list.fastForEachIndexed { index, icon ->
val isSelected = icon == iconsState.selected
Box(
contentAlignment = Alignment.Center,

View file

@ -2,6 +2,7 @@ package com.tangem.features.account.details
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.account.toUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -20,6 +21,7 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.analytics.AccountSettingsAnalyticEvents
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.portfolioIcon
import com.tangem.features.account.details.entity.AccountDetailsUM
import com.tangem.features.account.details.entity.AccountDetailsUM.ArchiveMode
@ -37,6 +39,7 @@ internal class AccountDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val archiveCryptoPortfolioUseCase: ArchiveCryptoPortfolioUseCase,
singleAccountSupplier: SingleAccountSupplier,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getUserWalletUseCase: GetUserWalletUseCase,
) : Model() {
@ -48,12 +51,14 @@ internal class AccountDetailsModel @Inject constructor(
private val accountId = params.account.accountId
init {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.AccountSettingsScreenOpened())
singleAccountSupplier(SingleAccountProducer.Params(accountId))
.onEach { account -> uiState.update { buildUI(account) } }
.launchIn(modelScope)
}
private fun onEditAccountClick(account: Account) {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonEdit())
router.push(AppRoute.EditAccount(account))
}
@ -62,17 +67,21 @@ internal class AccountDetailsModel @Inject constructor(
source = AppRoute.ManageTokens.Source.SETTINGS,
portfolioId = PortfolioId(account.accountId),
)
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonManageTokens())
router.push(route)
}
private fun onArchiveAccountClick() {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonArchiveAccount())
confirmArchiveDialog()
}
private fun confirmArchiveDialog() {
val secondAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
onClick = {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonCancelAccountArchivation())
},
)
val firstAction = EventMessageAction(
title = resourceReference(R.string.account_details_archive_action),
@ -90,6 +99,7 @@ internal class AccountDetailsModel @Inject constructor(
}
private fun archiveCryptoPortfolio() = modelScope.launch {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonArchiveAccountConfirmation())
uiState.update { it.toggleProgress(true) }
archiveCryptoPortfolioUseCase(accountId)
.onLeft { error ->
@ -97,6 +107,7 @@ internal class AccountDetailsModel @Inject constructor(
uiState.update { it.toggleProgress(false) }
}
.onRight {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.AccountArchived())
val message = resourceReference(R.string.account_archive_success_message)
messageSender.send(ToastMessage(message = message))
router.pop()
@ -104,21 +115,18 @@ internal class AccountDetailsModel @Inject constructor(
}
private fun failedArchiveDialog(error: ArchiveCryptoPortfolioUseCase.Error) {
val titleRes = when (error) {
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated,
is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed,
-> R.string.common_something_went_wrong
is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus,
-> R.string.account_could_not_archive_referral_program_title
}
val event = AccountSettingsAnalyticEvents.AccountError(
source = AccountSettingsAnalyticEvents.Source.ARCHIVE,
error = error.tag,
)
analyticsEventHandler.send(event)
val titleRes = R.string.common_something_went_wrong
val messageRes = when (error) {
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated,
is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed,
-> R.string.account_could_not_archive
-> R.string.account_generic_error_dialog_message
is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus,
-> R.string.account_could_not_archive_referral_program_message
}

View file

@ -72,7 +72,7 @@ internal class DefaultPortfolioFetcher @AssistedInject constructor(
private fun List<UserWallet>.filterWallets(mode: Mode): List<UserWallet> = this.filter { wallet ->
when (mode) {
is Mode.All -> if (mode.onlyMultiCurrency) wallet.isMultiCurrency else true
is Mode.All -> if (mode.isOnlyMultiCurrency) wallet.isMultiCurrency else true
is Mode.Wallet -> wallet.walletId == mode.walletId
}
}

View file

@ -29,6 +29,8 @@ internal class DefaultPortfolioSelectorController @Inject constructor(
override val isEnabled: MutableStateFlow<(UserWallet, AccountStatus) -> Boolean> = MutableStateFlow { _, _ -> true }
override suspend fun isAccountModeSync(): Boolean = isAccountsModeEnabledUseCase.invokeSync()
override fun selectAccount(accountId: AccountId?) {
_selectedAccount.tryEmit(accountId)
}

View file

@ -1,10 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:AskBiometryUM.kt$AskBiometryUM$val bottomSheetVariant: Boolean = false</ID>
<ID>BooleanPropertyNaming:AskBiometryUM.kt$AskBiometryUM$val showProgress: Boolean = false</ID>
<ID>BooleanPropertyNaming:DefaultAskBiometryComponent.kt$DefaultAskBiometryComponent$val bsShown by bsShown.collectAsStateWithLifecycle()</ID>
<ID>MultilineLambdaItParameter:AskBiometryModel.kt$AskBiometryModel${ uiMessageSender.send( SnackbarMessage(stringReference("Something went wrong. Please contact support: $it")), ) }</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -41,10 +41,10 @@ internal class DefaultAskBiometryComponent @AssistedInject constructor(
@Composable
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
val bsShown by bsShown.collectAsStateWithLifecycle()
val bsConfig = remember(this, bsShown) {
val isBSShown by bsShown.collectAsStateWithLifecycle()
val bsConfig = remember(this, isBSShown) {
TangemBottomSheetConfig(
isShown = bsShown,
isShown = isBSShown,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)

View file

@ -55,7 +55,7 @@ internal class AskBiometryModel @Inject constructor(
private val _uiState = MutableStateFlow(
AskBiometryUM(
bottomSheetVariant = params.isBottomSheetVariant,
isBottomSheetVariant = params.isBottomSheetVariant,
onAllowClick = ::onAllowClick,
onDontAllowClick = ::dontAllow,
onDismiss = ::dismiss,
@ -85,7 +85,7 @@ internal class AskBiometryModel @Inject constructor(
return@launch
}
_uiState.update { it.copy(showProgress = true) }
_uiState.update { it.copy(shouldShowProgress = true) }
/*
@ -96,7 +96,7 @@ internal class AskBiometryModel @Inject constructor(
uiMessageSender.send(
SnackbarMessage(stringReference("No selected user wallet")),
)
_uiState.update { it.copy(showProgress = false) }
_uiState.update { it.copy(shouldShowProgress = false) }
return@launch
}
@ -128,7 +128,7 @@ internal class AskBiometryModel @Inject constructor(
}
}
if (_uiState.value.bottomSheetVariant) {
if (_uiState.value.isBottomSheetVariant) {
dismissBSFlow.emit(Unit)
delay(timeMillis = 500)
}
@ -142,9 +142,9 @@ internal class AskBiometryModel @Inject constructor(
userWalletId = userWallet.walletId,
lockMethod = UserWalletsListRepository.LockMethod.Biometric,
changeUnsecured = false,
).onLeft {
).onLeft { error ->
uiMessageSender.send(
SnackbarMessage(stringReference("Something went wrong. Please contact support: $it")),
SnackbarMessage(stringReference("Something went wrong. Please contact support: $error")),
)
}
}

View file

@ -29,7 +29,7 @@ internal fun AskBiometry(state: AskBiometryUM, modifier: Modifier = Modifier) {
Column(
modifier = Modifier.weight(1f),
) {
if (state.bottomSheetVariant) {
if (state.isBottomSheetVariant) {
Header(onCloseClick = state.onDismiss)
}
@ -128,12 +128,12 @@ private fun Footer(state: AskBiometryUM, modifier: Modifier = Modifier) {
) {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
showProgress = state.showProgress,
showProgress = state.shouldShowProgress,
text = stringResourceSafe(id = R.string.save_user_wallet_agreement_allow_biometrics),
onClick = state.onAllowClick,
)
if (state.bottomSheetVariant.not()) {
if (state.isBottomSheetVariant.not()) {
SpacerH12()
SecondaryButton(
@ -199,7 +199,7 @@ private fun Preview() {
private fun PreviewBS() {
TangemThemePreview {
AskBiometry(
state = AskBiometryUM(bottomSheetVariant = true),
state = AskBiometryUM(isBottomSheetVariant = true),
)
}
}

View file

@ -3,8 +3,8 @@ package com.tangem.features.biometry.impl.ui.state
import com.tangem.core.ui.extensions.TextReference
internal data class AskBiometryUM(
val bottomSheetVariant: Boolean = false,
val showProgress: Boolean = false,
val isBottomSheetVariant: Boolean = false,
val shouldShowProgress: Boolean = false,
val error: TextReference? = null,
val onAllowClick: () -> Unit = {},
val onDontAllowClick: () -> Unit = {},

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.domain.settings)
implementation(projects.domain.wallets)
implementation(projects.domain.models)
implementation(projects.domain.hotWallet)
/** Core modules */
implementation(projects.core.configToggles)

View file

@ -2,15 +2,20 @@ package com.tangem.features.createwalletselection
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.analytics.utils.TrackingContextProxy
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.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.analytics.Shop
import com.tangem.core.ui.message.dialog.Dialogs.hotWalletCreationNotSupportedDialog
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM
import com.tangem.features.createwalletselection.impl.R
@ -28,9 +33,12 @@ import javax.inject.Inject
internal class CreateWalletSelectionModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val isHotWalletCreationSupported: IsHotWalletCreationSupported,
private val uiMessageSender: UiMessageSender,
) : Model() {
internal val uiState: StateFlow<CreateWalletSelectionUM>
@ -93,7 +101,20 @@ internal class CreateWalletSelectionModel @Inject constructor(
}
private fun onMobileWalletClick() {
router.push(AppRoute.CreateMobileWallet)
trackingContextProxy.addHotWalletContext()
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.Onboarding.ButtonMobileWallet(
source = AnalyticsParam.ScreensSources.AddNewWallet.value,
),
)
if (!isHotWalletCreationSupported()) {
uiMessageSender.send(
hotWalletCreationNotSupportedDialog(isHotWalletCreationSupported.getLeastVersionName()),
)
return
}
router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.AddNewWallet.value))
}
private fun onHardwareWalletClick() {
@ -101,8 +122,7 @@ internal class CreateWalletSelectionModel @Inject constructor(
}
private fun onBuyClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards)
analyticsEventHandler.send(Shop.ScreenOpened)
analyticsEventHandler.send(Basic.ButtonBuy(source = AnalyticsParam.ScreensSources.AddNewWallet))
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}

View file

@ -5,6 +5,9 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -22,6 +25,7 @@ import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -71,6 +75,7 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(
start = 16.dp,
top = 24.dp,
@ -113,6 +118,7 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun WalletBlock(
title: String,
@ -137,11 +143,11 @@ private fun WalletBlock(
vertical = 12.dp,
),
) {
Row {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
modifier = Modifier
.weight(1f, fill = false)
.padding(end = 8.dp),
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
@ -176,6 +182,7 @@ private fun WalletBlock(
private fun Feature(feature: CreateWalletSelectionUM.Feature, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size16),
@ -221,6 +228,8 @@ private fun AlreadyHaveTangemWalletBlock(
text = stringResourceSafe(R.string.wallet_add_hardware_purchase),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
SecondaryButton(
@ -242,7 +251,7 @@ private fun PreviewCreateWalletContent() {
onBackClick = { },
blocks = persistentListOf(
CreateWalletSelectionUM.Block(
title = resourceReference(R.string.wallet_create_hardware_title),
title = stringReference("Hardware wallet very long title"),
titleLabel = LabelUM(
text = resourceReference(R.string.common_recommended),
style = LabelStyle.ACCENT,
@ -277,6 +286,7 @@ private fun PreviewCreateWalletContent() {
onClick = { },
),
),
shouldShowAlreadyHaveWallet = true,
onBuyClick = { },
),
)

View file

@ -20,6 +20,7 @@ dependencies {
implementation(projects.domain.settings)
implementation(projects.domain.wallets)
implementation(projects.domain.models)
implementation(projects.domain.hotWallet)
/** Core modules */
implementation(projects.core.configToggles)

View file

@ -1,9 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:CreateWalletStartUM.kt$CreateWalletStartUM$val showScanSecondaryButton: Boolean</ID>
<ID>MultilineLambdaItParameter:CreateWalletStartContent.kt${ FeatureItem( iconResId = it.iconResId, text = it.text, ) }</ID>
<ID>MultilineLambdaItParameter:CreateWalletStartModel.kt$CreateWalletStartModel${ delay(HIDE_PROGRESS_DELAY) setLoading(false) when (it) { is SaveWalletError.DataError -&gt; Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -&gt; { userWalletsListRepository.unlock( userWalletId = userWallet.walletId, unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), ).onRight { appRouter.replaceAll(AppRoute.Wallet) } } } }</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -6,8 +6,9 @@ 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.analytics.models.Basic
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -18,12 +19,13 @@ 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.core.ui.message.dialog.Dialogs.hotWalletCreationNotSupportedDialog
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.analytics.ParamCardCurrencyConverter
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
@ -51,14 +53,16 @@ internal class CreateWalletStartModel @Inject constructor(
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 isHotWalletCreationSupported: IsHotWalletCreationSupported,
private val userWalletsListRepository: UserWalletsListRepository,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val params = paramsContainer.require<CreateWalletStartComponent.Params>()
@ -84,7 +88,7 @@ internal class CreateWalletStartModel @Inject constructor(
),
),
imageResId = R.drawable.img_hardware_wallet,
showScanSecondaryButton = true,
shouldShowScanSecondaryButton = true,
onPrimaryButtonClick = ::onBuyClick,
primaryButtonText = resourceReference(R.string.details_buy_wallet),
otherMethodTitle = resourceReference(R.string.welcome_create_wallet_mobile_title),
@ -112,7 +116,7 @@ internal class CreateWalletStartModel @Inject constructor(
),
),
imageResId = R.drawable.img_mobile_wallet,
showScanSecondaryButton = false,
shouldShowScanSecondaryButton = false,
onPrimaryButtonClick = ::onStartWithMobileWalletClick,
primaryButtonText = resourceReference(R.string.welcome_create_wallet_mobile_title),
otherMethodTitle = resourceReference(R.string.welcome_create_wallet_use_hardware_title),
@ -125,15 +129,38 @@ internal class CreateWalletStartModel @Inject constructor(
},
)
init {
analyticsEventHandler.send(
event = IntroductionProcess.CreateWalletIntroScreenOpened(),
)
}
private fun onScanClick() {
analyticsEventHandler.send(
event = IntroductionProcess.ButtonScanCard(AnalyticsParam.ScreensSources.CreateWalletIntro),
)
scanCard()
}
private fun onStartWithMobileWalletClick() {
router.push(AppRoute.CreateMobileWallet)
trackingContextProxy.addHotWalletContext()
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.Onboarding.ButtonMobileWallet(
source = AnalyticsParam.ScreensSources.CreateWalletIntro.value,
),
)
if (!isHotWalletCreationSupported()) {
uiMessageSender.send(
hotWalletCreationNotSupportedDialog(isHotWalletCreationSupported.getLeastVersionName()),
)
return
}
router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.CreateWalletIntro.value))
}
private fun onBuyClick() {
analyticsEventHandler.send(Basic.ButtonBuy(source = AnalyticsParam.ScreensSources.CreateWalletIntro))
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
@ -182,11 +209,11 @@ internal class CreateWalletStartModel @Inject constructor(
}
saveWalletUseCase(userWallet = userWallet).fold(
ifLeft = {
ifLeft = { error ->
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
when (it) {
is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet")
when (error) {
is SaveWalletError.DataError -> Timber.e(error.toString(), "Unable to save user wallet")
is SaveWalletError.WalletAlreadySaved -> {
userWalletsListRepository.unlock(
userWalletId = userWallet.walletId,
@ -199,28 +226,11 @@ internal class CreateWalletStartModel @Inject constructor(
},
ifRight = {
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse = scanResponse, isImported = userWallet.isImported)
appRouter.replaceAll(AppRoute.Wallet)
},
)
}
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
SignedIn(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListRepository.userWalletsSync().size.toString(),
isImported = isImported,
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
}
private fun setLoading(isLoading: Boolean) {
uiState.update { it.copy(isScanInProgress = isLoading) }
}

View file

@ -9,7 +9,7 @@ internal data class CreateWalletStartUM(
val featureItems: ImmutableList<FeatureItem>,
val imageResId: Int,
val isScanInProgress: Boolean,
val showScanSecondaryButton: Boolean,
val shouldShowScanSecondaryButton: Boolean,
val primaryButtonText: TextReference,
val onPrimaryButtonClick: () -> Unit,
val otherMethodDescription: TextReference,

View file

@ -120,10 +120,10 @@ internal fun CreateWalletStartContent(state: CreateWalletStartUM, modifier: Modi
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
state.featureItems.forEach {
state.featureItems.forEach { item ->
FeatureItem(
iconResId = it.iconResId,
text = it.text,
iconResId = item.iconResId,
text = item.text,
)
}
}
@ -143,7 +143,7 @@ internal fun CreateWalletStartContent(state: CreateWalletStartUM, modifier: Modi
)
},
bottomContent = {
if (state.showScanSecondaryButton) {
if (state.shouldShowScanSecondaryButton) {
SecondaryButtonIconEnd(
modifier = Modifier
.fillMaxWidth()
@ -232,7 +232,7 @@ internal fun CreateWalletStartContent(state: CreateWalletStartUM, modifier: Modi
minImageHeight = 160.dp,
)
}
if (!state.showScanSecondaryButton) {
if (!state.shouldShowScanSecondaryButton) {
FlowRow(
modifier = Modifier
.wrapContentWidth()
@ -425,7 +425,7 @@ private class CreateWalletStartStateProvider : CollectionPreviewParameterProvide
),
),
imageResId = R.drawable.img_hardware_wallet,
showScanSecondaryButton = true,
shouldShowScanSecondaryButton = true,
onPrimaryButtonClick = { },
primaryButtonText = resourceReference(R.string.details_buy_wallet),
otherMethodTitle = resourceReference(R.string.welcome_create_wallet_mobile_title),
@ -455,7 +455,7 @@ private class CreateWalletStartStateProvider : CollectionPreviewParameterProvide
),
),
imageResId = R.drawable.img_mobile_wallet,
showScanSecondaryButton = false,
shouldShowScanSecondaryButton = false,
onPrimaryButtonClick = { },
primaryButtonText = resourceReference(R.string.welcome_create_wallet_mobile_title),
otherMethodTitle = resourceReference(R.string.welcome_create_wallet_use_hardware_title),

View file

@ -20,6 +20,7 @@ dependencies {
implementation(projects.features.tester.api)
implementation(projects.features.createWalletSelection.api)
implementation(projects.features.hotWallet.api)
implementation(projects.features.tangempay.details.api)
/* Project - Core */
implementation(projects.core.decompose)
@ -46,6 +47,7 @@ dependencies {
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.legacy)
implementation(projects.domain.visa)
/* SDK */
// TODO: For TangemError model, should be removed after card domain scanning refactoring

View file

@ -9,7 +9,6 @@
<ID>MultilineLambdaItParameter:DetailsModel.kt$DetailsModel${ it.copy( selectFeedbackEmailTypeBSConfig = it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), ) }</ID>
<ID>MultilineLambdaItParameter:PreviewUserWalletListComponent.kt$PreviewUserWalletListComponent${ it.copy( balance = UserWalletItemUM.Balance.Loaded( value = "1.000 BTC", isFlickering = true, ), ) }</ID>
<ID>MultilineLambdaItParameter:UserWalletSaver.kt$UserWalletSaver${ val message = it.message if (!message.isNullOrEmpty()) { messageSender.send(SnackbarMessage(message)) } }</ID>
<ID>NonBooleanPropertyPrefixedWithIs:UserWalletListModel.kt$UserWalletListModel$private val isWalletSavingInProgress: MutableStateFlow&lt;Boolean&gt; = MutableStateFlow(value = false)</ID>
<ID>RedundantSuspendModifier:UserWalletSaver.kt$UserWalletSaver$suspend</ID>
<ID>UnnecessaryLet:ItemsBuilder.kt$ItemsBuilder$let(::add)</ID>
</CurrentIssues>

View file

@ -4,6 +4,9 @@ import android.content.res.Resources
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.AppInstanceIdProvider
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -13,13 +16,15 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.details.component.DetailsComponent
@ -29,6 +34,8 @@ import com.tangem.features.details.entity.DetailsUM
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.version.AppVersionProvider
import kotlinx.collections.immutable.ImmutableList
@ -46,7 +53,7 @@ import javax.inject.Inject
@Suppress("LongParameterList")
internal class DetailsModel @Inject constructor(
socialsBuilder: SocialsBuilder,
itemsBuilder: ItemsBuilder,
private val itemsBuilder: ItemsBuilder,
private val appVersionProvider: AppVersionProvider,
private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase,
private val router: Router,
@ -60,6 +67,11 @@ internal class DetailsModel @Inject constructor(
private val getWalletsUseCase: GetWalletsUseCase,
private val feedbackFeatureToggles: FeedbackFeatureToggles,
override val dispatchers: CoroutineDispatcherProvider,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val analyticsEventHandler: AnalyticsEventHandler,
private val tangemPayEligibilityManager: TangemPayEligibilityManager,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
) : Model() {
private val params: DetailsComponent.Params = paramsContainer.require()
@ -92,6 +104,8 @@ internal class DetailsModel @Inject constructor(
),
)
addTangemPayItemIfEligible()
state = MutableStateFlow(
value = DetailsUM(
items = items.value,
@ -216,7 +230,12 @@ internal class DetailsModel @Inject constructor(
private fun onBuyClick() {
modelScope.launch {
urlOpener.openUrl(buildBuyLink())
if (hotWalletFeatureToggles.isHotWalletEnabled) {
analyticsEventHandler.send(Basic.ButtonBuy(source = AnalyticsParam.ScreensSources.Settings))
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
} else {
urlOpener.openUrl(buildBuyLink())
}
}
}
@ -226,6 +245,16 @@ internal class DetailsModel @Inject constructor(
}
}
private fun addTangemPayItemIfEligible() {
if (!tangemPayFeatureToggles.isTangemPayEnabled) return
modelScope.launch {
val isEligible = tangemPayEligibilityManager.getEligibleWallets().isNotEmpty()
if (isEligible) {
items.update { itemsBuilder.addVisaItem(it) }
}
}
}
private fun getAppVersion(): String = "${appVersionProvider.versionName} (${appVersionProvider.versionCode})"
private suspend fun buildBuyLink(): String {

View file

@ -3,6 +3,9 @@ package com.tangem.features.details.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.userwallet.handle
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.SignIn
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
@ -39,6 +42,7 @@ internal class UserWalletListModel @Inject constructor(
private val userWalletSaver: UserWalletSaver,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val unlockWalletUseCase: UnlockWalletUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val isWalletSavingInProgress: MutableStateFlow<Boolean> = MutableStateFlow(value = false)
@ -87,6 +91,7 @@ internal class UserWalletListModel @Inject constructor(
private fun onAddNewWalletClick() {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
analyticsEventHandler.send(SignIn.ButtonAddWallet(AnalyticsParam.ScreensSources.SignIn))
router.push(AppRoute.CreateWalletSelection)
} else {
withProgress(isWalletSavingInProgress) {
@ -105,6 +110,7 @@ internal class UserWalletListModel @Inject constructor(
error.handle(
onUserCancelled = {},
onAlreadyUnlocked = { router.push(AppRoute.WalletSettings(userWalletId)) },
analyticsEventHandler = analyticsEventHandler,
showMessage = messageSender::send,
)
}

View file

@ -185,7 +185,9 @@ private fun Footer(model: DetailsFooterUM, modifier: Modifier = Modifier) {
}
Text(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing6)
.testTag(DetailsScreenTestTags.VERSION_NAME),
text = model.appVersion,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,

View file

@ -37,6 +37,17 @@ internal class ItemsBuilder @Inject constructor(private val router: Router) {
).let(::add)
}.toImmutableList()
fun addVisaItem(items: ImmutableList<DetailsItemUM>): ImmutableList<DetailsItemUM> {
return items.toMutableList().map { block ->
if (block.id == "shop" && block is DetailsItemUM.Basic) {
val newItems = block.items.toMutableList().apply { add(getVisaItem()) }
block.copy(items = newItems.toImmutableList())
} else {
block
}
}.toImmutableList()
}
private fun buildWalletConnectBlock(isWalletConnectAvailable: Boolean, userWalletId: UserWalletId): DetailsItemUM? {
return if (isWalletConnectAvailable) {
DetailsItemUM.WalletConnect(
@ -114,4 +125,15 @@ internal class ItemsBuilder @Inject constructor(private val router: Router) {
).let(::add)
}.toPersistentList(),
)
private fun getVisaItem(): DetailsItemUM.Basic.Item = DetailsItemUM.Basic.Item(
id = "get_tangem_visa",
block = BlockUM(
text = resourceReference(R.string.details_get_visa),
iconRes = R.drawable.ic_tangem_pay_24,
onClick = {
router.push(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.FromBannerInSettings))
},
),
)
}

View file

@ -1,6 +0,0 @@
package com.tangem.features.feed.entry
enum class BottomSheetState {
EXPANDED,
COLLAPSED,
}

View file

@ -6,7 +6,7 @@ import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.features.feed.entry.BottomSheetState
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
@Stable
interface FeedEntryComponent {

View file

@ -51,6 +51,7 @@ dependencies {
implementation(projects.domain.settings)
implementation(projects.domain.notifications.models)
implementation(projects.domain.transaction)
implementation(projects.domain.news)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.
@ -86,11 +87,13 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.navigation)
implementation(projects.core.utils)
/* Common */
implementation(projects.common.ui)
implementation(projects.common.uiCharts)
implementation(projects.common.routing)
implementation(projects.common.uiMarkets)
/* Libs */
implementation(projects.libs.crypto)

View file

@ -1,10 +1,13 @@
package com.tangem.features.feed.components
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
@ -13,11 +16,15 @@ import com.arkivanov.decompose.value.Value
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.entry.BottomSheetState
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.ui.EntryBottomSheetContent
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -35,7 +42,37 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
popCallback = { onChildBack() },
)
private val stack: Value<ChildStack<FeedEntryChildFactory.Child, Any>> = childStack(
private val clickIntents = object : FeedEntryClickIntents {
override fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) {
innerRouter.push(
route = FeedEntryChildFactory.Child.TokenDetails(
params = DefaultMarketsTokenDetailsComponent.Params(
token = token,
appCurrency = appCurrency,
shouldShowPortfolio = true,
analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams(
blockchain = null,
source = "Market",
),
),
),
)
}
override fun onMarketOpenClick(sortBy: SortByTypeUM) {
innerRouter.push(FeedEntryChildFactory.Child.TokenList)
}
override fun onArticleClick(articleId: Int) {
innerRouter.push(FeedEntryChildFactory.Child.NewsDetails)
}
override fun onOpenAllNews() {
innerRouter.push(FeedEntryChildFactory.Child.NewsList)
}
}
private val stack: Value<ChildStack<FeedEntryChildFactory.Child, ComposableModularContentComponent>> = childStack(
key = "main",
source = stackNavigation,
serializer = FeedEntryChildFactory.Child.serializer(),
@ -48,7 +85,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
componentContext = factoryContext,
router = innerRouter,
),
onTokenClick = ::marketsListTokenSelected,
feedEntryClickIntents = clickIntents,
)
},
)
@ -59,22 +96,15 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
bottomSheetState // TODO will be continued in next tasks.
}
val stackState by stack.subscribeAsState()
private fun marketsListTokenSelected(token: TokenMarketParams, appCurrency: AppCurrency) {
innerRouter.push(
route = FeedEntryChildFactory.Child.TokenDetails(
params = DefaultMarketsTokenDetailsComponent.Params(
token = token,
appCurrency = appCurrency,
shouldShowPortfolio = true,
analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams(
blockchain = null,
source = "Market",
),
),
),
BackHandler(enabled = bottomSheetState.value == BottomSheetState.EXPANDED) {
onChildBack()
}
EntryBottomSheetContent(
stackState = stackState,
onHeaderSizeChange = onHeaderSizeChange,
)
}
@ -88,4 +118,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
interface Factory : FeedEntryComponent.Factory {
override fun create(context: AppComponentContext): DefaultFeedEntryComponent
}
}
}
internal interface FeedEntryClickIntents : FeedModelClickIntents

View file

@ -3,16 +3,16 @@ package com.tangem.features.feed.components
import androidx.compose.runtime.Immutable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
import kotlinx.serialization.Serializable
import javax.inject.Inject
internal class FeedEntryChildFactory {
internal class FeedEntryChildFactory @Inject constructor() {
@Serializable
@Immutable
@ -42,8 +42,8 @@ internal class FeedEntryChildFactory {
fun createChild(
child: Child,
appComponentContext: AppComponentContext,
onTokenClick: (TokenMarketParams, AppCurrency) -> Unit,
): Any {
feedEntryClickIntents: FeedEntryClickIntents,
): ComposableModularContentComponent {
return when (child) {
is Child.TokenDetails -> {
DefaultMarketsTokenDetailsComponent(
@ -54,7 +54,12 @@ internal class FeedEntryChildFactory {
is Child.TokenList -> {
DefaultMarketsTokenListComponent(
appComponentContext = appComponentContext,
onTokenClick = onTokenClick,
onTokenClick = { token, appCurrency ->
feedEntryClickIntents.onMarketItemClick(
token,
appCurrency,
)
},
)
}
Child.NewsDetails -> {
@ -70,6 +75,7 @@ internal class FeedEntryChildFactory {
Child.Feed -> {
DefaultFeedComponent(
appComponentContext = appComponentContext,
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents),
)
}
}

View file

@ -1,23 +1,49 @@
package com.tangem.features.feed.components.feed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.features.feed.model.feed.FeedComponentModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.ui.feed.FeedList
import com.tangem.features.feed.ui.feed.FeedListHeader
internal class DefaultFeedComponent(
appComponentContext: AppComponentContext,
private val params: FeedParams,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val feedComponentModel = getOrCreateModel<FeedComponentModel, FeedParams>(params = params)
@Composable
override fun Title() {
val state by feedComponentModel.state.collectAsStateWithLifecycle()
FeedListHeader(state.searchBar)
}
@Composable
override fun Content(modifier: Modifier) {
LifecycleStartEffect(Unit) {
feedComponentModel.isVisibleOnScreen.value = true
onStopOrDispose {
feedComponentModel.isVisibleOnScreen.value = false
}
}
val state by feedComponentModel.state.collectAsStateWithLifecycle()
FeedList(
modifier = modifier,
state = state,
)
}
@Composable
override fun Footer() {
}
override fun Footer() = Unit
data class FeedParams(val feedClickIntents: FeedModelClickIntents)
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.feed.di
import com.tangem.features.feed.components.DefaultFeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface FeedComponentModule {
@Binds
@Singleton
fun bindFeedEntryComponent(factory: DefaultFeedEntryComponent.Factory): FeedEntryComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.feed.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.feed.model.feed.FeedComponentModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(FeedComponentModel::class)
fun bindsFeedComponentModel(model: FeedComponentModel): Model
}

View file

@ -0,0 +1,159 @@
package com.tangem.features.feed.model.converter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
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.*
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.state.MarketsListUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
internal class MarketsTokenItemConverter(
private val currentTrendInterval: MarketsListUM.TrendInterval,
private val appCurrency: AppCurrency,
) : Converter<TokenMarket, MarketsListItemUM> {
private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false)
override fun convert(value: TokenMarket): MarketsListItemUM {
return MarketsListItemUM(
id = value.id,
name = value.name,
currencySymbol = value.symbol,
ratingPosition = value.marketRating?.toString(),
marketCap = value.getMarketCap(),
iconUrl = value.imageUrlLarge,
price = value.getCurrentPrice(),
trendPercentText = value.getTrendPercent(),
trendType = value.getTrendType(),
chartData = value.getChartData(),
isUnder100kMarketCap = value.isUnderMarketCapLimit,
stakingRate = value.stakingRate?.format { percent() }?.let {
resourceReference(R.string.markets_apy_placeholder, wrappedList(it))
},
updateTimestamp = value.updateTimestamp,
)
}
fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM {
require(prev.id == new.id) {
"Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]"
}
return prevUI.copy(
name = new.name,
currencySymbol = new.symbol,
ratingPosition = new.marketRating?.toString(),
marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() },
iconUrl = new.imageUrlLarge,
price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) {
new.getCurrentPrice(
prev = prev,
)
},
trendPercentText = ifChanged(
prev.tokenQuotesShort,
new.tokenQuotesShort,
prevUI.trendPercentText,
) { new.getTrendPercent() },
trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() },
chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() },
)
}
private inline fun <T, R> ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R {
return if (force || prev != new) change(new) else prevR
}
private fun TokenMarket.getMarketCap(): String? {
val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null
return value.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).compact(
threeDigitsMethod = true,
)
}
}
private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {
val prevPrice = prev?.tokenQuotesShort?.currentPrice
val priceText = tokenQuotesShort.currentPrice.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).price()
}
val changeType = if (prevPrice != null) {
if (tokenQuotesShort.currentPrice > prevPrice) {
PriceChangeType.UP
} else {
PriceChangeType.DOWN
}
} else {
null
}
return MarketsListItemUM.Price(
text = priceText,
changeType = changeType,
)
}
private fun TokenMarket.getChartData(): MarketChartRawData? {
val chart = when (currentTrendInterval) {
MarketsListUM.TrendInterval.H24 -> tokenCharts.h24
MarketsListUM.TrendInterval.D7 -> tokenCharts.week
MarketsListUM.TrendInterval.M1 -> tokenCharts.month
}
return chart?.let { ct ->
priceAndTimePointValuesConverter.convert(
MarketChartData.Data(
y = ct.priceY.toImmutableList(),
x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted(),
)
}
}
@Suppress("MagicNumber")
private fun TokenMarket.getTrendType(): PriceChangeType {
val percent = when (currentTrendInterval) {
MarketsListUM.TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent
MarketsListUM.TrendInterval.D7 -> tokenQuotesShort.weekChangePercent
MarketsListUM.TrendInterval.M1 -> tokenQuotesShort.monthChangePercent
}
val scaled = percent?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
private fun TokenMarket.getTrendPercent(): String {
val percent = when (currentTrendInterval) {
MarketsListUM.TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent
MarketsListUM.TrendInterval.D7 -> tokenQuotesShort.weekChangePercent
MarketsListUM.TrendInterval.M1 -> tokenQuotesShort.monthChangePercent
}
return percent.format { percent() }
}
}

View file

@ -0,0 +1,347 @@
package com.tangem.features.feed.model.feed
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTopFiveMarketTokenUseCase
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentHashMap
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import javax.inject.Inject
@Stable
@ModelScoped
internal class FeedComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase,
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<DefaultFeedComponent.FeedParams>()
private var quotesUpdateJob: Job? = null
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val marketsBatchFlowManager = FeedMarketsBatchFlowManager(
getTopFiveMarketTokenUseCase = getTopFiveMarketTokenUseCase,
currentAppCurrency = Provider { currentAppCurrency.value },
modelScope = modelScope,
dispatchers = dispatchers,
)
internal val state: StateFlow<FeedListUM>
field = MutableStateFlow<FeedListUM>(initialState())
private val searchBarStateFactory by lazy(LazyThreadSafetyMode.NONE) {
SearchBarStateFactory(
currentStateProvider = Provider { state.value },
onStateUpdate = { newState -> state.update { newState } },
)
}
private val trendingNewsStateFactory by lazy(LazyThreadSafetyMode.NONE) {
TrendingNewsStateFactory(
currentStateProvider = Provider { state.value },
onStateUpdate = { newState -> state.update { newState } },
)
}
val isVisibleOnScreen = MutableStateFlow(false)
init {
updateCallbacks()
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase()
}
modelScope.launch(dispatchers.default) {
combine(
flow = marketsBatchFlowManager.itemsByOrder,
flow2 = marketsBatchFlowManager.loadingStatesByOrder,
flow3 = marketsBatchFlowManager.errorStatesByOrder,
flow4 = manageTrendingNewsUseCase.observeTrendingNews(),
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult ->
updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder)
trendingNewsStateFactory.updateTrendingNewsState(
result = trendingNewsResult,
onRetryClicked = {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase.invoke()
}
},
)
updateGlobalState()
val currentSortType = state.value.marketChartConfig.currentSortByType
val items = itemsByOrder[currentSortType]
val isLoading = loadingStatesByOrder[currentSortType] == true
if (items != null && items.isNotEmpty() && !isLoading) {
val order = currentSortType.toOrder()
marketsBatchFlowManager.loadCharts(order)
}
}.collect()
}
modelScope.launch(dispatchers.default) {
currentAppCurrency.drop(1).collect {
marketsBatchFlowManager.reloadAll()
}
}
modelScope.launch(dispatchers.default) {
TokenMarketListConfig.Order.entries.forEach { order ->
marketsBatchFlowManager.getOnLastBatchLoadedSuccessFlow(order)?.collect { batchKey ->
marketsBatchFlowManager.loadCharts(order)
if (batchKey == 0) {
startQuotesUpdateTimer()
}
}
}
}
}
private fun initialState(): FeedListUM {
return FeedListUM(
currentDate = getCurrentDate(),
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.markets_search_header_title),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
),
feedListCallbacks = FeedListCallbacks(
onSearchClick = {},
onMarketOpenClick = {},
onArticleClick = {},
onOpenAllNews = {},
onMarketItemClick = {},
onSortTypeClick = {},
),
news = NewsUM.Loading,
trendingArticle = null,
marketChartConfig = MarketChartConfig(
marketCharts = buildMap {
SortByTypeUM.entries.forEach {
put(it, MarketChartUM.Loading)
}
}.toPersistentHashMap(),
currentSortByType = SortByTypeUM.Trending,
),
globalState = GlobalFeedState.Loading,
)
}
private fun getCurrentDate(): String {
val localDate = DateTime(DateTime.now(), DateTimeZone.getDefault())
return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate)
}
private fun updateMarketCharts(
itemsByOrder: Map<SortByTypeUM, ImmutableList<com.tangem.common.ui.markets.models.MarketsListItemUM>>,
loadingStatesByOrder: Map<SortByTypeUM, Boolean>,
errorStatesByOrder: Map<SortByTypeUM, Boolean>,
) {
state.update { currentState ->
val newMarketCharts = buildMap {
SortByTypeUM.entries.forEach { sortByType ->
val items = itemsByOrder[sortByType] ?: persistentListOf()
val isLoading = loadingStatesByOrder[sortByType] == true
val hasError = errorStatesByOrder[sortByType] == true
when {
hasError -> {
put(
sortByType,
MarketChartUM.LoadingError(
onRetryClicked = {
marketsBatchFlowManager.reloadAll()
},
),
)
}
isLoading -> {
put(sortByType, MarketChartUM.Loading)
}
items.isEmpty() -> {
put(
sortByType,
MarketChartUM.LoadingError(
onRetryClicked = {
marketsBatchFlowManager.reloadAll()
},
),
)
}
else -> {
put(
sortByType,
MarketChartUM.Content(
items = items,
sortChartConfig = SortChartConfigUM(
sortByType = sortByType,
isSelected = sortByType == currentState.marketChartConfig.currentSortByType,
),
),
)
}
}
}
}.toPersistentHashMap()
currentState.copy(
marketChartConfig = currentState.marketChartConfig.copy(
marketCharts = newMarketCharts,
),
)
}
}
private fun updateGlobalState() {
state.update { currentState ->
val newsState = currentState.news
val marketCharts = currentState.marketChartConfig.marketCharts
val isNewsLoading = newsState is NewsUM.Loading
val areAllChartsLoading = marketCharts.values.all { it is MarketChartUM.Loading }
val isNewsError = newsState is NewsUM.Error
val areAllChartsError = marketCharts.values.all { it is MarketChartUM.LoadingError }
val newGlobalState = when {
isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading
isNewsError && areAllChartsError -> GlobalFeedState.Error(
onRetryClicked = {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase.invoke()
marketsBatchFlowManager.reloadAll()
}
},
)
else -> GlobalFeedState.Content
}
val currentGlobalState = currentState.globalState
if (currentGlobalState::class != newGlobalState::class) {
currentState.copy(globalState = newGlobalState)
} else {
currentState
}
}
}
private fun onSortTypeClick(sortByType: SortByTypeUM) {
state.update { currentState ->
val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) ->
when (chart) {
is MarketChartUM.Content -> {
chart.copy(
sortChartConfig = chart.sortChartConfig.copy(
isSelected = chartSortType == sortByType,
),
)
}
else -> chart
}
}
currentState.copy(
marketChartConfig = currentState.marketChartConfig.copy(
currentSortByType = sortByType,
marketCharts = updatedCharts.toPersistentHashMap(),
),
)
}
modelScope.launch(dispatchers.default) {
marketsBatchFlowManager.loadCharts(sortByType.toOrder())
}
}
private fun startQuotesUpdateTimer() {
quotesUpdateJob?.cancel()
quotesUpdateJob = modelScope.launch {
while (true) {
delay(DELAY_TO_FETCH_QUOTES)
isVisibleOnScreen.first { it }
marketsBatchFlowManager.updateQuotes()
}
}
}
private fun updateCallbacks() {
state.update { feedListUM ->
feedListUM.copy(
searchBar = state.value.searchBar.copy(onQueryChange = searchBarStateFactory::onSearchQueryChange),
feedListCallbacks = feedListUM.feedListCallbacks.copy(
onSortTypeClick = ::onSortTypeClick,
onMarketItemClick = { item ->
val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id)
if (tokenMarket != null) {
params.feedClickIntents.onMarketItemClick(
token = tokenMarket.toSerializableParam(),
appCurrency = currentAppCurrency.value,
)
}
},
onMarketOpenClick = { sortBy ->
params.feedClickIntents.onMarketOpenClick(sortBy)
},
onArticleClick = { articleId ->
params.feedClickIntents.onArticleClick(articleId)
},
onOpenAllNews = {
params.feedClickIntents.onOpenAllNews()
},
),
)
}
}
private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order {
return when (this) {
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating
SortByTypeUM.Trending -> TokenMarketListConfig.Order.Trending
SortByTypeUM.ExperiencedBuyers -> TokenMarketListConfig.Order.Buyers
SortByTypeUM.TopGainers -> TokenMarketListConfig.Order.TopGainers
SortByTypeUM.TopLosers -> TokenMarketListConfig.Order.TopLosers
SortByTypeUM.Staking -> TokenMarketListConfig.Order.Staking
}
}
companion object {
private const val DELAY_TO_FETCH_QUOTES = 60_000L
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.feed.model.feed
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.ui.market.state.SortByTypeUM
/**
* Callback interface for feed model navigation actions.
*/
internal interface FeedModelClickIntents {
fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency)
fun onMarketOpenClick(sortBy: SortByTypeUM)
fun onArticleClick(articleId: Int)
fun onOpenAllNews()
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.feed.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.features.feed.components.FeedEntryChildFactory
@Composable
internal fun EntryBottomSheetContent(
stackState: ChildStack<FeedEntryChildFactory.Child, ComposableModularContentComponent>,
onHeaderSizeChange: (Dp) -> Unit,
) {
val density = LocalDensity.current
Scaffold(
contentWindowInsets = WindowInsets(0.dp),
topBar = {
AnimatedContent(
targetState = stackState.active.instance,
modifier = Modifier.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
onHeaderSizeChange(coordinates.size.height.toDp())
}
}
},
) { currentState ->
currentState.Title()
}
},
content = { contentPadding ->
AnimatedContent(
stackState.active.instance,
) { currentState ->
currentState.Content(modifier = Modifier.padding(contentPadding))
}
},
)
}

View file

@ -1,9 +1,6 @@
package com.tangem.features.feed.ui.feed
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
@ -14,7 +11,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -22,25 +21,29 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
@ -48,19 +51,60 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState
import com.tangem.features.feed.ui.feed.state.FeedListCallbacks
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.MarketChartConfig
import com.tangem.features.feed.ui.feed.state.MarketChartUM
import com.tangem.features.feed.ui.market.components.MarketsListItem
import com.tangem.features.feed.ui.market.components.MarketsListItemPlaceholder
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun FeedList(state: FeedListUM, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier) {
val density = LocalDensity.current
internal fun FeedListHeader(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
SearchBar(
modifier = modifier
.drawBehind { drawRect(background) }
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp),
state = searchBarUM,
colors = TangemSearchBarDefaults.defaultTextFieldColors.copy(
focusedContainerColor = TangemTheme.colors.field.focused,
unfocusedContainerColor = TangemTheme.colors.field.focused,
),
)
}
@Composable
internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
AnimatedContent(
modifier = modifier,
targetState = state.globalState,
) { animatedState ->
when (animatedState) {
is GlobalFeedState.Loading -> {
FeeListLoading(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.drawBehind { drawRect(background) },
)
}
is GlobalFeedState.Error -> {
FeedListGlobalError(
onRetryClick = animatedState.onRetryClicked,
modifier = Modifier.drawBehind { drawRect(background) },
)
}
is GlobalFeedState.Content -> {
FeeListContent(
modifier = Modifier,
state = state,
)
}
}
}
}
@Composable
private fun FeeListContent(state: FeedListUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
@ -68,67 +112,61 @@ internal fun FeedList(state: FeedListUM, onHeaderSizeChange: (Dp) -> Unit, modif
.verticalScroll(rememberScrollState())
.drawBehind { drawRect(background) },
) {
SearchBar(
Column(
modifier = Modifier
.drawBehind { drawRect(background) }
.padding(
start = 16.dp,
end = 16.dp,
bottom = 8.dp,
)
.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
onHeaderSizeChange(coordinates.size.height.toDp())
}
}
.fillMaxSize()
.padding(WindowInsets.navigationBars.asPaddingValues()),
) {
SpacerH(20.dp)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = stringResourceSafe(R.string.feed_market_and_news),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = state.currentDate,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.tertiary,
)
SpacerH(32.dp)
AnimatedVisibility(state.marketChartConfig.marketCharts[SortByTypeUM.Rating] != null) {
val marketChart = remember(state.marketChartConfig.marketCharts[SortByTypeUM.Rating]) {
state.marketChartConfig.marketCharts[SortByTypeUM.Rating]
}
.padding(bottom = 4.dp),
state = state.searchBar,
)
if (marketChart != null) {
MarketBlock(
marketChart = marketChart,
feedListCallbacks = state.feedListCallbacks,
)
}
}
SpacerH(20.dp)
NewsBlock(
news = state.news,
feedListCallbacks = state.feedListCallbacks,
trendingArticle = state.trendingArticle,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = stringResourceSafe(R.string.feed_market_and_news),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = state.currentDate,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.tertiary,
)
SpacerH(32.dp)
MarketBlock(
marketChartConfig = state.marketChartConfig,
feedListCallbacks = state.feedListCallbacks,
)
NewsBlock(
news = state.news,
feedListCallbacks = state.feedListCallbacks,
trendingArticle = state.trendingArticle,
)
MarketPulseBlock(
marketChartConfig = state.marketChartConfig,
feedListCallbacks = state.feedListCallbacks,
)
MarketPulseBlock(
marketChartConfig = state.marketChartConfig,
feedListCallbacks = state.feedListCallbacks,
)
}
}
}
@Composable
private fun MarketBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
if (marketChartConfig.marketCharts.isNotEmpty()) {
private fun MarketBlock(marketChart: MarketChartUM, feedListCallbacks: FeedListCallbacks) {
Column(modifier = Modifier.fillMaxWidth()) {
Header(
title = {
Text(
@ -142,20 +180,33 @@ private fun MarketBlock(marketChartConfig: MarketChartConfig, feedListCallbacks:
SpacerH(12.dp)
marketChartConfig.marketCharts[SortByTypeUM.Rating]?.let { chart ->
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = chart,
)
}
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = marketChart,
)
SpacerH(32.dp)
}
}
@Composable
private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
val onSeeAllClick by rememberUpdatedState {
feedListCallbacks.onMarketOpenClick(marketChartConfig.currentSortByType)
}
if (marketChartConfig.marketCharts.isNotEmpty()) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_pulse_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { onSeeAllClick() },
)
LazyRow(
modifier = Modifier.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
@ -175,17 +226,6 @@ private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallb
}
}
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(marketChartConfig.currentSortByType) },
)
SpacerH(12.dp)
AnimatedContent(
@ -207,18 +247,41 @@ private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallb
@Suppress("CanBeNonNullable")
@Composable
private fun NewsBlock(
private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
AnimatedContent(news) { newsUM ->
when (newsUM) {
is NewsUM.Content -> {
if (newsUM.content.isNotEmpty()) {
NewsContentBlock(
feedListCallbacks = feedListCallbacks,
news = newsUM,
trendingArticle = trendingArticle,
)
}
}
NewsUM.Loading -> {
NewsLoadingBlock()
}
is NewsUM.Error -> {
NewsErrorBlock(onRetryClick = newsUM.onRetryClicked)
}
}
}
}
@Composable
private fun NewsContentBlock(
feedListCallbacks: FeedListCallbacks,
news: ImmutableList<ArticleConfigUM>,
news: NewsUM.Content,
trendingArticle: ArticleConfigUM?,
) {
if (news.isNotEmpty()) {
Column {
Header(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.subtitle1,
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
@ -244,13 +307,12 @@ private fun NewsBlock(
append(stringResourceSafe(R.string.feed_tangem_ai))
}
},
style = TangemTheme.typography.subtitle1,
style = TangemTheme.typography.h3,
)
}
},
onSeeAllClick = feedListCallbacks.onOpenAllNews,
)
SpacerH(12.dp)
trendingArticle?.let { article ->
@ -260,8 +322,8 @@ private fun NewsBlock(
.padding(horizontal = 16.dp),
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
SpacerH(12.dp)
}
@ -272,13 +334,16 @@ private fun NewsBlock(
state = rememberLazyListState(),
) {
items(
items = news,
items = news.content,
key = ArticleConfigUM::id,
) { article ->
ArticleCard(
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
modifier = Modifier.size(164.dp),
modifier = Modifier
.height(164.dp)
.widthIn(max = 216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
}
}
@ -292,6 +357,7 @@ private fun Header(title: @Composable () -> Unit, onSeeAllClick: () -> Unit) {
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
title()
@ -310,12 +376,11 @@ private fun Charts(
onItemClick: (MarketsListItemUM) -> Unit,
modifier: Modifier = Modifier,
) {
BlockCard(modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(modifier = Modifier.fillMaxWidth()) {
when (marketChart) {
MarketChartUM.Loading -> {
repeat(DEFAULT_CHART_SIZE_IN_MARKET) {
@ -323,7 +388,10 @@ private fun Charts(
}
}
is MarketChartUM.LoadingError -> {
// TODO will be created in [REDACTED_TASK_KEY]
UnableToLoadData(
onRetryClick = marketChart.onRetryClicked,
modifier = Modifier.fillMaxWidth(),
)
}
is MarketChartUM.Content -> {
marketChart.items.fastForEach { chart ->
@ -370,6 +438,43 @@ private fun FilterChip(sortByTypeUM: SortByTypeUM, isSelected: Boolean, onClick:
}
}
@Composable
private fun FeedListGlobalError(onRetryClick: () -> Unit, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
Box(
modifier = modifier
.fillMaxSize()
.drawBehind { drawRect(background) }
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = onRetryClick)
}
}
@Composable
private fun NewsErrorBlock(onRetryClick: () -> Unit) {
Column {
Header(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
}
},
onSeeAllClick = {},
)
SpacerH(12.dp)
UnableToLoadData(
onRetryClick = onRetryClick,
modifier = Modifier.fillMaxWidth(),
)
}
}
private const val DEFAULT_CHART_SIZE_IN_MARKET = 5
private const val GRADIENT_START = 0f
private const val GRADIENT_END = 0.5f
@ -380,9 +485,6 @@ private val LinearGradientSecondPart = Color(0xFFE05AED)
@Composable
private fun FeedListPreview() {
TangemThemePreview {
FeedList(
state = createFeedPreviewState(),
onHeaderSizeChange = {},
)
FeedList(state = createFeedPreviewState())
}
}

View file

@ -0,0 +1,122 @@
package com.tangem.features.feed.ui.feed
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
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.unit.dp
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.news.DefaultLoadingArticle
import com.tangem.common.ui.news.TrendingLoadingArticle
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.res.TangemThemePreview
@Composable
internal fun FeeListLoading(modifier: Modifier = Modifier) {
Column(modifier) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(WindowInsets.navigationBars.asPaddingValues()),
) {
MarketLoadingBlock()
NewsLoadingBlock()
MarketPulseLoadingBlock()
}
}
}
@Composable
internal fun MarketLoadingBlock() {
RectangleShimmer(
modifier = Modifier
.padding(start = 16.dp)
.size(width = 104.dp, height = 18.dp),
)
SpacerH(15.dp)
ChartsLoading(modifier = Modifier.padding(horizontal = 16.dp))
SpacerH(35.dp)
}
@Composable
internal fun MarketPulseLoadingBlock() {
RectangleShimmer(
modifier = Modifier
.padding(start = 16.dp)
.size(width = 104.dp, height = 18.dp),
)
SpacerH(15.dp)
LazyRow(
modifier = Modifier.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
state = rememberLazyListState(),
) {
items(DEFAULT_CHART_SIZE_IN_MARKET) {
RectangleShimmer(modifier = Modifier.size(width = 124.dp, height = 36.dp))
}
}
SpacerH(16.dp)
ChartsLoading(modifier = Modifier.padding(horizontal = 16.dp))
SpacerH(32.dp)
}
@Composable
internal fun NewsLoadingBlock() {
Column {
RectangleShimmer(
modifier = Modifier
.padding(start = 16.dp)
.size(width = 104.dp, height = 18.dp),
)
SpacerH(15.dp)
TrendingLoadingArticle(modifier = Modifier.padding(horizontal = 16.dp))
SpacerH(12.dp)
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(),
) {
items(DEFAULT_CHART_SIZE_IN_MARKET) {
DefaultLoadingArticle()
}
}
SpacerH(35.dp)
}
}
@Composable
private fun ChartsLoading(modifier: Modifier = Modifier) {
BlockCard(modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
repeat(DEFAULT_CHART_SIZE_IN_MARKET) {
MarketsListItemPlaceholder()
}
}
}
}
private const val DEFAULT_CHART_SIZE_IN_MARKET = 5
@Preview(showBackground = true)
@Composable
private fun FeedListLoadingPreview() {
TangemThemePreview {
Column {
MarketLoadingBlock()
NewsLoadingBlock()
MarketPulseLoadingBlock()
}
}
}

View file

@ -1,17 +1,16 @@
package com.tangem.features.feed.ui.feed.preview
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.common.ui.news.ArticleTagUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import kotlinx.collections.immutable.*
@ -38,7 +37,7 @@ internal object FeedListPreviewDataProvider {
onMarketItemClick = {},
onSortTypeClick = {},
),
news = articles.filter { it.isTrending.not() }.toImmutableList(),
news = NewsUM.Content(articles.filter { it.isTrending.not() }.toImmutableList()),
trendingArticle = articles.first { it.isTrending },
marketChartConfig = MarketChartConfig(
marketCharts = createMarketCharts(marketItems, includeErrorState = false),
@ -94,7 +93,6 @@ internal object FeedListPreviewDataProvider {
): MarketChartUM.Content {
return MarketChartUM.Content(
items = items,
triggerScrollReset = consumedEvent(),
sortChartConfig = SortChartConfigUM(
sortByType = sortByType,
isSelected = isSelected,
@ -107,7 +105,7 @@ internal object FeedListPreviewDataProvider {
id = 1,
title = "Bitcoin ETF reaches new highs, institutions pile in",
score = 0.82f,
createdAt = "2h ago",
createdAt = TextReference.Str("Yesterday"),
isTrending = true,
tags = createArticleTags(),
isViewed = false,
@ -116,7 +114,7 @@ internal object FeedListPreviewDataProvider {
id = 2,
title = "Layer 2 networks battle for dominance amid fee wars",
score = 0.71f,
createdAt = "4h ago",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = true,
@ -125,7 +123,7 @@ internal object FeedListPreviewDataProvider {
id = 3,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = false,
@ -134,7 +132,7 @@ internal object FeedListPreviewDataProvider {
id = 4,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = false,
@ -143,7 +141,7 @@ internal object FeedListPreviewDataProvider {
id = 5,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = false,
@ -152,28 +150,25 @@ internal object FeedListPreviewDataProvider {
id = 6,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = false,
),
)
private fun createArticleTags(): ImmutableSet<ArticleTagUM> {
private fun createArticleTags(): ImmutableSet<LabelUM> {
return persistentSetOf(
ArticleTagUM.Token(
title = TextReference.Str("BTC"),
iconState = CurrencyIconState.CoinIcon(
url = "",
fallbackResId = 0,
isGrayscale = false,
shouldShowCustomBadge = false,
LabelUM(
text = TextReference.Str("BTC"),
leadingContent = LabelLeadingContentUM.Token(
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/euro-coin.png",
),
),
ArticleTagUM.Category(TextReference.Str("Regulation")),
ArticleTagUM.Category(TextReference.Str("BTC")),
ArticleTagUM.Category(TextReference.Str("Supply")),
ArticleTagUM.Category(TextReference.Str("Demand")),
LabelUM(TextReference.Str("Regulation")),
LabelUM(TextReference.Str("BTC")),
LabelUM(TextReference.Str("Supply")),
LabelUM(TextReference.Str("Demand")),
)
}

View file

@ -1,10 +1,9 @@
package com.tangem.features.feed.ui.feed.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@ -14,9 +13,10 @@ internal data class FeedListUM(
val currentDate: String,
val searchBar: SearchBarUM,
val feedListCallbacks: FeedListCallbacks,
val news: ImmutableList<ArticleConfigUM>,
val news: NewsUM,
val trendingArticle: ArticleConfigUM?,
val marketChartConfig: MarketChartConfig,
val globalState: GlobalFeedState = GlobalFeedState.Content,
)
internal data class FeedListCallbacks(
@ -28,6 +28,13 @@ internal data class FeedListCallbacks(
val onSortTypeClick: (SortByTypeUM) -> Unit,
)
@Immutable
internal sealed interface NewsUM {
data object Loading : NewsUM
data class Content(val content: ImmutableList<ArticleConfigUM>) : NewsUM
data class Error(val onRetryClicked: () -> Unit) : NewsUM
}
internal data class MarketChartConfig(
val marketCharts: ImmutableMap<SortByTypeUM, MarketChartUM>,
val currentSortByType: SortByTypeUM = SortByTypeUM.TopGainers,
@ -40,7 +47,6 @@ internal sealed interface MarketChartUM {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val triggerScrollReset: StateEvent<Unit>,
val sortChartConfig: SortChartConfigUM,
) : MarketChartUM
@ -49,7 +55,14 @@ internal sealed interface MarketChartUM {
data class LoadingError(val onRetryClicked: () -> Unit) : MarketChartUM
}
data class SortChartConfigUM(
internal data class SortChartConfigUM(
val sortByType: SortByTypeUM,
val isSelected: Boolean,
)
)
@Immutable
internal sealed interface GlobalFeedState {
data object Loading : GlobalFeedState
data object Content : GlobalFeedState
data class Error(val onRetryClicked: () -> Unit) : GlobalFeedState
}

View file

@ -0,0 +1,391 @@
package com.tangem.features.feed.ui.feed.state
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.model.converter.MarketsTokenItemConverter
import com.tangem.features.feed.ui.market.state.MarketsListUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class FeedMarketsBatchFlowManager(
private val getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
private val currentAppCurrency: Provider<AppCurrency>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val managersByOrder = TokenMarketListConfig.Order.entries.associateWith { order ->
createManagerForOrder(order)
}
val itemsByOrder: StateFlow<Map<SortByTypeUM, ImmutableList<MarketsListItemUM>>> =
combine(
TokenMarketListConfig.Order.entries.mapNotNull { order ->
managersByOrder[order]?.uiItems?.map { items -> order to items }
},
) { itemsList ->
itemsList.associate { (order, items) ->
val sortByType = order.toSortByTypeUM()
sortByType to items
}
}.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = emptyMap(),
)
val loadingStatesByOrder: StateFlow<Map<SortByTypeUM, Boolean>> =
combine(
TokenMarketListConfig.Order.entries.mapNotNull { order ->
managersByOrder[order]?.isLoading?.map { isLoading -> order to isLoading }
},
) { loadingStatesList ->
loadingStatesList.associate { (order, isLoading) ->
val sortByType = order.toSortByTypeUM()
sortByType to isLoading
}
}.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = emptyMap(),
)
val errorStatesByOrder: StateFlow<Map<SortByTypeUM, Boolean>> =
combine(
TokenMarketListConfig.Order.entries.mapNotNull { order ->
managersByOrder[order]?.hasError?.map { hasError -> order to hasError }
},
) { errorStatesList ->
errorStatesList.associate { (order, hasError) ->
val sortByType = order.toSortByTypeUM()
sortByType to hasError
}
}.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = emptyMap(),
)
init {
managersByOrder.values.forEach { manager ->
manager.reload(currentAppCurrency().code)
}
}
private fun createManagerForOrder(order: TokenMarketListConfig.Order): SingleOrderManager {
val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
val batchFlow = getTopFiveMarketTokenUseCase(
batchingContext = TokenListBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
order = order,
)
return SingleOrderManager(
order = order,
actionsFlow = actionsFlow,
batchFlow = batchFlow,
currentAppCurrency = currentAppCurrency,
modelScope = modelScope,
dispatchers = dispatchers,
)
}
fun reloadAll() {
managersByOrder.values.forEach { manager ->
manager.reload(currentAppCurrency().code)
}
}
fun updateQuotes() {
managersByOrder.values.forEach { manager ->
manager.updateQuotes(currentAppCurrency().code)
}
}
fun loadCharts(order: TokenMarketListConfig.Order) {
managersByOrder[order]?.loadCharts()
}
fun getOnLastBatchLoadedSuccessFlow(order: TokenMarketListConfig.Order): Flow<Int>? {
return managersByOrder[order]?.onLastBatchLoadedSuccess
}
fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? {
return managersByOrder.values
.firstNotNullOfOrNull { manager -> manager.getTokenMarketById(tokenId) }
}
private class SingleOrderManager(
val order: TokenMarketListConfig.Order,
private val actionsFlow: MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>,
private val batchFlow: TokenListBatchFlow,
private val currentAppCurrency: Provider<AppCurrency>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val updateStateJob = JobHolder()
private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = resultBatches.map { it.uiBatches }
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>> =
uiBatches
.map { batches ->
batches.asSequence()
.map { it.data }
.flatten()
.toImmutableList()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = persistentListOf(),
)
val isLoading = batchFlow.state
.map { state ->
when (state.status) {
is PaginationStatus.InitialLoading -> true
is PaginationStatus.NextBatchLoading -> true
else -> false
}
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = false,
)
val hasError = batchFlow.state
.map { state ->
when (val status = state.status) {
is PaginationStatus.InitialLoadingError -> true
is PaginationStatus.Paginating -> status.lastResult is BatchFetchResult.Error
else -> false
}
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = false,
)
val onLastBatchLoadedSuccess = batchFlow.state
.distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size }
.mapNotNull { batchListState ->
when (val status = batchListState.status) {
is PaginationStatus.Paginating -> {
if (status.lastResult is BatchFetchResult.Success) {
batchListState.data.lastOrNull()?.key
} else {
null
}
}
is PaginationStatus.EndOfPagination -> {
batchListState.data.lastOrNull()?.key
}
else -> null
}
}
init {
batchFlow.state
.map { it.data }
.distinctUntilChanged { a, b ->
a.size == b.size &&
a.map { it.key } == b.map { it.key } &&
a.map { it.data }.flatten() == b.map { it.data }.flatten()
}
.onEach {
coroutineScope {
launch {
updateState(it)
}.saveIn(updateStateJob)
}
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
withContext(dispatchers.default) {
resultBatches.update { resultBatches ->
val items = resultBatches.uiBatches
val previousList = resultBatches.processedItems
val converter = MarketsTokenItemConverter(
currentTrendInterval = MarketsListUM.TrendInterval.H24,
appCurrency = currentAppCurrency(),
)
if (newList.isEmpty()) {
return@update ResultBatches(processedItems = emptyList())
}
val isInitialLoading =
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
val outItems = if (isInitialLoading) {
newList.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
// As nextBatchSize = 0, we only have one batch, but keep the logic for safety
if (previousList.size != newList.size) {
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = newList.filter { keysToAdd.contains(it.key) }
items + newBatches.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = newList[batchIndex]
if (prevBatch == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data.getOrNull(index)
val newItem = newBatch.data.getOrNull(index)
if (prevItem != null && newItem != null) {
converter.update(prevItem, marketsListItemUM, newItem)
} else {
newItem?.let { converter.convert(it) } ?: marketsListItemUM
}
},
)
}
}
}
currentCoroutineContext().ensureActive()
ResultBatches(
uiBatches = outItems,
processedItems = newList,
)
}
}
fun reload(fiatPriceCurrency: String) {
modelScope.launch(dispatchers.default) {
resultBatches.value = ResultBatches()
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
fiatPriceCurrency = fiatPriceCurrency,
searchText = null,
priceChangeInterval = TokenMarketListConfig.Interval.H24,
order = order,
),
),
)
}
}
fun updateQuotes(fiatPriceCurrency: String) {
modelScope.launch(dispatchers.default) {
actionsFlow.emit(
BatchAction.CancelUpdates {
it.updateRequest is TokenMarketUpdateRequest.UpdateQuotes
},
)
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchFlow
.state
.value
.data
.map { it.key }
.toSet(),
updateRequest = TokenMarketUpdateRequest.UpdateQuotes(
currencyId = fiatPriceCurrency,
),
async = true,
operationId = "update quotes",
),
)
}
}
fun loadCharts() {
modelScope.launch(dispatchers.default) {
val currentData = batchFlow.state.value.data
val alreadyLoadedChartsBatchKeys = currentData
.filter { batch ->
val first = batch.data.firstOrNull() ?: return@filter false
first.tokenCharts.h24 != null
}
.map { it.key }
.toSet()
val batchesKeysToLoad = currentData.map { it.key }.toSet().minus(alreadyLoadedChartsBatchKeys)
if (batchesKeysToLoad.isNotEmpty()) {
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchesKeysToLoad,
updateRequest = TokenMarketUpdateRequest.UpdateChart(
interval = TokenMarketListConfig.Interval.H24,
currency = currentAppCurrency().code,
),
async = true,
operationId = batchesKeysToLoad.toString() + "h24",
),
)
}
}
}
fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? {
return resultBatches.value.processedItems
?.asSequence()
?.flatMap { it.data }
?.firstOrNull { it.id == tokenId }
}
private data class ResultBatches(
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
)
}
private fun TokenMarketListConfig.Order.toSortByTypeUM(): SortByTypeUM {
return when (this) {
TokenMarketListConfig.Order.ByRating -> SortByTypeUM.Rating
TokenMarketListConfig.Order.Trending -> SortByTypeUM.Trending
TokenMarketListConfig.Order.Buyers -> SortByTypeUM.ExperiencedBuyers
TokenMarketListConfig.Order.TopGainers -> SortByTypeUM.TopGainers
TokenMarketListConfig.Order.TopLosers -> SortByTypeUM.TopLosers
TokenMarketListConfig.Order.Staking -> SortByTypeUM.Staking
}
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.feed.ui.feed.state
import com.tangem.utils.Provider
internal class SearchBarStateFactory(
private val currentStateProvider: Provider<FeedListUM>,
private val onStateUpdate: (FeedListUM) -> Unit,
) {
val searchQuery: String
get() = currentStateProvider().searchBar.query
fun onSearchQueryChange(query: String) {
val currentState = currentStateProvider()
onStateUpdate(
currentState.copy(
searchBar = currentState.searchBar.copy(
query = query,
isActive = query.isNotEmpty(),
),
),
)
}
}

View file

@ -0,0 +1,128 @@
package com.tangem.features.feed.ui.feed.state
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.FormattedDate
import com.tangem.core.ui.utils.getFormattedDate
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import org.joda.time.DateTime
internal class TrendingNewsStateFactory(
private val currentStateProvider: Provider<FeedListUM>,
private val onStateUpdate: (FeedListUM) -> Unit,
) {
fun updateTrendingNewsState(result: TrendingNews, onRetryClicked: () -> Unit) {
val currentState = currentStateProvider()
when (result) {
is TrendingNews.Data -> handleDataState(currentState, result.articles)
is TrendingNews.Error -> handleErrorState(currentState, onRetryClicked)
}
}
private fun handleDataState(currentState: FeedListUM, articles: List<ShortArticle>) {
val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles)
onStateUpdate(
currentState.copy(
trendingArticle = trendingArticle?.let { mapToArticleConfigUM(it, isTrending = true) },
news = NewsUM.Content(
commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList(),
),
),
)
}
private fun handleErrorState(currentState: FeedListUM, onRetryClicked: () -> Unit) {
onStateUpdate(
currentState.copy(
trendingArticle = null,
news = NewsUM.Error(onRetryClicked = onRetryClicked),
),
)
}
private fun separateTrendingAndCommonArticles(
articles: List<ShortArticle>,
): Pair<ShortArticle?, List<ShortArticle>> {
val trendingArticleIndex = articles.indexOfFirst { it.isTrending }
return if (trendingArticleIndex != -1) {
val trendingArticle = articles[trendingArticleIndex]
val commonArticles = articles.toMutableList().apply { removeAt(trendingArticleIndex) }
trendingArticle to commonArticles
} else {
null to articles
}
}
private fun mapToArticleConfigUM(article: ShortArticle, isTrending: Boolean): ArticleConfigUM {
return ArticleConfigUM(
id = article.id,
title = article.title,
score = article.score,
isTrending = isTrending,
tags = buildArticleTags(article),
createdAt = mapFormattedDate(article.createdAt),
isViewed = article.viewed,
)
}
private fun buildArticleTags(article: ShortArticle): kotlinx.collections.immutable.ImmutableSet<LabelUM> {
val categoryLabels = article.categories.map { category ->
LabelUM(text = TextReference.Str(category.name))
}
val tokenLabels = article.relatedTokens.map { token ->
LabelUM(
text = TextReference.Str(token.symbol),
leadingContent = LabelLeadingContentUM.Token(
iconUrl = getTokenIconUrlFromDefaultHost(
tokenId = CryptoCurrency.RawID(token.id),
),
),
)
}
return (categoryLabels + tokenLabels).toPersistentSet()
}
private fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
return when (formattedDate) {
is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(
id = R.plurals.news_published_hours_ago,
count = formattedDate.hours,
formatArgs = wrappedList(formattedDate.hours),
)
is FormattedDate.MinutesAgo -> TextReference.PluralRes(
id = R.plurals.news_published_minutes_ago,
count = formattedDate.minutes,
formatArgs = wrappedList(formattedDate.minutes),
)
is FormattedDate.Today -> TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str(formattedDate.time),
),
),
)
}
}
}

View file

@ -1,322 +0,0 @@
package com.tangem.features.feed.ui.market.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.tangem.common.ui.charts.MarketChartMini
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.tokens.TokenPriceText
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.core.ui.windowsize.WindowSizeType
import com.tangem.features.feed.ui.market.preview.MarketChartListItemPreviewDataProvider
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
internal fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContent(
modifier = modifier
.fillMaxWidth()
.clip(RectangleShape)
.clickable(onClick = onClick)
.testTag(MarketsTestTags.TOKENS_LIST_ITEM),
model = model,
)
}
@Composable
private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
Row(
modifier = modifier.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing15,
),
verticalAlignment = Alignment.CenterVertically,
) {
CoinIcon(
modifier = Modifier.size(TangemTheme.dimens.size36),
url = model.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
SpacerW12()
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TokenTitle(
modifier = Modifier.weight(1f, fill = false),
name = model.name,
currencySymbol = model.currencySymbol,
)
SpacerW8()
TokenPriceText(
modifier = Modifier.alignByBaseline(),
price = model.price.text,
priceChangeType = model.price.changeType,
)
}
SpacerH(height = TangemTheme.dimens.spacing2)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
TokenSubtitle(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
ratingPosition = model.ratingPosition,
marketCap = model.marketCap,
stakingRate = model.stakingRate,
)
PriceChangeInPercent(
modifier = Modifier.alignByBaseline(),
textStyle = TangemTheme.typography.caption2,
type = model.trendType,
valueInPercent = model.trendPercentText,
)
}
}
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Spacer(Modifier.width(TangemTheme.dimens.spacing10))
Chart(
chartType = model.chartType,
chartRawData = model.chartData,
)
}
}
}
@Composable
private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier = Modifier) {
Row(modifier = modifier) {
Text(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
text = name,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerW4()
Text(
modifier = Modifier.alignByBaseline(),
text = currencySymbol,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun TokenSubtitle(
ratingPosition: String?,
marketCap: String?,
stakingRate: TextReference?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
TokenRatingPlace(ratingPosition = ratingPosition)
if (marketCap != null) {
SpacerW4()
TokenMarketCapText(
modifier = Modifier.weight(1f, fill = false),
text = marketCap,
)
}
if (stakingRate != null) {
SpacerW4()
StakingRate(stakingRate = stakingRate.resolveReference())
}
}
}
@Composable
private fun RowScope.TokenRatingPlace(ratingPosition: String?) {
Box(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens.size16)
.background(
color = TangemTheme.colors.field.primary,
shape = TangemTheme.shapes.roundedCornersSmall2,
)
.padding(horizontal = TangemTheme.dimens.spacing5),
) {
Text(
text = ratingPosition ?: MINUS,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun RowScope.StakingRate(stakingRate: String) {
Box(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens.size16)
.border(
width = TangemTheme.dimens.size1,
color = TangemTheme.colors.field.primary,
shape = TangemTheme.shapes.roundedCornersSmall2,
)
.padding(horizontal = TangemTheme.dimens.spacing5),
) {
Text(
text = stakingRate,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun RowScope.TokenMarketCapText(text: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier.alignByBaseline(),
text = text,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?) {
val chartWidth = TangemTheme.dimens.size56
Box(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing2)
.size(height = TangemTheme.dimens.size24, width = chartWidth),
) {
if (chartRawData != null) {
MarketChartMini(
rawData = chartRawData,
type = chartType,
)
} else {
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size12)
.align(Alignment.Center),
radius = TangemTheme.dimens.radius3,
)
}
}
}
// region preview
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 260, name = "small width")
@Composable
private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM) {
TangemThemePreview {
var state1 by remember { mutableStateOf(state) }
var state2 by remember { mutableStateOf(state) }
var prices by remember {
mutableStateOf(
listOf(
100 to PriceChangeType.NEUTRAL,
200 to PriceChangeType.NEUTRAL,
),
)
}
Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
MarketsListItem(
modifier = Modifier,
model = state1,
)
MarketsListItem(
modifier = Modifier,
model = state2,
)
Row {
Button(
onClick = {
state1 = state1.copy(
trendType = PriceChangeType.entries.random(),
)
state2 = state2.copy(
trendType = PriceChangeType.entries.random(),
)
},
) { Text(text = "trend") }
Button(
onClick = {
prices = prices.map { (price, _) ->
if (Random.nextBoolean()) {
price.inc() to PriceChangeType.UP
} else {
price.dec() to PriceChangeType.DOWN
}
}
state1 = state1.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[0].first}023 $",
changeType = prices[0].second,
),
)
state2 = state2.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[1].first}023 $",
changeType = prices[1].second,
),
)
},
) { Text(text = "price") }
}
}
}
}

View file

@ -1,99 +0,0 @@
package com.tangem.features.feed.ui.market.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.windowsize.WindowSizeType
@Suppress("LongMethod")
@Composable
fun MarketsListItemPlaceholder() {
val density = LocalDensity.current
val windowSize = LocalWindowSize.current
val sp12 = with(density) { 12.sp.toDp() }
Row(
modifier = Modifier.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing15,
),
verticalAlignment = Alignment.CenterVertically,
) {
CircleShimmer(Modifier.size(TangemTheme.dimens.size36))
SpacerW12()
Column(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing4),
contentAlignment = Alignment.CenterStart,
) {
RectangleShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size70)
.height(sp12),
radius = TangemTheme.dimens.radius3,
)
}
SpacerH(height = TangemTheme.dimens.spacing2)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing2),
contentAlignment = Alignment.CenterStart,
) {
RectangleShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size52)
.height(sp12),
radius = TangemTheme.dimens.radius3,
)
}
}
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Spacer(Modifier.width(TangemTheme.dimens.spacing10))
Box {
RectangleShimmer(
modifier = Modifier
.align(Alignment.Center)
.width(TangemTheme.dimens.size56)
.height(TangemTheme.dimens.size12),
radius = TangemTheme.dimens.radius3,
)
}
}
}
}
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 320, name = "small width")
@Composable
private fun Preview() {
TangemThemePreview {
Column(Modifier.background(TangemTheme.colors.background.tertiary)) {
repeat(20) {
MarketsListItemPlaceholder()
}
}
}
}

View file

@ -1,115 +0,0 @@
package com.tangem.features.feed.ui.market.preview
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import kotlinx.collections.immutable.persistentListOf
@Suppress("MagicNumber")
internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider<MarketsListItemUM>(
collection = listOf(
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = "",
ratingPosition = "10",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.NEUTRAL,
chartData = null,
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = "$6.23348172384781234 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.DOWN,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = null,
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = null,
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = null,
marketCap = null,
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
),
)

View file

@ -1,46 +0,0 @@
package com.tangem.features.feed.ui.market.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
@Immutable
data class MarketsListItemUM(
val id: CryptoCurrency.RawID,
val name: String,
val currencySymbol: String,
val iconUrl: String?,
val ratingPosition: String?,
val marketCap: String?,
val price: Price,
val trendPercentText: String,
val trendType: PriceChangeType,
val chartData: MarketChartRawData?,
val isUnder100kMarketCap: Boolean,
val stakingRate: TextReference?,
val updateTimestamp: Long?,
) {
val chartType: MarketChartLook.Type = when (trendType) {
PriceChangeType.UP -> MarketChartLook.Type.Growing
PriceChangeType.DOWN -> MarketChartLook.Type.Falling
PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral
}
@Immutable
data class Price(
val text: String,
val changeType: PriceChangeType? = null,
)
@Suppress("NullableToStringCall")
fun getComposeKey(): String {
return id.value + TOKEN_LAZY_LIST_ID_SEPARATOR + marketCap.toString() + updateTimestamp
}
companion object {
const val TOKEN_LAZY_LIST_ID_SEPARATOR = "@"
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.market.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.fields.entity.SearchBarUM

View file

@ -6,9 +6,6 @@
<ID>BooleanPropertyNaming:HomeUM.kt$HomeUM$val scanInProgress: Boolean</ID>
<ID>MultilineLambdaItParameter:HomeModel.kt$HomeModel${ delay(HIDE_PROGRESS_DELAY) setLoading(false) when (it) { is SaveWalletError.DataError -&gt; Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -&gt; appRouter.replaceAll(AppRoute.Wallet) } }</ID>
<ID>MultilineLambdaItParameter:StoriesProgressBar.kt${ when (index) { currentStep -&gt; it.fillMaxWidth(progress.value) in 0 until currentStep -&gt; it.fillMaxWidth(fraction = 1f) else -&gt; it } }</ID>
<ID>NonBooleanPropertyPrefixedWithIs:StoriesAnimation.kt$val isFirstStepLaunched = remember { mutableStateOf(false) }</ID>
<ID>NonBooleanPropertyPrefixedWithIs:StoriesAnimation.kt$val isLaunched = remember { mutableStateOf(false) }</ID>
<ID>NonBooleanPropertyPrefixedWithIs:StoriesAnimation.kt$val isSecondStepLaunched = remember { mutableStateOf(false) }</ID>
<ID>ReusedModifierInstance:HomeButtonsV2.kt$StoriesButton( modifier = modifier, text = stringResourceSafe(id = R.string.common_get_started), useDarkerColors = false, onClick = onGetStartedClick, )</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -8,8 +8,8 @@ import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.entity.InitScreenLaunchMode
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic.SignedIn
import com.tangem.core.analytics.models.Basic.SignedIn.SignInType
import com.tangem.core.analytics.models.Basic.SignedInLegacy
import com.tangem.core.analytics.models.Basic.SignedInLegacy.SignInType
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -93,7 +93,7 @@ internal class HomeModel @Inject constructor(
val uiState = _uiState.asStateFlow()
init {
analyticsEventHandler.send(IntroductionProcess.ScreenOpened)
analyticsEventHandler.send(IntroductionProcess.ScreenOpened())
observeUserCountryChanges()
when (params.launchMode) {
@ -127,20 +127,20 @@ internal class HomeModel @Inject constructor(
}
private fun onScanClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonScanCard)
analyticsEventHandler.send(IntroductionProcess.ButtonScanCardLegacy())
scanCard()
}
private fun onShopClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards)
analyticsEventHandler.send(Shop.ScreenOpened)
analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards())
analyticsEventHandler.send(Shop.ScreenOpened())
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onSearchTokensClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonTokensList)
analyticsEventHandler.send(IntroductionProcess.ButtonTokensList())
router.push(AppRoute.ManageTokens(Source.STORIES))
}
@ -212,7 +212,7 @@ internal class HomeModel @Inject constructor(
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
SignedIn(
SignedInLegacy(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,

View file

@ -4,5 +4,9 @@ import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface CreateMobileWalletComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Unit, CreateMobileWalletComponent>
data class Params(
val source: String,
)
interface Factory : ComponentFactory<Params, CreateMobileWalletComponent>
}

View file

@ -11,17 +11,13 @@
<ID>BooleanPropertyNaming:ForgetWalletUM.kt$ForgetWalletUM$val secondCheckboxChecked: Boolean</ID>
<ID>BooleanPropertyNaming:HotAccessCodeRequestUM.kt$HotAccessCodeRequestUM$val useBiometricVisible: Boolean = true</ID>
<ID>BooleanPropertyNaming:HotWalletStepperComponent.kt$HotWalletStepperComponent.StepperUM$val showBackButton: Boolean</ID>
<ID>BooleanPropertyNaming:HotWalletStepperComponent.kt$HotWalletStepperComponent.StepperUM$val showFeedbackButton: Boolean</ID>
<ID>BooleanPropertyNaming:HotWalletStepperComponent.kt$HotWalletStepperComponent.StepperUM$val showSkipButton: Boolean</ID>
<ID>BooleanPropertyNaming:ManualBackupCheckUM.kt$ManualBackupCheckUM$val completeButtonEnabled: Boolean</ID>
<ID>BooleanPropertyNaming:ManualBackupCheckUM.kt$ManualBackupCheckUM$val completeButtonProgress: Boolean</ID>
<ID>BooleanPropertyNaming:ManualBackupCheckUM.kt$ManualBackupCheckUM.WordField$val error: Boolean</ID>
<ID>BooleanPropertyNaming:MobileWalletSetupFinishedContent.kt$var showConfetti by remember { mutableStateOf(false) }</ID>
<ID>BooleanPropertyNaming:UpgradeWalletModel.kt$UpgradeWalletModel$val otherWalletAndAlreadyCreated by lazy { userWallet?.walletId != params.userWalletId &amp;&amp; it.card.wallets.map { it.curve }.toSet().isNotEmpty() }</ID>
<ID>BooleanPropertyNaming:UpgradeWalletModel.kt$UpgradeWalletModel$val sameWalletButNotFinishedBackup by lazy { userWallet?.walletId == params.userWalletId &amp;&amp; BackupValidator.isValidFull(it.card).not() }</ID>
<ID>BooleanPropertyNaming:WalletBackupUM.kt$WalletBackupUM$val backedUp: Boolean</ID>
<ID>BooleanPropertyNaming:WalletHardwareBackupUM.kt$WalletHardwareBackupUM$val showPurchaseBlock: Boolean = false</ID>
<ID>MaxChainedCallsOnSameLine:UpgradeWalletModel.kt$UpgradeWalletModel$it.card.wallets.map { it.curve }.toSet().isNotEmpty()</ID>
<ID>MultilineLambdaItParameter:AddExistingWalletImportModel.kt$AddExistingWalletImportModel${ Timber.e(it) setImportProgress(false) }</ID>
<ID>MultilineLambdaItParameter:AddExistingWalletImportModel.kt$AddExistingWalletImportModel${ setImportProgress(false) when (it) { is SaveWalletError.DataError -&gt; Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -&gt; { uiMessageSender.send( SnackbarMessage(resourceReference(R.string.hw_import_seed_phrase_already_imported)), ) } } }</ID>
<ID>MultilineLambdaItParameter:CreateHardwareWalletModel.kt$CreateHardwareWalletModel${ delay(HIDE_PROGRESS_DELAY) setLoading(false) when (it) { is SaveWalletError.DataError -&gt; Timber.e(it.toString(), "Unable to save user wallet") is SaveWalletError.WalletAlreadySaved -&gt; { userWalletsListRepository.unlock( userWalletId = userWallet.walletId, unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), ).onRight { router.replaceAll(AppRoute.Wallet) } } } }</ID>
@ -48,14 +44,12 @@
<ID>MultilineLambdaItParameter:ManualBackupCheckModel.kt$ManualBackupCheckModel${ it.copy( words = seedPhrasePrivateInfo.mnemonic.mnemonicComponents.filterIndexed { index, _ -&gt; WORD_FIELD_INDICES.contains(index + 1) }.toImmutableList(), ) }</ID>
<ID>MultilineLambdaItParameter:ManualBackupPhraseContent.kt${ EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word${it + 1}", ) }</ID>
<ID>MultilineLambdaItParameter:ManualBackupPhraseModel.kt$ManualBackupPhraseModel${ it.copy( words = seedPhrasePrivateInfo.mnemonic.mnemonicComponents.mapIndexed { index, s -&gt; EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList(), ) }</ID>
<ID>MultilineLambdaItParameter:UpgradeWalletModel.kt$UpgradeWalletModel${ // Check if user attempted to upgrade before but something went wrong and a full reset is required val userWallet = coldUserWalletBuilderFactory.create(it).build() val sameWalletButNotFinishedBackup by lazy { userWallet?.walletId == params.userWalletId &amp;&amp; BackupValidator.isValidFull(it.card).not() } val otherWalletAndAlreadyCreated by lazy { userWallet?.walletId != params.userWalletId &amp;&amp; it.card.wallets.map { it.curve }.toSet().isNotEmpty() } if (userWallet != null &amp;&amp; (sameWalletButNotFinishedBackup || otherWalletAndAlreadyCreated)) { startResetCardsFlow.emit(userWallet) return@doOnSuccess } delay(DELAY_SDK_DIALOG_CLOSE) tangemSdkManager.changeDisplayedCardIdNumbersCount(it) navigateToUpgradeFlow(it) }</ID>
<ID>MultilineLambdaItParameter:ViewPhraseContent.kt${ EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word${it + 1}", ) }</ID>
<ID>MultilineLambdaItParameter:ViewPhraseModel.kt$ViewPhraseModel${ it.copy( words = words.mapIndexed { index, s -&gt; EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList(), ) }</ID>
<ID>NoNameShadowing:ManualBackupCheckModel.kt$ManualBackupCheckModel${ it.copy(completeButtonProgress = false) }</ID>
<ID>PropertyUsedBeforeDeclaration:AddExistingWalletImportModel.kt$AddExistingWalletImportModel$uiState</ID>
<ID>ReusedModifierInstance:AddExistingWalletImportContent.kt$OutlineTextFieldWithIcon( modifier = modifier .padding(horizontal = 16.dp) .fillMaxWidth(), value = state.passPhrase, onValueChange = state.passPhraseChange, iconResId = R.drawable.ic_information_24, iconColor = TangemTheme.colors.icon.informative, label = stringResourceSafe(id = R.string.common_passphrase), placeholder = stringResourceSafe(id = R.string.send_optional_field), onIconClick = state.onPassphraseInfoClick, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, keyboardType = KeyboardType.Password, ), )</ID>
<ID>ReusedModifierInstance:HotAccessCodeRequestFullScreenContent.kt$AnimatedVisibility( modifier = modifier, visible = state.isShown, enter = fadeIn(), exit = fadeOut(), ) { Column( Modifier .fillMaxSize() .background(TangemTheme.colors.background.primary), horizontalAlignment = Alignment.CenterHorizontally, ) { TangemTopAppBar( modifier = Modifier.statusBarsPadding(), startButton = TopAppBarButtonUM.Back(state.onDismiss), ) SpacerH(68.dp) Column( Modifier .weight(1f) .fillMaxWidth() .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( modifier = Modifier.animateEnterExit( enter = slideInVertically( tween(), initialOffsetY = { it + 200 }, ) + fadeIn(tween()), exit = slideOutVertically(tween(300)) { it - 200 } + fadeOut(tween()), ), text = stringResourceSafe(R.string.access_code_check_title), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, ) SpacerH24() PinTextField( modifier = Modifier.animateEnterExit( enter = slideInVertically( tween(), initialOffsetY = { it + 200 }, ) + fadeIn(tween()), exit = slideOutVertically(tween(300)) { it - 200 } + fadeOut(tween()), ), length = 6, isPasswordVisual = true, value = state.accessCode, 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, ) } } AnimatedVisibility( visible = state.useBiometricVisible, enter = fadeIn(), exit = fadeOut(), ) { SecondaryButton( modifier = Modifier .padding(16.dp) .fillMaxWidth() .navigationBarsPadding() .imePadding(), text = stringResourceSafe( id = R.string.welcome_unlock, stringResourceSafe(R.string.common_biometrics), ), onClick = state.useBiometricClick, ) } } }</ID>
<ID>ReusedModifierInstance:HotWalletStepper.kt$TangemTopAppBar( startButton = if (state.showBackButton) { TopAppBarButtonUM.Back(onBackClick) } else { null }, endButton = when { state.showSkipButton -&gt; TopAppBarButtonUM.Text( text = resourceReference(R.string.common_skip), onClicked = onSkipClick, ) state.showFeedbackButton -&gt; TopAppBarButtonUM.Icon( iconRes = R.drawable.ic_chat_24, onClicked = onFeedbackClick, ) else -&gt; null }, title = state.title, containerColor = TangemTheme.colors.background.primary, modifier = modifier, titleAlignment = Alignment.CenterHorizontally, )</ID>
<ID>SuspendFunSwallowedCancellation:AddExistingWalletImportModel.kt$AddExistingWalletImportModel$runCatching</ID>
<ID>SuspendFunSwallowedCancellation:ManualBackupCheckModel.kt$ManualBackupCheckModel$runCatching</ID>
<ID>SuspendFunSwallowedCancellation:ManualBackupPhraseModel.kt$ManualBackupPhraseModel$runCatching</ID>

View file

@ -2,12 +2,16 @@ package com.tangem.features.hotwallet.accesscode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.essenty.lifecycle.doOnResume
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.security.DisableScreenshotsDisposableEffect
import com.tangem.features.hotwallet.accesscode.ui.AccessCode
import com.tangem.domain.models.wallet.UserWalletId
@ -31,10 +35,18 @@ internal class AccessCodeComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
EventEffect(event = state.requestFocus) {
focusManager.clearFocus()
focusRequester.requestFocus()
}
DisableScreenshotsDisposableEffect()
AccessCode(
modifier = modifier,
focusRequester = focusRequester,
state = state,
)
}

View file

@ -7,10 +7,13 @@ 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.components.fields.PinTextColor
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.hotwallet.IsAccessCodeSimpleUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.CanUseBiometryUseCase
@ -28,7 +31,6 @@ import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -57,6 +59,7 @@ internal class AccessCodeModel @Inject constructor(
private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase,
private val setAskBiometryShownUseCase: SetAskBiometryShownUseCase,
private val canUseBiometryUseCase: CanUseBiometryUseCase,
private val isAccessCodeSimpleUseCase: IsAccessCodeSimpleUseCase,
private val uiMessageSender: UiMessageSender,
) : Model() {
@ -118,16 +121,56 @@ internal class AccessCodeModel @Inject constructor(
modelScope.launch {
delay(timeMillis = SUCCESS_DISPLAY_DURATION_MS)
params.callbacks.onNewAccessCodeInput(params.userWalletId, uiState.value.accessCode)
uiState.update { currentState ->
currentState.copy(
accessCode = "",
)
if (isAccessCodeSimpleUseCase(uiState.value.accessCode)) {
showSimpleAccessCodeDialog()
} else {
setNewCode()
}
}
}
private fun setNewCode() {
params.callbacks.onNewAccessCodeInput(params.userWalletId, uiState.value.accessCode)
uiState.update { currentState ->
currentState.copy(
accessCode = "",
)
}
}
private fun showSimpleAccessCodeDialog() {
uiMessageSender.send(
DialogMessage(
title = resourceReference(R.string.access_code_alert_validation_title),
message = resourceReference(R.string.access_code_alert_validation_description),
firstAction = EventMessageAction(
title = resourceReference(R.string.access_code_alert_validation_cancel),
onClick = {
uiState.update { currentState ->
currentState.copy(
accessCode = "",
onAccessCodeChange = ::onAccessCodeChange,
requestFocus = triggeredEvent(Unit, ::consumeRequestFocusEvent),
)
}
},
),
secondAction = EventMessageAction(
title = resourceReference(R.string.access_code_alert_validation_ok),
onClick = ::setNewCode,
),
isDismissable = false,
),
)
}
private fun consumeRequestFocusEvent() {
uiState.update { currentState ->
currentState.copy(requestFocus = consumedEvent())
}
}
private suspend fun showErrorAndReset() {
uiState.update { currentState ->
currentState.copy(
@ -152,11 +195,6 @@ internal class AccessCodeModel @Inject constructor(
tryToAskForBiometry()
userWalletsListRepository.saveWithoutLock(
userWallet.copy(backedUp = true),
canOverride = true,
)
userWalletsListRepository.setLock(
userWallet.walletId,
UserWalletsListRepository.LockMethod.AccessCode(accessCode.toCharArray()),
@ -179,30 +217,25 @@ internal class AccessCodeModel @Inject constructor(
hotWalletAccessor.unlockContextual(userWallet.hotWalletId)
}
launch(NonCancellable) {
var updatedHotWalletId = tangemHotSdk.changeAuth(
var updatedHotWalletId = tangemHotSdk.changeAuth(
unlockHotWallet = unlockHotWallet,
auth = HotAuth.Password(accessCode.toCharArray()),
)
if (walletsRepository.requireAccessCode().not()) {
updatedHotWalletId = tangemHotSdk.changeAuth(
unlockHotWallet = unlockHotWallet,
auth = HotAuth.Password(accessCode.toCharArray()),
auth = HotAuth.Biometry,
)
if (walletsRepository.requireAccessCode().not()) {
updatedHotWalletId = tangemHotSdk.changeAuth(
unlockHotWallet = unlockHotWallet,
auth = HotAuth.Biometry,
)
}
userWalletsListRepository.saveWithoutLock(
userWallet.copy(
hotWalletId = updatedHotWalletId,
backedUp = true,
),
canOverride = true,
)
clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId)
}
userWalletsListRepository.saveWithoutLock(
userWallet.copy(hotWalletId = updatedHotWalletId),
canOverride = true,
)
clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId)
params.callbacks.onAccessCodeUpdated(params.userWalletId)
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.features.hotwallet.accesscode.entity
import com.tangem.core.ui.components.fields.PinTextColor
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.features.hotwallet.accesscode.ACCESS_CODE_LENGTH
internal data class AccessCodeUM(
@ -8,6 +10,7 @@ internal data class AccessCodeUM(
val accessCodeColor: PinTextColor,
val onAccessCodeChange: (String) -> Unit,
val isConfirmMode: Boolean,
val requestFocus: StateEvent<Unit> = consumedEvent(),
) {
val accessCodeLength: Int = ACCESS_CODE_LENGTH
}

View file

@ -5,8 +5,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -20,7 +22,11 @@ import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM
@Suppress("LongParameterList", "LongMethod")
@Composable
internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) {
internal fun AccessCode(
state: AccessCodeUM,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
Column(
modifier = modifier
.fillMaxSize()
@ -77,6 +83,7 @@ internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) {
value = state.accessCode,
pinTextColor = state.accessCodeColor,
onValueChange = state.onAccessCodeChange,
focusRequester = focusRequester,
)
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.features.hotwallet.accesscoderequest
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.SignIn
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.ui.components.fields.PinTextColor
@ -33,6 +35,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository,
private val userWalletsListRepository: UserWalletsListRepository,
private val canUseBiometryUseCase: CanUseBiometryUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val result = MutableStateFlow<HotWalletPasswordRequester.Result?>(null)
@ -108,6 +111,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
onAccessCodeChange = ::onAccessCodeChange,
accessCode = "",
useBiometricClick = {
analyticsEventHandler.send(SignIn.ButtonBiometricSignIn())
dismissState()
result.value = HotWalletPasswordRequester.Result.UseBiometry
},

View file

@ -27,6 +27,7 @@ import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -110,7 +111,7 @@ internal class AddExistingWalletModel @Inject constructor(
title = resourceReference(R.string.access_code_alert_skip_ok),
onClick = {
if (userWalletId != null) {
modelScope.launch {
modelScope.launch(NonCancellable) {
setAccessCodeSkippedUseCase(userWalletId, true)
}
}

View file

@ -15,7 +15,6 @@ internal class AddExistingWalletStepperStateManager {
title = resourceReference(R.string.wallet_import_seed_navtitle),
showBackButton = true,
showSkipButton = false,
showFeedbackButton = true,
)
is AddExistingWalletRoute.BackupCompleted -> HotWalletStepperComponent.StepperUM(
@ -24,7 +23,6 @@ internal class AddExistingWalletStepperStateManager {
title = resourceReference(R.string.wallet_import_title),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
is AddExistingWalletRoute.SetAccessCode -> HotWalletStepperComponent.StepperUM(
@ -33,7 +31,6 @@ internal class AddExistingWalletStepperStateManager {
title = resourceReference(R.string.access_code_navtitle),
showBackButton = false,
showSkipButton = true,
showFeedbackButton = false,
)
is AddExistingWalletRoute.ConfirmAccessCode -> HotWalletStepperComponent.StepperUM(
@ -42,7 +39,6 @@ internal class AddExistingWalletStepperStateManager {
title = resourceReference(R.string.access_code_navtitle),
showBackButton = true,
showSkipButton = true,
showFeedbackButton = false,
)
is AddExistingWalletRoute.PushNotifications -> HotWalletStepperComponent.StepperUM(
@ -51,7 +47,6 @@ internal class AddExistingWalletStepperStateManager {
title = resourceReference(R.string.onboarding_title_notifications),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
is AddExistingWalletRoute.SetupFinished -> HotWalletStepperComponent.StepperUM(
@ -60,7 +55,6 @@ internal class AddExistingWalletStepperStateManager {
title = resourceReference(R.string.common_done),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
}
}

View file

@ -1,5 +1,8 @@
package com.tangem.features.hotwallet.addexistingwallet.im.port.model
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -37,6 +40,7 @@ internal class AddExistingWalletImportModel @Inject constructor(
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
private val saveUserWalletUseCase: SaveWalletUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val params: AddExistingWalletImportComponent.Params = paramsContainer.require()
@ -60,6 +64,7 @@ internal class AddExistingWalletImportModel @Inject constructor(
}
init {
analyticsEventHandler.send(OnboardingAnalyticsEvent.SeedPhrase.ImportSeedPhraseScreenOpened())
importSeedPhraseUiStateBuilder = ImportSeedPhraseUiStateBuilder(
modelScope = modelScope,
mnemonicRepository = mnemonicRepository,
@ -72,6 +77,7 @@ internal class AddExistingWalletImportModel @Inject constructor(
)
},
onPassphraseInfoClick = ::onPassphraseInfoClick,
onImportClick = { analyticsEventHandler.send(OnboardingAnalyticsEvent.SeedPhrase.ButtonImport()) },
)
}
@ -101,6 +107,23 @@ internal class AddExistingWalletImportModel @Inject constructor(
}
.onRight {
setImportProgress(false)
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.Onboarding.Finished(
source = AnalyticsParam.ScreensSources.ImportWallet.value,
),
)
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
source = AnalyticsParam.ScreensSources.ImportWallet.value,
creationType = OnboardingAnalyticsEvent.CreateWallet.WalletCreationType.SeedImport,
seedPhraseLength = mnemonic.mnemonicComponents.size,
passPhraseState = if (passphrase.isNullOrBlank()) {
AnalyticsParam.EmptyFull.Empty
} else {
AnalyticsParam.EmptyFull.Full
},
),
)
params.callbacks.onWalletImported(userWallet.walletId)
}
}.onFailure {

View file

@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class ImportSeedPhraseUiStateBuilder(
private val modelScope: CoroutineScope,
private val mnemonicRepository: MnemonicRepository,
@ -24,6 +25,7 @@ internal class ImportSeedPhraseUiStateBuilder(
private val updateUiState: ((AddExistingWalletImportUM) -> AddExistingWalletImportUM) -> Unit,
private val importWallet: (mnemonic: Mnemonic, passphrase: String?) -> Unit,
private val onPassphraseInfoClick: () -> Unit,
private val onImportClick: () -> Unit,
) {
private val wordsCheckJobHolder = JobHolder()
private var importedMnemonic: Mnemonic? = null
@ -57,6 +59,7 @@ internal class ImportSeedPhraseUiStateBuilder(
}
private fun onCreateWallet() {
onImportClick()
val mnemonic = importedMnemonic ?: return
val passphrase = passphrase?.takeIf { it.isNotEmpty() }
importWallet(mnemonic, passphrase)

View file

@ -1,8 +1,10 @@
package com.tangem.features.hotwallet.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
@ -18,6 +20,7 @@ import com.tangem.core.ui.res.TangemTheme
private const val DISABLED_COLORS_ALPHA = 0.5f
@OptIn(ExperimentalLayoutApi::class)
@Suppress("LongParameterList")
@Composable
internal fun OptionBlock(
@ -43,11 +46,11 @@ internal fun OptionBlock(
}
.padding(16.dp),
) {
Row {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
modifier = Modifier
.weight(1f, fill = false)
.padding(end = 4.dp),
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,

View file

@ -5,8 +5,8 @@ import com.tangem.common.core.TangemSdkError
import com.tangem.common.routing.AppRoute
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.analytics.models.Basic
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
@ -16,8 +16,7 @@ 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.ParamCardCurrencyConverter
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.SaveWalletError
@ -53,6 +52,7 @@ internal class CreateHardwareWalletModel @Inject constructor(
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
@ -66,20 +66,26 @@ internal class CreateHardwareWalletModel @Inject constructor(
)
init {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.CreateWalletScreenOpened)
trackingContextProxy.addHotWalletContext()
analyticsEventHandler.send(WalletSettingsAnalyticEvents.CreateWalletScreenOpened())
}
override fun onDestroy() {
trackingContextProxy.removeContext()
super.onDestroy()
}
private fun onBuyTangemWalletClick() {
analyticsEventHandler.send(Basic.ButtonBuy(source = AnalyticsParam.ScreensSources.CreateWallet))
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onScanDeviceClick() {
analyticsEventHandler.send(
event = IntroductionProcess.ButtonScanCard(AnalyticsParam.ScreensSources.CreateWallet),
)
scanCard()
}
@ -143,28 +149,11 @@ internal class CreateHardwareWalletModel @Inject constructor(
},
ifRight = {
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse = scanResponse, isImported = userWallet.isImported)
router.replaceAll(AppRoute.Wallet)
},
)
}
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
SignedIn(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListRepository.userWalletsSync().size.toString(),
isImported = isImported,
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
}
private fun setLoading(isLoading: Boolean) {
uiState.update { it.copy(isScanInProgress = isLoading) }
}

View file

@ -3,6 +3,8 @@ package com.tangem.features.hotwallet.createhardwarewallet.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -42,6 +44,7 @@ internal fun CreateHardwareWalletContent(state: CreateHardwareWalletUM, modifier
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(
start = 16.dp,
top = 24.dp,

View file

@ -1,13 +1,21 @@
package com.tangem.features.hotwallet.createmobilewallet
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.analytics.utils.TrackingContextProxy
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.message.dialog.Dialogs.hotWalletCreationNotSupportedDialog
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.domain.wallets.usecase.SyncWalletWithRemoteUseCase
import com.tangem.features.hotwallet.CreateMobileWalletComponent
import com.tangem.features.hotwallet.createmobilewallet.entity.CreateMobileWalletUM
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotAuth
@ -25,6 +33,7 @@ import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class CreateMobileWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
private val saveUserWalletUseCase: SaveWalletUseCase,
@ -32,8 +41,13 @@ internal class CreateMobileWalletModel @Inject constructor(
private val router: Router,
private val tangemHotSdk: TangemHotSdk,
private val trackingContextProxy: TrackingContextProxy,
private val isHotWalletCreationSupported: IsHotWalletCreationSupported,
private val uiMessageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val params: CreateMobileWalletComponent.Params = paramsContainer.require()
internal val uiState: StateFlow<CreateMobileWalletUM>
field = MutableStateFlow(
CreateMobileWalletUM(
@ -46,6 +60,12 @@ internal class CreateMobileWalletModel @Inject constructor(
init {
trackingContextProxy.addHotWalletContext()
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.Onboarding.Started(source = params.source),
)
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.SeedPhrase.CreateMobileScreenOpened(source = params.source),
)
}
override fun onDestroy() {
@ -54,10 +74,15 @@ internal class CreateMobileWalletModel @Inject constructor(
}
private fun onImportClick() {
analyticsEventHandler.send(OnboardingAnalyticsEvent.SeedPhrase.ButtonImportWallet())
checkHotWalletCreationSupported(notSupported = { return })
router.push(AppRoute.AddExistingWallet)
}
private fun onCreateClick() {
analyticsEventHandler.send(OnboardingAnalyticsEvent.CreateWallet.ButtonCreateWallet())
checkHotWalletCreationSupported(notSupported = { return })
modelScope.launch {
uiState.update {
it.copy(createButtonLoading = true)
@ -70,9 +95,20 @@ internal class CreateMobileWalletModel @Inject constructor(
saveUserWalletUseCase(userWallet)
launch(NonCancellable) {
analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished(source = params.source))
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
source = params.source,
creationType = OnboardingAnalyticsEvent.CreateWallet.WalletCreationType.NewSeed,
seedPhraseLength = SEED_PHRASE_LENGTH,
passPhraseState = AnalyticsParam.EmptyFull.Empty,
),
)
launch(dispatchers.main + NonCancellable) {
syncWalletWithRemoteUseCase(userWalletId = userWallet.walletId)
}
router.replaceAll(AppRoute.Wallet)
}.onFailure { throwable ->
Timber.e(throwable)
@ -81,4 +117,17 @@ internal class CreateMobileWalletModel @Inject constructor(
}
}
}
private inline fun checkHotWalletCreationSupported(notSupported: () -> Unit) {
if (!isHotWalletCreationSupported()) {
uiMessageSender.send(
hotWalletCreationNotSupportedDialog(isHotWalletCreationSupported.getLeastVersionName()),
)
notSupported()
}
}
companion object {
private const val SEED_PHRASE_LENGTH = 12
}
}

View file

@ -15,7 +15,7 @@ import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
internal class DefaultCreateMobileWalletComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Unit,
@Assisted private val params: CreateMobileWalletComponent.Params,
) : CreateMobileWalletComponent, AppComponentContext by context {
private val model: CreateMobileWalletModel = getOrCreateModel(params)
@ -31,6 +31,9 @@ internal class DefaultCreateMobileWalletComponent @AssistedInject constructor(
@AssistedFactory
interface Factory : CreateMobileWalletComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultCreateMobileWalletComponent
override fun create(
context: AppComponentContext,
params: CreateMobileWalletComponent.Params,
): DefaultCreateMobileWalletComponent
}
}

View file

@ -3,6 +3,8 @@ package com.tangem.features.hotwallet.createmobilewallet.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -41,6 +43,7 @@ internal fun CreateMobileWalletContent(state: CreateMobileWalletUM, modifier: Mo
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(
start = 16.dp,
top = 24.dp,

View file

@ -3,6 +3,8 @@ package com.tangem.features.hotwallet.manualbackup.start.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -23,7 +25,7 @@ import com.tangem.features.hotwallet.manualbackup.start.entity.ManualBackupStart
@Composable
internal fun ManualBackupStartContent(state: ManualBackupStartUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
modifier
.background(TangemTheme.colors.background.primary)
.fillMaxSize()
.padding(
@ -33,51 +35,58 @@ internal fun ManualBackupStartContent(state: ManualBackupStartUM, modifier: Modi
bottom = 16.dp,
),
) {
Text(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 8.dp,
.weight(1f)
.verticalScroll(rememberScrollState()),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 8.dp,
),
text = stringResourceSafe(R.string.backup_info_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 8.dp,
),
text = stringResourceSafe(
R.string.backup_info_description,
state.seepPhraseLength.toString(),
),
text = stringResourceSafe(R.string.backup_info_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 8.dp,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
FeatureBlock(
modifier = Modifier
.padding(top = 24.dp),
title = stringResourceSafe(R.string.backup_info_save_title),
description = stringResourceSafe(
R.string.backup_info_save_description,
state.seepPhraseLength.toString(),
),
text = stringResourceSafe(
R.string.backup_info_description,
state.seepPhraseLength.toString(),
),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
FeatureBlock(
modifier = Modifier
.padding(top = 24.dp),
title = stringResourceSafe(R.string.backup_info_save_title),
description = stringResourceSafe(
R.string.backup_info_save_description,
state.seepPhraseLength.toString(),
),
iconRes = R.drawable.ic_lock_24,
)
FeatureBlock(
modifier = Modifier
.padding(top = 24.dp),
title = stringResourceSafe(R.string.backup_info_keep_title),
description = stringResourceSafe(R.string.backup_info_keep_description),
iconRes = R.drawable.ic_settings_24,
)
Spacer(modifier = Modifier.weight(1f))
iconRes = R.drawable.ic_lock_24,
)
FeatureBlock(
modifier = Modifier
.padding(top = 24.dp),
title = stringResourceSafe(R.string.backup_info_keep_title),
description = stringResourceSafe(R.string.backup_info_keep_description),
iconRes = R.drawable.ic_settings_24,
)
Spacer(modifier = Modifier.weight(1f))
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()

View file

@ -14,7 +14,6 @@ interface HotWalletStepperComponent : ComposableContentComponent {
val title: TextReference,
val showBackButton: Boolean,
val showSkipButton: Boolean,
val showFeedbackButton: Boolean,
) {
companion object {
fun initialState() = StepperUM(
@ -23,7 +22,6 @@ interface HotWalletStepperComponent : ComposableContentComponent {
title = TextReference.EMPTY,
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
}
}

View file

@ -34,7 +34,6 @@ internal class DefaultHotWalletStepperComponent @AssistedInject constructor(
modifier = modifier,
onBackClick = model::onBackClick,
onSkipClick = model::onSkipClick,
onFeedbackClick = model::onFeedbackClick,
)
}

View file

@ -34,9 +34,4 @@ internal class HotWalletStepperModel @Inject constructor(
// TODO send analytics
params.callback.onSkipClick()
}
fun onFeedbackClick() {
// TODO send analytics
// openFeedback()
}
}

View file

@ -31,7 +31,6 @@ internal fun HotWalletStepper(
state: HotWalletStepperComponent.StepperUM,
onBackClick: () -> Unit,
onSkipClick: () -> Unit,
onFeedbackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val fraction = state.currentStep.toFloat() / state.steps.coerceAtLeast(1)
@ -47,20 +46,16 @@ internal fun HotWalletStepper(
} else {
null
},
endButton = when {
state.showSkipButton -> TopAppBarButtonUM.Text(
endButton = if (state.showSkipButton) {
TopAppBarButtonUM.Text(
text = resourceReference(R.string.common_skip),
onClicked = onSkipClick,
)
state.showFeedbackButton -> TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_chat_24,
onClicked = onFeedbackClick,
)
else -> null
} else {
null
},
title = state.title,
containerColor = TangemTheme.colors.background.primary,
modifier = modifier,
titleAlignment = Alignment.CenterHorizontally,
)
@ -95,11 +90,9 @@ private fun HotWalletStepper_Preview() {
title = resourceReference(R.string.common_done),
showBackButton = true,
showSkipButton = false,
showFeedbackButton = true,
),
onBackClick = {},
onSkipClick = {},
onFeedbackClick = {},
)
}
}

View file

@ -28,6 +28,7 @@ internal class DefaultUpgradeWalletComponent @AssistedInject constructor(
private val resetCardsComponent = resetCardsComponentFactory.create(
context = child("ResetCardsComponent"),
params = ResetCardsComponent.Params(
source = ResetCardsComponent.Params.Source.Upgrade,
callbacks = model.resetCardsComponentCallbacks,
),
)

View file

@ -7,6 +7,9 @@ import com.tangem.common.doOnResult
import com.tangem.common.doOnSuccess
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -18,7 +21,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.toWrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.card.BackupValidator
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
@ -60,6 +63,7 @@ internal class UpgradeWalletModel @Inject constructor(
private val tangemSdkManager: TangemSdkManager,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val params = paramsContainer.require<UpgradeWalletComponent.Params>()
@ -77,22 +81,26 @@ internal class UpgradeWalletModel @Inject constructor(
)
init {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.HardwareUpgradeScreenOpened)
trackingContextProxy.addHotWalletContext()
analyticsEventHandler.send(WalletSettingsAnalyticEvents.HardwareUpgradeScreenOpened())
}
override fun onDestroy() {
trackingContextProxy.removeContext()
clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId)
super.onDestroy()
}
private fun onBuyTangemWalletClick() {
analyticsEventHandler.send(Basic.ButtonBuy(source = AnalyticsParam.ScreensSources.Upgrade))
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onContinueClick() {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonStartUpgrade)
analyticsEventHandler.send(IntroductionProcess.ButtonScanCard(AnalyticsParam.ScreensSources.Upgrade))
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonStartUpgrade())
scanCard()
}
@ -131,12 +139,9 @@ internal class UpgradeWalletModel @Inject constructor(
scanResponse: ScanResponse,
onSuccess: suspend () -> Unit,
) {
// Check if user attempted to upgrade before but something went wrong and a full reset is required
val userWallet = coldUserWalletBuilderFactory.create(scanResponse).build()
val isSameWalletButNotFinishedBackup = userWallet?.walletId == params.userWalletId &&
BackupValidator.isValidFull(scanResponse.card).not()
if (userWallet != null && isSameWalletButNotFinishedBackup) {
if (userWallet?.walletId == params.userWalletId) {
startResetCardsFlow.emit(userWallet)
return
}
@ -201,7 +206,7 @@ internal class UpgradeWalletModel @Inject constructor(
}
override fun onComplete() {
setLoading(false)
scanCard()
}
}
}

View file

@ -3,6 +3,8 @@ package com.tangem.features.hotwallet.upgradewallet.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@ -44,6 +46,7 @@ internal fun UpgradeWalletContent(state: UpgradeWalletUM, modifier: Modifier = M
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(
start = 16.dp,
top = 24.dp,

View file

@ -33,6 +33,7 @@ import com.tangem.features.hotwallet.WalletActivationComponent
import com.tangem.features.hotwallet.stepper.api.HotWalletStepperComponent
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -61,7 +62,7 @@ internal class WalletActivationModel @Inject constructor(
val pushNotificationsCallbacks = PushNotificationsCallbacks()
val mobileWalletSetupFinishedModelCallbacks = MobileWalletSetupFinishedModelCallbacks()
val isStartingWithAccessCode = params.isBackupExists
private val isStartingWithAccessCode = params.isBackupExists
val stackNavigation = StackNavigation<WalletActivationRoute>()
val startRoute = if (isStartingWithAccessCode) {
WalletActivationRoute.SetAccessCode
@ -70,18 +71,28 @@ internal class WalletActivationModel @Inject constructor(
}
val currentRoute: MutableStateFlow<WalletActivationRoute> = MutableStateFlow(startRoute)
private val source = AnalyticsParam.ScreensSources.Main
private val action = WalletSettingsAnalyticEvents.RecoveryPhraseScreenAction.Backup
private val analyticsSource = AnalyticsParam.ScreensSources.Main
private val analyticsAction = WalletSettingsAnalyticEvents.RecoveryPhraseScreenAction.Backup
init {
trackingContextProxy.addHotWalletContext()
if (startRoute is WalletActivationRoute.ManualBackupStart) {
analyticsEventHandler.send(
event = WalletSettingsAnalyticEvents.RecoveryPhraseScreenInfo(
source = source.value,
action = action.value,
),
)
when (startRoute) {
is WalletActivationRoute.ManualBackupStart -> {
analyticsEventHandler.send(
event = WalletSettingsAnalyticEvents.RecoveryPhraseScreenInfo(
source = analyticsSource.value,
action = analyticsAction.value,
),
)
}
is WalletActivationRoute.SetAccessCode -> {
analyticsEventHandler.send(
event = WalletSettingsAnalyticEvents.AccessCodeScreenOpened(
source = analyticsSource.value,
),
)
}
else -> Unit
}
}
@ -134,7 +145,7 @@ internal class WalletActivationModel @Inject constructor(
secondAction = EventMessageAction(
title = resourceReference(R.string.access_code_alert_skip_ok),
onClick = {
modelScope.launch {
modelScope.launch(NonCancellable) {
setAccessCodeSkippedUseCase(userWalletId, true)
}
navigateToPushNotificationsOrNext()
@ -160,8 +171,8 @@ internal class WalletActivationModel @Inject constructor(
stackNavigation.push(WalletActivationRoute.ManualBackupPhrase)
analyticsEventHandler.send(
event = WalletSettingsAnalyticEvents.RecoveryPhraseScreen(
source = source.value,
action = action.value,
source = analyticsSource.value,
action = analyticsAction.value,
),
)
}
@ -172,8 +183,8 @@ internal class WalletActivationModel @Inject constructor(
stackNavigation.push(WalletActivationRoute.ManualBackupCheck)
analyticsEventHandler.send(
event = WalletSettingsAnalyticEvents.RecoveryPhraseCheck(
source = source.value,
action = action.value,
source = analyticsSource.value,
action = analyticsAction.value,
),
)
}
@ -184,8 +195,8 @@ internal class WalletActivationModel @Inject constructor(
stackNavigation.push(WalletActivationRoute.ManualBackupCompleted)
analyticsEventHandler.send(
event = WalletSettingsAnalyticEvents.BackupCompleteScreen(
source = source.value,
action = action.value,
source = analyticsSource.value,
action = analyticsAction.value,
),
)
}
@ -193,6 +204,9 @@ internal class WalletActivationModel @Inject constructor(
inner class ManualBackupCompletedModelCallbacks : ManualBackupCompletedComponent.ModelCallbacks {
override fun onContinueClick(userWalletId: UserWalletId) {
analyticsEventHandler.send(
event = WalletSettingsAnalyticEvents.AccessCodeScreenOpened(source = analyticsSource.value),
)
stackNavigation.push(WalletActivationRoute.SetAccessCode)
}
@ -201,6 +215,9 @@ internal class WalletActivationModel @Inject constructor(
inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks {
override fun onNewAccessCodeInput(userWalletId: UserWalletId, accessCode: String) {
analyticsEventHandler.send(
event = WalletSettingsAnalyticEvents.ReEnterAccessCodeScreen(source = analyticsSource.value),
)
stackNavigation.push(WalletActivationRoute.ConfirmAccessCode(accessCode))
}

View file

@ -16,7 +16,6 @@ internal class WalletActivationStepperStateManager @Inject constructor() {
title = resourceReference(R.string.common_backup),
showBackButton = true,
showSkipButton = false,
showFeedbackButton = true,
)
is WalletActivationRoute.ManualBackupPhrase -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_BACKUP_PHRASE,
@ -24,7 +23,6 @@ internal class WalletActivationStepperStateManager @Inject constructor() {
title = resourceReference(R.string.common_backup),
showBackButton = true,
showSkipButton = false,
showFeedbackButton = true,
)
is WalletActivationRoute.ManualBackupCheck -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_BACKUP_CHECK,
@ -32,7 +30,6 @@ internal class WalletActivationStepperStateManager @Inject constructor() {
title = resourceReference(R.string.common_backup),
showBackButton = true,
showSkipButton = false,
showFeedbackButton = true,
)
is WalletActivationRoute.ManualBackupCompleted -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_BACKUP_COMPLETED,
@ -40,7 +37,6 @@ internal class WalletActivationStepperStateManager @Inject constructor() {
title = resourceReference(R.string.common_backup),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
is WalletActivationRoute.SetAccessCode -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_ACCESS_CODE,
@ -48,7 +44,6 @@ internal class WalletActivationStepperStateManager @Inject constructor() {
title = resourceReference(R.string.access_code_navtitle),
showBackButton = false,
showSkipButton = true,
showFeedbackButton = false,
)
is WalletActivationRoute.ConfirmAccessCode -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_ACCESS_CODE,
@ -56,7 +51,6 @@ internal class WalletActivationStepperStateManager @Inject constructor() {
title = resourceReference(R.string.access_code_navtitle),
showBackButton = true,
showSkipButton = true,
showFeedbackButton = false,
)
is WalletActivationRoute.PushNotifications -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_NOTIFICATIONS,
@ -64,7 +58,6 @@ internal class WalletActivationStepperStateManager @Inject constructor() {
title = resourceReference(R.string.onboarding_title_notifications),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
is WalletActivationRoute.SetupFinished -> HotWalletStepperComponent.StepperUM(
currentStep = STEP_DONE,
@ -72,7 +65,6 @@ internal class WalletActivationStepperStateManager @Inject constructor() {
title = resourceReference(R.string.common_done),
showBackButton = false,
showSkipButton = false,
showFeedbackButton = false,
)
}
}

View file

@ -3,6 +3,8 @@ package com.tangem.features.hotwallet.walletbackup.model
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -24,6 +26,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@Suppress("LongParameterList")
@ -36,11 +39,14 @@ internal class WalletBackupModel @Inject constructor(
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val router: Router,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val params: WalletBackupComponent.Params = paramsContainer.require()
private val isScreenOpenedEventSent: AtomicBoolean = AtomicBoolean(false)
val uiState: StateFlow<WalletBackupUM>
field = MutableStateFlow(
WalletBackupUM(
@ -67,20 +73,33 @@ internal class WalletBackupModel @Inject constructor(
)
init {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.BackupScreenOpened(isManualBackupEnabled = true))
trackingContextProxy.addHotWalletContext()
getUserWalletUseCase.invokeFlow(params.userWalletId)
.onEach { either ->
either.fold(
ifLeft = {
Timber.e("Error on getting user wallet: $it")
},
ifRight = {
updateBackupStatuses(it)
ifRight = { userWallet ->
if (!isScreenOpenedEventSent.get() && userWallet is UserWallet.Hot) {
analyticsEventHandler.send(
event = WalletSettingsAnalyticEvents.BackupScreenOpened(
isBackedUp = userWallet.backedUp,
),
)
isScreenOpenedEventSent.set(true)
}
updateBackupStatuses(userWallet)
},
)
}.launchIn(modelScope)
}
override fun onDestroy() {
trackingContextProxy.removeContext()
super.onDestroy()
}
private fun updateBackupStatuses(userWallet: UserWallet) {
uiState.update { currentState ->
if (userWallet is UserWallet.Hot) {
@ -111,13 +130,14 @@ internal class WalletBackupModel @Inject constructor(
)
private fun onBuyClick() {
analyticsEventHandler.send(Basic.ButtonBuy(source = AnalyticsParam.ScreensSources.Backup))
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onRecoveryPhraseClick() {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonRecoveryPhrase)
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonRecoveryPhrase())
if (uiState.value.backedUp) {
getUserWalletUseCase.invoke(params.userWalletId)
.fold(
@ -160,7 +180,7 @@ internal class WalletBackupModel @Inject constructor(
}
private fun onHardwareWalletClick() {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonHardwareUpdate)
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonHardwareUpdate())
router.push(AppRoute.WalletHardwareBackup(params.userWalletId))
}
}

View file

@ -4,6 +4,8 @@ import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -49,6 +51,7 @@ internal class WalletHardwareBackupModel @Inject constructor(
private val urlOpener: UrlOpener,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val messageSender: UiMessageSender,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
@ -114,10 +117,16 @@ internal class WalletHardwareBackupModel @Inject constructor(
)
init {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.HardwareBackupScreenOpened)
trackingContextProxy.addHotWalletContext()
analyticsEventHandler.send(WalletSettingsAnalyticEvents.HardwareBackupScreenOpened())
showPurchaseBlockWithDelay()
}
override fun onDestroy() {
trackingContextProxy.removeContext()
super.onDestroy()
}
private fun showPurchaseBlockWithDelay() {
modelScope.launch {
delay(SHOW_PURCHASE_BLOCK_DELAY)
@ -126,7 +135,7 @@ internal class WalletHardwareBackupModel @Inject constructor(
}
private fun onCreateNewWalletClick() {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonCreateNewWallet)
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonCreateNewWallet())
router.push(AppRoute.CreateHardwareWallet)
}
@ -134,7 +143,7 @@ internal class WalletHardwareBackupModel @Inject constructor(
val userWallet = getUserWalletUseCase.invoke(params.userWalletId)
.getOrElse { error("Cannot find user wallet with id: ${params.userWalletId.stringValue}") }
if (userWallet is UserWallet.Hot) {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonUpgradeCurrent)
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonUpgradeCurrent())
if (!userWallet.backedUp) {
messageSender.send(makeBackupAtFirstAlertBS)
} else {
@ -160,6 +169,7 @@ internal class WalletHardwareBackupModel @Inject constructor(
}
private fun onBuyClick() {
analyticsEventHandler.send(Basic.ButtonBuy(source = AnalyticsParam.ScreensSources.HardwareWallet))
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}

View file

@ -117,6 +117,8 @@ private fun PurchaseBlock(onBuyClick: () -> Unit, modifier: Modifier = Modifier)
text = stringResourceSafe(R.string.wallet_add_hardware_purchase),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
SecondaryButton(

View file

@ -15,7 +15,6 @@
<ID>MultilineLambdaItParameter:ChooseManagedTokenContent.kt${ add( CurrencyItemUM.Basic( id = ManagedCryptoCurrency.ID( value = "ID+$it", ), name = "Bitcoin", symbol = "BTC", icon = CurrencyIconState.Loading, networks = CurrencyItemUM.Basic.NetworksUM.Collapsed, onExpandClick = {}, ), ) }</ID>
<ID>MultilineLambdaItParameter:ChooseManagedTokensModel.kt$ChooseManagedTokensModel${ it.copy( notificationUM = null, ) }</ID>
<ID>MultilineLambdaItParameter:CurrencyItemMapper.kt${ it.toCurrencyNetworkModel( isSelected = it.network in addedIn, isEditable = false, onSelectedStateChange = { _, _ -&gt; }, onLongTap = { _ -&gt; }, ) }</ID>
<ID>MultilineLambdaItParameter:CurrencyNetworksMapper.kt${ it.toCurrencyNetworkModel( isSelected = it.network in addedIn, isEditable = isItemsEditable, onSelectedStateChange = onSelectedStateChange, onLongTap = onLongTap, ) }</ID>
<ID>MultilineLambdaItParameter:CustomCurrencyFormOperations.kt${ it[Field.CONTRACT_ADDRESS] = it.getValue(Field.CONTRACT_ADDRESS).copy( error = when (exception) { CustomTokenFormValidationException.ContractAddress.Invalid -&gt; { resourceReference(R.string.custom_token_creation_error_invalid_contract_address) } }, ) }</ID>
<ID>MultilineLambdaItParameter:CustomCurrencyFormOperations.kt${ it[Field.DECIMALS] = it.getValue(Field.DECIMALS).copy( error = when (exception) { is CustomTokenFormValidationException.Decimals.Empty -&gt; { null // Should not display this error } is CustomTokenFormValidationException.Decimals.Invalid -&gt; { resourceReference( R.string.custom_token_creation_error_wrong_decimals, wrappedList(ValidateTokenFormUseCase.MAX_DECIMALS), ) } }, ) }</ID>
<ID>MultilineLambdaItParameter:CustomTokenFormContent.kt$PreviewCustomTokenFormComponentProvider${ it[Field.CONTRACT_ADDRESS] = it[Field.CONTRACT_ADDRESS]!!.copy( label = stringReference("Contract address"), value = "0x1234567890", error = stringReference("Contract address is invalid"), placeholder = stringReference("0x1234567890"), ) }</ID>

View file

@ -31,6 +31,19 @@ internal sealed class CustomTokenAnalyticsEvent(
),
)
class AddTokenToAnotherAccount(
currencySymbol: String,
derivationPath: String,
source: ManageTokensSource,
) : CustomTokenAnalyticsEvent(
event = "Button - Add Token To Another Account",
params = mapOf(
AnalyticsParam.Key.TOKEN_PARAM to currencySymbol,
AnalyticsParam.Key.DERIVATION to derivationPath,
AnalyticsParam.Key.SOURCE to source.analyticsName,
),
)
class NetworkSelected(networkName: String, source: ManageTokensSource) : CustomTokenAnalyticsEvent(
event = "Custom Token Network Selected",
params = mapOf(

View file

@ -49,7 +49,7 @@ internal sealed class ManageTokensAnalyticEvent(
AnalyticsParam.Key.SOURCE to source.analyticsName,
),
)
data object ButtonLater : ManageTokensAnalyticEvent(
class ButtonLater : ManageTokensAnalyticEvent(
event = "Button - Later",
params = mapOf(AnalyticsParam.SOURCE to ManageTokensSource.ONBOARDING.analyticsName),
)

View file

@ -2,6 +2,7 @@ package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
@ -16,7 +17,7 @@ internal interface CustomTokenFormComponent : ComposableContentComponent {
val source: ManageTokensSource,
val onSelectNetworkClick: (CustomTokenFormValues) -> Unit,
val onSelectDerivationPathClick: (CustomTokenFormValues) -> Unit,
val onCurrencyAdded: () -> Unit,
val onCurrencyAdded: (currency: CryptoCurrency) -> Unit,
)
interface Factory : ComponentFactory<Params, CustomTokenFormComponent>

View file

@ -15,6 +15,7 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent
import com.tangem.features.managetokens.component.AddCustomTokenComponent
@ -232,7 +233,16 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor(
}
}
private fun dismissAndNotify() {
private fun dismissAndNotify(currency: CryptoCurrency) {
val account = addedToAccount
if (account is Account.CryptoPortfolio && !account.isMainAccount) {
val event = CustomTokenAnalyticsEvent.AddTokenToAnotherAccount(
currencySymbol = currency.symbol,
derivationPath = currency.network.derivationPath.value.orEmpty(),
source = params.source,
)
analyticsEventHandler.send(event)
}
params.onCurrencyAdded(addedToAccount)
dismiss()
}

View file

@ -364,7 +364,7 @@ internal class CustomTokenFormModel @Inject constructor(
return@resource
}
params.onCurrencyAdded()
params.onCurrencyAdded(currency)
}
private fun selectNetwork() {

View file

@ -287,7 +287,7 @@ internal class OnboardingManageTokensModel @Inject constructor(
}
},
) {
analyticsEventHandler.send(ManageTokensAnalyticEvent.ButtonLater)
analyticsEventHandler.send(ManageTokensAnalyticEvent.ButtonLater())
useCasesFacade.saveManagedTokensUseCase(
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,

View file

@ -6,10 +6,10 @@ import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.markets.entry.BottomSheetState
import kotlinx.serialization.Serializable
@Stable

View file

@ -1,6 +0,0 @@
package com.tangem.features.markets.entry
enum class BottomSheetState {
EXPANDED,
COLLAPSED,
}

View file

@ -6,6 +6,7 @@ import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
@Stable
interface MarketsEntryComponent {

View file

@ -7,10 +7,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.markets.entry.BottomSheetState
@Stable
interface MarketsTokenListComponent : ComposableContentComponent {

View file

@ -45,6 +45,8 @@ dependencies {
implementation(projects.domain.settings)
implementation(projects.domain.notifications.models)
implementation(projects.domain.transaction)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.yieldSupply)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.

View file

@ -37,7 +37,6 @@
<ID>MultilineLambdaItParameter:AddToPortfolioBSContentUMFactory.kt$AddToPortfolioBSContentUMFactory${ if (it != selectedWalletId) { onAnotherWalletSelect(it) onWalletSelectorVisibilityChange(false) } }</ID>
<ID>MultilineLambdaItParameter:AddToPortfolioBottomSheet.kt${ Content( modifier = Modifier.fillMaxWidth(), state = it, ) WalletSelectorBottomSheet(it.walletSelectorConfig) }</ID>
<ID>MultilineLambdaItParameter:AddToPortfolioManager.kt$AddToPortfolioManager${ it.toMutableMap().apply { this[userWalletId] = if (isAddAction) { this[userWalletId].orEmpty() + network } else { this[userWalletId].orEmpty() - network } } }</ID>
<ID>MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ PortfolioData.CryptoCurrencyData( userWallet = selectedPortfolio.userWallet, status = addedToken, actions = it.states, ) }</ID>
<ID>MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ Timber.e(it) params.callback.onDismiss() }</ID>
<ID>MultilineLambdaItParameter:AddToPortfolioModel.kt$AddToPortfolioModel${ tokenActionsData.emit(it) navigation.replaceAll(AddToPortfolioRoutes.TokenActions) }</ID>
<ID>MultilineLambdaItParameter:AddTokenModel.kt$AddTokenModel${ processError(error = it) uiState.value = um.toggleProgress(false) return@launch }</ID>
@ -58,7 +57,6 @@
<ID>MultilineLambdaItParameter:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager${ when (val status = it.status) { is PaginationStatus.Paginating -&gt; { if (status.lastResult is BatchFetchResult.Success) { it.data.size == 1 } else { null } } is PaginationStatus.EndOfPagination -&gt; { it.data.size == 1 } else -&gt; null } }</ID>
<ID>MultilineLambdaItParameter:MarketsListItem.kt${ if (Random.nextBoolean()) { it.first.inc() to PriceChangeType.UP } else { it.first.dec() to PriceChangeType.DOWN } }</ID>
<ID>MultilineLambdaItParameter:MarketsListLazyColumn.kt${ (it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() ?.let { rawId -&gt; CryptoCurrency.RawID(rawId) } }</ID>
<ID>MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it == BottomSheetState.EXPANDED) { analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened) } }</ID>
<ID>MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it) { analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(tokenFound = false)) } }</ID>
<ID>MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it.isNotEmpty()) { activeListManager.getBatchKeysByItemIds(visibleItemIds.value) } else { null } }</ID>
<ID>MultilineLambdaItParameter:MarketsListModel.kt$MarketsListModel${ if (it.list !is ListUM.Content) { visibleItemIds.value = emptyList() } }</ID>
@ -118,12 +116,6 @@
<ID>NoNameShadowing:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel${ it.copy( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.ERROR, ), body = if (it.body is MarketsTokenDetailsUM.Body.Error) { MarketsTokenDetailsUM.Body.Nothing } else { it.body }, ) }</ID>
<ID>NoNameShadowing:MyPortfolioUMFactory.kt$MyPortfolioUMFactory${ networkIds.contains(it.status.currency.network.backendId) }</ID>
<ID>NoNameShadowing:NewMarketsPortfolioDelegate.kt$NewMarketsPortfolioDelegate$portfolio</ID>
<ID>NonBooleanPropertyPrefixedWithIs:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$val isInInitialLoadingErrorState = batchFlow.state .map { it.status is PaginationStatus.InitialLoadingError } .distinctUntilChanged() .stateIn( scope = modelScope, started = SharingStarted.Eagerly, initialValue = false, )</ID>
<ID>NonBooleanPropertyPrefixedWithIs:MarketsListBatchFlowManager.kt$MarketsListBatchFlowManager$val isSearchNotFoundState = batchFlow.state .map { currentSearchText().isNullOrEmpty().not() &amp;&amp; it.status is PaginationStatus.EndOfPagination &amp;&amp; it.data.isEmpty() } .distinctUntilChanged() .stateIn( scope = modelScope, started = SharingStarted.Eagerly, initialValue = false, )</ID>
<ID>NonBooleanPropertyPrefixedWithIs:MarketsListModel.kt$MarketsListModel$val isVisibleOnScreen = MutableStateFlow(false)</ID>
<ID>NonBooleanPropertyPrefixedWithIs:MarketsListUMStateManager.kt$MarketsListUMStateManager$val isInSearchStateFlow = state.map { it.searchBar.isActive }.distinctUntilChanged()</ID>
<ID>NonBooleanPropertyPrefixedWithIs:MarketsTokenDetailsModel.kt$MarketsTokenDetailsModel$val isVisibleOnScreen = MutableStateFlow(false)</ID>
<ID>NonBooleanPropertyPrefixedWithIs:TokenActionsHandler.kt$TokenActionsHandler$private val isDemoCardUseCase: IsDemoCardUseCase</ID>
<ID>NullableToStringCall:MarketsListItemUM.kt$MarketsListItemUM$marketCap.toString()</ID>
<ID>PropertyUsedBeforeDeclaration:MarketsListModel.kt$MarketsListModel$activeListManager</ID>
<ID>PropertyUsedBeforeDeclaration:MarketsListUMStateManager.kt$MarketsListUMStateManager$state</ID>

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