Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-04 16:46:41 +03:00
commit 512ab6d54d
206 changed files with 8933 additions and 630 deletions

View file

@ -74,6 +74,7 @@ dependencies {
implementation(projects.data.tokens) implementation(projects.data.tokens)
implementation(projects.data.txhistory) implementation(projects.data.txhistory)
implementation(projects.data.wallets) implementation(projects.data.wallets)
implementation(projects.data.analytics)
/** Features */ /** Features */
implementation(projects.features.onboarding) implementation(projects.features.onboarding)

View file

@ -14,6 +14,7 @@ import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.blockchain.common.WalletManagerFactory
import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder
import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.data.source.preferences.PreferencesDataSource import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.datasource.api.common.MoshiConverter import com.tangem.datasource.api.common.MoshiConverter
@ -216,6 +217,9 @@ internal class TapApplication : Application(), ImageLoaderFactory {
@Inject @Inject
lateinit var sendFeatureToggles: SendFeatureToggles lateinit var sendFeatureToggles: SendFeatureToggles
@Inject
lateinit var oneTimeEventFilter: OneTimeEventFilter
// endregion Injected // endregion Injected
override fun onCreate() { override fun onCreate() {
@ -353,6 +357,8 @@ internal class TapApplication : Application(), ImageLoaderFactory {
factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder()) factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder())
factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder()) factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder())
factory.addFilter(oneTimeEventFilter)
val buildData = AnalyticsHandlerBuilder.Data( val buildData = AnalyticsHandlerBuilder.Data(
application = application, application = application,
config = config, config = config,

View file

@ -9,8 +9,8 @@ class AmplitudeAnalyticsHandler(
override fun id(): String = ID override fun id(): String = ID
override fun send(event: String, params: Map<String, String>) { override fun send(eventId: String, params: Map<String, String>) {
client.logEvent(event, params) client.logEvent(eventId, params)
} }
companion object { companion object {

View file

@ -12,8 +12,8 @@ class AppsFlyerAnalyticsHandler(
override fun id(): String = ID override fun id(): String = ID
override fun send(event: String, params: Map<String, String>) { override fun send(eventId: String, params: Map<String, String>) {
client.logEvent(event, params) client.logEvent(eventId, params)
} }
override fun send(event: AnalyticsEvent) { override fun send(event: AnalyticsEvent) {

View file

@ -14,8 +14,8 @@ class FirebaseAnalyticsHandler(
override fun id(): String = ID override fun id(): String = ID
override fun send(event: String, params: Map<String, String>) { override fun send(eventId: String, params: Map<String, String>) {
client.logEvent(event, params) client.logEvent(eventId, params)
} }
override fun send(event: AnalyticsEvent) { override fun send(event: AnalyticsEvent) {

View file

@ -91,4 +91,16 @@ internal object SettingsDomainModule {
): UpdateBalanceHidingSettingsUseCase { ): UpdateBalanceHidingSettingsUseCase {
return UpdateBalanceHidingSettingsUseCase(balanceHidingRepository) return UpdateBalanceHidingSettingsUseCase(balanceHidingRepository)
} }
@Provides
@ViewModelScoped
fun provideSetWalletsScrollPreviewIsShown(settingsRepository: SettingsRepository): NeverToShowWalletsScrollPreview {
return NeverToShowWalletsScrollPreview(settingsRepository = settingsRepository)
}
@Provides
@ViewModelScoped
fun provideIsWalletsScrollPreviewEnabled(settingsRepository: SettingsRepository): IsWalletsScrollPreviewEnabled {
return IsWalletsScrollPreviewEnabled(settingsRepository = settingsRepository)
}
} }

View file

@ -2,10 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.tokens.* import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.*
import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
@ -231,4 +228,10 @@ internal object TokensDomainModule {
): GetMissedAddressesCryptoCurrenciesUseCase { ): GetMissedAddressesCryptoCurrenciesUseCase {
return GetMissedAddressesCryptoCurrenciesUseCase(currenciesRepository = currenciesRepository) return GetMissedAddressesCryptoCurrenciesUseCase(currenciesRepository = currenciesRepository)
} }
@Provides
@ViewModelScoped
fun provideGetGlobalTokenListUseCase(tokensListRepository: TokensListRepository): GetGlobalTokenListUseCase {
return GetGlobalTokenListUseCase(repository = tokensListRepository)
}
} }

View file

@ -1,6 +1,9 @@
package com.tangem.tap.di.domain package com.tangem.tap.di.domain
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
@ -15,10 +18,25 @@ internal object TransactionDomainModule {
@Provides @Provides
@ViewModelScoped @ViewModelScoped
fun provideGetUseCase( fun provideGetFeeUseCase(
walletManagersFacade: WalletManagersFacade, walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
): GetFeeUseCase { ): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade, dispatchers) return GetFeeUseCase(walletManagersFacade, dispatchers)
} }
@Provides
@ViewModelScoped
fun provideSendTransactionUseCase(
isDemoCardUseCase: IsDemoCardUseCase,
walletManagersFacade: WalletManagersFacade,
cardSdkConfigRepository: CardSdkConfigRepository,
): SendTransactionUseCase {
return SendTransactionUseCase(
isDemoCardUseCase = isDemoCardUseCase,
cardSdkConfigRepository = cardSdkConfigRepository,
walletManagersFacade = walletManagersFacade,
)
}
} }

View file

@ -67,15 +67,17 @@ internal class BiometricUserWalletsListManager(
} }
} }
.map { .map {
if (throwIfNotAllWalletsUnlocked && state.value.userWallets.any(UserWallet::isLocked)) { val userWallets = state.value.userWallets
Timber.e("Not all user wallets have been unlocked")
if (throwIfNotAllWalletsUnlocked && userWallets.any(UserWallet::isLocked)) {
Timber.e("Some user wallets remain locked")
throw UserWalletsListError.NotAllUserWalletsUnlocked throw UserWalletsListError.NotAllUserWalletsUnlocked
} }
val selectedUserWallet = selectedUserWalletSync val selectedUserWallet = selectedUserWalletSync
if (selectedUserWallet == null || selectedUserWallet.isLocked) { if (selectedUserWallet == null || selectedUserWallet.isLocked) {
Timber.e("Unable to find selected user wallet") findAndSetUnlockedUserWallet(userWallets)
throw UserWalletsListError.NoUserWalletSelected ?: throw UserWalletsListError.NoUserWalletSelected
} else { } else {
selectedUserWallet selectedUserWallet
} }
@ -280,13 +282,14 @@ internal class BiometricUserWalletsListManager(
prevSelectedWalletId: UserWalletId?, prevSelectedWalletId: UserWalletId?,
userWallets: List<UserWallet>, userWallets: List<UserWallet>,
): UserWalletId? { ): UserWalletId? {
val findUnlockedAndSet = { return prevSelectedWalletId
userWallets.firstOrNull { !it.isLocked } ?: (selectedUserWalletRepository.get() ?: findAndSetUnlockedUserWallet(userWallets)?.walletId)
?.walletId }
?.also { selectedUserWalletRepository.set(it) }
}
return prevSelectedWalletId ?: (selectedUserWalletRepository.get() ?: findUnlockedAndSet()) private fun findAndSetUnlockedUserWallet(userWallets: List<UserWallet>): UserWallet? {
return userWallets
.firstOrNull { !it.isLocked }
?.also { selectedUserWalletRepository.set(it.walletId) }
} }
private fun changeSelectedUserWalletIdIfNeeded(walletsIdsToRemove: List<UserWalletId>) { private fun changeSelectedUserWalletIdIfNeeded(walletsIdsToRemove: List<UserWalletId>) {

View file

@ -14,11 +14,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.SpacerH4 import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.SpacerW32 import com.tangem.core.ui.components.SpacerW32
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.appsettings.AppSettingsItemsFactory import com.tangem.tap.features.details.ui.appsettings.AppSettingsItemsFactory
import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Item import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Item
import com.tangem.core.ui.components.TangemSwitch
@Composable @Composable
internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier) { internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier) {
@ -63,6 +63,7 @@ internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier
checked = item.isChecked, checked = item.isChecked,
enabled = item.isEnabled, enabled = item.isEnabled,
onCheckedChange = item.onCheckedChange, onCheckedChange = item.onCheckedChange,
checkedColor = TangemTheme.colors.icon.accent,
) )
} }
} }

View file

