Updated on 2026-08-14

This commit is contained in:
Tangem 2024-09-24 15:47:20 +04:00
parent 3440e75d5d
commit 3a14b92fd8
18 changed files with 211 additions and 35 deletions

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.ktx.Firebase
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRoute.ManageTokens.Source
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -67,7 +68,7 @@ internal class HomeViewModel @Inject constructor(
analyticsEventHandler.send(IntroductionProcess.ButtonTokensList())
store.dispatch(TokensAction.SetArgs.ReadAccess)
store.dispatchNavigationAction { push(AppRoute.ManageTokens()) }
store.dispatchNavigationAction { push(AppRoute.ManageTokens(Source.STORIES)) }
}
private fun scanCard() {

View file

@ -11,6 +11,7 @@ import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.managetokens.ManageTokensToggles
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles
import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter
@ -126,9 +127,15 @@ internal class ChildFactory @Inject constructor(
}
is AppRoute.ManageTokens -> {
if (manageTokensToggles.isFeatureEnabled) {
val source = when (route.source) {
AppRoute.ManageTokens.Source.SETTINGS -> ManageTokensSource.SETTINGS
AppRoute.ManageTokens.Source.ONBOARDING -> ManageTokensSource.ONBOARDING
AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES
}
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = ManageTokensComponent.Params(route.userWalletId),
params = ManageTokensComponent.Params(route.userWalletId, source),
componentFactory = manageTokensComponentFactory,
)
} else {

View file

@ -179,9 +179,16 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class ManageTokens(
val source: Source,
val userWalletId: UserWalletId? = null,
) : AppRoute(path = "/manage_tokens/$userWalletId"), RouteBundleParams {
) : AppRoute(path = "${source.name.lowercase()}/manage_tokens/$userWalletId"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
enum class Source {
STORIES,
ONBOARDING,
SETTINGS,
}
}
@Serializable

View file

@ -11,7 +11,7 @@ dependencies {
kapt(deps.hilt.kapt)
/** Analytics - Models */
implementation(projects.core.analytics.models)
api(projects.core.analytics.models)
/** Domain */
implementation(projects.domain.analytics)

View file

@ -22,9 +22,19 @@ sealed class AnalyticsParam {
data object Closed : RateApp("Close")
}
sealed class OnOffState(val value: String) {
data object On : OnOffState("On")
data object Off : OnOffState("Off")
enum class OnOffState(val value: String) {
On("On"),
Off("Off"),
;
companion object {
fun from(enabled: Boolean): String {
val state = if (enabled) On else Off
return state.value
}
}
}
sealed class OrganizeSortType(val value: String) {
@ -135,6 +145,22 @@ sealed class AnalyticsParam {
class SingleCurrency(currencyName: String) : WalletType(currencyName)
}
enum class Validation(val value: String) {
OK(value = "Ok"),
ERROR(value = "Error"),
;
companion object {
fun from(isValid: Boolean): String {
val status = if (isValid) OK else ERROR
return status.value
}
}
}
companion object Key {
const val BLOCKCHAIN = "blockchain"
const val TOKEN_PARAM = "Token"
@ -158,6 +184,9 @@ sealed class AnalyticsParam {
const val VALIDATION = "Validation"
const val BLOCKCHAIN_EXCEPTION_HOST = "exception_host"
const val BLOCKCHAIN_SELECTED_HOST = "selected_host"
const val INPUT = "Input"
const val COUNT = "Count"
const val DERIVATION = "Derivation"
// region swap
const val TOKEN_CATEGORY = "Token"

View file

@ -0,0 +1,11 @@
package com.tangem.core.analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
class DummyAnalyticsEventHandler : AnalyticsEventHandler {
override fun send(event: AnalyticsEvent) {
/* no-op */
}
}

View file

@ -6,7 +6,10 @@ import com.tangem.domain.wallets.models.UserWalletId
interface ManageTokensComponent : ComposableContentComponent {
data class Params(val userWalletId: UserWalletId?)
data class Params(
val userWalletId: UserWalletId?,
val source: ManageTokensSource,
)
interface Factory : ComponentFactory<Params, ManageTokensComponent>
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.managetokens.component
enum class ManageTokensSource {
STORIES,
ONBOARDING,
SETTINGS,
}

View file

@ -20,6 +20,7 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.common.routing)
implementation(projects.core.featuretoggles)
implementation(projects.core.analytics)
/* Project - Domain */
implementation(projects.domain.manageTokens)

View file

@ -0,0 +1,55 @@
package com.tangem.features.managetokens.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.features.managetokens.component.ManageTokensSource
internal sealed class ManageTokensAnalyticEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(
category = "ManageTokens",
event = event,
params = params,
) {
class ScreenOpened(source: ManageTokensSource) : ManageTokensAnalyticEvent(
event = "Manage Tokens Screen Opened",
params = mapOf(AnalyticsParam.Key.SOURCE to source.name),
)
class TokensIsNotFound(query: String, source: ManageTokensSource) : ManageTokensAnalyticEvent(
event = "Token Is Not Found",
params = mapOf(
AnalyticsParam.Key.INPUT to query,
AnalyticsParam.Key.SOURCE to source.name,
),
)
class TokenSwitcherChanged(
tokenSymbol: String,
isSelected: Boolean,
source: ManageTokensSource,
) : ManageTokensAnalyticEvent(
event = "Token Switcher Changed",
params = mapOf(
AnalyticsParam.Key.TOKEN_PARAM to tokenSymbol,
AnalyticsParam.Key.STATE to AnalyticsParam.OnOffState.from(isSelected),
AnalyticsParam.Key.SOURCE to source.name,
),
)
class TokenAdded(
tokensCount: Int,
source: ManageTokensSource,
) : ManageTokensAnalyticEvent(
event = "Token Added",
params = mapOf(
AnalyticsParam.Key.COUNT to tokensCount.toString(),
AnalyticsParam.Key.SOURCE to source.name,
),
)
// TODO: Will be used later
data object ButtonLater : ManageTokensAnalyticEvent(event = "Button - Later")
}

View file

@ -24,7 +24,7 @@ import dagger.assisted.AssistedInject
internal class DefaultManageTokensComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: ManageTokensComponent.Params,
@Assisted private val params: ManageTokensComponent.Params,
private val addCustomTokenComponentFactory: AddCustomTokenComponent.Factory,
) : ManageTokensComponent, AppComponentContext by context {

View file

@ -1,7 +1,9 @@
package com.tangem.features.managetokens.model
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -16,6 +18,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.SaveManagedTokensUseCase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig
@ -35,6 +38,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ComponentScoped
internal class ManageTokensModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
@ -42,6 +46,7 @@ internal class ManageTokensModel @Inject constructor(
private val manageTokensListManager: ManageTokensListManager,
private val messageSender: UiMessageSender,
private val saveManagedTokensUseCase: SaveManagedTokensUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer,
) : Model() {
@ -68,7 +73,7 @@ internal class ManageTokensModel @Inject constructor(
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(params.userWalletId)
manageTokensListManager.launchPagination(params)
}
}
@ -79,6 +84,8 @@ internal class ManageTokensModel @Inject constructor(
}
private fun getInitialState(userWalletId: UserWalletId?): ManageTokensUM {
analyticsEventHandler.send(ManageTokensAnalyticEvent.ScreenOpened(params.source))
return if (userWalletId == null) {
createReadContentModel()
} else {
@ -140,8 +147,7 @@ internal class ManageTokensModel @Inject constructor(
state
.distinctUntilChanged { old, new ->
// It's also used to skip search activation to avoid searching an empty query
old.search.query == new.search.query &&
(old.search.isActive == new.search.isActive || new.search.isActive)
old.search.query == new.search.query && new.search.isActive
}
.transform { state ->
val query = state.search.query
@ -161,11 +167,19 @@ internal class ManageTokensModel @Inject constructor(
}
private fun updateItems(items: ImmutableList<CurrencyItemUM>) {
state.update { state ->
val updatedState = state.updateAndGet { state ->
state.copySealed(
items = items,
)
}
if (updatedState.items.isEmpty() && updatedState.search.isActive) {
val event = ManageTokensAnalyticEvent.TokensIsNotFound(
query = updatedState.search.query,
source = params.source,
)
analyticsEventHandler.send(event)
}
}
private fun updatePaginationStatus(status: PaginationStatus<*>) {
@ -234,7 +248,7 @@ internal class ManageTokensModel @Inject constructor(
}
private fun consumeScrollToTopEvent() {
this.state.update { state ->
state.update { state ->
state.copySealed(
scrollToTop = consumedEvent(),
)
@ -269,19 +283,26 @@ internal class ManageTokensModel @Inject constructor(
}
}
private fun saveChanges() {
modelScope.launch {
state.update { state -> state.copySealed(isSavingInProgress = true) }
saveManagedTokensUseCase.invoke(
userWalletId = requireNotNull(params.userWalletId),
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).fold(
ifLeft = { Timber.e(it, "Failed to save changes") },
ifRight = { router.pop() },
)
state.update { state -> state.copySealed(isSavingInProgress = false) }
private fun saveChanges() = resource(
acquire = { state.update { state -> state.copySealed(isSavingInProgress = true) } },
release = { state.update { state -> state.copySealed(isSavingInProgress = false) } },
) {
val event = ManageTokensAnalyticEvent.TokenAdded(
tokensCount = manageTokensListManager.currenciesToAdd.value.values.sumOf { it.size },
source = params.source,
)
analyticsEventHandler.send(event)
saveManagedTokensUseCase(
userWalletId = requireNotNull(params.userWalletId),
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).getOrElse {
Timber.e(it, "Failed to save changes")
return@resource
}
router.pop()
}
private fun searchCurrencies(query: String) {

View file

@ -1,6 +1,7 @@
package com.tangem.features.managetokens.utils.list
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.clipboard.ClipboardManager
@ -8,12 +9,15 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.CheckHasLinkedTokensUseCase
import com.tangem.domain.managetokens.GetManagedTokensUseCase
import com.tangem.domain.managetokens.RemoveCustomManagedCryptoCurrencyUseCase
import com.tangem.domain.managetokens.CheckHasLinkedTokensUseCase
import com.tangem.domain.managetokens.model.*
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.impl.R
import com.tangem.pagination.BatchAction
@ -42,10 +46,12 @@ internal class ManageTokensListManager @Inject constructor(
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val messageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
clipboardManager: ClipboardManager,
) : ManageTokensUiActions {
private lateinit var scope: CoroutineScope
private lateinit var source: ManageTokensSource
private val jobHolder = JobHolder()
private val actionsFlow: MutableSharedFlow<ManageTokensBatchAction> = MutableSharedFlow(
@ -74,8 +80,9 @@ internal class ManageTokensListManager @Inject constructor(
.distinctUntilChanged()
val uiItems: Flow<ImmutableList<CurrencyItemUM>> = uiManager.items
suspend fun launchPagination(userWalletId: UserWalletId?) = coroutineScope {
suspend fun launchPagination(params: ManageTokensComponent.Params) = coroutineScope {
scope = this
source = params.source
val batchFlow = getManagedTokensUseCase(
context = ManageTokensListBatchingContext(
@ -85,13 +92,13 @@ internal class ManageTokensListManager @Inject constructor(
)
batchFlow.state
.onEach { state -> updateState(state, userWalletId) }
.onEach { state -> updateState(state, params.userWalletId) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
// Initial load
reload(userWalletId)
reload(params.userWalletId)
}
suspend fun reload(userWalletId: UserWalletId?) {
@ -159,12 +166,16 @@ internal class ManageTokensListManager @Inject constructor(
changedCurrenciesManager.addCurrency(currency, network)
sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = true)
sendSelectCurrencyAnalyticsEvent(currency, isSelected = true)
}
override fun removeCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network) {
changedCurrenciesManager.removeCurrency(currency, network)
sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = false)
sendSelectCurrencyAnalyticsEvent(currency, isSelected = false)
}
override fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) {
@ -180,6 +191,15 @@ internal class ManageTokensListManager @Inject constructor(
network: Network,
): Boolean = !changedCurrenciesManager.containsCurrency(currency, network)
private fun sendSelectCurrencyAnalyticsEvent(currency: ManagedCryptoCurrency.Token, isSelected: Boolean) {
val event = ManageTokensAnalyticEvent.TokenSwitcherChanged(
tokenSymbol = currency.symbol,
isSelected = isSelected,
source = source,
)
analyticsEventHandler.send(event)
}
private fun sendSelectCurrencyAction(
batchKey: Int,
currencyId: ManagedCryptoCurrency.ID,

View file

@ -9,5 +9,7 @@ internal sealed class Settings(
error: Throwable? = null,
) : AnalyticsEvent(category, event, params, error) {
class ButtonCreateBackup : Settings(event = "Button - Create Backup")
data object ButtonCreateBackup : Settings(event = "Button - Create Backup")
data object ButtonManageTokens : Settings(event = "Button - Manage Tokens")
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.walletsettings.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.analytics.DummyAnalyticsEventHandler
import com.tangem.core.decompose.navigation.DummyRouter
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.walletsettings.component.WalletSettingsComponent
@ -13,7 +14,10 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent {
private val previewState = WalletSettingsUM(
popBack = {},
items = ItemsBuilder(router = DummyRouter()).buildItems(
items = ItemsBuilder(
router = DummyRouter(),
analyticsEventHandler = DummyAnalyticsEventHandler(),
).buildItems(
userWalletId = UserWalletId("011"),
userWalletName = "My Wallet",
isReferralAvailable = true,

View file

@ -145,7 +145,7 @@ internal class WalletSettingsModel @Inject constructor(
}
private fun onLinkMoreCardsClick(scanResponse: ScanResponse) {
analyticsEventHandler.send(Settings.ButtonCreateBackup())
analyticsEventHandler.send(Settings.ButtonCreateBackup)
analyticsContextProxy.addContext(scanResponse)

View file

@ -1,12 +1,15 @@
package com.tangem.feature.walletsettings.utils
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRoute.ManageTokens.Source
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.block.model.BlockUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.walletsettings.analytics.Settings
import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM
import com.tangem.feature.walletsettings.impl.R
import kotlinx.collections.immutable.PersistentList
@ -17,6 +20,7 @@ import javax.inject.Inject
@ComponentScoped
internal class ItemsBuilder @Inject constructor(
private val router: Router,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
@Suppress("LongParameterList")
@ -62,7 +66,10 @@ internal class ItemsBuilder @Inject constructor(
BlockUM(
text = resourceReference(R.string.add_tokens_title),
iconRes = R.drawable.ic_tether_24,
onClick = { router.push(AppRoute.ManageTokens(userWalletId)) },
onClick = {
analyticsEventHandler.send(Settings.ButtonManageTokens)
router.push(AppRoute.ManageTokens(Source.SETTINGS, userWalletId))
},
).let(::add)
}

View file

@ -14,6 +14,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRoute.ManageTokens.Source
import com.tangem.common.routing.AppRouter
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.redux.ReduxStateHolder
@ -140,7 +141,7 @@ internal class DefaultWalletRouter(
}
override fun openManageTokensScreen(userWalletId: UserWalletId) {
router.push(AppRoute.ManageTokens(userWalletId = userWalletId))
router.push(AppRoute.ManageTokens(Source.SETTINGS, userWalletId))
}
override fun openScanFailedDialog(onTryAgain: () -> Unit) {