@ -20,6 +20,8 @@ import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.tokens.TokenWithBlockchain import com.tangem.domain.tokens.TokenWithBlockchain
import com.tangem.domain.tokens.TokensAction import com.tangem.domain.tokens.TokensAction
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.walletconnect.WalletConnectActions
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.operations.derivation.ExtendedPublicKeysMap import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.* import com.tangem.tap.*
@ -90,13 +92,17 @@ object TokensMiddleware {
if (scanResponse.supportsHdWallet()) { if (scanResponse.supportsHdWallet()) {
deriveMissingCoins(scanResponse = scanResponse, currencyList = currencyList) { deriveMissingCoins(scanResponse = scanResponse, currencyList = currencyList) {
submitNewAdd( submitNewAdd(
userWalletId = action.userWallet.walletId, userWallet = action.userWallet,
updatedScanResponse = it, updatedScanResponse = it,
currencyList = currencyList, currencyList = currencyList,
) )
} }
} else { } else {
submitNewAdd(userWalletId = action.userWallet.walletId, scanResponse, currencyList = currencyList) submitNewAdd(
userWallet = action.userWallet,
updatedScanResponse = scanResponse,
currencyList = currencyList,
)
} }
} }
} }
@ -375,16 +381,18 @@ object TokensMiddleware {
} }
private fun submitNewAdd( private fun submitNewAdd(
userWalletId: UserWalletId, userWallet: UserWallet,
updatedScanResponse: ScanResponse, updatedScanResponse: ScanResponse,
currencyList: List<CryptoCurrency>, currencyList: List<CryptoCurrency>,
) { ) {
scope.launch { scope.launch {
userWalletsListManager.update( userWalletsListManager.update(
userWalletId = userWalletId, userWalletId = userWallet.walletId,
update = { it.copy(scanResponse = updatedScanResponse) }, update = { it.copy(scanResponse = updatedScanResponse) },
).doOnSuccess { ).doOnSuccess {
addCryptoCurrenciesUseCase(userWalletId, currencyList) addCryptoCurrenciesUseCase(userWallet.walletId, currencyList).onRight {
store.dispatch(action = WalletConnectActions.New.SetupUserChains(userWallet = userWallet))
}
} }
} }
store.dispatchOnMain(NavigationAction.PopBackTo()) store.dispatchOnMain(NavigationAction.PopBackTo())

View file

@ -1,19 +1,18 @@
plugins { plugins {
alias(deps.plugins.android.library) alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt) alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration") id("configuration")
} }
dependencies { dependencies {
/** DI */ /** DI */
implementation(deps.hilt.android) implementation(deps.hilt.core)
kapt(deps.hilt.kapt) kapt(deps.hilt.kapt)
/** Core shouldn't depends on core, but in case with utils and logging its necessary */ /** Core shouldn't depends on core, but in case with utils and logging its necessary */
implementation(projects.core.utils) implementation(projects.core.utils)
implementation(projects.core.analytics.models) implementation(projects.core.analytics.models)
implementation(deps.kotlin.coroutines)
} }

View file

@ -8,4 +8,7 @@ open class AnalyticsEvent(
val event: String, val event: String,
var params: Map<String, String> = mapOf(), var params: Map<String, String> = mapOf(),
val error: Throwable? = null, val error: Throwable? = null,
) ) {
val id: String = "[$category] $event"
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.analytics.models
interface OneTimeAnalyticsEvent {
val oneTimeEventId: String
}

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.core.analytics" />

View file

@ -16,7 +16,7 @@ interface AnalyticsEventFilter {
* An internal filter check that, on external or internal conditions, recognizes the possibility of * An internal filter check that, on external or internal conditions, recognizes the possibility of
* sending an event. * sending an event.
*/ */
fun canBeSent(event: AnalyticsEvent): Boolean suspend fun canBeSent(event: AnalyticsEvent): Boolean
/** /**
* Performs a check to see if the event can be dispatched by a specific handler * Performs a check to see if the event can be dispatched by a specific handler

View file

@ -12,13 +12,11 @@ interface AnalyticsEventHandler {
interface AnalyticsHandler : AnalyticsEventHandler { interface AnalyticsHandler : AnalyticsEventHandler {
fun id(): String fun id(): String
fun send(event: String, params: Map<String, String> = emptyMap()) fun send(eventId: String, params: Map<String, String> = emptyMap())
override fun send(event: AnalyticsEvent) { override fun send(event: AnalyticsEvent) {
send(prepareEventString(event), event.params) send(event.id, event.params)
} }
fun prepareEventString(event: AnalyticsEvent): String = "[${event.category}] ${event.event}"
} }
interface ErrorEventHandler { interface ErrorEventHandler {

View file

@ -2,6 +2,8 @@ package com.tangem.core.analytics.di
import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.core.analytics.repository.AnalyticsRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -10,11 +12,16 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class AnalyticsModule { internal object AnalyticsModule {
@Singleton @Singleton
@Provides @Provides
fun provideAnalyticsHandler(): AnalyticsEventHandler { fun provideAnalyticsHandler(): AnalyticsEventHandler {
return Analytics // todo replace after refactoring calling Analytics in whole project return Analytics // todo replace after refactoring calling Analytics in whole project
} }
@Provides
fun provideOneTimeEventFilter(analyticsRepository: AnalyticsRepository): OneTimeEventFilter {
return OneTimeEventFilter(analyticsRepository)
}
} }

View file

@ -0,0 +1,30 @@
package com.tangem.core.analytics.filter
import com.tangem.core.analytics.api.AnalyticsEventFilter
import com.tangem.core.analytics.api.AnalyticsHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.OneTimeAnalyticsEvent
import com.tangem.core.analytics.repository.AnalyticsRepository
class OneTimeEventFilter(
private val analyticsRepository: AnalyticsRepository,
) : AnalyticsEventFilter {
override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = event is OneTimeAnalyticsEvent
override suspend fun canBeSent(event: AnalyticsEvent): Boolean {
if (event !is OneTimeAnalyticsEvent) return true
val isSent = analyticsRepository.checkIsEventSent(event.oneTimeEventId)
if (!isSent) {
analyticsRepository.setIsEventSent(event.oneTimeEventId)
}
return !isSent
}
override fun canBeConsumedByHandler(handler: AnalyticsHandler, event: AnalyticsEvent): Boolean {
return canBeAppliedTo(event)
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.core.analytics.repository
interface AnalyticsRepository {
suspend fun checkIsEventSent(eventId: String): Boolean
suspend fun setIsEventSent(eventId: String)
}

View file

@ -32,6 +32,10 @@ object PreferencesKeys {
val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") } val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") }
val BALANCE_HIDING_SETTINGS_KEY by lazy { stringPreferencesKey(name = "balanceHidingSettings") } val BALANCE_HIDING_SETTINGS_KEY by lazy { stringPreferencesKey(name = "balanceHidingSettings") }
val WALLETS_SCROLL_PREVIEW_KEY by lazy { booleanPreferencesKey(name = "walletsScrollPreview") }
val SENT_ONE_TIME_EVENTS_KEY by lazy { stringPreferencesKey(name = "sentOneTimeEvents") }
} }
/** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore<Preferences> */ /** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore<Preferences> */

View file

@ -34,5 +34,9 @@
{ {
"name": "REDESIGNED_SEND_SCREEN_ENABLED", "name": "REDESIGNED_SEND_SCREEN_ENABLED",
"version": "undefined" "version": "undefined"
},
{
"name": "WALLETS_SCROLLING_PREVIEW_ENABLED",
"version": "5.4.0"
} }
] ]

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<resources> <resources>
<string name="common_fee_error">Erhalt der Gebühr fehlgeschlagen</string> <string name="common_fee_error">Erhalt der Gebühr fehlgeschlagen</string>
<string name="no_account_generic">Laden Sie %1$s+ %2$s auf um ein Konto zu erstellen</string>
<string name="send_error_dust_amount_format">Minimaler Betrag ist %s</string> <string name="send_error_dust_amount_format">Minimaler Betrag ist %s</string>
<string name="send_error_dust_change">Restbestand zu klein</string> <string name="send_error_dust_change">Restbestand zu klein</string>
<string name="send_error_invalid_fee_value">Falsche Gebühr</string> <string name="send_error_invalid_fee_value">Falsche Gebühr</string>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<resources> <resources>
<string name="common_fee_error">Échec de réception des commissions</string> <string name="common_fee_error">Échec de réception des commissions</string>
<string name="no_account_generic">Pour créer un compte, téléchargez %1$s+ %2$s</string>
<string name="send_error_dust_amount_format">Le montant minimal est de %s</string> <string name="send_error_dust_amount_format">Le montant minimal est de %s</string>
<string name="send_error_dust_change">Le reste est trop petit</string> <string name="send_error_dust_change">Le reste est trop petit</string>
<string name="send_error_invalid_fee_value">Commission non valide</string> <string name="send_error_invalid_fee_value">Commission non valide</string>

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<resources> <resources>
<string name="common_fee_error">Impossibile ottenere la commissione</string> <string name="common_fee_error">Impossibile ottenere la commissione</string>
<string name="no_account_generic">Scarica %1$s+ %2$s per creare un account</string>
<string name="send_error_dust_amount_format">L\'importo minimo è di %s</string> <string name="send_error_dust_amount_format">L\'importo minimo è di %s</string>
<string name="send_error_dust_change">L\'importo residuo è molto basso</string> <string name="send_error_dust_change">L\'importo residuo è molto basso</string>
<string name="send_error_invalid_fee_value">Commissione non valida</string> <string name="send_error_invalid_fee_value">Commissione non valida</string>

View file

@ -4,7 +4,6 @@
<string name="address_type_legacy">遺留資產</string> <string name="address_type_legacy">遺留資產</string>
<string name="common_fee_error">獲取費用失敗</string> <string name="common_fee_error">獲取費用失敗</string>
<string name="kaspa_withdrawal_message_warning">由於 Kaspa 的限制,只有%1$d UTXO 可以放入單次交易中。這意味著您只能發送%2$s或更少數量。您需要減少數量。</string> <string name="kaspa_withdrawal_message_warning">由於 Kaspa 的限制,只有%1$d UTXO 可以放入單次交易中。這意味著您只能發送%2$s或更少數量。您需要減少數量。</string>
<string name="no_account_generic">加載 %1$s+ %2$s 以創建帳戶</string>
<string name="no_account_polkadot">目標帳戶未激活。發送 %s 或更多以激活帳戶</string> <string name="no_account_polkadot">目標帳戶未激活。發送 %s 或更多以激活帳戶</string>
<string name="send_error_dust_amount_format">最小數量是 %s</string> <string name="send_error_dust_amount_format">最小數量是 %s</string>
<string name="send_error_dust_change">更動太小</string> <string name="send_error_dust_change">更動太小</string>

View file

@ -13,6 +13,7 @@ import androidx.compose.material.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -162,11 +163,12 @@ fun CardWithIcon(
* >Figma component</a> * >Figma component</a>
*/ */
@Composable @Composable
fun IconWithTitleAndDescription( internal fun IconWithTitleAndDescription(
title: String, title: String,
description: String, description: String?,
icon: @Composable () -> Unit, icon: @Composable () -> Unit,
additionalContent: @Composable () -> Unit = {}, additionalContent: @Composable () -> Unit = {},
iconBackground: Color = TangemTheme.colors.background.secondary,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -182,7 +184,7 @@ fun IconWithTitleAndDescription(
Box( Box(
modifier = Modifier modifier = Modifier
.background( .background(
color = TangemTheme.colors.background.secondary, color = iconBackground,
shape = CircleShape, shape = CircleShape,
) )
.height(TangemTheme.dimens.size40) .height(TangemTheme.dimens.size40)
@ -205,12 +207,14 @@ fun IconWithTitleAndDescription(
color = TangemTheme.colors.text.primary1, color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1, style = TangemTheme.typography.subtitle1,
) )
SpacerH4() if (description != null) {
Text( SpacerH4()
text = description, Text(
color = TangemTheme.colors.text.secondary, text = description,
style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary,
) style = TangemTheme.typography.body2,
)
}
} }
additionalContent() additionalContent()

View file

@ -23,7 +23,7 @@ import com.tangem.core.ui.res.TangemTheme
@Composable @Composable
fun TangemSwitch( fun TangemSwitch(
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
checkedColor: Color = TangemTheme.colors.icon.accent, checkedColor: Color = TangemTheme.colors.control.checked,
uncheckedColor: Color = TangemTheme.colors.icon.informative, uncheckedColor: Color = TangemTheme.colors.icon.informative,
size: Dp = 48.dp, size: Dp = 48.dp,
checked: Boolean = false, checked: Boolean = false,

View file

@ -9,6 +9,7 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R import com.tangem.core.ui.R
@ -36,6 +37,28 @@ fun WarningCard(title: String, description: String, icon: @Composable (() -> Uni
) )
} }
/**
* A card with a warning icon to the left and title without description shown to the right of it.
*
* @param title title of the warning in bold
*
* @see <a href = "https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?node-id=290%3A217&t=yMepJZTRh5bLkOoJ-1"
* >Figma component</a>
*/
@Composable
fun WarningCardTitleOnly(title: String, icon: @Composable (() -> Unit)? = null) {
WarningCardMaterial3Style(
content = {
WarningBody(
title = title,
description = null,
icon = icon,
iconBackground = TangemTheme.colors.button.disabled,
)
},
)
}
/** /**
* [WarningCard], but clickable (with an 'greater then' icon to the left) * [WarningCard], but clickable (with an 'greater then' icon to the left)
* *
@ -105,7 +128,8 @@ fun RefreshableWaringCard(
@Composable @Composable
private fun WarningBody( private fun WarningBody(
title: String, title: String,
description: String, description: String?,
iconBackground: Color = TangemTheme.colors.background.secondary,
icon: @Composable (() -> Unit)? = null, icon: @Composable (() -> Unit)? = null,
additionalContent: @Composable () -> Unit = {}, additionalContent: @Composable () -> Unit = {},
) { ) {
@ -119,6 +143,7 @@ private fun WarningBody(
contentDescription = null, contentDescription = null,
) )
}, },
iconBackground = iconBackground,
) )
} }
@ -136,6 +161,20 @@ private fun WarningCardSurface(onClick: (() -> Unit)? = null, content: @Composab
} }
} }
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun WarningCardMaterial3Style(onClick: (() -> Unit)? = null, content: @Composable () -> Unit) {
Card(
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
backgroundColor = TangemTheme.colors.button.disabled,
elevation = TangemTheme.dimens.elevation0,
onClick = onClick ?: {},
enabled = onClick != null,
) {
content()
}
}
// endregion elements // endregion elements
// region Preview // region Preview

View file

@ -4,10 +4,13 @@ import androidx.annotation.DrawableRes
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -39,7 +42,10 @@ fun AppBarWithBackButtonAndIcon(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(size = TangemTheme.dimens.size24) .size(size = TangemTheme.dimens.size24)
.clickable { onBackClick() }, .clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
) { onBackClick() },
tint = TangemTheme.colors.icon.primary1, tint = TangemTheme.colors.icon.primary1,
) )
AnimatedContent( AnimatedContent(

View file

@ -6,6 +6,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -20,6 +21,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
inline fun <reified T : TangemBottomSheetConfigContent> TangemBottomSheet( inline fun <reified T : TangemBottomSheetConfigContent> TangemBottomSheet(
config: TangemBottomSheetConfig, config: TangemBottomSheetConfig,
contentColor: Color = TangemTheme.colors.background.primary,
crossinline content: @Composable ColumnScope.(T) -> Unit, crossinline content: @Composable ColumnScope.(T) -> Unit,
) { ) {
var isVisible by remember { mutableStateOf(value = config.isShow) } var isVisible by remember { mutableStateOf(value = config.isShow) }
@ -29,7 +31,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemBottomSheet(
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = config.onDismissRequest, onDismissRequest = config.onDismissRequest,
sheetState = sheetState, sheetState = sheetState,
containerColor = TangemTheme.colors.background.primary, containerColor = contentColor,
shape = TangemTheme.shapes.bottomSheetLarge, shape = TangemTheme.shapes.bottomSheetLarge,
dragHandle = { TangemBottomSheetDraggableHeader() }, dragHandle = { TangemBottomSheetDraggableHeader() },
) { ) {

View file

@ -1,4 +1,4 @@
package com.tangem.core.ui.components.fields package com.tangem.core.ui.components.fields.visualtransformations
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString

View file

@ -0,0 +1,87 @@
package com.tangem.core.ui.components.transactions
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.toTimeFormat
/**
* Common transaction done screen title
*
* @param titleRes title resource
* @param date transaction timestamp in millis
*/
@Composable
fun TransactionDoneTitle(@StringRes titleRes: Int, date: Long, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = R.drawable.ic_empty_in_process_64),
contentDescription = null,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8)
.size(TangemTheme.dimens.size64),
)
Text(
text = stringResource(id = titleRes),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing32),
)
Text(
text = stringResource(id = R.string.send_date_format, date.toDateFormat(), date.toTimeFormat()),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing4),
)
}
}
// region Previews
@Preview
@Composable
private fun TransactionDoneTitlePreview_Light() {
TangemTheme {
TransactionDoneTitle(
titleRes = R.string.sent_transaction_sent_title,
date = 0,
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing16),
)
}
}
@Preview
@Composable
private fun TransactionDoneTitlePreview_Dark() {
TangemTheme(isDark = true) {
TransactionDoneTitle(
titleRes = R.string.sent_transaction_sent_title,
date = 0,
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing16),
)
}
}
// endregion

View file

@ -7,18 +7,34 @@ import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
fun Modifier.roundedShapeItemDecoration(currentIndex: Int, lastIndex: Int): Modifier = composed { fun Modifier.roundedShapeItemDecoration(
val modifierWithHorizontalPadding = this.padding(horizontal = TangemTheme.dimens.spacing16) currentIndex: Int,
lastIndex: Int,
addDefaultPadding: Boolean = true,
): Modifier = composed {
val modifier = if (addDefaultPadding) this.padding(horizontal = TangemTheme.dimens.spacing16) else this
val isSingleItem = currentIndex == 0 && lastIndex == 0 val isSingleItem = currentIndex == 0 && lastIndex == 0
when { when {
isSingleItem -> { isSingleItem -> {
modifierWithHorizontalPadding modifier
.padding(top = TangemTheme.dimens.spacing14) .then(
if (addDefaultPadding) {
Modifier.padding(top = TangemTheme.dimens.spacing14)
} else {
Modifier
},
)
.clip(shape = TangemTheme.shapes.roundedCornersXMedium) .clip(shape = TangemTheme.shapes.roundedCornersXMedium)
} }
currentIndex == 0 -> { currentIndex == 0 -> {
modifierWithHorizontalPadding modifier
.padding(top = TangemTheme.dimens.spacing14) .then(
if (addDefaultPadding) {
Modifier.padding(top = TangemTheme.dimens.spacing14)
} else {
Modifier
},
)
.clip( .clip(
shape = RoundedCornerShape( shape = RoundedCornerShape(
topStart = TangemTheme.dimens.radius16, topStart = TangemTheme.dimens.radius16,
@ -27,7 +43,7 @@ fun Modifier.roundedShapeItemDecoration(currentIndex: Int, lastIndex: Int): Modi
) )
} }
currentIndex == lastIndex -> { currentIndex == lastIndex -> {
modifierWithHorizontalPadding modifier
.clip( .clip(
shape = RoundedCornerShape( shape = RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius16, bottomStart = TangemTheme.dimens.radius16,
@ -35,6 +51,6 @@ fun Modifier.roundedShapeItemDecoration(currentIndex: Int, lastIndex: Int): Modi
), ),
) )
} }
else -> modifierWithHorizontalPadding else -> modifier
} }
} }

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.event
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.NonRestartableComposable
import kotlinx.coroutines.launch
/** /**
* A Composable function that reacts to a given [StateEvent], executing the provided action only once when the event * A Composable function that reacts to a given [StateEvent], executing the provided action only once when the event
@ -17,8 +18,8 @@ import androidx.compose.runtime.NonRestartableComposable
fun <A> EventEffect(event: StateEvent<A>, onTrigger: suspend (data: A) -> Unit) { fun <A> EventEffect(event: StateEvent<A>, onTrigger: suspend (data: A) -> Unit) {
LaunchedEffect(event) { LaunchedEffect(event) {
if (event is StateEvent.Triggered<A>) { if (event is StateEvent.Triggered<A>) {
onTrigger(event.data) launch { onTrigger(event.data) }
event.onConsume() .invokeOnCompletion { event.onConsume() }
} }
} }
} }

View file

@ -32,6 +32,16 @@ fun CryptoCurrency.Token.tryGetBackgroundForTokenIcon(
): Color { ): Color {
if (isGrayscale) return TangemColorPalette.Dark2 if (isGrayscale) return TangemColorPalette.Dark2
return tryGetBackgroundForTokenIcon(contractAddress = contractAddress, fallbackColor = fallbackColor)
}
/**
* Tries to extract a background color from the contract address of a token.
*
* @param fallbackColor The color to use as a fallback.
* @return The extracted background color or the fallback color if extraction fails or if it is a test network token.
*/
fun tryGetBackgroundForTokenIcon(contractAddress: String, fallbackColor: Color = TangemColorPalette.Black): Color {
return try { return try {
val colorHex = "#" + contractAddress.substring(range = COLOR_HEX_START_INDEX..COLOR_HEX_END_INDEX) val colorHex = "#" + contractAddress.substring(range = COLOR_HEX_START_INDEX..COLOR_HEX_END_INDEX)
Color(colorHex.toColorInt()) Color(colorHex.toColorInt())

View file

@ -1,5 +1,6 @@
package com.tangem.core.ui.utils package com.tangem.core.ui.utils
import com.tangem.domain.tokens.model.CryptoCurrency
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.text.NumberFormat import java.text.NumberFormat
@ -24,6 +25,10 @@ object BigDecimalFormatter {
return formatter.format(cryptoAmount) + "\u2009$cryptoCurrency" return formatter.format(cryptoAmount) + "\u2009$cryptoCurrency"
} }
fun formatCryptoAmount(cryptoAmount: BigDecimal?, cryptoCurrency: CryptoCurrency): String {
return formatCryptoAmount(cryptoAmount, cryptoCurrency.symbol, cryptoCurrency.decimals)
}
fun formatFiatAmount( fun formatFiatAmount(
fiatAmount: BigDecimal?, fiatAmount: BigDecimal?,
fiatCurrencyCode: String, fiatCurrencyCode: String,

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M28.384,0.616C27.896,0.128 27.104,0.128 26.616,0.616C26.128,1.104 26.128,1.896 26.616,2.384L31.002,6.769C17.519,7.294 6.75,18.389 6.75,32C6.75,45.945 18.055,57.25 32,57.25C45.945,57.25 57.25,45.945 57.25,32C57.25,31.31 56.69,30.75 56,30.75C55.31,30.75 54.75,31.31 54.75,32C54.75,44.564 44.564,54.75 32,54.75C19.435,54.75 9.25,44.564 9.25,32C9.25,19.785 18.878,9.818 30.959,9.273L26.616,13.616C26.128,14.104 26.128,14.896 26.616,15.384C27.104,15.872 27.896,15.872 28.384,15.384L34.884,8.884C35.372,8.396 35.372,7.604 34.884,7.116L28.384,0.616ZM39,22.75H40.25V24V26V26.518L39.884,26.884L33.768,33L39.884,39.116L40.25,39.482V40V42V43.25H39H25H23.75V42V40V39.482L24.116,39.116L30.232,33L24.116,26.884L23.75,26.518V26V24V22.75H25H39ZM26.25,25.482L32,31.232L37.75,25.482V25.25H26.25V25.482ZM37.75,40.518L32,34.768L26.25,40.518V40.75H37.75V40.518Z"
android:fillColor="#C9C9C9"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M12,22C10.633,22 9.342,21.737 8.125,21.212C6.908,20.688 5.846,19.971 4.938,19.063C4.029,18.154 3.313,17.092 2.787,15.875C2.263,14.658 2,13.367 2,12C2,10.617 2.263,9.321 2.787,8.113C3.313,6.904 4.029,5.846 4.938,4.938C5.846,4.029 6.908,3.313 8.125,2.787C9.342,2.263 10.633,2 12,2C13.383,2 14.679,2.263 15.887,2.787C17.096,3.313 18.154,4.029 19.063,4.938C19.971,5.846 20.688,6.904 21.212,8.113C21.737,9.321 22,10.617 22,12C22,13.367 21.737,14.658 21.212,15.875C20.688,17.092 19.971,18.154 19.063,19.063C18.154,19.971 17.096,20.688 15.887,21.212C14.679,21.737 13.383,22 12,22ZM12,19.95C12.433,19.35 12.808,18.725 13.125,18.075C13.442,17.425 13.7,16.733 13.9,16H10.1C10.3,16.733 10.558,17.425 10.875,18.075C11.192,18.725 11.567,19.35 12,19.95ZM9.4,19.55C9.1,19 8.837,18.429 8.613,17.837C8.387,17.246 8.2,16.633 8.05,16H5.1C5.583,16.833 6.188,17.558 6.912,18.175C7.637,18.792 8.467,19.25 9.4,19.55ZM14.6,19.55C15.533,19.25 16.362,18.792 17.087,18.175C17.813,17.558 18.417,16.833 18.9,16H15.95C15.8,16.633 15.613,17.246 15.387,17.837C15.163,18.429 14.9,19 14.6,19.55ZM4.25,14H7.65C7.6,13.667 7.563,13.337 7.537,13.012C7.512,12.688 7.5,12.35 7.5,12C7.5,11.65 7.512,11.313 7.537,10.988C7.563,10.663 7.6,10.333 7.65,10H4.25C4.167,10.333 4.104,10.663 4.063,10.988C4.021,11.313 4,11.65 4,12C4,12.35 4.021,12.688 4.063,13.012C4.104,13.337 4.167,13.667 4.25,14ZM9.65,14H14.35C14.4,13.667 14.438,13.337 14.462,13.012C14.488,12.688 14.5,12.35 14.5,12C14.5,11.65 14.488,11.313 14.462,10.988C14.438,10.663 14.4,10.333 14.35,10H9.65C9.6,10.333 9.563,10.663 9.538,10.988C9.512,11.313 9.5,11.65 9.5,12C9.5,12.35 9.512,12.688 9.538,13.012C9.563,13.337 9.6,13.667 9.65,14ZM16.35,14H19.75C19.833,13.667 19.896,13.337 19.938,13.012C19.979,12.688 20,12.35 20,12C20,11.65 19.979,11.313 19.938,10.988C19.896,10.663 19.833,10.333 19.75,10H16.35C16.4,10.333 16.438,10.663 16.462,10.988C16.487,11.313 16.5,11.65 16.5,12C16.5,12.35 16.487,12.688 16.462,13.012C16.438,13.337 16.4,13.667 16.35,14ZM15.95,8H18.9C18.417,7.167 17.813,6.442 17.087,5.825C16.362,5.208 15.533,4.75 14.6,4.45C14.9,5 15.163,5.571 15.387,6.162C15.613,6.754 15.8,7.367 15.95,8ZM10.1,8H13.9C13.7,7.267 13.442,6.575 13.125,5.925C12.808,5.275 12.433,4.65 12,4.05C11.567,4.65 11.192,5.275 10.875,5.925C10.558,6.575 10.3,7.267 10.1,8ZM5.1,8H8.05C8.2,7.367 8.387,6.754 8.613,6.162C8.837,5.571 9.1,5 9.4,4.45C8.467,4.75 7.637,5.208 6.912,5.825C6.188,6.442 5.583,7.167 5.1,8Z"
android:fillColor="#1E1E1E"/>
</group>
</vector>

1
data/analytics/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/buld

View file

@ -0,0 +1,32 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.analytics"
}
dependencies {
/** Project - Analytics */
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
/** AndroidX */
implementation(deps.androidx.datastore)
/** DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.kotlin.coroutines)
implementation(deps.timber)
}

View file

@ -0,0 +1,27 @@
package com.tangem.data.analytics
import com.tangem.core.analytics.repository.AnalyticsRepository
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectListSync
internal class DefaultAnalyticsRepository(
private val appPreferencesStore: AppPreferencesStore,
) : AnalyticsRepository {
override suspend fun checkIsEventSent(eventId: String): Boolean {
val sentEvents = appPreferencesStore
.getObjectListSync<String>(PreferencesKeys.SENT_ONE_TIME_EVENTS_KEY)
return eventId in sentEvents
}
override suspend fun setIsEventSent(eventId: String) {
appPreferencesStore.editData { mutablePreferences ->
val sentEvents = mutablePreferences.getObject<List<String>>(PreferencesKeys.SENT_ONE_TIME_EVENTS_KEY)
val updatedSentEvents = sentEvents.orEmpty() + eventId
mutablePreferences.setObject(PreferencesKeys.SENT_ONE_TIME_EVENTS_KEY, updatedSentEvents)
}
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.data.analytics.di
import com.tangem.core.analytics.repository.AnalyticsRepository
import com.tangem.data.analytics.DefaultAnalyticsRepository
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal object AnalyticsDataModule {
@Provides
fun provideAnalyticsRepository(appPreferencesStore: AppPreferencesStore): AnalyticsRepository {
return DefaultAnalyticsRepository(appPreferencesStore)
}
}

View file

@ -13,6 +13,10 @@ android {
dependencies { dependencies {
implementation(deps.androidx.datastore) implementation(deps.androidx.datastore)
implementation(deps.tangem.blockchain) {
exclude(module = "joda-time")
}
implementation(deps.hilt.android) implementation(deps.hilt.android)
kapt(deps.hilt.kapt) kapt(deps.hilt.kapt)

View file

@ -1,6 +1,7 @@
package com.tangem.data.card package com.tangem.data.card
import com.tangem.TangemSdk import com.tangem.TangemSdk
import com.tangem.blockchain.common.CommonSigner
import com.tangem.common.UserCodeType import com.tangem.common.UserCodeType
import com.tangem.common.core.CardIdDisplayFormat import com.tangem.common.core.CardIdDisplayFormat
import com.tangem.common.core.UserCodeRequestPolicy import com.tangem.common.core.UserCodeRequestPolicy
@ -57,4 +58,16 @@ internal class DefaultCardSdkConfigRepository(
} }
override fun isAccessCodeSavingEnabled(): Boolean = preferencesDataSource.shouldSaveAccessCodes override fun isAccessCodeSavingEnabled(): Boolean = preferencesDataSource.shouldSaveAccessCodes
override fun getCommonSigner(cardId: String?) = CommonSigner(
tangemSdk = sdk,
cardId = cardId,
initialMessage = null,
)
override fun isLinkedTerminal() = sdk.config.linkedTerminal
override fun setLinkedTerminal(isLinked: Boolean?) {
sdk.config.linkedTerminal = isLinked
}
} }

View file

@ -1,16 +1,35 @@
package com.tangem.data.settings package com.tangem.data.settings
import com.tangem.data.source.preferences.PreferencesDataSource import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
internal class DefaultSettingsRepository( internal class DefaultSettingsRepository(
private val preferencesDataSource: PreferencesDataSource, private val preferencesDataSource: PreferencesDataSource,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : SettingsRepository { ) : SettingsRepository {
override suspend fun shouldShowSaveUserWalletScreen(): Boolean { override suspend fun shouldShowSaveUserWalletScreen(): Boolean {
return withContext(dispatchers.io) { preferencesDataSource.shouldShowSaveUserWalletScreen } return withContext(dispatchers.io) { preferencesDataSource.shouldShowSaveUserWalletScreen }
} }
override suspend fun isWalletScrollPreviewEnabled(): Boolean {
return appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.WALLETS_SCROLL_PREVIEW_KEY,
default = true,
)
}
override suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean) {
appPreferencesStore.store(
key = PreferencesKeys.WALLETS_SCROLL_PREVIEW_KEY,
value = isEnabled,
)
}
} }

View file

@ -21,10 +21,12 @@ internal object SettingsDataModule {
@Singleton @Singleton
fun provideSettingsRepository( fun provideSettingsRepository(
preferencesDataSource: PreferencesDataSource, preferencesDataSource: PreferencesDataSource,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
): SettingsRepository { ): SettingsRepository {
return DefaultSettingsRepository( return DefaultSettingsRepository(
preferencesDataSource = preferencesDataSource, preferencesDataSource = preferencesDataSource,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers, dispatchers = dispatchers,
) )
} }

View file

@ -45,4 +45,5 @@ dependencies {
implementation(deps.jodatime) implementation(deps.jodatime)
implementation(deps.timber) implementation(deps.timber)
implementation(deps.retrofit) // For HttpException implementation(deps.retrofit) // For HttpException
implementation(deps.androidx.paging.runtime)
} }

View file

@ -1,10 +1,7 @@
package com.tangem.data.tokens.di package com.tangem.data.tokens.di
import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.tokens.repository.DefaultCurrenciesRepository import com.tangem.data.tokens.repository.*
import com.tangem.data.tokens.repository.DefaultMarketCryptoCurrencyRepository
import com.tangem.data.tokens.repository.DefaultNetworksRepository
import com.tangem.data.tokens.repository.DefaultQuotesRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.network.NetworksStatusesStore import com.tangem.datasource.local.network.NetworksStatusesStore
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -12,10 +9,7 @@ import com.tangem.datasource.local.quote.QuotesStore
import com.tangem.datasource.local.token.UserMarketCoinsStore import com.tangem.datasource.local.token.UserMarketCoinsStore
import com.tangem.datasource.local.token.UserTokensStore import com.tangem.datasource.local.token.UserTokensStore
import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.*
import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
@ -93,4 +87,18 @@ internal object TokensDataModule {
): MarketCryptoCurrencyRepository { ): MarketCryptoCurrencyRepository {
return DefaultMarketCryptoCurrencyRepository(userMarketCoinsStore) return DefaultMarketCryptoCurrencyRepository(userMarketCoinsStore)
} }
@Provides
@Singleton
fun providesTokensListRepository(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
quotesRepository: QuotesRepository,
): TokensListRepository {
return DefaultTokensListRepository(
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
quotesRepository = quotesRepository,
)
}
} }

View file

@ -0,0 +1,77 @@
package com.tangem.data.tokens.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.domain.tokens.model.Token
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
/**
* A PagingSource responsible for retrieving tokens from the Tangem Tech API and adding to them quotes information.
*
* @property api Tangem Tech API
* @property quotesRepository Repository providing quotes data.
* @property dispatchers Coroutine dispatchers provider.
* @property searchText The search text used to filter tokens.
*/
internal class CoinsPagingSource(
private val api: TangemTechApi,
private val quotesRepository: QuotesRepository,
private val dispatchers: CoroutineDispatcherProvider,
private val searchText: String?,
) : PagingSource<Int, Token>() {
override fun getRefreshKey(state: PagingState<Int, Token>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(other = 1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(other = 1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Token> {
val page = params.key ?: 0
return com.tangem.utils.coroutines.runCatching(dispatchers.io) {
api.getCoins(
active = true, // TODO change when voting functionality is implemented
searchText = searchText,
offset = page * params.loadSize,
limit = params.loadSize,
)
}.fold(
onSuccess = { response ->
val coinsIds = response.coins.map { coin ->
CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(coin.id),
suffix = CryptoCurrency.ID.Suffix.RawID(coin.id),
)
}
val quotes = quotesRepository.getQuotesSync(currenciesIds = coinsIds.toSet(), refresh = false)
LoadResult.Page(
data = CoinsResponseConverter.convert(
CoinsData(
response.coins,
response.imageHost,
quotes,
),
),
prevKey = if (page == 0) null else page.minus(other = 1),
nextKey = if (response.coins.isEmpty()) null else page.plus(other = 1),
)
},
onFailure = { LoadResult.Error(it) },
)
}
}
data class CoinsData(
val coins: List<CoinsResponse.Coin>,
val imageHost: String?,
val quotes: Set<Quote>,
)

View file

@ -0,0 +1,51 @@
package com.tangem.data.tokens.paging
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.tokens.utils.getNetworkStandardType
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Token
import com.tangem.utils.converter.Converter
/**
* Converter from data model [CoinsResponse] to list of domain models [Token]
*/
internal object CoinsResponseConverter : Converter<CoinsData, List<Token>> {
override fun convert(value: CoinsData): List<Token> {
return value.coins.map { coin ->
val id = CryptoCurrency.ID(
CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
CryptoCurrency.ID.Body.NetworkId(coin.id),
CryptoCurrency.ID.Suffix.RawID(coin.id),
)
val quote = value.quotes.find { it.rawCurrencyId == id.rawCurrencyId }
Token(
id = id.rawCurrencyId ?: coin.name,
name = coin.name,
symbol = coin.symbol,
iconUrl = getIconUrl(coin.id, value.imageHost),
isAvailable = coin.active,
networks = coin.networks.mapNotNull { network ->
val blockchain = Blockchain.fromNetworkId(network.networkId) ?: return@mapNotNull null
Token.Network(
networkId = network.networkId,
standardType = getNetworkStandardType(blockchain).name,
address = network.contractAddress,
iconUrl = getIconUrl(network.networkId, value.imageHost),
decimalCount = network.decimalCount?.toInt(),
)
},
quote = quote,
)
}
}
}
internal fun getIconUrl(id: String, imageHost: String? = null): String {
return "${imageHost ?: DEFAULT_IMAGE_HOST}large/$id.png"
}
private const val DEFAULT_IMAGE_HOST =
"https://s3.eu-central-1.amazonaws.com/tangem.api/coins/"

View file

@ -0,0 +1,45 @@
package com.tangem.data.tokens.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.tangem.data.tokens.paging.CoinsPagingSource
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.tokens.model.Token
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.repository.TokensListRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
/**
* Default repository implementation for managing operations related to a complete set of tokens
*
* @property tangemTechApi Tangem Tech API
* @property dispatchers coroutine dispatchers provider
* @property quotesRepository responsible for providing cryptocurrency quotes data
*
*/
internal class DefaultTokensListRepository(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val quotesRepository: QuotesRepository,
) : TokensListRepository {
override fun getTokens(searchText: String?): Flow<PagingData<Token>> {
return Pager(
config = PagingConfig(
pageSize = 100,
prefetchDistance = 70,
enablePlaceholders = false,
),
pagingSourceFactory = {
CoinsPagingSource(
api = tangemTechApi,
dispatchers = dispatchers,
searchText = searchText,
quotesRepository = quotesRepository,
)
},
).flow
}
}

View file

@ -50,7 +50,7 @@ private fun getNetworkDerivationPath(
} }
} }
private fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType { internal fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType {
return when (blockchain) { return when (blockchain) {
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.StandardType.ERC20 Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.StandardType.ERC20
Blockchain.BSC, Blockchain.BSCTestnet -> Network.StandardType.BEP20 Blockchain.BSC, Blockchain.BSCTestnet -> Network.StandardType.BEP20

View file

@ -19,4 +19,8 @@ dependencies {
implementation(deps.tangem.card.core) implementation(deps.tangem.card.core)
implementation(deps.tangem.blockchain) {
exclude(module = "joda-time")
}
} }

View file

@ -1,6 +1,7 @@
package com.tangem.domain.card.repository package com.tangem.domain.card.repository
import com.tangem.TangemSdk import com.tangem.TangemSdk
import com.tangem.blockchain.common.CommonSigner
import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ProductType
/** /**
@ -28,4 +29,13 @@ interface CardSdkConfigRepository {
/** Check if access code saving is enabled */ /** Check if access code saving is enabled */
fun isAccessCodeSavingEnabled(): Boolean fun isAccessCodeSavingEnabled(): Boolean
/** Get common signer by [cardId] */
fun getCommonSigner(cardId: String?): CommonSigner
/** Check if linked terminal is enabled */
fun isLinkedTerminal(): Boolean?
/** Set linked terminal by [isLinked] */
fun setLinkedTerminal(isLinked: Boolean?)
} }

View file

@ -10,6 +10,7 @@ import com.tangem.blockchain.blockchains.solana.RentProvider
import com.tangem.blockchain.common.* import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchain.common.address.AddressType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.common.txhistory.TransactionHistoryRequest import com.tangem.blockchain.common.txhistory.TransactionHistoryRequest
import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.Result
@ -35,6 +36,7 @@ import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import timber.log.Timber import timber.log.Timber
import java.math.BigDecimal import java.math.BigDecimal
import java.util.EnumSet
@Suppress("LargeClass") @Suppress("LargeClass")
// FIXME: Move to its own module and make internal // FIXME: Move to its own module and make internal
@ -431,6 +433,58 @@ class DefaultWalletManagersFacade(
) )
} }
override suspend fun validateTransaction(
amount: Amount,
fee: Amount?,
userWalletId: UserWalletId,
network: Network,
): EnumSet<TransactionError>? {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
return walletManager?.validateTransaction(amount, fee)
}
override suspend fun createTransaction(
amount: Amount,
fee: Fee,
memo: String?,
destination: String,
userWalletId: UserWalletId,
network: Network,
): TransactionData? {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
val txData = walletManager?.createTransaction(amount, fee, destination)?.copy(
extras = null, // todo add memo [[REDACTED_JIRA]]
)
return txData
}
override suspend fun sendTransaction(
txData: TransactionData,
signer: CommonSigner,
userWalletId: UserWalletId,
network: Network,
): SimpleResult {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
return (walletManager as TransactionSender).send(txData, signer)
}
private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set<CryptoCurrency.Token>) { private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set<CryptoCurrency.Token>) {
if (tokens.isEmpty()) return if (tokens.isEmpty()) return

View file

@ -2,13 +2,13 @@ package com.tangem.domain.walletmanager
import arrow.core.Either import arrow.core.Either
import com.tangem.blockchain.blockchains.solana.RentProvider import com.tangem.blockchain.blockchains.solana.RentProvider
import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchain.common.address.AddressType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
@ -19,6 +19,7 @@ import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult
import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.math.BigDecimal import java.math.BigDecimal
import java.util.EnumSet
// TODO: Move to its own module // TODO: Move to its own module
/** /**
@ -164,4 +165,57 @@ interface WalletManagersFacade {
userWalletId: UserWalletId, userWalletId: UserWalletId,
network: Network, network: Network,
): Result<TransactionFee>? ): Result<TransactionFee>?
/**
* Validates transaction
*
* @param amount of transaction
* @param fee of transaction
* @param userWalletId selected wallet id
* @param network network of currency
*/
@Deprecated("Will be removed in future")
suspend fun validateTransaction(
amount: Amount,
fee: Amount?,
userWalletId: UserWalletId,
network: Network,
): EnumSet<TransactionError>?
/**
* Creates transaction [TransactionData]
*
* @param amount of transaction
* @param fee of transaction
* @param memo of transaction optional
* @param destination address
* @param userWalletId selected wallet id
* @param network network of currency
*/
@Suppress("LongParameterList")
@Deprecated("Will be removed in future")
suspend fun createTransaction(
amount: Amount,
fee: Fee,
memo: String?,
destination: String,
userWalletId: UserWalletId,
network: Network,
): TransactionData?
/**
* Sends transaction
*
* @param txData transaction data
* @param signer card signer
* @param userWalletId selected wallet id
* @param network network of currency
*/
@Deprecated("Will be removed in future")
suspend fun sendTransaction(
txData: TransactionData,
signer: CommonSigner,
userWalletId: UserWalletId,
network: Network,
): SimpleResult
} }

View file

@ -10,6 +10,7 @@ import com.tangem.domain.walletmanager.model.CryptoCurrencyTransaction
import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult
import timber.log.Timber import timber.log.Timber
import java.math.BigDecimal import java.math.BigDecimal
import java.util.concurrent.TimeUnit
import com.tangem.blockchain.common.address.Address as SdkAddress import com.tangem.blockchain.common.address.Address as SdkAddress
internal class UpdateWalletManagerResultFactory { internal class UpdateWalletManagerResultFactory {

View file

@ -0,0 +1,15 @@
package com.tangem.domain.settings
import com.tangem.domain.settings.repositories.SettingsRepository
/**
* Checks if wallets scroll preview is enabled
*
* @property settingsRepository settings repository
*
[REDACTED_AUTHOR]
*/
class IsWalletsScrollPreviewEnabled(private val settingsRepository: SettingsRepository) {
suspend operator fun invoke(): Boolean = settingsRepository.isWalletScrollPreviewEnabled()
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.settings
import com.tangem.domain.settings.repositories.SettingsRepository
/**
* Never to show wallets scroll preview
*
* @property settingsRepository settings repository
*
[REDACTED_AUTHOR]
*/
class NeverToShowWalletsScrollPreview(
private val settingsRepository: SettingsRepository,
) {
suspend operator fun invoke() = settingsRepository.setWalletScrollPreviewAvailability(isEnabled = false)
}

View file

@ -3,4 +3,8 @@ package com.tangem.domain.settings.repositories
interface SettingsRepository { interface SettingsRepository {
suspend fun shouldShowSaveUserWalletScreen(): Boolean suspend fun shouldShowSaveUserWalletScreen(): Boolean
suspend fun isWalletScrollPreviewEnabled(): Boolean
suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean)
} }

View file

@ -22,6 +22,9 @@ dependencies {
/** Project - Other */ /** Project - Other */
implementation(projects.core.utils) implementation(projects.core.utils)
/** Android - Other */
implementation(deps.androidx.paging.runtime)
/** Utils */ /** Utils */
implementation(deps.jodatime) implementation(deps.jodatime)
implementation(deps.reKotlin) implementation(deps.reKotlin)

View file

@ -12,4 +12,7 @@ android {
dependencies { dependencies {
implementation(projects.domain.txhistory.models) implementation(projects.domain.txhistory.models)
implementation(projects.core.analytics.models) implementation(projects.core.analytics.models)
implementation(deps.tangem.blockchain) {
exclude(module = "joda-time")
}
} }

View file

@ -3,14 +3,17 @@ package com.tangem.domain.tokens.model
import java.math.BigDecimal import java.math.BigDecimal
/** /**
* Represents a financial quote for a specific cryptocurrency, including its fiat exchange rate and price change. * Represents financial information for a specific cryptocurrency, including its fiat exchange rate and price change.
* *
* @property rawCurrencyId The unique identifier of the token for which the quote is provided. * @property rawCurrencyId The unique identifier of the cryptocurrency for which the financial information is provided.
* @property fiatRate The current fiat exchange rate for the cryptocurrency. * @property fiatRate The current fiat exchange rate for the cryptocurrency.
* @property priceChange The price change for the cryptocurrency. * @property priceChange The price change for the cryptocurrency.
* @property values The values representing the cryptocurrency's price changes over a 24-hour period,
* suitable for chart plotting.
*/ */
data class Quote( data class Quote(
val rawCurrencyId: String, val rawCurrencyId: String,
val fiatRate: BigDecimal, val fiatRate: BigDecimal,
val priceChange: BigDecimal, val priceChange: BigDecimal,
val values: List<Double>? = null,
) )

View file

@ -0,0 +1,41 @@
package com.tangem.domain.tokens.model
/**
* Represents a domain model for a token (as part of a general list of tokens).
*
* @property id The unique identifier of the token.
* @property networks List of networks associated with the token.
* @property isAvailable Indicates whether this token is supported in Tangem app.
* @property quote Equivalent prices for the token.
* @property name The name of the token.
* @property symbol The brief name of the token, e.g., "BTC".
* @property iconUrl URL of the token's icon.
*/
data class Token(
val id: String,
val networks: List<Network>,
val isAvailable: Boolean,
val quote: Quote?,
val name: String,
val symbol: String,
val iconUrl: String,
) {
/**
* Represents a domain model for a network associated with a token.
*
* @property networkId The unique identifier of the network.
* @property standardType The type of blockchain associated with the network.
* @property address The contract address of the token on the current network.
* It is null for the main currencies of the network.
* @property iconUrl URL of the network's icon.
* @property decimalCount The decimal count associated with the token on the network.
*/
data class Network(
val networkId: String,
val standardType: String,
val address: String?,
val iconUrl: String,
val decimalCount: Int?,
)
}

View file

@ -0,0 +1,24 @@
package com.tangem.domain.tokens.utils
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.domain.tokens.model.CryptoCurrency
import java.math.BigDecimal
/** Converts `BigDecimal` [cryptoCurrency] to [Amount] */
fun BigDecimal.convertToAmount(cryptoCurrency: CryptoCurrency) = Amount(
currencySymbol = cryptoCurrency.symbol,
value = this,
decimals = cryptoCurrency.decimals,
type = when (cryptoCurrency) {
is CryptoCurrency.Coin -> AmountType.Coin
is CryptoCurrency.Token -> AmountType.Token(
token = Token(
symbol = cryptoCurrency.symbol,
contractAddress = cryptoCurrency.contractAddress,
decimals = cryptoCurrency.decimals,
),
)
},
)

View file

@ -32,10 +32,7 @@ class GetCryptoCurrencyActionsUseCase(
) { ) {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
suspend operator fun invoke( operator fun invoke(userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus): Flow<TokenActionsState> {
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): Flow<TokenActionsState> {
val operations = CurrenciesStatusesOperations( val operations = CurrenciesStatusesOperations(
currenciesRepository = currenciesRepository, currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository, quotesRepository = quotesRepository,
@ -43,19 +40,25 @@ class GetCryptoCurrencyActionsUseCase(
userWalletId = userWallet.walletId, userWalletId = userWallet.walletId,
) )
val networkId = cryptoCurrencyStatus.currency.network.id val networkId = cryptoCurrencyStatus.currency.network.id
val networkFlow = if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) {
operations.getNetworkCoinForSingleWalletWithTokenFlow(networkId) return flow {
} else if (!userWallet.isMultiCurrency) { val networkFlow = if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) {
operations.getPrimaryCurrencyStatusFlow() operations.getNetworkCoinForSingleWalletWithTokenFlow(networkId)
} else { } else if (!userWallet.isMultiCurrency) {
operations.getNetworkCoinFlow(networkId, cryptoCurrencyStatus.currency.network.derivationPath) operations.getPrimaryCurrencyStatusFlow()
} } else {
return networkFlow.mapLatest { maybeCoinStatus -> operations.getNetworkCoinFlow(networkId, cryptoCurrencyStatus.currency.network.derivationPath)
createTokenActionsState( }
userWalletId = userWallet.walletId,
coinStatus = maybeCoinStatus.getOrNull(), val flow = networkFlow.mapLatest { maybeCoinStatus ->
cryptoCurrencyStatus = cryptoCurrencyStatus, createTokenActionsState(
) userWalletId = userWallet.walletId,
coinStatus = maybeCoinStatus.getOrNull(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
)
}
emitAll(flow)
}.flowOn(dispatchers.io) }.flowOn(dispatchers.io)
} }

View file

@ -0,0 +1,22 @@
package com.tangem.domain.tokens
import androidx.paging.PagingData
import com.tangem.domain.tokens.model.Token
import com.tangem.domain.tokens.repository.TokensListRepository
import kotlinx.coroutines.flow.Flow
/**
* Use case for retrieving a complete set of tokens with their quotes (filtered by a search text if provided).
*
* @property repository The repository responsible for fetching and preparing the list of tokens with their quotes.
*/
class GetGlobalTokenListUseCase(private val repository: TokensListRepository) {
/**
* @param searchText The text used for filtering the list of tokens (not required).
* @return A [Flow] emitting [PagingData] containing the tokens with their quotes.
*/
operator fun invoke(searchText: String?): Flow<PagingData<Token>> {
return repository.getTokens(searchText = searchText?.ifBlank(defaultValue = { null }))
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.tokens.repository
import androidx.paging.PagingData
import com.tangem.domain.tokens.model.Token
import kotlinx.coroutines.flow.Flow
/**
* Repository interface for managing operations related to a complete set of tokens
* (not associated with any specific card).
* */
interface TokensListRepository {
/**
* Retrieves a list of available tokens with quotes, can be filtered based on the provided search text.
*
* @param searchText The search text used to filter tokens.
* @return A [Flow] emitting [PagingData] containing the tokens with quotes matching the search criteria.
*/
fun getTokens(searchText: String?): Flow<PagingData<Token>>
}

View file

@ -14,10 +14,15 @@ dependencies {
implementation(projects.core.utils) implementation(projects.core.utils)
/** Tangem SDKs */
implementation(deps.tangem.card.core)
implementation(deps.tangem.blockchain) implementation(deps.tangem.blockchain)
implementation(projects.domain.models)
implementation(projects.domain.legacy) implementation(projects.domain.legacy)
implementation(projects.domain.wallets.models) implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens) implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models) implementation(projects.domain.tokens.models)
implementation(projects.domain.demo)
implementation(projects.domain.card)
} }

View file

@ -0,0 +1,10 @@
package com.tangem.domain.transaction.error
sealed class SendTransactionError {
object DemoCardError : SendTransactionError()
data class DataError(val message: String?) : SendTransactionError()
data class NetworkError(val message: String?) : SendTransactionError()
}

View file

@ -0,0 +1,61 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
class SendTransactionUseCase(
private val isDemoCardUseCase: IsDemoCardUseCase,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val walletManagersFacade: WalletManagersFacade,
) {
suspend operator fun invoke(
txData: TransactionData,
userWallet: UserWallet,
network: Network,
): Either<SendTransactionError?, Boolean> {
val signer = cardSdkConfigRepository.getCommonSigner(
userWallet.cardId,
)
val linkedTerminal = cardSdkConfigRepository.isLinkedTerminal()
if (userWallet.scanResponse.card.isStart2Coin) {
cardSdkConfigRepository.setLinkedTerminal(false)
}
val sendResult = try {
if (isDemoCardUseCase(cardId = userWallet.cardId)) {
SendTransactionError.DemoCardError.left()
} else {
walletManagersFacade.sendTransaction(
txData = txData,
signer = signer,
userWalletId = userWallet.walletId,
network = network,
).right()
}
} catch (ex: Exception) {
cardSdkConfigRepository.setLinkedTerminal(linkedTerminal)
SendTransactionError.DataError(ex.message).left()
}
cardSdkConfigRepository.setLinkedTerminal(linkedTerminal)
return sendResult.fold(
ifRight = { result ->
when (result) {
is SimpleResult.Success -> true.right()
is SimpleResult.Failure -> SendTransactionError.NetworkError(result.error.message).left()
}
},
ifLeft = { it.left() },
)
}
}

View file

@ -2,12 +2,13 @@ package com.tangem.domain.wallets.usecase
import arrow.core.Either import arrow.core.Either
import arrow.core.raise.either import arrow.core.raise.either
import com.tangem.common.doOnFailure import arrow.core.right
import com.tangem.common.doOnSuccess import com.tangem.common.CompletionResult
import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.wallets.legacy.WalletsStateHolder import com.tangem.domain.wallets.legacy.WalletsStateHolder
import com.tangem.domain.wallets.legacy.ensureUserWalletListManagerNotNull import com.tangem.domain.wallets.legacy.ensureUserWalletListManagerNotNull
import com.tangem.domain.wallets.models.SelectWalletError import com.tangem.domain.wallets.models.SelectWalletError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.models.UserWalletId
/** /**
@ -22,16 +23,20 @@ class SelectWalletUseCase(
private val reduxStateHolder: ReduxStateHolder, private val reduxStateHolder: ReduxStateHolder,
) { ) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<SelectWalletError, Unit> { suspend operator fun invoke(userWalletId: UserWalletId): Either<SelectWalletError, UserWallet> {
return either { return either {
val userWalletsListManager = ensureUserWalletListManagerNotNull( val userWalletsListManager = ensureUserWalletListManagerNotNull(
walletsStateHolder = walletsStateHolder, walletsStateHolder = walletsStateHolder,
raise = { SelectWalletError.DataError }, raise = { SelectWalletError.DataError },
) )
userWalletsListManager.select(userWalletId) return when (val result = userWalletsListManager.select(userWalletId)) {
.doOnFailure { raise(SelectWalletError.UnableToSelectUserWallet) } is CompletionResult.Failure -> raise(SelectWalletError.UnableToSelectUserWallet)
.doOnSuccess { reduxStateHolder.onUserWalletSelected(it) } is CompletionResult.Success -> {
reduxStateHolder.onUserWalletSelected(result.data)
result.data.right()
}
}
} }
} }
} }

View file

@ -47,6 +47,9 @@ dependencies {
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(projects.core.utils) implementation(projects.core.utils)
/** Project - Data */
implementation(projects.data.tokens)
/** Domain modules */ /** Domain modules */
implementation(projects.common) implementation(projects.common)
implementation(projects.domain.card) implementation(projects.domain.card)

View file

@ -0,0 +1,31 @@
package com.tangem.managetokens.presentation.common.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.managetokens.impl.R
import kotlinx.collections.immutable.ImmutableList
internal sealed class ChooseWalletState {
data class Choose(
val wallets: ImmutableList<WalletState>,
val selectedWallet: WalletState?,
val onChooseWalletClick: () -> Unit,
val onCloseChoosingWalletClick: () -> Unit,
) : ChooseWalletState()
object NoSelection : ChooseWalletState()
class Warning(val type: ChooseWalletWarning) : ChooseWalletState() {
val message: TextReference
get() = when (type) {
ChooseWalletWarning.SINGLE_CURRENCY ->
TextReference.Res(R.string.manage_tokens_wallet_support_only_one_network_title)
ChooseWalletWarning.WALLET_INCOMPATIBLE ->
TextReference.Res(R.string.manage_tokens_wallet_does_not_supported_blockchain)
}
}
}
enum class ChooseWalletWarning {
SINGLE_CURRENCY,
WALLET_INCOMPATIBLE,
}

View file

@ -0,0 +1,90 @@
package com.tangem.managetokens.presentation.common.state
import androidx.compose.runtime.MutableState
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.getGreyedOutIconRes
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
/**
* Network item state
*
* @property name network name
* @property protocolName network protocol name
* @property id network id
* @property blockchain blockchain
* @property iconRes network icon id from resources
*/
internal sealed interface NetworkItemState {
val name: String
val protocolName: String
val id: String
val blockchain: Blockchain
val iconRes: Int
get() = when (this) {
is Selectable -> this.iconResId
is Toggleable -> this.iconResId.value
}
/**
* Network item state that can be added and deleted
*
* @property name network name
* @property protocolName network protocol name
* @property id network id
* @property blockchain blockchain
* @property iconResId network icon id from resources
* @property isMainNetwork flag that determines if the network is the main network for the token
* @property isAdded flag that determines if the user has saved the network
* @property address contract address
* @property decimals decimal count
* @property onToggleClick lambda be invoked when switch is been toggled
*/
@Suppress("LongParameterList")
class Toggleable(
override val name: String,
override val protocolName: String,
override val id: String,
override val blockchain: Blockchain,
val iconResId: MutableState<Int>,
val isMainNetwork: Boolean,
val isAdded: MutableState<Boolean>,
val address: String?,
val decimals: Int?,
val onToggleClick: (TokenItemState.Loaded, Toggleable) -> Unit,
) : NetworkItemState {
/**
* Change toggle state [isAdded].
*
* It is a hack that helps us to change element of flow
*/
fun changeToggleState() {
val reverseState = !isAdded.value
isAdded.value = reverseState
iconResId.value = if (reverseState) getActiveIconRes(blockchain.id) else getGreyedOutIconRes(blockchain.id)
}
}
/**
* Network item state that can be selected
*
* @property name network name
* @property protocolName network protocol name
* @property iconResId network icon id from resources
* @property id network id
* @property blockchain blockchain
* @property onNetworkClick lambda be invoked when network item is been clicked
*
*/
class Selectable(
override val name: String,
override val protocolName: String,
val iconResId: Int,
override val id: String,
override val blockchain: Blockchain,
val onNetworkClick: (NetworkItemState) -> Unit,
) : NetworkItemState
}

View file

@ -0,0 +1,8 @@
package com.tangem.managetokens.presentation.common.state
internal data class WalletState(
val walletId: String,
val artworkUrl: String?,
val walletName: String,
val onSelected: (String) -> Unit,
)

View file

@ -0,0 +1,27 @@
package com.tangem.managetokens.presentation.common.state.previewdata
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.common.state.WalletState
import kotlinx.collections.immutable.persistentListOf
internal object ChooseWalletStatePreviewData {
val state: ChooseWalletState.Choose
get() = ChooseWalletState.Choose(
wallets = persistentListOf(
walletState,
walletState.copy(walletId = "2"),
),
selectedWallet = walletState,
onChooseWalletClick = {},
onCloseChoosingWalletClick = {},
)
private val walletState: WalletState
get() = WalletState(
walletName = "My wallet",
walletId = "1",
artworkUrl = "",
onSelected = {},
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.managetokens.presentation.common.ui
import androidx.compose.runtime.Composable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
@Composable
internal fun ChooseWalletBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<ChooseWalletBottomSheetConfig>(
config = config,
contentColor = TangemTheme.colors.background.tertiary,
) {
ChooseWalletScreen(state = it.chooseWalletState)
}
}
internal class ChooseWalletBottomSheetConfig(
val chooseWalletState: ChooseWalletState.Choose,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,151 @@
package com.tangem.managetokens.presentation.common.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.common.state.WalletState
import com.tangem.managetokens.presentation.common.state.previewdata.ChooseWalletStatePreviewData
@Composable
internal fun ChooseWalletScreen(state: ChooseWalletState.Choose, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing16),
) {
item {
Box(
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size44),
) {
IconButton(
onClick = state.onCloseChoosingWalletClick,
modifier = Modifier.align(Alignment.CenterStart),
) {
Icon(
painterResource(id = R.drawable.ic_back_24),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
)
}
Text(
text = stringResource(id = R.string.manage_tokens_wallet_selector_title),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
)
}
}
items(
count = state.wallets.count(),
key = { index -> state.wallets[index].walletId },
) { index ->
WalletItem(
wallet = state.wallets[index],
selectedWallet = state.selectedWallet,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.wallets.lastIndex,
addDefaultPadding = false,
),
)
}
item {
SpacerH(height = TangemTheme.dimens.spacing16)
}
}
}
@Composable
private fun WalletItem(wallet: WalletState, selectedWallet: WalletState?, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clickable { wallet.onSelected(wallet.walletId) }
.background(TangemTheme.colors.background.action)
.defaultMinSize(minHeight = TangemTheme.dimens.size72)
.padding(horizontal = TangemTheme.dimens.spacing16),
verticalAlignment = Alignment.CenterVertically,
) {
SubcomposeAsyncImage(
modifier = Modifier.size(height = TangemTheme.dimens.size30, width = TangemTheme.dimens.size50),
model = ImageRequest.Builder(context = LocalContext.current)
.data(wallet.artworkUrl)
.crossfade(enable = true)
.build(),
loading = {
Image(
painter = painterResource(R.drawable.card_placeholder_primary),
contentDescription = null,
)
},
error = {
Image(
painter = painterResource(R.drawable.card_placeholder_primary),
contentDescription = null,
)
},
contentDescription = null,
)
SpacerW12()
Text(
text = wallet.walletName,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
SpacerWMax()
if (selectedWallet == wallet) {
Icon(
painter = painterResource(id = R.drawable.ic_check_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
}
@Preview
@Composable
private fun Preview_ChooseWalletScreen_Light() {
TangemTheme(isDark = false) {
ChooseWalletScreen(
state = ChooseWalletStatePreviewData.state,
)
}
}
@Preview
@Composable
private fun Preview_ChooseWalletScreen_Dark() {
TangemTheme(isDark = false) {
ChooseWalletScreen(
state = ChooseWalletStatePreviewData.state,
)
}
}

View file

@ -0,0 +1,157 @@
package com.tangem.managetokens.presentation.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.managetokens.presentation.managetokens.state.previewdata.TokenItemStatePreviewData
@Composable
internal fun NetworkItem(state: NetworkItemState, tokenState: TokenItemState.Loaded?, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(TangemTheme.colors.background.action)
.defaultMinSize(minHeight = TangemTheme.dimens.size68)
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
NetworkIcon(model = state)
SpacerW(width = TangemTheme.dimens.spacing12)
Text(
text = state.name,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
)
SpacerW(width = TangemTheme.dimens.spacing6)
Text(
text = state.protocolName,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
.weight(1f),
)
if (state is NetworkItemState.Toggleable) {
TangemSwitch(
onCheckedChange = {
state.onToggleClick(tokenState!!, state)
},
checked = state.isAdded.value,
)
}
}
}
@Composable
internal fun NetworkIcon(model: NetworkItemState, modifier: Modifier = Modifier) {
Box(modifier = modifier.size(size = TangemTheme.dimens.size36)) {
val isAdded = when (model) {
is NetworkItemState.Selectable -> true
is NetworkItemState.Toggleable -> model.isAdded.value
}
if (!isAdded) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size36)
.clip(CircleShape)
.background(TangemTheme.colors.control.unchecked),
)
}
Icon(
painter = painterResource(id = model.iconRes),
contentDescription = null,
modifier = Modifier.size(size = TangemTheme.dimens.size36),
tint = if (isAdded) Color.Unspecified else TangemTheme.colors.text.tertiary,
)
if (model is NetworkItemState.Toggleable && model.isMainNetwork) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(TangemTheme.dimens.size10)
.clip(CircleShape)
.background(TangemTheme.colors.stroke.transparency),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size8)
.clip(CircleShape)
.background(TangemTheme.colors.icon.accent),
)
}
}
}
}
@Preview
@Composable
private fun Preview_NetworkItem_Light(@PreviewParameter(NetworkItemStateProvider::class) state: NetworkItemState) {
TangemTheme(isDark = false) {
NetworkItem(state, tokenState = TokenItemStatePreviewData.loadedPriceDown as TokenItemState.Loaded)
}
}
@Preview
@Composable
private fun Preview_NetworkItem_Dark(@PreviewParameter(NetworkItemStateProvider::class) state: NetworkItemState) {
TangemTheme(isDark = true) {
NetworkItem(state, tokenState = TokenItemStatePreviewData.loadedPriceDown as TokenItemState.Loaded)
}
}
private class NetworkItemStateProvider : CollectionPreviewParameterProvider<NetworkItemState>(
collection = listOf(
NetworkItemState.Toggleable(
name = "Ethereum",
protocolName = "ETH",
iconResId = mutableStateOf(R.drawable.img_polygon_22),
isMainNetwork = true,
isAdded = mutableStateOf(true),
id = "",
address = "",
onToggleClick = { _, _ -> },
blockchain = Blockchain.Ethereum,
decimals = 0,
),
NetworkItemState.Toggleable(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = mutableStateOf(R.drawable.ic_bsc_16),
isMainNetwork = false,
isAdded = mutableStateOf(false),
id = "",
address = "",
onToggleClick = { _, _ -> },
blockchain = Blockchain.BSC,
decimals = 0,
),
NetworkItemState.Selectable(
name = "Ethereum",
protocolName = "ETH",
iconResId = R.drawable.img_polygon_22,
id = "",
onNetworkClick = { },
blockchain = Blockchain.Ethereum,
),
),
)

View file

@ -0,0 +1,70 @@
package com.tangem.managetokens.presentation.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.res.TangemTheme
@Composable
fun SimpleSelectionBlock(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
roundedCorners: Boolean = true,
) {
Column(
modifier = modifier
.then(
if (roundedCorners) {
Modifier.clip(shape = RoundedCornerShape(TangemTheme.dimens.radius16))
} else {
Modifier
},
)
.background(color = TangemTheme.colors.background.action)
.clickable { onClick() }
.padding(
horizontal = TangemTheme.dimens.spacing20,
vertical = TangemTheme.dimens.spacing16,
)
.fillMaxWidth(),
) {
Text(
text = title,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
)
SpacerH(height = TangemTheme.dimens.spacing4)
Text(
text = subtitle,
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
)
}
}
@Preview
@Composable
private fun Preview_SimpleSelectionBlock_Light() {
TangemTheme(isDark = false) {
SimpleSelectionBlock(title = "Wallet", subtitle = "Family Wallet", onClick = { })
}
}
@Preview
@Composable
private fun Preview_SimpleSelectionBlock_Dark() {
TangemTheme(isDark = true) {
SimpleSelectionBlock(title = "Wallet", subtitle = "Family Wallet", onClick = { })
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.managetokens.presentation.managetokens.state
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import kotlinx.collections.immutable.ImmutableList
internal data class ChooseNetworkState(
val nativeNetworks: ImmutableList<NetworkItemState>,
val nonNativeNetworks: ImmutableList<NetworkItemState>,
val onNonNativeNetworkHintClick: () -> Unit,
val onCloseChooseNetworkScreen: () -> Unit,
)

View file

@ -8,7 +8,8 @@ import com.tangem.features.managetokens.impl.R
data class DerivationNotificationState( data class DerivationNotificationState(
val totalNeeded: Int, val totalNeeded: Int,
val missingAddressesCount: Int, val totalWallets: Int,
val walletsToDerive: Int,
val onGenerateClick: () -> Unit, val onGenerateClick: () -> Unit,
) { ) {
val config = NotificationConfig( val config = NotificationConfig(
@ -25,8 +26,8 @@ data class DerivationNotificationState(
onClick = onGenerateClick, onClick = onGenerateClick,
additionalText = pluralReference( additionalText = pluralReference(
id = R.plurals.manage_tokens_number_of_wallets_android, id = R.plurals.manage_tokens_number_of_wallets_android,
count = totalNeeded, count = totalWallets,
formatArgs = wrappedList(missingAddressesCount, totalNeeded), formatArgs = wrappedList(walletsToDerive, totalWallets),
), ),
), ),
) )

View file

@ -0,0 +1,24 @@
package com.tangem.managetokens.presentation.managetokens.state
import androidx.paging.PagingData
import com.tangem.core.ui.event.StateEvent
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.common.state.Event
import kotlinx.coroutines.flow.Flow
internal data class ManageTokensState(
val searchBarState: SearchBarState,
val tokens: Flow<PagingData<TokenItemState>>,
val isLoading: Boolean,
val addCustomTokenButton: AddCustomTokenButton,
val chooseWalletState: ChooseWalletState,
val derivationNotification: DerivationNotificationState? = null,
val selectedToken: TokenItemState.Loaded? = null,
val showChooseWalletScreen: Boolean = false,
val event: StateEvent<Event>,
)
data class AddCustomTokenButton(
val isVisible: Boolean,
val onClick: () -> Unit,
)

View file

@ -1,5 +1,7 @@
package com.tangem.managetokens.presentation.managetokens.state package com.tangem.managetokens.presentation.managetokens.state
import androidx.compose.runtime.MutableState
internal sealed class TokenItemState { internal sealed class TokenItemState {
abstract val id: String abstract val id: String
@ -9,11 +11,13 @@ internal sealed class TokenItemState {
data class Loaded( data class Loaded(
override val id: String, override val id: String,
val name: String, val name: String,
val currencyId: String, val currencySymbol: String,
val tokenId: String,
val tokenIcon: TokenIconState, val tokenIcon: TokenIconState,
val quotes: QuotesState, val quotes: QuotesState,
val rate: String?, val rate: String?,
val availableAction: TokenButtonType, val availableAction: MutableState<TokenButtonType>,
val onButtonClick: (String) -> Unit, val chooseNetworkState: ChooseNetworkState,
val onButtonClick: (Loaded) -> Unit,
) : TokenItemState() ) : TokenItemState()
} }

View file

@ -0,0 +1,60 @@
package com.tangem.managetokens.presentation.managetokens.state.previewdata
import androidx.compose.runtime.mutableStateOf
import com.tangem.blockchain.common.Blockchain
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.managetokens.state.ChooseNetworkState
import kotlinx.collections.immutable.toImmutableList
internal object ChooseNetworkStatePreviewData {
val state = ChooseNetworkState(
nativeNetworks = nativeNetworks.toImmutableList(),
nonNativeNetworks = nonNativeNetworks.toImmutableList(),
onNonNativeNetworkHintClick = {},
onCloseChooseNetworkScreen = {},
)
}
internal val nativeNetworks = listOf(
NetworkItemState.Toggleable(
name = "Ethereum",
protocolName = "ETH",
iconResId = mutableStateOf(R.drawable.img_polygon_22),
isMainNetwork = true,
isAdded = mutableStateOf(true),
id = "",
onToggleClick = { _, _ -> },
blockchain = Blockchain.Ethereum,
address = "",
decimals = 0,
),
)
internal val nonNativeNetworks = listOf(
NetworkItemState.Toggleable(
name = "Ethereum",
protocolName = "ETH",
iconResId = mutableStateOf(R.drawable.img_kusama_22),
isMainNetwork = false,
isAdded = mutableStateOf(true),
id = "",
onToggleClick = { _, _ -> },
blockchain = Blockchain.Ethereum,
address = "",
decimals = 0,
),
NetworkItemState.Toggleable(
name = "BNB SMART CHAIN",
protocolName = "BEP20",
iconResId = mutableStateOf(R.drawable.ic_bsc_16),
isMainNetwork = false,
isAdded = mutableStateOf(false),
id = "",
onToggleClick = { _, _ -> },
blockchain = Blockchain.BSC,
address = "",
decimals = 0,
),
)

View file

@ -4,8 +4,9 @@ import com.tangem.managetokens.presentation.managetokens.state.DerivationNotific
object DerivationNotificationStatePreviewData { object DerivationNotificationStatePreviewData {
val state = DerivationNotificationState( val state = DerivationNotificationState(
totalNeeded = 3, totalNeeded = 5,
missingAddressesCount = 2, totalWallets = 3,
walletsToDerive = 2,
onGenerateClick = {}, onGenerateClick = {},
) )
} }

View file

@ -0,0 +1,40 @@
package com.tangem.managetokens.presentation.managetokens.state.previewdata
import androidx.paging.PagingData
import com.tangem.core.ui.event.consumedEvent
import com.tangem.managetokens.presentation.common.state.previewdata.ChooseWalletStatePreviewData
import com.tangem.managetokens.presentation.managetokens.state.AddCustomTokenButton
import com.tangem.managetokens.presentation.managetokens.state.ManageTokensState
import com.tangem.managetokens.presentation.managetokens.state.SearchBarState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import kotlinx.coroutines.flow.flowOf
internal object ManageTokensStatePreviewData {
val loadedState: ManageTokensState
get() = ManageTokensState(
searchBarState = searchState,
tokens = flowOf(PagingData.from(tokens)),
isLoading = false,
addCustomTokenButton = AddCustomTokenButton(true, {}),
derivationNotification = DerivationNotificationStatePreviewData.state,
event = consumedEvent(),
chooseWalletState = ChooseWalletStatePreviewData.state,
)
val loadingState: ManageTokensState
get() = loadedState.copy(isLoading = true)
private val tokens: List<TokenItemState>
get() = listOf(
TokenItemStatePreviewData.loadedPriceDown,
TokenItemStatePreviewData.loadedPriceUp,
)
private val searchState: SearchBarState
get() = SearchBarState(
query = "",
onQueryChange = {},
active = false,
onActiveChange = {},
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.managetokens.presentation.managetokens.state.previewdata package com.tangem.managetokens.presentation.managetokens.state.previewdata
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.tangem.managetokens.presentation.managetokens.state.* import com.tangem.managetokens.presentation.managetokens.state.*
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -13,7 +14,8 @@ internal object TokenItemStatePreviewData {
get() = TokenItemState.Loaded( get() = TokenItemState.Loaded(
id = "BTC", id = "BTC",
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
currencyId = "BTC", tokenId = "BTC",
currencySymbol = "BTC",
tokenIcon = tokenIconState, tokenIcon = tokenIconState,
quotes = QuotesState.Content( quotes = QuotesState.Content(
priceChange = "0.43%", priceChange = "0.43%",
@ -21,15 +23,17 @@ internal object TokenItemStatePreviewData {
chartData = persistentListOf(10f, 2f, 5f, 3f, 4f, 8f, 9f, 7f, 4f), chartData = persistentListOf(10f, 2f, 5f, 3f, 4f, 8f, 9f, 7f, 4f),
), ),
rate = "31 285.72$", rate = "31 285.72$",
availableAction = TokenButtonType.ADD, availableAction = mutableStateOf(TokenButtonType.ADD),
onButtonClick = {}, onButtonClick = {},
chooseNetworkState = ChooseNetworkStatePreviewData.state,
) )
val loadedPriceUp: TokenItemState val loadedPriceUp: TokenItemState
get() = TokenItemState.Loaded( get() = TokenItemState.Loaded(
id = "BTC", id = "BTC",
name = "Bitcoin", name = "Bitcoin",
currencyId = "BTC", tokenId = "BTC",
currencySymbol = "BTC",
tokenIcon = tokenIconState, tokenIcon = tokenIconState,
quotes = QuotesState.Content( quotes = QuotesState.Content(
priceChange = "0.43%", priceChange = "0.43%",
@ -37,8 +41,9 @@ internal object TokenItemStatePreviewData {
chartData = persistentListOf(1f, 3f, 4f, 8f, 12f, 10f, 8f, 3f, 5f, 7f), chartData = persistentListOf(1f, 3f, 4f, 8f, 12f, 10f, 8f, 3f, 5f, 7f),
), ),
rate = "31 285.72$", rate = "31 285.72$",
availableAction = TokenButtonType.NOT_AVAILABLE, availableAction = mutableStateOf(TokenButtonType.NOT_AVAILABLE),
onButtonClick = {}, onButtonClick = {},
chooseNetworkState = ChooseNetworkStatePreviewData.state,
) )
private val tokenIconState: TokenIconState private val tokenIconState: TokenIconState

View file

@ -0,0 +1,24 @@
package com.tangem.managetokens.presentation.managetokens.ui
import androidx.compose.runtime.Composable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
@Composable
internal fun ChooseNetworkBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<ChooseNetworkBottomSheetConfig>(
config = config,
contentColor = TangemTheme.colors.background.tertiary,
) {
ChooseNetworkScreen(state = it.selectedToken, walletState = it.chooseWalletState)
}
}
internal class ChooseNetworkBottomSheetConfig(
val selectedToken: TokenItemState.Loaded,
val chooseWalletState: ChooseWalletState,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,205 @@
package com.tangem.managetokens.presentation.managetokens.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.WarningCardTitleOnly
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.common.state.previewdata.ChooseWalletStatePreviewData
import com.tangem.managetokens.presentation.common.ui.components.NetworkItem
import com.tangem.managetokens.presentation.common.ui.components.SimpleSelectionBlock
import com.tangem.managetokens.presentation.managetokens.state.ChooseNetworkState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.managetokens.presentation.managetokens.state.previewdata.TokenItemStatePreviewData
@Composable
internal fun ChooseNetworkScreen(
state: TokenItemState.Loaded,
walletState: ChooseWalletState,
modifier: Modifier = Modifier,
) {
val networkState = state.chooseNetworkState
LazyColumn(
modifier = modifier
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing16),
) {
item {
Text(
text = stringResource(id = R.string.manage_tokens_network_selector_title),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth(),
)
}
when (walletState) {
is ChooseWalletState.Choose -> {
item {
SpacerH(height = TangemTheme.dimens.spacing10)
}
item {
SimpleSelectionBlock(
title = stringResource(id = R.string.manage_tokens_network_selector_wallet),
subtitle = walletState.selectedWallet?.walletName ?: "",
onClick = walletState.onChooseWalletClick,
)
}
}
ChooseWalletState.NoSelection -> Unit
is ChooseWalletState.Warning -> {
item {
SpacerH(height = TangemTheme.dimens.spacing10)
}
item {
WarningCardTitleOnly(
title = stringResource(id = R.string.manage_tokens_wallet_support_only_one_network_title),
)
}
}
}
item {
SpacerH(height = TangemTheme.dimens.spacing16)
}
item {
if (networkState.nativeNetworks.isNotEmpty()) {
NativeNetworks(networkState = networkState, tokenState = state)
}
}
if (networkState.nonNativeNetworks.isNotEmpty()) {
item {
NonNativeNetworksHeader(networkState.onNonNativeNetworkHintClick)
}
item {
SpacerH(height = TangemTheme.dimens.spacing8)
}
item {
this@LazyColumn.NonNativeNetworks(networkState = networkState, tokenState = state)
}
}
}
}
@Composable
private fun NativeNetworks(networkState: ChooseNetworkState, tokenState: TokenItemState.Loaded) {
Column {
Text(
text = stringResource(id = R.string.manage_tokens_network_selector_native_title),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
)
SpacerH(height = TangemTheme.dimens.spacing2)
Text(
text = stringResource(id = R.string.manage_tokens_network_selector_native_subtitle),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
)
SpacerH(height = TangemTheme.dimens.spacing8)
networkState.nativeNetworks.forEachIndexed { index, network ->
NetworkItem(
state = network,
tokenState = tokenState,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = networkState.nativeNetworks.lastIndex,
addDefaultPadding = false,
),
)
}
SpacerH(height = TangemTheme.dimens.spacing16)
}
}
@Composable
private fun LazyListScope.NonNativeNetworks(networkState: ChooseNetworkState, tokenState: TokenItemState.Loaded) {
items(
count = networkState.nonNativeNetworks.count(),
key = { index -> networkState.nonNativeNetworks[index].id },
) { index ->
NetworkItem(
state = networkState.nonNativeNetworks[index],
tokenState = tokenState,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = networkState.nonNativeNetworks.lastIndex,
addDefaultPadding = false,
),
)
}
item {
SpacerH(height = TangemTheme.dimens.spacing16)
}
}
@Composable
private fun NonNativeNetworksHeader(onNonNativeNetworkHintClick: () -> Unit) {
Column {
Row {
Text(
text = stringResource(id = R.string.manage_tokens_network_selector_non_native_title),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
)
SpacerW(width = TangemTheme.dimens.spacing2)
Icon(
painter = painterResource(id = R.drawable.ic_information_24),
tint = TangemTheme.colors.icon.inactive,
contentDescription = null,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.clickable { onNonNativeNetworkHintClick() },
)
}
SpacerH(height = TangemTheme.dimens.spacing2)
Text(
text = stringResource(id = R.string.manage_tokens_network_selector_non_native_subtitle),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
)
}
}
@Preview
@Composable
private fun Preview_ChooseNetworkScreen_Light() {
TangemTheme(isDark = false) {
ChooseNetworkScreen(
state = TokenItemStatePreviewData.loadedPriceDown as TokenItemState.Loaded,
walletState = ChooseWalletStatePreviewData.state,
)
}
}
@Preview
@Composable
private fun Preview_ChooseNetworkScreen_Dark() {
TangemTheme(isDark = true) {
ChooseNetworkScreen(
state = TokenItemStatePreviewData.loadedPriceDown as TokenItemState.Loaded,
walletState = ChooseWalletStatePreviewData.state,
)
}
}

View file

@ -0,0 +1,144 @@
package com.tangem.managetokens.presentation.managetokens.ui
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.paging.compose.collectAsLazyPagingItems
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.res.TangemTheme
import com.tangem.managetokens.presentation.common.state.AlertState
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.common.ui.ChooseWalletBottomSheet
import com.tangem.managetokens.presentation.common.ui.ChooseWalletBottomSheetConfig
import com.tangem.managetokens.presentation.common.ui.EventEffect
import com.tangem.managetokens.presentation.common.ui.components.Alert
import com.tangem.managetokens.presentation.managetokens.state.ManageTokensState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.managetokens.presentation.managetokens.state.previewdata.ManageTokensStatePreviewData
import com.tangem.managetokens.presentation.managetokens.ui.components.DerivationNotification
import com.tangem.managetokens.presentation.managetokens.ui.components.TokensList
import com.tangem.managetokens.presentation.managetokens.ui.components.TokensSearchBar
@Composable
internal fun ManageTokensScreen(state: ManageTokensState) {
var alertState by remember { mutableStateOf<AlertState?>(value = null) }
EventEffect(
event = state.event,
onAlertStateSet = { alertState = it },
)
alertState?.let {
Alert(state = it, onDismiss = { alertState = null })
}
Content(state)
}
@Composable
private fun Content(state: ManageTokensState) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = TangemTheme.colors.background.primary)
.padding(top = TangemTheme.dimens.spacing32),
) {
Column {
val listState = rememberLazyListState()
val raiseSearchBar by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
val elevation by animateDpAsState(
targetValue = if (raiseSearchBar) {
TangemTheme.dimens.elevation8
} else {
TangemTheme.dimens.elevation0
},
label = "top_bar_elevation",
)
Surface(
elevation = elevation,
modifier = Modifier,
) {
TokensSearchBar(
state = state.searchBarState,
modifier = Modifier
.background(color = TangemTheme.colors.background.primary)
.padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing20),
)
}
val tokens = state.tokens.collectAsLazyPagingItems()
TokensList(tokens = tokens, addCustomTokenButton = state.addCustomTokenButton)
}
state.derivationNotification?.let {
DerivationNotification(
config = it.config,
modifier = Modifier
.align(Alignment.BottomCenter),
)
}
state.selectedToken?.let { selectedToken ->
ManageTokensBottomSheet(selectedToken = selectedToken, state = state)
}
}
}
@Composable
private fun ManageTokensBottomSheet(selectedToken: TokenItemState.Loaded, state: ManageTokensState) {
if (state.showChooseWalletScreen && state.chooseWalletState is ChooseWalletState.Choose) {
val config = TangemBottomSheetConfig(
isShow = true,
content = ChooseWalletBottomSheetConfig(state.chooseWalletState),
onDismissRequest = state.chooseWalletState.onCloseChoosingWalletClick,
)
ChooseWalletBottomSheet(config)
} else {
val config = TangemBottomSheetConfig(
isShow = true,
content = ChooseNetworkBottomSheetConfig(
selectedToken = selectedToken,
chooseWalletState = state.chooseWalletState,
),
onDismissRequest = selectedToken.chooseNetworkState.onCloseChooseNetworkScreen,
)
ChooseNetworkBottomSheet(config)
}
}
@Preview
@Composable
private fun Preview_ManageTokensScreen_LightTheme(
@PreviewParameter(ManageTokensConfigProvider::class)
state: ManageTokensState,
) {
TangemTheme(isDark = false) {
ManageTokensScreen(state)
}
}
@Preview
@Composable
private fun Preview_ManageTokensScreen_DarkTheme(
@PreviewParameter(ManageTokensConfigProvider::class)
state: ManageTokensState,
) {
TangemTheme(isDark = true) {
ManageTokensScreen(state)
}
}
private class ManageTokensConfigProvider : CollectionPreviewParameterProvider<ManageTokensState>(
collection = listOf(
ManageTokensStatePreviewData.loadingState,
ManageTokensStatePreviewData.loadedState,
),
)

View file

@ -0,0 +1,65 @@
package com.tangem.managetokens.presentation.managetokens.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
@Composable
internal fun AddCustomTokenButton(onButtonClick: () -> Unit, modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size68)
.fillMaxWidth()
.clickable { onButtonClick() }
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size36)
.background(color = TangemTheme.colors.button.secondary, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_plus_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
SpacerW(width = TangemTheme.dimens.spacing12)
Text(
text = stringResource(id = R.string.add_custom_token_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Preview
@Composable
private fun AddCustomTokenButton_Preview_Light() {
TangemTheme(isDark = false) {
AddCustomTokenButton(onButtonClick = { })
}
}
@Preview
@Composable
private fun AddCustomTokenButton_Preview_Dark() {
TangemTheme(isDark = true) {
AddCustomTokenButton(onButtonClick = { })
}
}

View file

@ -54,7 +54,7 @@ private fun LoadedTokenItem(state: TokenItemState.Loaded, modifier: Modifier = M
modifier = Modifier modifier = Modifier
.weight(weight = 1f), .weight(weight = 1f),
) { ) {
TokenName(name = state.name, currencyId = state.currencyId) TokenName(name = state.name, currencyId = state.currencySymbol)
TokenPriceData(price = state.rate, quotesState = state.quotes) TokenPriceData(price = state.rate, quotesState = state.quotes)
} }
SpacerW24() SpacerW24()
@ -67,8 +67,8 @@ private fun LoadedTokenItem(state: TokenItemState.Loaded, modifier: Modifier = M
} }
TokenButton( TokenButton(
type = state.availableAction, type = state.availableAction.value,
onClick = { state.onButtonClick(state.currencyId) }, onClick = { state.onButtonClick(state) },
) )
} }
} }

View file

@ -0,0 +1,59 @@
package com.tangem.managetokens.presentation.managetokens.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.managetokens.state.AddCustomTokenButton
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
private const val PLACEHOLDER_ITEMS_COUNT = 50
@Composable
internal fun TokensList(tokens: LazyPagingItems<TokenItemState>, addCustomTokenButton: AddCustomTokenButton) {
LazyColumn {
item {
Text(
text = stringResource(id = R.string.manage_tokens_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
)
}
if (tokens.loadState.refresh is LoadState.Loading) {
items(PLACEHOLDER_ITEMS_COUNT) {
TokenRowItem(state = TokenItemState.Loading(it.toString()))
}
} else {
val tokensList = tokens.itemSnapshotList
if (tokensList.isEmpty()) {
item {
Text(
text = stringResource(id = R.string.manage_tokens_nothing_found),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
)
}
}
items(items = tokensList.items, key = TokenItemState::id) { token ->
TokenRowItem(state = token)
}
if (addCustomTokenButton.isVisible) {
item {
AddCustomTokenButton(onButtonClick = { addCustomTokenButton.onClick })
}
}
}
}
}

View file

@ -45,6 +45,7 @@ dependencies {
implementation(projects.core.featuretoggles) implementation(projects.core.featuretoggles)
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(projects.core.utils) implementation(projects.core.utils)
implementation(projects.core.navigation)
/** Domain modules */ /** Domain modules */
implementation(projects.domain.models) implementation(projects.domain.models)
@ -58,6 +59,8 @@ dependencies {
implementation(projects.domain.txhistory) implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models) implementation(projects.domain.txhistory.models)
implementation(projects.domain.transaction) implementation(projects.domain.transaction)
implementation(projects.domain.card)
implementation(projects.domain.demo)
/** Feature modules */ /** Feature modules */
implementation(projects.features.send.api) implementation(projects.features.send.api)

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.di package com.tangem.features.send.impl.di
import com.tangem.core.navigation.ReduxNavController
import com.tangem.features.send.api.navigation.SendRouter import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.DefaultSendRouter import com.tangem.features.send.impl.navigation.DefaultSendRouter
import dagger.Module import dagger.Module
@ -17,7 +18,7 @@ internal object SendRouterModule {
@Provides @Provides
@ActivityScoped @ActivityScoped
fun provideSendRouter(): SendRouter { fun provideSendRouter(reduxNavController: ReduxNavController): SendRouter {
return DefaultSendRouter() return DefaultSendRouter(reduxNavController)
} }
} }

View file

@ -1,9 +1,17 @@
package com.tangem.features.send.impl.navigation package com.tangem.features.send.impl.navigation
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.tangem.features.send.api.navigation.SendRouter import com.tangem.core.navigation.NavigationAction
import com.tangem.core.navigation.ReduxNavController
import com.tangem.features.send.impl.presentation.SendFragment import com.tangem.features.send.impl.presentation.SendFragment
internal class DefaultSendRouter : SendRouter { internal class DefaultSendRouter(
private val reduxNavController: ReduxNavController,
) : InnerSendRouter {
override fun getEntryFragment(): Fragment = SendFragment.create() override fun getEntryFragment(): Fragment = SendFragment.create()
override fun openUrl(url: String) {
reduxNavController.navigate(NavigationAction.OpenUrl(url = url))
}
} }

View file

@ -0,0 +1,9 @@
package com.tangem.features.send.impl.navigation
import com.tangem.features.send.api.navigation.SendRouter
interface InnerSendRouter : SendRouter {
/** Open website by [url] */
fun openUrl(url: String)
}

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