Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-25 16:39:35 +03:00
commit bfd903b43c
595 changed files with 12755 additions and 19837 deletions

View file

@ -152,6 +152,7 @@ dependencies {
implementation(projects.domain.manageTokens)
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
@ -304,6 +305,8 @@ dependencies {
implementation(projects.features.tokenRecieve.impl)
implementation(projects.features.yieldSupply.api)
implementation(projects.features.yieldSupply.impl)
implementation(projects.features.approval.api)
implementation(projects.features.approval.impl)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)

View file

@ -146,11 +146,8 @@ abstract class BaseTestCase : TestCase(
return ApplicationInjectionExecutionRule(
toggleStates = mapOf(
"SWAP_REDESIGN_ENABLED" to false,
"NEW_ONRAMP_MAIN_ENABLED" to true,
"HOT_WALLET_ENABLED" to true,
"YIELD_SUPPLY_FEATURE_ENABLED" to true,
"ACCOUNTS_FEATURE_ENABLED" to true,
"FEED_ENABLED" to true,
"GASLESS_TRANSACTIONS_ENABLED" to true,
)
)

View file

@ -1,9 +1,7 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
@ -22,30 +20,32 @@ class SendTest : BaseTestCase() {
@DisplayName("Send: check fee notification")
@Test
fun checkFeeNotificationTest() {
val currencyName = "POL (ex-MATIC)"
val feeCurrencyName = "Ethereum"
val feeCurrencySymbol = "ETH"
val scenarioName = "eth_network_balance"
val scenarioState = "Empty"
val currencyName = "USDC"
val feeCurrencyName = "Solana"
val feeCurrencySymbol = "SOL"
val balanceScenarioName = "solana_balance"
val tokensScenarioName = "user_tokens_api"
val balanceState = "Empty"
val tokensState = "SolanaUSDC"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
resetWireMockScenarioState(balanceScenarioName)
resetWireMockScenarioState(tokensScenarioName)
}
).run {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
step("Set WireMock scenario: '$tokensScenarioName' to state: '$tokensState'") {
setWireMockScenarioState(scenarioName = tokensScenarioName, state = tokensState)
}
step("Set WireMock scenario: '$balanceScenarioName' to state: '$balanceState'") {
setWireMockScenarioState(scenarioName = balanceScenarioName, state = balanceState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP)
}
step("Click on token with name: $currencyName") {
onMainScreen { tokenWithTitleAndAddress(currencyName).clickWithAssertion() }
}

View file

@ -23,7 +23,7 @@ class KusamaWarningsTest : BaseTestCase() {
private val tokenName = "Kusama"
private val amountToLeaveLessThanDeposit = "0.300333"
private val amountToLeaveGreaterThanDeposit = "0.1"
private val depositAmount = "KSM 0.000333333333"
private val depositAmount = "KSM 0.000003333"
private val warningTitle = getResourceString(R.string.send_notification_existential_deposit_title)
private val warningMessage = getResourceString(

View file

@ -23,7 +23,7 @@ class PolkadotWarningsTest : BaseTestCase() {
private val tokenName = "Polkadot"
private val amountToLeaveLessThanDeposit = "1.299"
private val amountToLeaveGreaterThanDeposit = "0.2"
private val depositAmount = "DOT 1.00"
private val depositAmount = "DOT 0.01"
private val warningTitle = getResourceString(R.string.send_notification_existential_deposit_title)
private val warningMessage = getResourceString(

View file

@ -19,7 +19,7 @@ import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.data.card.TransactionSignerFactory
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -48,7 +48,6 @@ import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient
import com.tangem.tap.common.log.TangemAppLoggerInitializer
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@ -58,12 +57,12 @@ import dagger.hilt.components.SingletonComponent
@Suppress("TooManyFunctions")
interface ApplicationEntryPoint {
fun getEnvironmentConfigStorage(): EnvironmentConfigStorage
fun getAppStateHolder(): AppStateHolder
fun getIssuersConfigStorage(): IssuersConfigStorage
fun getEnvironmentConfig(): EnvironmentConfig
fun getFeatureTogglesManager(): FeatureTogglesManager
fun getExcludedBlockchainsManager(): ExcludedBlockchainsManager
@ -120,8 +119,6 @@ interface ApplicationEntryPoint {
fun getOnboardingRepository(): OnboardingRepository
fun getCoroutineDispatcherProvider(): CoroutineDispatcherProvider
fun getExcludedBlockchains(): ExcludedBlockchains
fun getAppLogsStore(): AppLogsStore

View file

@ -44,7 +44,6 @@ import com.tangem.domain.settings.ShouldInitiallyAskPermissionUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.staking.SendUnsubmittedHashesUseCase
import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tester.api.TesterMenuLauncher
import com.tangem.google.GoogleServicesHelper
import com.tangem.operations.backup.BackupService
@ -161,9 +160,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
@Inject
internal lateinit var clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase
@Inject
internal lateinit var tangemPayFeatureToggles: TangemPayFeatureToggles
private val viewModel: MainViewModel by viewModels()
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode>

View file

@ -36,7 +36,6 @@ import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -73,23 +72,19 @@ import com.tangem.tap.common.log.TangemAppLoggerInitializer
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.appReducer
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.domain.tasks.product.DerivationsFinder
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.BuildConfig
import dagger.hilt.EntryPoints
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.rekotlin.Store
import timber.log.Timber
lateinit var store: Store<AppState>
val foregroundActivityObserver = ForegroundActivityObserver
internal lateinit var derivationsFinder: DerivationsFinder
open class TangemApplication : Application(), ImageLoaderFactory, Configuration.Provider {
@ -100,12 +95,12 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val appStateHolder: AppStateHolder
get() = entryPoint.getAppStateHolder()
private val environmentConfigStorage: EnvironmentConfigStorage
get() = entryPoint.getEnvironmentConfigStorage()
private val issuersConfigStorage: IssuersConfigStorage
get() = entryPoint.getIssuersConfigStorage()
private val environmentConfig: EnvironmentConfig
get() = entryPoint.getEnvironmentConfig()
private val featureTogglesManager: FeatureTogglesManager
get() = entryPoint.getFeatureTogglesManager()
@ -190,9 +185,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val onboardingRepository: OnboardingRepository
get() = entryPoint.getOnboardingRepository()
private val dispatchers: CoroutineDispatcherProvider
get() = entryPoint.getCoroutineDispatcherProvider()
private val excludedBlockchains: ExcludedBlockchains
get() = entryPoint.getExcludedBlockchains()
@ -312,9 +304,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
Timber.i(excludedBlockchainsManager.toString())
}
runBlocking {
initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize())
}
initWithConfigDependency(environmentConfig = environmentConfig)
abTestsManager.init()
@ -348,15 +338,10 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
)
}
derivationsFinder = DerivationsFinder(
userTokensResponseStore = userTokensResponseStore,
dispatchers = dispatchers,
)
appStateHolder.mainStore = store
wcInitializeUseCase.init(
projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId,
projectId = environmentConfig.walletConnectProjectId,
)
}
@ -387,7 +372,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
shareManager = shareManager,
appRouter = appRouter,
transactionSignerFactory = transactionSignerFactory,
environmentConfigStorage = environmentConfigStorage,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
onboardingRepository = onboardingRepository,
excludedBlockchains = excludedBlockchains,

View file

@ -6,7 +6,6 @@ import com.tangem.tap.common.redux.legacy.LegacyMiddleware
import com.tangem.tap.features.details.redux.DetailsMiddleware
import com.tangem.tap.features.details.redux.DetailsState
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupMiddleware
import com.tangem.tap.features.wallet.redux.middlewares.TradeCryptoMiddleware
import com.tangem.tap.proxy.redux.DaggerGraphMiddleware
import com.tangem.tap.proxy.redux.DaggerGraphState
import org.rekotlin.Middleware
@ -29,7 +28,6 @@ data class AppState(
AccessCodeRequestPolicyMiddleware().middleware,
DaggerGraphMiddleware.daggerGraphMiddleware,
LegacyMiddleware.legacyMiddleware,
TradeCryptoMiddleware.middleware,
)
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.tap.data
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.network.exchangeServices.SellService
/**
* Default implementation of [OfframpRepository]
*
* @property sellService sell service for getting offramp URL
*/
internal class DefaultOfframpRepository(
private val sellService: SellService,
) : OfframpRepository {
override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency,
fiatCurrencyCode: String,
walletAddress: String,
): String? {
return sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive,
)
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.tap.di
import com.tangem.datasource.api.moonpay.MoonPayApi
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.exchange.RampStateManager
@ -69,14 +69,14 @@ internal object ActivityModule {
@Provides
@Singleton
fun provideExchangeService(
environmentConfigStorage: EnvironmentConfigStorage,
getSelectedWalletUseCase: GetSelectedWalletUseCase,
moonPayApi: MoonPayApi,
environmentConfig: EnvironmentConfig,
): SellService {
return MoonPayService(
api = moonPayApi,
apiKeyProvider = Provider { environmentConfigStorage.getConfigSync().moonPayApiKey },
secretKeyProvider = Provider { environmentConfigStorage.getConfigSync().moonPayApiSecretKey },
apiKey = environmentConfig.moonPayApiKey,
secretKey = environmentConfig.moonPayApiSecretKey,
userWalletProvider = { getSelectedWalletUseCase.sync().getOrNull() },
)
}

View file

@ -12,6 +12,7 @@ import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
@ -40,6 +41,7 @@ internal class TangemSdkManagerModule {
appFinisher: AppFinisher,
sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
analyticsExceptionHandler: AnalyticsExceptionHandler,
blockchainToDeriveFinder: BlockchainToDeriveFinder,
dispatchers: CoroutineDispatcherProvider,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
@ -56,6 +58,7 @@ internal class TangemSdkManagerModule {
appFinisher = appFinisher,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
analyticsExceptionHandler = analyticsExceptionHandler,
blockchainToDeriveFinder = blockchainToDeriveFinder,
dispatchers = dispatchers,
)
}

View file

@ -1,9 +1,9 @@
package com.tangem.tap.di.domain
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.earn.repository.EarnRepository
import com.tangem.domain.earn.usecase.*
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -19,15 +19,15 @@ object EarnDomainModule {
}
@Provides
fun provideManageEarnNetworksUseCase(
fun provideGetEarnNetworksUseCase(
earnRepository: EarnRepository,
multiAccountListSupplier: MultiAccountListSupplier,
userWalletsListRepository: UserWalletsListRepository,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
): GetEarnNetworksUseCase {
return GetEarnNetworksUseCase(
earnRepository = earnRepository,
multiAccountListSupplier = multiAccountListSupplier,
userWalletsListRepository = userWalletsListRepository,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
)
}

View file

@ -1,9 +1,13 @@
package com.tangem.tap.di.domain
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.repositories.*
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.tap.data.DefaultOfframpRepository
import com.tangem.tap.network.exchangeServices.SellService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -275,4 +279,16 @@ internal object OnrampDomainModule {
promoRepository = promoRepository,
)
}
@Provides
@Singleton
fun provideOfframpRepository(sellService: SellService): OfframpRepository {
return DefaultOfframpRepository(sellService)
}
@Provides
@Singleton
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
return GetOfframpUrlUseCase(offrampRepository)
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
@ -176,20 +177,6 @@ internal object TokensDomainModule {
return GetCryptoCurrencyUseCase(currenciesRepository, multiWalletCryptoCurrenciesSupplier)
}
@Provides
@Singleton
fun provideToggleTokenListGroupingUseCase(
dispatchers: CoroutineDispatcherProvider,
): ToggleTokenListGroupingUseCase {
return ToggleTokenListGroupingUseCase(dispatchers)
}
@Provides
@Singleton
fun provideToggleTokenListSortingUseCase(dispatchers: CoroutineDispatcherProvider): ToggleTokenListSortingUseCase {
return ToggleTokenListSortingUseCase(dispatchers)
}
@Provides
@Singleton
fun provideApplyTokenListSortingUseCase(
@ -378,6 +365,7 @@ internal object TokensDomainModule {
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
stakingIdFactory: StakingIdFactory,
dispatchers: CoroutineDispatcherProvider,
): WalletBalanceFetcher {
@ -388,6 +376,7 @@ internal object TokensDomainModule {
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
multiStakingBalanceFetcher = multiStakingBalanceFetcher,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
stakingIdFactory = stakingIdFactory,
dispatchers = dispatchers,
)

View file

@ -53,11 +53,7 @@ import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.api.visa.VisaCardActivationResponse
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
import com.tangem.tap.derivationsFinder
import com.tangem.tap.domain.tasks.product.CreateProductWalletTask
import com.tangem.tap.domain.tasks.product.ResetBackupCardTask
import com.tangem.tap.domain.tasks.product.ResetToFactorySettingsTask
import com.tangem.tap.domain.tasks.product.ScanProductTask
import com.tangem.tap.domain.tasks.product.*
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
@ -85,6 +81,7 @@ internal class DefaultTangemSdkManager(
private val appFinisher: AppFinisher,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder,
dispatchers: CoroutineDispatcherProvider,
) : TangemSdkManager {
@ -172,7 +169,7 @@ internal class DefaultTangemSdkManager(
runTaskAsyncReturnOnMain(
runnable = ScanProductTask(
card = null,
derivationsFinder = derivationsFinder,
blockchainToDeriveFinder = blockchainToDeriveFinder,
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
visaCardScanHandler = visaCardScanHandler,
visaCoroutineScope = this,

View file

@ -0,0 +1,74 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.tap.features.demo.DemoHelper
import javax.inject.Inject
/**
* Finder of blockchains to derive.
* Returns only saved, default or demo blockchains without any additional logic
* (no cardano/ethereum additions or unnecessary blockchain removals).
*/
class BlockchainToDeriveFinder @Inject constructor(
private val walletAccountsFetcher: WalletAccountsFetcher,
) {
suspend fun find(card: CardDTO): Set<BlockchainToDerive> {
if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet()
val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet()
val derivationStyle = card.derivationStyleProvider.getDerivationStyle()
val blockchains = getBlockchains(userWalletId).ifEmpty {
if (DemoHelper.isDemoCardId(card.cardId)) {
getDemoBlockchains(derivationStyle, card.cardId)
} else {
getDefaultBlockchains(derivationStyle)
}
}
return blockchains
}
private suspend fun getBlockchains(userWalletId: UserWalletId): Set<BlockchainToDerive> {
return walletAccountsFetcher.getSaved(userWalletId)?.accounts.orEmpty()
.flatMap { accountDTO ->
accountDTO.tokens.orEmpty()
.filter { it.contractAddress == null }
}
.mapNotNull { coin ->
val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null
val derivationPath = coin.derivationPath?.let(::DerivationPath) ?: return@mapNotNull null
BlockchainToDerive(blockchain, derivationPath)
}
.toSet()
}
private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): Set<BlockchainToDerive> {
return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle)
}
private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): Set<BlockchainToDerive> {
val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum)
return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle)
}
private fun Set<Blockchain>.mapToBlockchainsWithDerivations(
derivationStyle: DerivationStyle?,
): Set<BlockchainToDerive> {
return mapNotNullTo(hashSetOf()) { blockchain ->
val derivationPath = blockchain.derivationPath(derivationStyle) ?: return@mapNotNullTo null
BlockchainToDerive(blockchain, derivationPath)
}
}
}

View file

@ -1,131 +0,0 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.blockchain.blockchains.cardano.CardanoUtils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.card.common.TapWorkarounds.hasOldStyleDerivation
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal data class BlockchainToDerive(
val blockchain: Blockchain,
val derivationPath: DerivationPath?,
)
// FIXME: May be move to DI, currently unnecessary
internal class DerivationsFinder(
private val userTokensResponseStore: UserTokensResponseStore,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun findBlockchainsToDerive(
card: CardDTO,
derivationStyleProvider: DerivationStyleProvider,
): Set<BlockchainToDerive> {
if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet()
val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet()
val derivationStyle = derivationStyleProvider.getDerivationStyle()
val blockchains = withContext(dispatchers.io) {
getBlockchains(userWalletId)
}.ifEmpty {
if (DemoHelper.isDemoCardId(card.cardId)) {
getDemoBlockchains(derivationStyle, card.cardId)
} else {
getDefaultBlockchains(derivationStyle)
}
}
// we should generate second key for cardano
// because cardano address generation for wallet2 requires keys from 2 derivations
// https://developers.cardano.org/docs/get-started/cardano-serialization-lib/generating-keys/
blockchains.addSecondCardanoDerivationIfPresent()
if (card.settings.isHDWalletAllowed) {
blockchains.addEthereumBlockchains(derivationStyle)
}
// pay attention to this
if (!card.hasOldStyleDerivation) {
blockchains.removeUnnecessaryBlockchains()
}
return blockchains
}
private suspend fun getBlockchains(userWalletId: UserWalletId): MutableSet<BlockchainToDerive> {
val responseTokens = userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)?.tokens
?: return hashSetOf()
return responseTokens.asSequence()
.filter { it.contractAddress == null }
.mapNotNull { coin ->
val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null
val derivationPath = coin.derivationPath?.let(::DerivationPath)
BlockchainToDerive(blockchain, derivationPath)
}
.toMutableSet()
}
// TODO: Move to user wallet config
private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): MutableSet<BlockchainToDerive> {
return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle)
}
// TODO: Move to user wallet config
private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): MutableSet<BlockchainToDerive> {
val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum)
return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle)
}
}
private fun MutableSet<BlockchainToDerive>.addEthereumBlockchains(derivationStyle: DerivationStyle?) {
val ethereumBlockchains = setOf(Blockchain.Ethereum, Blockchain.EthereumTestnet)
.mapToBlockchainsWithDerivations(derivationStyle)
addAll(ethereumBlockchains)
}
private fun MutableSet<BlockchainToDerive>.removeUnnecessaryBlockchains() {
val unnecessaryBlockchains = listOf(
Blockchain.BSC, Blockchain.BSCTestnet,
Blockchain.Polygon, Blockchain.PolygonTestnet,
Blockchain.RSK,
Blockchain.Fantom, Blockchain.FantomTestnet,
Blockchain.Avalanche, Blockchain.AvalancheTestnet,
)
removeAll { it.blockchain in unnecessaryBlockchains }
}
private fun MutableSet<BlockchainToDerive>.addSecondCardanoDerivationIfPresent() {
val cardanoDerivation = this
.firstOrNull { it.blockchain == Blockchain.Cardano }
?.derivationPath
?: return
val secondCardanoBlockchain = BlockchainToDerive(
blockchain = Blockchain.Cardano,
derivationPath = CardanoUtils.extendedDerivationPath(cardanoDerivation),
)
add(secondCardanoBlockchain)
}
private fun Set<Blockchain>.mapToBlockchainsWithDerivations(
derivationStyle: DerivationStyle?,
): MutableSet<BlockchainToDerive> {
return mapTo(hashSetOf()) { blockchain ->
BlockchainToDerive(blockchain, blockchain.derivationPath(derivationStyle))
}
}

View file

@ -13,16 +13,14 @@ import com.tangem.common.tlv.Tlv
import com.tangem.common.tlv.TlvDecoder
import com.tangem.crypto.CryptoUtils
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.domain.card.common.TapWorkarounds.isExcluded
import com.tangem.domain.card.common.TapWorkarounds.isNotSupportedInThatRelease
import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.card.common.TwinsHelper
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.card.configs.CardConfig
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_IDS
import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_PREFIX
@ -45,11 +43,10 @@ import com.tangem.tap.scope
import com.tangem.tap.store
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.collections.set
internal class ScanProductTask(
private val card: Card?,
private val derivationsFinder: DerivationsFinder?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val visaCardScanHandler: VisaCardScanHandler?,
private val visaCoroutineScope: CoroutineScope?,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
@ -81,7 +78,7 @@ internal class ScanProductTask(
readVisaCard(
session = session,
cardDto = cardDto,
scanWalletProcessor = ScanWalletProcessor(derivationsFinder),
scanWalletProcessor = ScanWalletProcessor(blockchainToDeriveFinder),
callback = callback,
)
return
@ -89,7 +86,7 @@ internal class ScanProductTask(
val commandProcessor = when {
cardDto.isTangemTwins -> ScanTwinProcessor()
else -> ScanWalletProcessor(derivationsFinder)
else -> ScanWalletProcessor(blockchainToDeriveFinder)
}
commandProcessor.proceed(cardDto, session) { processorResult ->
when (processorResult) {
@ -170,7 +167,7 @@ internal class ScanProductTask(
}
private class ScanWalletProcessor(
private val derivationsFinder: DerivationsFinder?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
) : ProductCommandProcessor<ScanResponse> {
var primaryCard: PrimaryCard? = null
@ -293,7 +290,6 @@ private class ScanWalletProcessor(
callback: (result: CompletionResult<ScanResponse>) -> Unit,
) {
val productType = getWalletProductType(card)
val config = CardConfig.createConfig(card)
scope.launch {
val scanResponse = ScanResponse(
card = card,
@ -301,8 +297,7 @@ private class ScanWalletProcessor(
walletData = session.environment.walletData,
primaryCard = primaryCard,
)
val derivations =
collectDerivations(card, config, scanResponse.derivationStyleProvider)
val derivations = collectDerivations(card, scanResponse)
if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) {
callback(CompletionResult.Success(scanResponse))
return@launch
@ -332,32 +327,13 @@ private class ScanWalletProcessor(
private suspend fun collectDerivations(
card: CardDTO,
config: CardConfig,
derivationStyleProvider: DerivationStyleProvider,
scanResponse: ScanResponse,
): Map<ByteArrayKey, List<DerivationPath>> {
val derivations = mutableMapOf<ByteArrayKey, List<DerivationPath>>()
val blockchains = derivationsFinder
?.findBlockchainsToDerive(card, derivationStyleProvider)
?: return derivations
val blockchains = blockchainToDeriveFinder
?.find(card)
?: return emptyMap()
blockchains.forEach { blockchain ->
val curve = config.primaryCurve(blockchain.blockchain)
val wallet = card.wallets.firstOrNull { it.curve == curve } ?: return@forEach
if (wallet.chainCode == null) return@forEach
val key = wallet.publicKey.toMapKey()
val path = blockchain.derivationPath
if (path != null) {
val addedDerivations = derivations[key]
if (addedDerivations != null) {
derivations[key] = addedDerivations + path
} else {
derivations[key] = listOf(path)
}
}
}
return derivations
return MissedDerivationsFinder(scanResponse).findByBlockchainsToDerive(blockchains)
}
}

View file

@ -4,5 +4,9 @@ import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.tokens.TokensFeatureToggles
internal class DefaultTokensFeatureToggles(
@Suppress("UnusedPrivateMember") private val featureTogglesManager: FeatureTogglesManager,
) : TokensFeatureToggles
private val featureTogglesManager: FeatureTogglesManager,
) : TokensFeatureToggles {
override val isMultiAddressUtxoEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("MULTI_ADDRESS_UTXO_ENABLED")
}

View file

@ -28,7 +28,7 @@ class FinalizeTwinTask(
is CompletionResult.Success ->
ScanProductTask(
card = readResult.data,
derivationsFinder = null,
blockchainToDeriveFinder = null,
visaCardScanHandler = null,
visaCoroutineScope = null,
shouldCheckIsAlreadyActivated = false,

View file

@ -88,7 +88,7 @@ internal class DefaultUserWalletsListRepository(
.map { wallets.updateWith(it) }
}
.doOnSuccess { loadedWallets ->
userWallets.update { toUpdate ->
userWallets.update { _ ->
val selectedUserWalletId = selectedUserWalletRepository.get()
selectedUserWallet.value = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId }
?: loadedWallets.firstOrNull()?.also {
@ -240,7 +240,7 @@ internal class DefaultUserWalletsListRepository(
}
}
@Suppress("CyclomaticComplexMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
override suspend fun unlock(
userWalletId: UserWalletId,
unlockMethod: UserWalletsListRepository.UnlockMethod,
@ -315,7 +315,13 @@ internal class DefaultUserWalletsListRepository(
sensitiveInformationRepository.getAll(listOf(encryptionKey))
.doOnSuccess { sensitiveInfo ->
updateWallets { it?.updateWith(sensitiveInfo) }
updateWallets { wallets ->
// It is necessary to update derivations because when scanning we obtain the missing keys
wallets?.updateWith(
walletIdToSensitiveInformation = sensitiveInfo,
walletIdToDerivedKeys = mapOf(userWallet.walletId to scanResponse.derivedKeys),
)
}
trackSignInEvent(userWallet, Basic.SignedIn.SignInType.Card)
}
.doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) }

View file

@ -1,8 +1,10 @@
package com.tangem.tap.domain.userWalletList.utils
import com.tangem.domain.models.scan.KeyWalletPublicKey
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.domain.userWalletList.model.UserWalletPublicInformation
import com.tangem.tap.domain.userWalletList.model.UserWalletSensitiveInformation
@ -72,7 +74,10 @@ internal fun List<UserWalletPublicInformation>.toUserWallets(): List<UserWallet>
return this.map { it.toUserWallet() }
}
internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInformation): UserWallet {
internal fun UserWallet.updateWith(
sensitiveInformation: UserWalletSensitiveInformation,
derivedKeys: Map<KeyWalletPublicKey, ExtendedPublicKeysMap>?,
): UserWallet {
return when (this) {
is UserWallet.Cold -> {
copy(
@ -80,6 +85,7 @@ internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInfo
card = scanResponse.card.copy(
wallets = requireNotNull(sensitiveInformation.wallets),
),
derivedKeys = derivedKeys ?: scanResponse.derivedKeys,
// visaCardActivationStatus = sensitiveInformation.visaCardActivationStatus,
),
)
@ -92,14 +98,20 @@ internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInfo
internal fun List<UserWallet>.updateWith(
walletIdToSensitiveInformation: Map<UserWalletId, UserWalletSensitiveInformation>,
walletIdToDerivedKeys: Map<UserWalletId, Map<KeyWalletPublicKey, ExtendedPublicKeysMap>>? = null,
): List<UserWallet> {
return if (walletIdToSensitiveInformation.isEmpty()) {
this
} else {
this.map { wallet ->
walletIdToSensitiveInformation[wallet.walletId]
?.let(wallet::updateWith)
?: wallet
val sensitiveInformation = walletIdToSensitiveInformation[wallet.walletId]
val derivedKeys = walletIdToDerivedKeys?.get(wallet.walletId)
if (sensitiveInformation != null) {
wallet.updateWith(sensitiveInformation, derivedKeys)
} else {
wallet
}
}
}
}

View file

@ -221,7 +221,7 @@ internal class CardSettingsModel @Inject constructor(
val card = scanResponse.card
modelScope.launch {
val hasTangemPay = onboardingRepository.checkCustomerWallet(userWalletId).getOrNull() == true
val hasTangemPay = onboardingRepository.hasTangemPayInWallet(userWalletId).getOrNull() == true
store.dispatchNavigationAction {
push(
route = AppRoute.ResetToFactory(

View file

@ -1,66 +0,0 @@
package com.tangem.tap.features.wallet.redux.middlewares
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.Analytics
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.tap.common.analytics.events.Token
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store
import org.rekotlin.Middleware
@Deprecated("Will be removed soon")
object TradeCryptoMiddleware {
val middleware: Middleware<AppState> = { _, appState ->
{ nextDispatch ->
{ action ->
if (action is TradeCryptoAction) {
handle(appState, action)
}
nextDispatch(action)
}
}
}
private fun handle(state: () -> AppState?, action: TradeCryptoAction) {
if (DemoHelper.tryHandle(state)) return
when (action) {
is TradeCryptoAction.FinishSelling -> openReceiptUrl(action.transactionId)
is TradeCryptoAction.Sell -> proceedSellAction(action)
}
}
private fun proceedSellAction(action: TradeCryptoAction.Sell) {
val networkAddress = action.cryptoCurrencyStatus.value.networkAddress
?.defaultAddress
?.let(NetworkAddress.Address::value)
?: return
val currency = action.cryptoCurrencyStatus.currency
store.inject(DaggerGraphState::appStateHolder).sellService?.getUrl(
cryptoCurrency = currency,
fiatCurrencyName = action.appCurrencyCode,
walletAddress = networkAddress,
isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive,
)?.let { url ->
store.dispatchOpenUrl(url)
Analytics.send(Token.Withdraw.ScreenOpened())
}
}
private fun openReceiptUrl(transactionId: String) {
store.dispatchNavigationAction(AppRouter::pop)
val sellService = store.inject(DaggerGraphState::appStateHolder).sellService
sellService?.getSellCryptoReceiptUrl(transactionId = transactionId)
?.let(store::dispatchOpenUrl)
}
}

View file

@ -3,7 +3,7 @@ package com.tangem.tap.network.auth
import com.tangem.common.extensions.toHexString
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.Provider
@ -11,7 +11,7 @@ import com.tangem.utils.ProviderSuspend
internal class DefaultAuthProvider(
private val userWalletsListRepository: UserWalletsListRepository,
private val environmentConfigStorage: EnvironmentConfigStorage,
private val environmentConfig: EnvironmentConfig,
) : AuthProvider {
override suspend fun getCardPublicKey(): String {
@ -47,11 +47,11 @@ internal class DefaultAuthProvider(
ApiEnvironment.DEV,
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
-> environmentConfigStorage.getConfigSync().tangemApiKeyDev
-> environmentConfig.tangemApiKeyDev
ApiEnvironment.STAGE_2,
ApiEnvironment.STAGE,
-> environmentConfigStorage.getConfigSync().tangemApiKeyStage
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().tangemApiKey
-> environmentConfig.tangemApiKeyStage
ApiEnvironment.PROD -> environmentConfig.tangemApiKey
} ?: error("No tangem tech api config provided")
}
}
@ -60,8 +60,8 @@ internal class DefaultAuthProvider(
return ProviderSuspend {
when (apiEnvironment.invoke()) {
ApiEnvironment.DEV,
-> environmentConfigStorage.getConfigSync().gaslessTxApiKeyDev
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().gaslessTxApiKey
-> environmentConfig.gaslessTxApiKeyDev
ApiEnvironment.PROD -> environmentConfig.gaslessTxApiKey
else -> error("No gasless tx api config provided for ${apiEnvironment.invoke()}")
} ?: error("No gasless tx api config provided")
}

View file

@ -1,15 +1,15 @@
package com.tangem.tap.network.auth
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider
internal class DefaultP2PEthPoolAuthProvider(
private val environmentConfigStorage: EnvironmentConfigStorage,
private val environmentConfig: EnvironmentConfig,
) : P2PEthPoolAuthProvider {
override fun getApiKey(): String {
val keys = environmentConfigStorage.getConfigSync().p2pApiKey
val keys = environmentConfig.p2pApiKey
?: error("No P2P api keys provided")
return if (P2PEthPoolStakingConfig.USE_TESTNET) keys.hoodi else keys.mainnet

View file

@ -1,13 +1,13 @@
package com.tangem.tap.network.auth
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.lib.auth.StakeKitAuthProvider
internal class DefaultStakeKitAuthProvider(
private val environmentConfigStorage: EnvironmentConfigStorage,
private val environmentConfig: EnvironmentConfig,
) : StakeKitAuthProvider {
override fun getApiKey(): String {
return environmentConfigStorage.getConfigSync().stakeKitApiKey ?: error("No StakeKit api key provided")
return environmentConfig.stakeKitApiKey ?: error("No StakeKit api key provided")
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.tap.network.auth.di
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
@ -22,11 +22,11 @@ internal class AuthModule {
@Singleton
fun provideAuthProvider(
userWalletsListRepository: UserWalletsListRepository,
environmentConfigStorage: EnvironmentConfigStorage,
environmentConfig: EnvironmentConfig,
): AuthProvider {
return DefaultAuthProvider(
userWalletsListRepository = userWalletsListRepository,
environmentConfigStorage = environmentConfigStorage,
environmentConfig = environmentConfig,
)
}
@ -38,14 +38,14 @@ internal class AuthModule {
@Provides
@Singleton
fun provideStakeKitAuthProvider(environmentConfigStorage: EnvironmentConfigStorage): StakeKitAuthProvider {
return DefaultStakeKitAuthProvider(environmentConfigStorage)
fun provideStakeKitAuthProvider(environmentConfig: EnvironmentConfig): StakeKitAuthProvider {
return DefaultStakeKitAuthProvider(environmentConfig)
}
@Provides
@Singleton
fun provideP2PEthPoolAuthProvider(environmentConfigStorage: EnvironmentConfigStorage): P2PEthPoolAuthProvider {
return DefaultP2PEthPoolAuthProvider(environmentConfigStorage)
fun provideP2PEthPoolAuthProvider(environmentConfig: EnvironmentConfig): P2PEthPoolAuthProvider {
return DefaultP2PEthPoolAuthProvider(environmentConfig)
}
@Provides

View file

@ -21,6 +21,4 @@ interface SellService {
walletAddress: String,
isDarkTheme: Boolean,
): String?
fun getSellCryptoReceiptUrl(transactionId: String): String?
}

View file

@ -19,7 +19,6 @@ import com.tangem.tap.domain.model.Currency
import com.tangem.tap.network.exchangeServices.SellService
import com.tangem.tap.network.exchangeServices.SellServiceInitializationStatus
import com.tangem.tap.network.exchangeServices.moonpay.models.MoonPayAvailableCurrency
import com.tangem.utils.Provider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import timber.log.Timber
@ -28,8 +27,8 @@ import javax.crypto.spec.SecretKeySpec
class MoonPayService(
private val api: MoonPayApi,
private val apiKeyProvider: Provider<String>,
private val secretKeyProvider: Provider<String>,
private val apiKey: String,
private val secretKey: String,
private val userWalletProvider: () -> UserWallet?,
) : SellService {
@ -47,18 +46,18 @@ class MoonPayService(
_initializationStatus.value = lceLoading()
performRequest {
val userStatus = when (val result = performRequest { api.getUserStatus(apiKeyProvider()) }) {
val userStatus = when (val result = performRequest { api.getUserStatus(apiKey) }) {
is Result.Failure -> {
Timber.e("Failed to load user status", result.error)
Timber.e(result.error, "Failed to load user status")
_initializationStatus.value = result.error.lceError()
return@performRequest
}
is Result.Success -> result.data
}
val currencies = when (val result = performRequest { api.getCurrencies(apiKeyProvider()) }) {
val currencies = when (val result = performRequest { api.getCurrencies(apiKey) }) {
is Result.Failure -> {
Timber.e("Failed to load currencies", result.error)
Timber.e(result.error, "Failed to load currencies")
_initializationStatus.value = result.error.lceError()
return@performRequest
}
@ -163,7 +162,7 @@ class MoonPayService(
val uri = Uri.Builder()
.scheme(SCHEME)
.authority(URL_SELL)
.appendQueryParameter("apiKey", apiKeyProvider())
.appendQueryParameter("apiKey", apiKey)
.appendQueryParameter("baseCurrencyCode", moonpayCurrency.currencyCode.uppercase())
.appendQueryParameter("refundWalletAddress", walletAddress)
.appendQueryParameter("redirectURL", "tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}")
@ -177,17 +176,9 @@ class MoonPayService(
return uri.build().toString()
}
override fun getSellCryptoReceiptUrl(transactionId: String): String {
return Uri.Builder()
.scheme(SCHEME)
.authority(URL_SELL)
.appendPath("transaction_receipt")
.appendQueryParameter("transactionId", transactionId).build().toString()
}
private fun createSignature(data: String): String {
val sha256Hmac = Mac.getInstance("HmacSHA256")
val secretKey = SecretKeySpec(secretKeyProvider().toByteArray(), "HmacSHA256")
val secretKey = SecretKeySpec(secretKey.toByteArray(), "HmacSHA256")
sha256Hmac.init(secretKey)
val sha256encoded = sha256Hmac.doFinal("?$data".toByteArray())
return Base64.encodeToString(sha256encoded, Base64.NO_WRAP)

View file

@ -11,7 +11,6 @@ import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.data.card.TransactionSignerFactory
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
@ -63,7 +62,6 @@ data class DaggerGraphState(
val shareManager: ShareManager? = null,
val appRouter: AppRouter? = null,
val transactionSignerFactory: TransactionSignerFactory? = null,
val environmentConfigStorage: EnvironmentConfigStorage? = null,
val onboardingV2FeatureToggles: OnboardingV2FeatureToggles? = null,
val onboardingRepository: OnboardingRepository? = null,
val excludedBlockchains: ExcludedBlockchains? = null,

View file

@ -18,7 +18,6 @@ import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.*
import com.tangem.features.kyc.KycComponent
@ -26,7 +25,6 @@ import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.tokenlist.MarketsTokenListComponent
import com.tangem.features.nft.component.NFTComponent
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
@ -67,7 +65,6 @@ internal class ChildFactory @Inject constructor(
private val walletHardwareBackupComponentFactory: WalletHardwareBackupComponent.Factory,
private val disclaimerComponentFactory: DisclaimerComponent.Factory,
private val manageTokensComponentFactory: ManageTokensComponent.Factory,
private val marketsTokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory,
private val marketsTokenListComponentFactory: MarketsTokenListComponent.FactoryScreen,
private val onrampComponentFactory: OnrampComponent.Factory,
private val onrampSuccessComponentFactory: OnrampSuccessComponent.Factory,
@ -117,7 +114,6 @@ internal class ChildFactory @Inject constructor(
private val kycComponentFactory: KycComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val feedFeatureToggle: FeedFeatureToggle,
) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -193,39 +189,21 @@ internal class ChildFactory @Inject constructor(
)
}
is AppRoute.MarketsTokenDetails -> {
if (feedFeatureToggle.isFeedEnabled) {
createComponentChild(
context = context,
params = FeedEntryRoute.MarketTokenDetails(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
FeedEntryRoute.MarketTokenDetails.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = feedEntryComponentFactory,
)
} else {
createComponentChild(
context = context,
params = MarketsTokenDetailsComponent.Params(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
MarketsTokenDetailsComponent.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = marketsTokenDetailsComponentFactory,
)
}
createComponentChild(
context = context,
params = FeedEntryRoute.MarketTokenDetails(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
FeedEntryRoute.MarketTokenDetails.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = feedEntryComponentFactory,
)
}
is AppRoute.Onramp -> {
createComponentChild(

View file

@ -7,7 +7,6 @@ import com.tangem.common.routing.DeepLinkScheme
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
@ -53,7 +52,6 @@ internal class DeepLinkFactory @Inject constructor(
private val promoDeepLink: PromoDeeplinkHandler.Factory,
private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory,
private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory,
private val feedFeatureToggle: FeedFeatureToggle,
) {
private val permittedAppRoute = MutableStateFlow(false)
@ -127,7 +125,7 @@ internal class DeepLinkFactory @Inject constructor(
onboardVisaDeepLink.create(deeplinkUri)
return
}
deeplinkUri.path?.startsWith("/news") == true && feedFeatureToggle.isFeedEnabled -> {
deeplinkUri.path?.startsWith("/news") == true -> {
newsDetailsDeepLink.create(coroutineScope, deeplinkUri)
return
}

View file

@ -0,0 +1,134 @@
package com.tangem.tap.data
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.network.exchangeServices.SellService
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultOfframpRepositoryTest {
private val sellService: SellService = mockk()
private val repository = DefaultOfframpRepository(sellService)
private val cryptoCurrency: CryptoCurrency = mockk()
private val fiatCurrencyCode = "USD"
private val walletAddress = "0x1234567890abcdef"
@BeforeEach
fun setUp() {
mockkObject(MutableAppThemeModeHolder)
}
@AfterEach
fun tearDown() {
clearMocks(sellService)
unmockkObject(MutableAppThemeModeHolder)
}
@Test
fun `getOfframpUrl should return url when sellService returns url with light theme`() {
// Arrange
val expectedUrl = "https://moonpay.com/sell?address=$walletAddress&theme=light"
every { MutableAppThemeModeHolder.isDarkThemeActive } returns false
every {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
} returns expectedUrl
// Act
val result = repository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress,
)
// Assert
assertThat(result).isEqualTo(expectedUrl)
verify(exactly = 1) {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
}
}
@Test
fun `getOfframpUrl should return url when sellService returns url with dark theme`() {
// Arrange
val expectedUrl = "https://moonpay.com/sell?address=$walletAddress&theme=dark"
every { MutableAppThemeModeHolder.isDarkThemeActive } returns true
every {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = true,
)
} returns expectedUrl
// Act
val result = repository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress,
)
// Assert
assertThat(result).isEqualTo(expectedUrl)
verify(exactly = 1) {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = true,
)
}
}
@Test
fun `getOfframpUrl should return null when sellService returns null`() {
// Arrange
every { MutableAppThemeModeHolder.isDarkThemeActive } returns false
every {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
} returns null
// Act
val result = repository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress,
)
// Assert
assertThat(result).isNull()
verify(exactly = 1) {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
}
}
}

View file

@ -0,0 +1,252 @@
package com.tangem.tap.domain.tasks.product
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class BlockchainToDeriveFinderTest {
private val walletAccountsFetcher = mockk<WalletAccountsFetcher>()
private val finder = BlockchainToDeriveFinder(
walletAccountsFetcher = walletAccountsFetcher,
)
@AfterEach
fun tearDown() {
clearMocks(walletAccountsFetcher)
}
@Test
fun `GIVEN card is not HD wallet THEN return empty set`() = runTest {
// Arrange
val card = mockk<CardDTO> {
every { this@mockk.settings.isHDWalletAllowed } returns false
}
// Act
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `GIVEN card has empty wallets THEN return empty set`() = runTest {
// Arrange
val card = mockk<CardDTO> {
every { this@mockk.settings.isHDWalletAllowed } returns true
every { this@mockk.wallets } returns emptyList()
}
// Act
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `GIVEN saved bitcoin THEN return only bitcoin`() = runTest {
// Arrange
val card = createCardDTO()
val response = createResponse(Blockchain.Bitcoin)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store and common demo card THEN return demo blockchains`() = runTest {
// Arrange
val demoCardId = "AC01000000045754"
val card = createCardDTO(cardId = demoCardId)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
createExpected(Blockchain.Dogecoin),
createExpected(Blockchain.Solana),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store and DE00 demo card THEN return demo blockchains`() = runTest {
// Arrange
val demoCardId = "DE00"
val card = createCardDTO(cardId = demoCardId)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
createExpected(Blockchain.Dogecoin),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store THEN return default blockchains`() = runTest {
// Arrange
val card = createCardDTO()
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN saved cardano THEN return only cardano`() = runTest {
// Arrange
val card = createCardDTO()
val response = createResponse(Blockchain.Cardano)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Cardano),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN saved eth-like blockchains THEN return all saved blockchains without filtering`() = runTest {
// Arrange
val card = createCardDTO()
val blockchains = listOf(Blockchain.Ethereum, Blockchain.BSC, Blockchain.Polygon)
val response = createResponse(*blockchains.toTypedArray())
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = blockchains.mapTo(hashSetOf(), ::createExpected)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
private fun createCardDTO(cardId: String = "0001", batchId: String = "AC10"): CardDTO {
val wallet = mockk<CardDTO.Wallet> {
every { this@mockk.publicKey } returns byteArrayOf(0)
}
return mockk<CardDTO> {
every { this@mockk.cardId } returns cardId
every { this@mockk.batchId } returns batchId
every { this@mockk.settings.isHDWalletAllowed } returns true
every { this@mockk.settings.isKeysImportAllowed } returns true
every { this@mockk.firmwareVersion } returns CardDTO.FirmwareVersion(
major = 6,
minor = 33,
patch = 0,
type = com.tangem.common.card.FirmwareVersion.FirmwareType.Release,
)
every { this@mockk.wallets } returns listOf(wallet)
}
}
private fun createResponse(vararg blockchains: Blockchain): GetWalletAccountsResponse {
val tokens = blockchains.map { blockchain ->
mockk<UserTokensResponse.Token> {
every { this@mockk.networkId } returns blockchain.toNetworkId()
every { this@mockk.derivationPath } returns blockchain.getDerivationPath().rawPath
every { this@mockk.contractAddress } returns null
}
}
val account = mockk<WalletAccountDTO> {
every { this@mockk.tokens } returns tokens
}
return mockk {
every { this@mockk.accounts } returns listOf(account)
}
}
private fun createExpected(
blockchain: Blockchain,
derivationPath: DerivationPath = blockchain.getDerivationPath(),
): BlockchainToDerive {
return BlockchainToDerive(blockchain = blockchain, derivationPath = derivationPath)
}
private fun Blockchain.getDerivationPath(): DerivationPath {
return derivationPath(DerivationStyle.V3)!!
}
private companion object {
// for byteArrayOf(0)
val userWalletId = UserWalletId("41448576B8DA24C7D8F5F0F79863D20D7D8312A7F9E50D3248304136DDB7AAD7")
}
}

View file

@ -5,7 +5,6 @@ import com.tangem.common.routing.AppRoute
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
@ -87,7 +86,6 @@ class DeepLinkFactoryTest {
private val newsDeeplink = mockk<NewsDetailsDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
}
private val feedFeatureToggle = mockk<FeedFeatureToggle>()
private val mockedUri = mockk<Uri>(relaxed = true)
private val isFromOnNewIntent: Boolean = false
@ -112,7 +110,6 @@ class DeepLinkFactoryTest {
promoDeepLink = promoDeepLinkFactory,
onboardVisaDeepLink = onboardVisaDeepLink,
newsDetailsDeepLink = newsDeeplink,
feedFeatureToggle = feedFeatureToggle,
)
@OptIn(ExperimentalCoroutinesApi::class)

View file

@ -1,6 +1,3 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import java.util.concurrent.ConcurrentHashMap
plugins {
alias(deps.plugins.kotlin.android) apply false
alias(deps.plugins.kotlin.jvm) apply false
@ -33,83 +30,13 @@ interface Injected {
val fs: FileSystemOperations
}
data class TestStats(
val total: Long = 0,
val passed: Long = 0,
val failed: Long = 0,
val skipped: Long = 0,
)
val testResultsByModule = ConcurrentHashMap<String, TestStats>()
// Test task to run unit tests for debug/googleDebug variant (Android) and all JVM modules
val unitTest by tasks.registering {
group = "verification"
description = "Run unit tests for debug/googleDebug variant and all JVM modules"
doLast {
if (testResultsByModule.isNotEmpty()) {
val totalStats = testResultsByModule.values.fold(TestStats()) { acc, stats ->
TestStats(
total = acc.total + stats.total,
passed = acc.passed + stats.passed,
failed = acc.failed + stats.failed,
skipped = acc.skipped + stats.skipped,
)
}
println("\n" + "=".repeat(80))
println("TEST SUMMARY")
println("=".repeat(80))
testResultsByModule.toSortedMap().forEach { (module, stats) ->
println(" $module: ${stats.total} tests (${stats.passed} passed, ${stats.failed} failed, ${stats.skipped} skipped)")
}
println("-".repeat(80))
println("TOTAL: ${totalStats.total} tests in ${testResultsByModule.size} modules")
println(" Passed: ${totalStats.passed}")
println(" Failed: ${totalStats.failed}")
println(" Skipped: ${totalStats.skipped}")
println("=".repeat(80))
}
}
}
// Test Logging and testCI dependencies
subprojects {
tasks.withType<Test>().configureEach {
println("Test task scheduled: $path")
testLogging {
exceptionFormat = TestExceptionFormat.FULL
showStandardStreams = true
afterSuite(KotlinClosure2<TestDescriptor, TestResult, Unit>({ desc, result ->
if (desc.parent == null) { // will match the outermost suite
testResultsByModule[path] = TestStats(
total = result.testCount,
passed = result.successfulTestCount,
failed = result.failedTestCount,
skipped = result.skippedTestCount,
)
val output =
"Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)"
val startItem = "| "
val endItem = " |"
val repeatLength = startItem.length + output.length + endItem.length
println(
"\n" + "-".repeat(repeatLength) + "\n" + startItem + output + endItem + "\n" + "-".repeat(
repeatLength
)
)
}
}))
}
}
// Register testCI dependencies
// App module
plugins.withId("com.android.application") {
afterEvaluate {

View file

@ -408,12 +408,12 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class EditAccount(
val account: Account,
val account: Account.CryptoPortfolio,
) : AppRoute(path = "/edit_account/${account.accountId.value}")
@Serializable
data class AccountDetails(
val account: Account,
val account: Account.CryptoPortfolio,
) : AppRoute(path = "/account_details/${account.accountId.value}")
@Serializable

View file

@ -9,6 +9,8 @@ import kotlin.coroutines.suspendCoroutine
object TangemSiteUrlBuilder {
const val NOTE_MIGRATION_URL = "https://tangem.com/en/?promocode=Note10"
suspend fun getUtmTags(campaign: String?): String {
val langCode = Locale.getDefault().language
val utmCampaignPart = campaign?.let { "&utm_campaign=$it-$langCode" }.orEmpty()

View file

@ -73,7 +73,7 @@ object MockScanResponseFactory {
CardDTO.Wallet(
CardWallet(
publicKey = curve.name.toByteArray(), // IMPORTANT: public key must equal to curve name
chainCode = null,
chainCode = ByteArray(32), // chainCode must not be null for HD wallets
curve = curve,
settings = createSettings(),
totalSignedHashes = null,

View file

@ -47,7 +47,7 @@ class AccountCryptoPortfolioItemStateConverter(
)
return TokenItemState.Content(
id = account.accountId.toItemId(),
iconState = AccountIconItemStateConverter.convert(this),
iconState = AccountIconItemStateConverter().convert(this),
titleState = TokenItemState.TitleState.Content(
text = accountName.toUM().value,
),
@ -73,7 +73,7 @@ class AccountCryptoPortfolioItemStateConverter(
private fun Account.CryptoPortfolio.mapToLoadingState(): TokenItemState.Content {
return TokenItemState.Content(
id = account.accountId.toItemId(),
iconState = AccountIconItemStateConverter.convert(account),
iconState = AccountIconItemStateConverter().convert(account),
titleState = TokenItemState.TitleState.Content(
text = accountName.toUM().value,
),
@ -95,7 +95,7 @@ class AccountCryptoPortfolioItemStateConverter(
private fun Account.CryptoPortfolio.mapToUnreachableState(): TokenItemState.Unreachable {
return TokenItemState.Unreachable(
id = account.accountId.toItemId(),
iconState = AccountIconItemStateConverter.convert(account),
iconState = AccountIconItemStateConverter().convert(account),
titleState = TokenItemState.TitleState.Content(
text = accountName.toUM().value,
),

View file

@ -1,11 +1,14 @@
package com.tangem.common.ui.account
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.utils.converter.Converter
object AccountIconItemStateConverter : Converter<Account, CurrencyIconState.CryptoPortfolio> {
class AccountIconItemStateConverter(
val size: AccountIconSize = AccountIconSize.Default,
) : Converter<Account, CurrencyIconState.CryptoPortfolio> {
override fun convert(value: Account): CurrencyIconState.CryptoPortfolio = when (value) {
is Account.CryptoPortfolio -> when {
@ -13,11 +16,13 @@ object AccountIconItemStateConverter : Converter<Account, CurrencyIconState.Cryp
char = value.accountName.toUM().value,
color = value.icon.color.getUiColor(),
isGrayscale = false,
size = size,
)
else -> CurrencyIconState.CryptoPortfolio.Icon(
resId = value.icon.value.getResId(),
color = value.icon.color.getUiColor(),
isGrayscale = false,
size = size,
)
}
is Account.Payment -> TODO("[REDACTED_JIRA]")

View file

@ -1,56 +0,0 @@
package com.tangem.common.ui.alerts
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.common.ui.alerts.models.AlertTransactionErrorUM
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.utils.converter.Converter
class TransactionErrorAlertConverter(
private val popBackStack: () -> Unit,
private val onFailedTxEmailClick: (String) -> Unit,
) : Converter<SendTransactionError, AlertUM?> {
override fun convert(value: SendTransactionError): AlertUM? {
return when (value) {
is SendTransactionError.DemoCardError -> AlertDemoModeUM(
onConfirmClick = popBackStack,
)
is SendTransactionError.TangemSdkError -> AlertTransactionErrorUM(
code = value.code.toString(),
cause = null,
causeTextReference = resourceReference(value.messageRes, wrappedList(value.args)),
onConfirmClick = { onFailedTxEmailClick(value.code.toString()) },
)
is SendTransactionError.BlockchainSdkError -> AlertTransactionErrorUM(
code = value.code.toString(),
cause = value.message,
onConfirmClick = { onFailedTxEmailClick("${value.code}: ${value.message.orEmpty()}") },
)
is SendTransactionError.DataError -> AlertTransactionErrorUM(
code = "",
cause = value.message,
onConfirmClick = { onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.NetworkError -> AlertTransactionErrorUM(
code = value.code.orEmpty(),
cause = value.message.orEmpty(),
onConfirmClick = { onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.UnknownError -> AlertTransactionErrorUM(
code = "",
cause = value.ex?.localizedMessage,
onConfirmClick = { onFailedTxEmailClick(value.ex?.localizedMessage.orEmpty()) },
)
is SendTransactionError.CreateAccountUnderfunded -> AlertTransactionErrorUM(
code = "",
cause = null,
causeTextReference = resourceReference(R.string.no_account_polkadot, wrappedList(value.amount)),
onConfirmClick = popBackStack,
)
else -> null
}
}
}

View file

@ -0,0 +1,83 @@
package com.tangem.common.ui.alerts
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.transaction.error.SendTransactionError
import javax.inject.Inject
class TransactionErrorDialogFactory @Inject constructor() {
fun create(
error: SendTransactionError,
popBackStack: () -> Unit,
onFailedTxEmailClick: (String) -> Unit,
): DialogMessage? {
return when (error) {
is SendTransactionError.DemoCardError -> demoModeDialog(popBackStack)
is SendTransactionError.TangemSdkError -> transactionErrorDialog(
causeTextReference = resourceReference(error.messageRes, wrappedList(error.args)),
code = error.code.toString(),
onConfirmClick = { onFailedTxEmailClick(error.code.toString()) },
)
is SendTransactionError.BlockchainSdkError -> transactionErrorDialog(
cause = error.message,
code = error.code.toString(),
onConfirmClick = { onFailedTxEmailClick("${error.code}: ${error.message.orEmpty()}") },
)
is SendTransactionError.DataError -> transactionErrorDialog(
cause = error.message,
code = "",
onConfirmClick = { onFailedTxEmailClick(error.message.orEmpty()) },
)
is SendTransactionError.NetworkError -> transactionErrorDialog(
cause = error.message.orEmpty(),
code = error.code.orEmpty(),
onConfirmClick = { onFailedTxEmailClick(error.message.orEmpty()) },
)
is SendTransactionError.UnknownError -> transactionErrorDialog(
cause = error.ex?.localizedMessage,
code = "",
onConfirmClick = { onFailedTxEmailClick(error.ex?.localizedMessage.orEmpty()) },
)
is SendTransactionError.CreateAccountUnderfunded -> transactionErrorDialog(
causeTextReference = resourceReference(
R.string.no_account_polkadot,
wrappedList(error.amount),
),
code = "",
onConfirmClick = popBackStack,
)
else -> null
}
}
private fun demoModeDialog(onConfirmClick: () -> Unit): DialogMessage = DialogMessage(
title = resourceReference(id = R.string.warning_demo_mode_title),
message = resourceReference(id = R.string.warning_demo_mode_message),
firstAction = EventMessageAction(
title = resourceReference(id = R.string.common_ok),
onClick = onConfirmClick,
),
)
private fun transactionErrorDialog(
cause: String? = null,
causeTextReference: TextReference? = null,
code: String,
onConfirmClick: () -> Unit,
): DialogMessage = DialogMessage(
title = resourceReference(id = R.string.send_alert_transaction_failed_title),
message = resourceReference(
id = R.string.send_alert_transaction_failed_text,
formatArgs = wrappedList(causeTextReference ?: cause.orEmpty(), code),
),
firstAction = EventMessageAction(
title = resourceReference(id = R.string.common_support),
onClick = onConfirmClick,
),
)
}

View file

@ -1,13 +0,0 @@
package com.tangem.common.ui.alerts.models
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
data class AlertDemoModeUM(
override val onConfirmClick: () -> Unit,
) : AlertUM {
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
override val title: TextReference = resourceReference(id = R.string.warning_demo_mode_title)
override val message: TextReference = resourceReference(id = R.string.warning_demo_mode_message)
}

View file

@ -1,21 +0,0 @@
package com.tangem.common.ui.alerts.models
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
data class AlertTransactionErrorUM(
val code: String,
val cause: String?,
val causeTextReference: TextReference? = null,
override val onConfirmClick: () -> Unit,
) : AlertUM {
override val title: TextReference = resourceReference(id = R.string.send_alert_transaction_failed_title)
override val message: TextReference = resourceReference(
id = R.string.send_alert_transaction_failed_text,
formatArgs = wrappedList(causeTextReference ?: cause.orEmpty(), code),
)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.common_support)
}

View file

@ -1,12 +0,0 @@
package com.tangem.common.ui.alerts.models
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
interface AlertUM {
val title: TextReference?
val message: TextReference
val confirmButtonText: TextReference
val onConfirmClick: (() -> Unit)?
}

View file

@ -37,6 +37,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
@Deprecated("Use GiveApprovalComponent")
@Composable
fun GiveTxPermissionBottomSheet(config: TangemBottomSheetConfig) {
var isPermissionAlertShow by remember { mutableStateOf(false) }

View file

@ -1,17 +1,35 @@
package com.tangem.common.ui.notifications
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.ds.TangemPagerIndicator
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
fun LazyListScope.notifications(
notifications: ImmutableList<NotificationUM>,
@ -106,8 +124,107 @@ fun LazyListScope.notifications(
contentColor = contentColor,
modifier = modifier
.padding(top = topPadding)
.animateItem(),
.animateItem(null, null, null),
)
},
)
}
}
/**
* Displays a list of notifications in a stacked manner using a HorizontalPager.
* If there are multiple notifications, a PagerIndicator is shown below the notifications.
*
* @param notifications List of TangemMessageUM objects to be displayed.
* @param containerColor Color to be used for the background of the notifications.
* @param modifier Optional Modifier for the notifications.
*/
fun LazyListScope.stackedNotifications(
notifications: ImmutableList<TangemMessageUM>?,
containerColor: Color,
modifier: Modifier = Modifier,
) {
item {
if (!notifications.isNullOrEmpty()) {
val notificationsPagerState = rememberPagerState(
pageCount = { notifications.size },
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = TangemTheme.dimens2.x2),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
HorizontalPager(
state = notificationsPagerState,
modifier = Modifier
.fillMaxSize()
.animateItem(null, null, null),
) { page ->
TangemMessage(
messageUM = notifications[page],
contentColor = containerColor,
modifier = modifier,
)
}
if (notifications.size > 1) {
TangemPagerIndicator(
pagerState = notificationsPagerState,
)
}
}
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun StackedNotifications_Preview(
@PreviewParameter(StackedNotificationsPreviewProvider::class) params: ImmutableList<TangemMessageUM>,
) {
TangemThemePreviewRedesign {
val contentColor = TangemTheme.colors2.surface.level1
LazyColumn(
modifier = Modifier
.background(contentColor)
.padding(16.dp),
) {
stackedNotifications(
notifications = params,
containerColor = contentColor,
)
}
}
}
private class StackedNotificationsPreviewProvider : PreviewParameterProvider<ImmutableList<TangemMessageUM>> {
override val values: Sequence<ImmutableList<TangemMessageUM>>
get() = sequenceOf(
persistentListOf(
TangemMessageUM(
id = "0",
title = stringReference("First notification"),
subtitle = stringReference("This is the first notification"),
messageEffect = TangemMessageEffect.Magic,
),
),
persistentListOf(
TangemMessageUM(
id = "0",
title = stringReference("First notification"),
subtitle = stringReference("This is the first notification"),
messageEffect = TangemMessageEffect.Magic,
),
TangemMessageUM(
id = "1",
title = stringReference("Second notification"),
subtitle = stringReference("This is the second notification"),
messageEffect = TangemMessageEffect.Card,
),
),
)
}
// endregion

View file

@ -5,8 +5,7 @@ import com.tangem.core.abtests.BuildConfig
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.abtests.manager.impl.AmplitudeABTestsManager
import com.tangem.core.abtests.manager.impl.StubABTestsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.utils.Provider
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -24,7 +23,7 @@ internal object ABTestsManagerModule {
@Singleton
fun provideABTestsManager(
application: Application,
environmentConfigStorage: EnvironmentConfigStorage,
environmentConfig: EnvironmentConfig,
dispatchers: CoroutineDispatcherProvider,
): ABTestsManager {
return if (BuildConfig.AB_TESTS_ENABLED) {
@ -32,7 +31,7 @@ internal object ABTestsManagerModule {
} else {
AmplitudeABTestsManager(
application = application,
apiKeyProvider = Provider { environmentConfigStorage.getConfigSync().amplitudeApiKey },
apiKey = environmentConfig.amplitudeApiKey,
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}

View file

@ -7,14 +7,13 @@ import com.amplitude.experiment.ExperimentConfig
import com.amplitude.experiment.ExperimentUser
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.utils.Provider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
internal class AmplitudeABTestsManager(
val application: Application,
val apiKeyProvider: Provider<String>,
val apiKey: String,
val scope: CoroutineScope,
) : ABTestsManager {
@ -28,7 +27,7 @@ internal class AmplitudeABTestsManager(
client = Experiment.initializeWithAmplitudeAnalytics(
application = application,
apiKey = apiKeyProvider(),
apiKey = apiKey,
config = ExperimentConfig
.builder()
.automaticFetchOnAmplitudeIdentityChange(true)

View file

@ -0,0 +1,17 @@
package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
/**
* Offramp (withdraw/sell) analytics events
*/
sealed class OfframpAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Token / Withdraw", event = event, params = params) {
/**
* Withdraw screen opened event
*/
data object ScreenOpened : OfframpAnalyticsEvent("Withdraw Screen Opened")
}

View file

@ -7,14 +7,7 @@
"name": "VISA_ONBOARDING_ENABLED",
"version": "undefined"
},
{
"name": "STAKING_TON_ENABLED",
"version": "5.28.0"
},
{
"name": "STAKING_CARDANO_ENABLED",
"version": "5.31.1"
},
{
"name": "STAKING_ETH_ENABLED",
"version": "undefined"
@ -31,30 +24,10 @@
"name": "HOT_WALLET_CREATION_RESTRICTION_ENABLED",
"version": "5.32.0"
},
{
"name": "TANGEM_PAY_ENABLED",
"version": "5.31.0"
},
{
"name": "YIELD_SUPPLY_FEATURE_ENABLED",
"version": "5.30.0"
},
{
"name": "YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED",
"version": "5.33.0"
},
{
"name": "NEW_ONRAMP_MAIN_ENABLED",
"version": "5.31.0"
},
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "5.33.0"
},
{
"name": "FEED_ENABLED",
"version": "5.33.0"
},
{
"name": "APP_REDESIGN_ENABLED",
"version": "undefined"
@ -78,5 +51,17 @@
{
"name": "WALLET_REORDER_FEATURE_ENABLED",
"version": "5.34"
},
{
"name": "GASLESS_APPROVAL_ENABLED",
"version": "undefined"
},
{
"name": "TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED",
"version": "undefined"
},
{
"name": "MULTI_ADDRESS_UTXO_ENABLED",
"version": "undefined"
}
]

View file

@ -1,4 +1,6 @@
import com.tangem.plugin.configuration.configurations.EnvironmentConfigGenerator
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
import com.tangem.plugin.configuration.model.BuildType
plugins {
alias(deps.plugins.android.library)
@ -10,14 +12,53 @@ plugins {
id("configuration")
}
abstract class GenerateEnvironmentConfigTask : DefaultTask() {
@get:InputFile
abstract val configFile: RegularFileProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val input = configFile.get().asFile
require(input.exists()) { "Config file not found: ${input.absolutePath}" }
logger.lifecycle("Generating EnvironmentConfig from ${input.name}")
EnvironmentConfigGenerator.generate(input, outputDir.get().asFile)
}
}
android {
namespace = "com.tangem.datasource"
sourceSets["main"].java.srcDir(layout.buildDirectory.dir("generated/source/environment-config"))
room {
schemaDirectory("$projectDir/schemas")
}
}
androidComponents {
onVariants { variant ->
val buildType = BuildType.values().firstOrNull { it.id == variant.buildType } ?: BuildType.Debug
val configFile = rootProject.file(
"app/src/main/assets/tangem-app-config/config_${buildType.environment}.json",
)
tasks.register<GenerateEnvironmentConfigTask>(
"generateEnvironmentConfig${variant.name.replaceFirstChar { it.uppercaseChar() }}",
) {
this.configFile.set(configFile)
outputDir.set(layout.buildDirectory.dir("generated/source/environment-config"))
}
}
}
tasks.named("preBuild") {
dependsOn(tasks.matching { it.name.startsWith("generateEnvironmentConfig") })
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}

View file

@ -1,11 +1,10 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.utils.ProviderSuspend
import kotlinx.coroutines.flow.first
internal class BlockAid(
private val configStorage: EnvironmentConfigStorage,
private val environmentConfig: EnvironmentConfig,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
@ -21,9 +20,7 @@ internal class BlockAid(
put(
key = "X-API-KEY",
value = ProviderSuspend {
requireNotNull(
configStorage.getConfig().first { !it.blockAidApiKey.isNullOrEmpty() }.blockAidApiKey,
)
requireNotNull(environmentConfig.blockAidApiKey)
},
)
put("accept", ProviderSuspend { "application/json" })

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.utils.RequestHeader
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.utils.ProviderSuspend
@ -11,13 +11,13 @@ import com.tangem.utils.version.AppVersionProvider
/**
* Express [ApiConfig]
*
* @property environmentConfigStorage environment config storage
* @property environmentConfig environment config
* @property expressAuthProvider express auth provider
* @property appVersionProvider app version provider
* @property appInfoProvider app info provider
*/
internal class Express(
private val environmentConfigStorage: EnvironmentConfigStorage,
private val environmentConfig: EnvironmentConfig,
private val expressAuthProvider: ExpressAuthProvider,
private val appVersionProvider: AppVersionProvider,
private val appInfoProvider: AppInfoProvider,
@ -100,9 +100,9 @@ internal class Express(
private fun getApiKey(isProd: Boolean): String {
return if (isProd) {
environmentConfigStorage.getConfigSync().express
environmentConfig.express
} else {
environmentConfigStorage.getConfigSync().devExpress
environmentConfig.devExpress
}
?.apiKey
?: error("No express config provided")

View file

@ -1,13 +1,13 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal sealed class TangemPay(
private val environmentConfig: EnvironmentConfig,
private val appVersionProvider: AppVersionProvider,
private val environmentConfigStorage: EnvironmentConfigStorage,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
@ -61,8 +61,8 @@ internal sealed class TangemPay(
return when (apiEnvironment) {
ApiEnvironment.MOCK,
ApiEnvironment.DEV,
-> environmentConfigStorage.getConfigSync().bffStaticTokenDev
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().bffStaticToken
-> environmentConfig.bffStaticTokenDev
ApiEnvironment.PROD -> environmentConfig.bffStaticToken
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
ApiEnvironment.DEV_2,
@ -72,9 +72,9 @@ internal sealed class TangemPay(
}
class Bff(
environmentConfig: EnvironmentConfig,
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
) : TangemPay(appVersionProvider, environmentConfigStorage) {
) : TangemPay(environmentConfig, appVersionProvider) {
override fun getBaseUrl(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.DEV -> "https://api.dev.us.paera.com/bff-v2/"
@ -90,9 +90,9 @@ internal sealed class TangemPay(
}
class Auth(
environmentConfig: EnvironmentConfig,
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
) : TangemPay(appVersionProvider, environmentConfigStorage) {
) : TangemPay(environmentConfig, appVersionProvider) {
override fun getBaseUrl(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.DEV -> "https://api.dev.us.paera.com/"

View file

@ -2,7 +2,7 @@ package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.info.AppInfoProvider
@ -10,7 +10,7 @@ import com.tangem.utils.version.AppVersionProvider
/** YieldSupply [ApiConfig] */
internal class YieldSupply(
private val environmentConfigStorage: EnvironmentConfigStorage,
private val environmentConfig: EnvironmentConfig,
private val appVersionProvider: AppVersionProvider,
private val authProvider: AuthProvider,
private val appInfoProvider: AppInfoProvider,
@ -78,8 +78,8 @@ internal class YieldSupply(
ApiEnvironment.DEV_3,
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
-> environmentConfigStorage.getConfigSync().yieldModuleApiKeyDev
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().yieldModuleApiKey
-> environmentConfig.yieldModuleApiKeyDev
ApiEnvironment.PROD -> environmentConfig.yieldModuleApiKey
} ?: error("No tangem tech api config provided")
}
}

View file

@ -12,7 +12,7 @@ data class OrderResponse(
@Json(name = "id") val id: String,
@Json(name = "customer_id") val customerId: String?,
@Json(name = "type") val type: String?,
@Json(name = "status") val status: String,
@Json(name = "status") val status: Status,
@Json(name = "step") val step: String?,
@Json(name = "data") val data: Data,
@Json(name = "step_change_code") val stepChangeCode: Int?,
@ -29,5 +29,20 @@ data class OrderResponse(
@Json(name = "payment_account_id") val paymentAccountId: String?,
@Json(name = "transaction_hash") val transactionHash: String?,
)
@JsonClass(generateAdapter = false)
enum class Status {
@Json(name = "NEW")
NEW,
@Json(name = "PROCESSING")
PROCESSING,
@Json(name = "COMPLETED")
COMPLETED,
@Json(name = "CANCELED")
CANCELED,
}
}
}

View file

@ -5,10 +5,10 @@ import com.tangem.crypto.CryptoUtils
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
internal class Sha256SignatureVerifier(
private val environmentConfigStorage: EnvironmentConfigStorage,
private val environmentConfig: EnvironmentConfig,
private val apiConfigsManager: ApiConfigsManager,
) : DataSignatureVerifier {
@ -24,8 +24,8 @@ internal class Sha256SignatureVerifier(
private fun getPubKey(): String? {
val expressConfig = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.Express)
return when (expressConfig.environment) {
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().express?.signVerifierPublicKey
else -> environmentConfigStorage.getConfigSync().devExpress?.signVerifierPublicKey
ApiEnvironment.PROD -> environmentConfig.express?.signVerifierPublicKey
else -> environmentConfig.devExpress?.signVerifierPublicKey
}
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.datasource.di
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.*
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
@ -21,13 +21,13 @@ internal object ApiConfigsModule {
@Provides
@IntoSet
fun provideExpressConfig(
environmentConfigStorage: EnvironmentConfigStorage,
environmentConfig: EnvironmentConfig,
expressAuthProvider: ExpressAuthProvider,
appVersionProvider: AppVersionProvider,
appInfoProvider: AppInfoProvider,
): ApiConfig {
return Express(
environmentConfigStorage = environmentConfigStorage,
environmentConfig = environmentConfig,
expressAuthProvider = expressAuthProvider,
appVersionProvider = appVersionProvider,
appInfoProvider = appInfoProvider,
@ -73,12 +73,12 @@ internal object ApiConfigsModule {
@Provides
@IntoSet
fun provideYieldSupplyConfig(
environmentConfigStorage: EnvironmentConfigStorage,
environmentConfig: EnvironmentConfig,
appVersionProvider: AppVersionProvider,
authProvider: AuthProvider,
appInfoProvider: AppInfoProvider,
): ApiConfig = YieldSupply(
environmentConfigStorage = environmentConfigStorage,
environmentConfig = environmentConfig,
appVersionProvider = appVersionProvider,
authProvider = authProvider,
appInfoProvider = appInfoProvider,
@ -87,21 +87,21 @@ internal object ApiConfigsModule {
@Provides
@IntoSet
fun provideTangemPayBffConfig(
environmentConfig: EnvironmentConfig,
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
): ApiConfig = TangemPay.Bff(appVersionProvider, environmentConfigStorage)
): ApiConfig = TangemPay.Bff(environmentConfig, appVersionProvider)
@Provides
@IntoSet
fun provideTangemPayAuthConfig(
environmentConfig: EnvironmentConfig,
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
): ApiConfig = TangemPay.Auth(appVersionProvider, environmentConfigStorage)
): ApiConfig = TangemPay.Auth(environmentConfig, appVersionProvider)
@Provides
@IntoSet
fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig {
return BlockAid(environmentConfigStorage)
fun provideBlockAidConfig(environmentConfig: EnvironmentConfig): ApiConfig {
return BlockAid(environmentConfig)
}
@Provides

View file

@ -9,6 +9,7 @@ import com.tangem.common.json.MoshiJsonConverter
import com.tangem.datasource.api.common.adapter.*
import com.tangem.datasource.local.config.providers.models.ProviderModel
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.datasource.utils.SerializeNullsFactory
import com.tangem.domain.models.scan.serialization.*
import dagger.Module
@ -45,6 +46,15 @@ class MoshiModule {
.withSubtype(NetworkStatusDM.Verified::class.java, "amounts")
.withSubtype(NetworkStatusDM.NoAccount::class.java, "amount_to_create_account"),
)
.add(
NamePolymorphicAdapterFactory.of(PaymentAccountStatusDM::class.java)
.withSubtype(PaymentAccountStatusDM.NotCreated::class.java, "not_created")
.withSubtype(PaymentAccountStatusDM.UnderReview::class.java, "kyc_status")
.withSubtype(PaymentAccountStatusDM.IssuingCard::class.java, "issuing_card")
.withSubtype(PaymentAccountStatusDM.Locked::class.java, "locked")
.withSubtype(PaymentAccountStatusDM.Loaded::class.java, "balance")
.withSubtype(PaymentAccountStatusDM.CardIssueFailed::class.java, "card_issue_failed"),
)
.add(
PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc")
.withSubtype(NFTCollection.Identifier.EVM::class.java, "evm")

View file

@ -3,7 +3,7 @@ package com.tangem.datasource.di
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.crypto.Sha256SignatureVerifier
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -17,9 +17,9 @@ internal object SecurityModule {
@Provides
@Singleton
fun provideDataSignatureVerifier(
environmentConfigStorage: EnvironmentConfigStorage,
environmentConfig: EnvironmentConfig,
apiConfigsManager: ApiConfigsManager,
): DataSignatureVerifier {
return Sha256SignatureVerifier(environmentConfigStorage, apiConfigsManager)
return Sha256SignatureVerifier(environmentConfig, apiConfigsManager)
}
}

View file

@ -1,9 +1,8 @@
package com.tangem.datasource.di.local.config
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.config.environment.DefaultEnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.converter.GeneratedEnvironmentConfigConverter
import com.tangem.datasource.local.config.issuers.DefaultIssuersConfigStorage
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.config.providers.BlockchainProvidersStorage
@ -23,11 +22,8 @@ internal object ConfigModule {
@Provides
@Singleton
fun provideEnvironmentConfigStorage(assetLoader: AssetLoader): EnvironmentConfigStorage {
return DefaultEnvironmentConfigStorage(
assetLoader = assetLoader,
environmentConfigStore = RuntimeStateStore(defaultValue = EnvironmentConfig()),
)
fun provideEnvironmentConfig(): EnvironmentConfig {
return GeneratedEnvironmentConfigConverter.convert()
}
@Provides

View file

@ -1,41 +0,0 @@
package com.tangem.datasource.local.config.environment
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.config.environment.converter.EnvironmentConfigConverter
import com.tangem.datasource.local.config.environment.models.EnvironmentConfigModel
import com.tangem.datasource.local.datastore.RuntimeStateStore
import kotlinx.coroutines.flow.Flow
import timber.log.Timber
/**
* Default implementation for storing [EnvironmentConfig]
*
* @property assetLoader asset loader
* @property environmentConfigStore config store
*/
internal class DefaultEnvironmentConfigStorage(
private val assetLoader: AssetLoader,
private val environmentConfigStore: RuntimeStateStore<EnvironmentConfig>,
) : EnvironmentConfigStorage {
override suspend fun initialize(): EnvironmentConfig {
val environmentConfigModel = assetLoader.load<EnvironmentConfigModel>(fileName = CONFIG_FILE_NAME)
?: return environmentConfigStore.get().value
val config = EnvironmentConfigConverter.convert(value = environmentConfigModel)
environmentConfigStore.store(value = config)
Timber.i("Config [$CONFIG_FILE_NAME] loaded successfully")
return config
}
override fun getConfig(): Flow<EnvironmentConfig> = environmentConfigStore.get()
override fun getConfigSync(): EnvironmentConfig = environmentConfigStore.get().value
private companion object {
const val CONFIG_FILE_NAME = "tangem-app-config/config_${BuildConfig.ENVIRONMENT}"
}
}

View file

@ -1,20 +0,0 @@
package com.tangem.datasource.local.config.environment
import kotlinx.coroutines.flow.Flow
/**
* Storage for [EnvironmentConfig]
*
[REDACTED_AUTHOR]
*/
interface EnvironmentConfigStorage {
/** Initialize and return [EnvironmentConfig] */
suspend fun initialize(): EnvironmentConfig
/** Get [EnvironmentConfig] as [Flow] */
fun getConfig(): Flow<EnvironmentConfig>
/** Get [EnvironmentConfig] synchronously */
fun getConfigSync(): EnvironmentConfig
}

View file

@ -0,0 +1,181 @@
package com.tangem.datasource.local.config.environment.converter
import com.tangem.blockchain.common.*
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig
import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.AppsFlyer
import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.DevExpress
import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.Express
import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.GetBlockAccessTokens
import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.P2pApiKey
import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.TonCenterApiKey
import com.tangem.datasource.local.config.environment.models.ExpressModel
import com.tangem.datasource.local.config.environment.models.P2PKeys
/**
* Converts [GeneratedEnvironmentConfig] to [EnvironmentConfig]
*
* This converter maps the auto-generated config (from JSON) to the domain model.
* The generated config has nested objects that mirror the JSON structure.
*/
internal object GeneratedEnvironmentConfigConverter {
fun convert(): EnvironmentConfig {
return EnvironmentConfig(
moonPayApiKey = GeneratedEnvironmentConfig.moonPayApiKey,
moonPayApiSecretKey = GeneratedEnvironmentConfig.moonPayApiSecretKey,
mercuryoWidgetId = GeneratedEnvironmentConfig.mercuryoWidgetId,
mercuryoSecret = GeneratedEnvironmentConfig.mercuryoSecret,
blockchainSdkConfig = createBlockchainSdkConfig(),
amplitudeApiKey = GeneratedEnvironmentConfig.amplitudeApiKey,
appsFlyerApiKey = AppsFlyer.appsFlyerDevKey,
appsAppId = AppsFlyer.appsFlyerAppID,
walletConnectProjectId = GeneratedEnvironmentConfig.walletConnectProjectId,
express = createExpressModel(
apiKey = Express.apiKey,
signVerifierPublicKey = Express.signVerifierPublicKey,
),
devExpress = createExpressModel(
apiKey = DevExpress.apiKey,
signVerifierPublicKey = DevExpress.signVerifierPublicKey,
),
stakeKitApiKey = GeneratedEnvironmentConfig.stakeKitApiKey,
p2pApiKey = createP2PKeys(),
blockAidApiKey = GeneratedEnvironmentConfig.blockaidApiKey,
tangemApiKey = GeneratedEnvironmentConfig.tangemApiKey,
tangemApiKeyDev = GeneratedEnvironmentConfig.tangemApiKeyDev,
tangemApiKeyStage = GeneratedEnvironmentConfig.tangemApiKeyStage,
yieldModuleApiKey = GeneratedEnvironmentConfig.yieldModuleApiKey,
yieldModuleApiKeyDev = GeneratedEnvironmentConfig.yieldModuleApiKeyDev,
bffStaticToken = GeneratedEnvironmentConfig.bffStaticToken,
bffStaticTokenDev = GeneratedEnvironmentConfig.bffStaticTokenDev,
gaslessTxApiKeyDev = GeneratedEnvironmentConfig.gaslessTxApiKeyDev,
gaslessTxApiKey = GeneratedEnvironmentConfig.gaslessTxApiKey,
)
}
private fun createExpressModel(apiKey: String?, signVerifierPublicKey: String?): ExpressModel? {
return if (!apiKey.isNullOrEmpty() && !signVerifierPublicKey.isNullOrEmpty()) {
ExpressModel(apiKey = apiKey, signVerifierPublicKey = signVerifierPublicKey)
} else {
null
}
}
private fun createP2PKeys(): P2PKeys? {
val mainnet = P2pApiKey.mainnet
val hoodi = P2pApiKey.hoodi
return if (mainnet.isNotEmpty() && hoodi.isNotEmpty()) {
P2PKeys(mainnet = mainnet, hoodi = hoodi)
} else {
null
}
}
private fun createBlockchainSdkConfig(): BlockchainSdkConfig {
return BlockchainSdkConfig(
blockchairCredentials = BlockchairCredentials(
apiKey = GeneratedEnvironmentConfig.blockchairApiKeys,
authToken = GeneratedEnvironmentConfig.blockchairAuthorizationToken,
),
blockcypherTokens = GeneratedEnvironmentConfig.blockcypherTokens.toSet(),
quickNodeSolanaCredentials = QuickNodeCredentials(
apiKey = GeneratedEnvironmentConfig.quiknodeApiKey,
subdomain = GeneratedEnvironmentConfig.quiknodeSubdomain,
),
quickNodeBscCredentials = QuickNodeCredentials(
apiKey = GeneratedEnvironmentConfig.bscQuiknodeApiKey,
subdomain = GeneratedEnvironmentConfig.bscQuiknodeSubdomain,
),
quickNodePlasmaCredentials = QuickNodeCredentials(
apiKey = GeneratedEnvironmentConfig.quiknodePlasmaApiKey,
subdomain = GeneratedEnvironmentConfig.quiknodePlasmaSubdomain,
),
quickNodeMonadCredentials = QuickNodeCredentials(
apiKey = GeneratedEnvironmentConfig.quiknodeMonadApiKey,
subdomain = GeneratedEnvironmentConfig.quiknodeMonadSubdomain,
),
infuraProjectId = GeneratedEnvironmentConfig.infuraProjectId,
tronGridApiKey = GeneratedEnvironmentConfig.tronGridApiKey,
nowNodeCredentials = NowNodeCredentials(apiKey = GeneratedEnvironmentConfig.nowNodesApiKey),
getBlockCredentials = createGetBlockCredentials(),
kaspaSecondaryApiUrl = GeneratedEnvironmentConfig.kaspaSecondaryApiUrl,
tonCenterCredentials = TonCenterCredentials(
mainnetApiKey = TonCenterApiKey.mainnet,
testnetApiKey = TonCenterApiKey.testnet,
),
chiaFireAcademyApiKey = GeneratedEnvironmentConfig.chiaFireAcademyApiKey,
chiaTangemApiKey = GeneratedEnvironmentConfig.chiaTangemApiKey,
hederaArkhiaApiKey = GeneratedEnvironmentConfig.hederaArkhiaKey,
polygonScanApiKey = GeneratedEnvironmentConfig.polygonScanApiKey,
bittensorDwellirApiKey = GeneratedEnvironmentConfig.bittensorDwellirKey,
bittensorOnfinalityApiKey = GeneratedEnvironmentConfig.bittensorOnfinalityKey,
dwellirApiKey = GeneratedEnvironmentConfig.dwellirApiKey,
koinosProApiKey = GeneratedEnvironmentConfig.koinosProApiKey,
alephiumApiKey = GeneratedEnvironmentConfig.alephiumTangemApiKey,
moralisApiKey = GeneratedEnvironmentConfig.moralisApiKey,
etherscanApiKey = GeneratedEnvironmentConfig.etherscanApiKey,
blinkApiKey = GeneratedEnvironmentConfig.blinkApiKey,
tatumApiKey = GeneratedEnvironmentConfig.tatumApiKey,
)
}
private fun createGetBlockCredentials(): GetBlockCredentials {
return GetBlockCredentials(
xrp = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Xrp.jsonRpc),
cardano = GetBlockAccessToken(rosetta = GetBlockAccessTokens.Cardano.rosetta),
avalanche = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Avalanche.jsonRpc),
eth = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Ethereum.jsonRpc),
etc = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.EthereumClassic.jsonRpc),
fantom = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Fantom.jsonRpc),
rsk = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Rsk.jsonRpc),
bsc = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Bsc.jsonRpc),
polygon = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Polygon.jsonRpc),
gnosis = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Xdai.jsonRpc),
cronos = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Cronos.jsonRpc),
solana = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Solana.jsonRpc),
ton = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Ton.jsonRpc),
tron = GetBlockAccessToken(rest = GetBlockAccessTokens.Tron.rest),
cosmos = GetBlockAccessToken(rest = GetBlockAccessTokens.CosmosHub.rest),
near = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Near.jsonRpc),
aptos = GetBlockAccessToken(rest = GetBlockAccessTokens.Aptos.rest),
dogecoin = GetBlockAccessToken(
jsonRpc = GetBlockAccessTokens.Dogecoin.jsonRpc,
blockBookRest = GetBlockAccessTokens.Dogecoin.blockBookRest,
),
litecoin = GetBlockAccessToken(
jsonRpc = GetBlockAccessTokens.Litecoin.jsonRpc,
blockBookRest = GetBlockAccessTokens.Litecoin.blockBookRest,
),
dash = GetBlockAccessToken(
jsonRpc = GetBlockAccessTokens.Dash.jsonRpc,
blockBookRest = GetBlockAccessTokens.Dash.blockBookRest,
),
bitcoin = GetBlockAccessToken(
jsonRpc = GetBlockAccessTokens.Bitcoin.jsonRpc,
blockBookRest = GetBlockAccessTokens.Bitcoin.blockBookRest,
),
algorand = GetBlockAccessToken(rest = GetBlockAccessTokens.Algorand.rest),
zkSyncEra = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Zksync.jsonRpc),
polygonZkEvm = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.PolygonZkevm.jsonRpc),
base = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Base.jsonRpc),
blast = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Blast.jsonRpc),
filecoin = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Filecoin.jsonRpc),
arbitrum = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.ArbitrumOne.jsonRpc),
bitcoinCash = GetBlockAccessToken(
jsonRpc = GetBlockAccessTokens.BitcoinCash.jsonRpc,
blockBookRest = GetBlockAccessTokens.BitcoinCash.blockBookRest,
),
kusama = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Kusama.jsonRpc),
moonbeam = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Moonbeam.jsonRpc),
optimism = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Optimism.jsonRpc),
polkadot = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Polkadot.jsonRpc),
shibarium = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Shibarium.jsonRpc),
sui = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Sui.jsonRpc),
telos = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Telos.jsonRpc),
tezos = GetBlockAccessToken(rest = GetBlockAccessTokens.Tezos.rest),
monad = GetBlockAccessToken(rest = GetBlockAccessTokens.Monad.rest),
stellar = GetBlockAccessToken(rest = GetBlockAccessTokens.Stellar.rest),
)
}
}

View file

@ -0,0 +1,53 @@
@file:Suppress("BooleanPropertyNaming")
package com.tangem.datasource.local.visa.entity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.domain.models.kyc.KycStatus
import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType
import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel
import java.math.BigDecimal
/**
* Payment account status for storage in the local cache.
*
* @see [com.tangem.domain.pay.PaymentAccountStatus]
*/
@JsonClass(generateAdapter = true, generator = PolymorphicAdapterType.NAME_POLYMORPHIC_ADAPTER)
sealed interface PaymentAccountStatusDM {
@NameLabel("not_created")
data class NotCreated(
@Json(name = "not_created") val marker: Boolean = true,
) : PaymentAccountStatusDM
@NameLabel("kyc_status")
data class UnderReview(
@Json(name = "kyc_status") val kycStatus: KycStatus,
) : PaymentAccountStatusDM
@NameLabel("issuing_card")
data class IssuingCard(
@Json(name = "issuing_card") val marker: Boolean = true,
) : PaymentAccountStatusDM
@NameLabel("locked")
data class Locked(
@Json(name = "locked") val marker: Boolean = true,
) : PaymentAccountStatusDM
@NameLabel("balance")
data class Loaded(
@Json(name = "card_id") val cardId: String,
@Json(name = "last_four_digits") val lastFourDigits: String,
@Json(name = "balance") val balance: BigDecimal,
@Json(name = "currency_code") val currencyCode: String,
@Json(name = "deposit_address") val depositAddress: String?,
@Json(name = "is_pin_set") val isPinSet: Boolean,
) : PaymentAccountStatusDM
@NameLabel("card_issue_failed")
data class CardIssueFailed(
@Json(name = "card_issue_failed") val marker: Boolean = true,
) : PaymentAccountStatusDM
}

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.common.config
import com.google.common.truth.Truth
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.utils.ProviderSuspend
import io.mockk.clearMocks
import io.mockk.every
@ -19,6 +20,7 @@ class ApiConfigTest {
private val appAuthProvider = mockk<AuthProvider>()
private val apiKeyProvider = mockk<ProviderSuspend<String>>()
private val environmentConfig = mockk<EnvironmentConfig>()
@BeforeEach
fun setup() {
@ -47,7 +49,7 @@ class ApiConfigTest {
when (it) {
ApiConfig.ID.Express -> {
Express(
environmentConfigStorage = mockk(),
environmentConfig = environmentConfig,
expressAuthProvider = mockk(),
appVersionProvider = mockk(),
appInfoProvider = mockk(),
@ -55,7 +57,7 @@ class ApiConfigTest {
}
ApiConfig.ID.YieldSupply -> {
YieldSupply(
environmentConfigStorage = mockk(),
environmentConfig = environmentConfig,
appVersionProvider = mockk(),
authProvider = appAuthProvider,
appInfoProvider = mockk(),
@ -70,14 +72,14 @@ class ApiConfigTest {
}
ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk())
ApiConfig.ID.TangemPay -> TangemPay.Bff(
environmentConfig = environmentConfig,
appVersionProvider = mockk(),
environmentConfigStorage = mockk()
)
ApiConfig.ID.TangemPayAuth -> TangemPay.Auth(
environmentConfig = environmentConfig,
appVersionProvider = mockk(),
environmentConfigStorage = mockk()
)
ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk())
ApiConfig.ID.BlockAid -> BlockAid(environmentConfig = environmentConfig)
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk())
ApiConfig.ID.News -> News(

View file

@ -1,45 +0,0 @@
package com.tangem.datasource.api.common.config.managers
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.models.ExpressModel
import kotlinx.coroutines.flow.flowOf
/**
* Mock [EnvironmentConfigStorage] implementation for [ProdApiConfigsManagerTest]
*
[REDACTED_AUTHOR]
*/
internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage {
private val environmentConfig = EnvironmentConfig(
express = ExpressModel(apiKey = EXPRESS_API_KEY, signVerifierPublicKey = "vocibus"),
devExpress = ExpressModel(apiKey = EXPRESS_DEV_API_KEY, signVerifierPublicKey = "pellentesque"),
blockAidApiKey = BLOCK_AID_API_KEY,
tangemApiKey = TANGEM_API_KEY,
tangemApiKeyDev = TANGEM_API_KEY_DEV,
bffStaticToken = TANGEM_PAY_BFF_KEY,
bffStaticTokenDev = TANGEM_PAY_BFF_KEY_DEV,
tangemApiKeyStage = TANGEM_API_KEY_STAGE,
yieldModuleApiKey = YIELD_MODULE_KEY,
yieldModuleApiKeyDev = YIELD_MODULE_KEY_DEV,
)
override suspend fun initialize() = environmentConfig
override fun getConfig() = flowOf(environmentConfig)
override fun getConfigSync() = environmentConfig
companion object {
const val EXPRESS_API_KEY = "express_api_key"
const val EXPRESS_DEV_API_KEY = "express_dev_api_key"
const val BLOCK_AID_API_KEY = "block_aid_api_key"
const val TANGEM_API_KEY = "tangem_api_key"
const val TANGEM_API_KEY_DEV = "tangem_api_key_dev"
const val TANGEM_PAY_BFF_KEY = "tangem_pay_bff_key"
const val TANGEM_PAY_BFF_KEY_DEV = "tangem_pay_bff_key_dev"
const val TANGEM_GASLESS_API_KEY = "tangem_gasless_api_key"
const val TANGEM_API_KEY_STAGE = "tangem_api_key_stage"
const val YIELD_MODULE_KEY = "yield_module_api_key"
const val YIELD_MODULE_KEY_DEV = "yield_module_api_key_dev"
}
}

View file

@ -10,10 +10,8 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.EXTERNAL_BUIL
import com.tangem.datasource.api.common.config.ApiConfig.Companion.INTERNAL_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_GASLESS_API_KEY
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_PAY_BFF_KEY_DEV
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.environment.models.ExpressModel
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
@ -39,7 +37,7 @@ import java.util.TimeZone
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ProdApiConfigsManagerTest {
private val environmentConfigStorage = MockEnvironmentConfigStorage()
private val environmentConfig = createMockEnvironmentConfig()
private val appVersionProvider = mockk<AppVersionProvider>()
private val expressAuthProvider = mockk<ExpressAuthProvider>()
private val stakeKitAuthProvider = mockk<StakeKitAuthProvider>()
@ -94,7 +92,7 @@ internal class ProdApiConfigsManagerTest {
when (it) {
ApiConfig.ID.Express -> {
Express(
environmentConfigStorage = environmentConfigStorage,
environmentConfig = environmentConfig,
expressAuthProvider = expressAuthProvider,
appVersionProvider = appVersionProvider,
appInfoProvider = appInfoProvider,
@ -102,7 +100,7 @@ internal class ProdApiConfigsManagerTest {
}
ApiConfig.ID.YieldSupply -> {
YieldSupply(
environmentConfigStorage = environmentConfigStorage,
environmentConfig = environmentConfig,
appVersionProvider = appVersionProvider,
authProvider = appAuthProvider,
appInfoProvider = appInfoProvider,
@ -117,14 +115,14 @@ internal class ProdApiConfigsManagerTest {
}
ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = stakeKitAuthProvider)
ApiConfig.ID.TangemPay -> TangemPay.Bff(
environmentConfig = environmentConfig,
appVersionProvider = appVersionProvider,
environmentConfigStorage = environmentConfigStorage,
)
ApiConfig.ID.TangemPayAuth -> TangemPay.Auth(
environmentConfig = environmentConfig,
appVersionProvider = appVersionProvider,
environmentConfigStorage = environmentConfigStorage,
)
ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage)
ApiConfig.ID.BlockAid -> BlockAid(environmentConfig = environmentConfig)
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider)
ApiConfig.ID.News -> News(
@ -188,9 +186,9 @@ internal class ProdApiConfigsManagerTest {
headers = mapOf(
"api-key" to ProviderSuspend {
if (environment == ApiEnvironment.PROD) {
MockEnvironmentConfigStorage.EXPRESS_API_KEY
EXPRESS_API_KEY
} else {
MockEnvironmentConfigStorage.EXPRESS_DEV_API_KEY
EXPRESS_DEV_API_KEY
}
},
"session-id" to ProviderSuspend { EXPRESS_SESSION_ID },
@ -237,7 +235,7 @@ internal class ProdApiConfigsManagerTest {
environment = ApiEnvironment.PROD,
baseUrl = "https://yield.tangem.org/",
headers = mapOf(
"api-key" to ProviderSuspend { MockEnvironmentConfigStorage.YIELD_MODULE_KEY },
"api-key" to ProviderSuspend { YIELD_MODULE_KEY },
"card_id" to ProviderSuspend { APP_CARD_ID },
"card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY },
"version" to ProviderSuspend { VERSION_NAME },
@ -426,5 +424,48 @@ internal class ProdApiConfigsManagerTest {
const val P2P_API_KEY = "p2p_api_key"
const val APP_CARD_ID = "app_card_id"
const val APP_CARD_PUBLIC_KEY = "Bearer app_public_key"
// Mock config values
const val TANGEM_API_KEY = "tangem_api_key"
const val TANGEM_GASLESS_API_KEY = "tangem_gasless_api_key"
const val TANGEM_PAY_BFF_KEY_DEV = "tangem_pay_bff_key_dev"
const val BLOCK_AID_API_KEY = "block_aid_api_key"
const val EXPRESS_API_KEY = "express_api_key"
const val EXPRESS_DEV_API_KEY = "express_dev_api_key"
const val YIELD_MODULE_KEY = "yield_module_key"
fun createMockEnvironmentConfig(): EnvironmentConfig {
return EnvironmentConfig(
moonPayApiKey = "moon_pay_api_key",
moonPayApiSecretKey = "moon_pay_secret_key",
mercuryoWidgetId = "mercuryo_widget_id",
mercuryoSecret = "mercuryo_secret",
blockchainSdkConfig = mockk(relaxed = true),
amplitudeApiKey = "amplitude_api_key",
appsFlyerApiKey = "appsflyer_api_key",
appsAppId = "apps_app_id",
walletConnectProjectId = "wallet_connect_project_id",
express = ExpressModel(
apiKey = EXPRESS_API_KEY,
signVerifierPublicKey = "express_public_key",
),
devExpress = ExpressModel(
apiKey = EXPRESS_DEV_API_KEY,
signVerifierPublicKey = "express_dev_public_key",
),
stakeKitApiKey = STAKE_KIT_API_KEY,
p2pApiKey = null,
blockAidApiKey = BLOCK_AID_API_KEY,
tangemApiKey = TANGEM_API_KEY,
tangemApiKeyDev = TANGEM_API_KEY,
tangemApiKeyStage = TANGEM_API_KEY,
yieldModuleApiKey = YIELD_MODULE_KEY,
yieldModuleApiKeyDev = YIELD_MODULE_KEY,
bffStaticToken = TANGEM_PAY_BFF_KEY_DEV,
bffStaticTokenDev = TANGEM_PAY_BFF_KEY_DEV,
gaslessTxApiKeyDev = TANGEM_GASLESS_API_KEY,
gaslessTxApiKey = TANGEM_GASLESS_API_KEY,
)
}
}
}

View file

@ -61,12 +61,12 @@ dependencies {
api(deps.jodatime)
implementation(deps.timber)
implementation(deps.markdown)
implementation(deps.haze) {
api(deps.haze) {
exclude(module = "activity-compose")
exclude(module = "activity")
exclude(module = "activity-ktx")
}
implementation(deps.haze.materials) {
api(deps.haze.materials) {
exclude(module = "activity-compose")
exclude(module = "activity")
exclude(module = "activity-ktx")

View file

@ -0,0 +1,70 @@
@file:Suppress("MagicNumber", "UnnecessaryParentheses")
package com.tangem.core.ui.components.background
import androidx.compose.animation.core.withInfiniteAnimationFrameMillis
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import com.tangem.core.ui.shader.TangemShader
import com.tangem.core.ui.shader.runtime.buildEffect
import kotlin.math.round
@Composable
fun Modifier.shaderBackground(
shader: TangemShader,
speed: Float = 1f,
fallback: () -> Brush = {
Brush.horizontalGradient(listOf(Color.Transparent, Color.Transparent))
},
): Modifier {
val runtimeEffect = remember(shader) { buildEffect(shader) }
var size: Size by remember { mutableStateOf(Size(-1f, -1f)) }
val speedModifier = shader.speedModifier
val time by if (runtimeEffect.isSupported) {
var startMillis = remember(shader) { -1L }
produceState(0f, speedModifier) {
while (true) {
withInfiniteAnimationFrameMillis { frameTimeMillis ->
if (startMillis < 0) startMillis = frameTimeMillis
value = ((frameTimeMillis - startMillis) / 16.6f) / 10f
}
}
}
} else {
remember { mutableFloatStateOf(-1f) }
}
return this then Modifier.onGloballyPositioned {
size = Size(it.size.width.toFloat(), it.size.height.toFloat())
}.drawBehind {
runtimeEffect.update(
shader = shader,
time = (time * speed * speedModifier).round(3),
width = size.width,
height = size.height,
) // set uniforms for the shaders
if (runtimeEffect.isReady) {
drawRect(brush = runtimeEffect.build())
} else {
drawRect(brush = fallback())
}
}
}
private fun Float.round(decimals: Int): Float {
var multiplier = 1.0f
repeat(decimals) { multiplier *= 10 }
return round(this * multiplier) / multiplier
}

View file

@ -0,0 +1,169 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.components.background.northernlights
import androidx.compose.runtime.Composable
import android.graphics.BlurMaskFilter
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
@Suppress("LongMethod")
@Composable
internal fun MovingColorfulBlubsBackground(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "FluidMeshGradient")
// ── Circle 1 (left) ──────────────────────────────────────────────────────
val color1 by transition.animateColor(
initialValue = Color(0xFF3355EE),
targetValue = Color(0xFF5577FF),
animationSpec = infiniteRepeatable(
animation = tween(4_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "color1",
)
val x1 by transition.animateFloat(
initialValue = 0.05f,
targetValue = 0.28f,
animationSpec = infiniteRepeatable(
animation = tween(5_500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "x1",
)
val y1 by transition.animateFloat(
initialValue = 0.0f,
targetValue = 0.18f,
animationSpec = infiniteRepeatable(
animation = tween(6_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "y1",
)
// ── Circle 2 (right) ─────────────────────────────────────────────────────
val color2 by transition.animateColor(
initialValue = Color(0xFF7733CC),
targetValue = Color(0xFF4455EE),
animationSpec = infiniteRepeatable(
animation = tween(5_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(1_500),
),
label = "color2",
)
val x2 by transition.animateFloat(
initialValue = 0.68f,
targetValue = 0.92f,
animationSpec = infiniteRepeatable(
animation = tween(7_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "x2",
)
val y2 by transition.animateFloat(
initialValue = 0.02f,
targetValue = 0.20f,
animationSpec = infiniteRepeatable(
animation = tween(5_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(2_000),
),
label = "y2",
)
// ── Oval (center) ────────────────────────────────────────────────────────
val ovalColor by transition.animateColor(
initialValue = Color(0xFF5533CC),
targetValue = Color(0xFF8844EE),
animationSpec = infiniteRepeatable(
animation = tween(7_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(2_500),
),
label = "ovalColor",
)
// ── Circle 3 (center) ────────────────────────────────────────────────────
val color3 by transition.animateColor(
initialValue = Color(0xFF9933BB),
targetValue = Color(0xFFBB44DD),
animationSpec = infiniteRepeatable(
animation = tween(6_000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(3_000),
),
label = "color3",
)
val x3 by transition.animateFloat(
initialValue = 0.35f,
targetValue = 0.58f,
animationSpec = infiniteRepeatable(
animation = tween(6_500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(1_000),
),
label = "x3",
)
val y3 by transition.animateFloat(
initialValue = 0.0f,
targetValue = 0.15f,
animationSpec = infiniteRepeatable(
animation = tween(4_500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(500),
),
label = "y3",
)
var blurRadiusState by remember { mutableFloatStateOf(0f) }
val circlePaint1 = remember { Paint() }
val circlePaint2 = remember { Paint() }
val circlePaint3 = remember { Paint() }
val ovalPaint = remember { Paint() }
Canvas(modifier = modifier) {
val blurRadius = (size.minDimension * 0.28f).coerceIn(60f, 300f)
val circleRadius = size.width * 0.52f
// Update maskFilter only when blur radius changes meaningfully
if (blurRadiusState != blurRadius) {
blurRadiusState = blurRadius
val mf = BlurMaskFilter(blurRadius, BlurMaskFilter.Blur.NORMAL)
circlePaint1.asFrameworkPaint().maskFilter = mf
circlePaint2.asFrameworkPaint().maskFilter = mf
circlePaint3.asFrameworkPaint().maskFilter = mf
ovalPaint.asFrameworkPaint().maskFilter = mf
}
circlePaint1.color = color1.copy(alpha = 0.85f)
circlePaint2.color = color2.copy(alpha = 0.85f)
circlePaint3.color = color3.copy(alpha = 0.85f)
ovalPaint.color = ovalColor.copy(alpha = 0.80f)
drawIntoCanvas { canvas ->
canvas.drawCircle(Offset(x1 * size.width, y1 * size.height), circleRadius, circlePaint1)
canvas.drawCircle(Offset(x2 * size.width, y2 * size.height), circleRadius, circlePaint2)
canvas.drawCircle(Offset(x3 * size.width, y3 * size.height), circleRadius, circlePaint3)
val halfW = size.width * 0.68f
val halfH = size.width * 0.24f
val ovalCx = size.width * 0.50f
val ovalCy = 0f
canvas.drawOval(
Rect(left = ovalCx - halfW, top = ovalCy - halfH, right = ovalCx + halfW, bottom = ovalCy + halfH),
ovalPaint,
)
}
}
}

View file

@ -0,0 +1,151 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.components.background.northernlights
import android.os.Build
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.background.shaderBackground
import com.tangem.core.ui.res.LocalPowerSavingState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.shader.NorthernLightsMeshGradientShader
/**
* Animated northern lights background.
* Uses a RuntimeShader on Android 13+ and falls back to a simpler implementation on older versions and in power saving mode.
*/
@Composable
fun NorthernLightsBackground(modifier: Modifier = Modifier, forceSimpleVersion: Boolean = false) {
val isPowerSavingMode by LocalPowerSavingState.current.isPowerSavingModeEnabled.collectAsState()
if (!forceSimpleVersion && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !isPowerSavingMode) {
NorthernLightsBackgroundWithShader(modifier)
} else {
MovingColorfulBlubsBackground(modifier)
}
}
@Suppress("LongMethod")
@Composable
private fun NorthernLightsBackgroundWithShader(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "FluidMeshGradientV2")
val backgroundColor = TangemTheme.colors2.surface.level1
// Each track cycles through 4 states (matching the screenshot frames):
// deep/dark → saturated+bright → light/pastel → vibrant/vivid → back
// 16 s total per track, staggered so no two tracks peak simultaneously.
// ── Color 1 indigo → bright blue → lavender → hot violet ──────────────
val color1 by transition.animateColor(
initialValue = Color(0xFF2A1480),
targetValue = Color(0xFF2A1480),
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 16_000
Color(0xFF2A1480) at 0 using FastOutSlowInEasing
Color(0xFF4477EE) at 4_000 using FastOutSlowInEasing
Color(0xFFBBAAEE) at 8_000 using FastOutSlowInEasing
Color(0xFF8833EE) at 12_000 using FastOutSlowInEasing
},
repeatMode = RepeatMode.Restart,
),
label = "color1",
)
// ── Color 2 dark blue → cyan-blue → sky → teal ─────────────────────────
val color2 by transition.animateColor(
initialValue = Color(0xFF1444AA),
targetValue = Color(0xFF1444AA),
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 16_000
Color(0xFF1444AA) at 0 using FastOutSlowInEasing
Color(0xFF22AADD) at 4_000 using FastOutSlowInEasing
Color(0xFF99BBDD) at 8_000 using FastOutSlowInEasing
Color(0xFF44DDCC) at 12_000 using FastOutSlowInEasing
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(4_000),
),
label = "color2",
)
// ── Color 3 dark purple → medium purple → rose pink → magenta ──────────
val color3 by transition.animateColor(
initialValue = Color(0xFF4422BB),
targetValue = Color(0xFF4422BB),
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 16_000
Color(0xFF4422BB) at 0 using FastOutSlowInEasing
Color(0xFF7733CC) at 4_000 using FastOutSlowInEasing
Color(0xFFDD88BB) at 8_000 using FastOutSlowInEasing
Color(0xFFEE44AA) at 12_000 using FastOutSlowInEasing
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(8_000),
),
label = "color3",
)
// ── Color 4 dark violet → medium violet → light pink → hot pink ────────
val color4 by transition.animateColor(
initialValue = Color(0xFF331199),
targetValue = Color(0xFF331199),
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 16_000
Color(0xFF331199) at 0 using FastOutSlowInEasing
Color(0xFF6644CC) at 4_000 using FastOutSlowInEasing
Color(0xFFCC77DD) at 8_000 using FastOutSlowInEasing
Color(0xFFFF66CC) at 12_000 using FastOutSlowInEasing
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(2_000),
),
label = "color4",
)
// Keep a stable shader instance so the RuntimeShader is never recreated.
// Colors are pushed each recomposition via updateColors().
val shader = remember {
NorthernLightsMeshGradientShader(
colors = arrayOf(
Color(0xFF2A1480),
Color(0xFF1444AA),
Color(0xFF4422BB),
Color(0xFF331199),
backgroundColor,
),
speed = 0.5f,
scale = 4f,
)
}
val colorsArray = remember { Array(5) { Color.Unspecified } }
colorsArray[0] = color1
colorsArray[1] = color2
colorsArray[2] = color3
colorsArray[3] = color4
colorsArray[4] = backgroundColor
shader.updateColors(colorsArray)
Box(
modifier = modifier
.background(backgroundColor)
.fillMaxSize()
.shaderBackground(shader),
)
}

View file

@ -20,6 +20,7 @@ import com.tangem.core.ui.res.TangemThemePreview
fun TangemPullToRefreshContainer(
config: PullToRefreshConfig,
modifier: Modifier = Modifier,
indicatorModifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val state = rememberPullToRefreshState()
@ -32,7 +33,7 @@ fun TangemPullToRefreshContainer(
modifier = modifier,
indicator = {
Indicator(
modifier = Modifier.align(Alignment.TopCenter),
modifier = indicatorModifier.align(Alignment.TopCenter),
isRefreshing = config.isRefreshing,
state = state,
containerColor = TangemTheme.colors.background.tertiary,

View file

@ -45,18 +45,32 @@ fun TextStyle.applyBladeBrush(isEnabled: Boolean, textColor: Color): TextStyle {
override fun createShader(size: Size): Shader {
val center = Offset(size.width / 2f, size.height / 2f)
val diagonal = sqrt(size.width * size.width + size.height * size.height)
val direction = Offset(x = 1f, y = 0.5f)
val halfDist = diagonal / 2f
val baseStart = center - direction * halfDist
val baseEnd = center + direction * halfDist
val shift = direction * offset * diagonal
// Subtle diagonal angle, similar to iOS shimmer
val direction = Offset(x = 1f, y = 0.3f)
// Half-width of the blob (80% of diagonal total — wide, soft sweep)
val bandHalf = diagonal * 0.40f
// Sweep the highlight center from left-of-element to right-of-element.
// offset 0..1 maps to a full pass including off-screen padding on both sides.
val shift = direction * ((offset - 0.5f) * diagonal * 1.5f)
val highlightCenter = center + shift
// Full color text with a wide, gradual low-alpha dip sweeping left → right
return LinearGradientShader(
colors = listOf(textColor.copy(alpha = 0.2f), textColor),
from = baseStart + shift,
to = baseEnd + shift,
colorStops = listOf(0.0f, 0.15f),
tileMode = TileMode.Mirror,
colors = listOf(
textColor,
textColor.copy(alpha = 0.75f),
textColor.copy(alpha = 0.45f),
textColor.copy(alpha = 0.3f),
textColor.copy(alpha = 0.45f),
textColor.copy(alpha = 0.75f),
textColor,
),
from = highlightCenter - direction * bandHalf,
to = highlightCenter + direction * bandHalf,
colorStops = listOf(0f, 0.15f, 0.35f, 0.5f, 0.65f, 0.85f, 1f),
tileMode = TileMode.Clamp,
)
}
}

View file

@ -0,0 +1,370 @@
package com.tangem.core.ui.ds
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
private const val ANIMATION_DURATION = 300
private const val MAX_VISIBLE_DOTS = 5
private const val MIN_HIDDEN_FOR_SMALL_DOT = 2
private const val MIN_DISTANCE_FOR_SMALL_DOT = 3
private const val MIN_DISTANCE_FOR_HINT_DOT = 2
private val SPACING = 4.dp
private val CURRENT_DOT_SIZE = DpSize(16.dp, 8.dp)
private val NORMAL_DOT_SIZE = DpSize(8.dp, 8.dp)
private val HINT_DOT_SIZE = DpSize(6.dp, 6.dp)
private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp)
/**
* // TODO Cleanup and document this code, it's quite complex and has some "magic numbers" that need explanation.
*
* A pager indicator that adapts to the number of pages and the current page index.
*
* For 5 or fewer pages, it shows all dots with the current page highlighted.
* For more than 5 pages, it shows a sliding window of 5 dots with size and opacity indicating position.
*
* @param pagerState state of the pager to observe
* @param activeIndicatorColor color for the active page indicator
* @param inactiveIndicatorColor color for the inactive page indicators
* @param modifier modifier for styling
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun TangemPagerIndicator(
pagerState: PagerState,
modifier: Modifier = Modifier,
activeIndicatorColor: Color = TangemTheme.colors2.graphic.neutral.primary,
inactiveIndicatorColor: Color = TangemTheme.colors2.graphic.neutral.tertiary,
) {
val totalPages = pagerState.pageCount
val currentIndex = pagerState.currentPage
if (totalPages == 0) return
val density = LocalDensity.current
val (targetLower, targetUpper) = getWindowBounds(totalPages, currentIndex)
var displayLower by remember { mutableIntStateOf(targetLower) }
var displayUpper by remember { mutableIntStateOf(targetUpper) }
var prevTargetLower by remember { mutableIntStateOf(targetLower) }
val slideOffset = remember { Animatable(0f) }
var isSliding by remember { mutableStateOf(false) }
var slideDirection by remember { mutableIntStateOf(0) }
val fadeProgress = remember { Animatable(0f) }
var fadeJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(targetLower) {
if (targetLower != prevTargetLower && totalPages > MAX_VISIBLE_DOTS) {
fadeJob?.cancel()
slideOffset.stop()
fadeProgress.stop()
val dir = if (targetLower > prevTargetLower) 1 else -1
val edgeDotSize = with(density) { (HINT_DOT_SIZE.width + SPACING).toPx() }
val halfEdge = edgeDotSize / 2
isSliding = true
slideDirection = dir
fadeProgress.snapTo(0f)
if (dir > 0) {
displayLower = prevTargetLower
displayUpper = targetUpper
slideOffset.snapTo(halfEdge)
} else {
displayLower = targetLower
displayUpper = prevTargetLower + MAX_VISIBLE_DOTS
slideOffset.snapTo(-halfEdge)
}
prevTargetLower = targetLower
fadeJob = launch {
fadeProgress.animateTo(1f, tween(ANIMATION_DURATION))
}
slideOffset.animateTo(
if (dir > 0) -halfEdge else halfEdge,
tween(ANIMATION_DURATION),
)
displayLower = targetLower
displayUpper = targetUpper
slideOffset.snapTo(0f)
isSliding = false
slideDirection = 0
}
}
val visibleIndices = (displayLower until displayUpper).toList()
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier.offset {
IntOffset(slideOffset.value.roundToInt(), 0)
},
horizontalArrangement = Arrangement.spacedBy(SPACING),
verticalAlignment = Alignment.CenterVertically,
) {
visibleIndices.forEach { index ->
val dotAlpha = when {
!isSliding -> 1f
slideDirection > 0 && index == displayLower -> 1f - fadeProgress.value
slideDirection > 0 && index == displayUpper - 1 -> fadeProgress.value
slideDirection < 0 && index == displayUpper - 1 -> 1f - fadeProgress.value
slideDirection < 0 && index == displayLower -> fadeProgress.value
else -> 1f
}
key(index) {
Dot(
index = index,
currentIndex = currentIndex,
totalPages = totalPages,
activeColor = activeIndicatorColor,
inactiveColor = inactiveIndicatorColor,
modifier = Modifier.graphicsLayer { alpha = dotAlpha },
)
}
}
}
}
}
private fun getWindowBounds(totalPages: Int, currentIndex: Int): Pair<Int, Int> {
if (totalPages <= MAX_VISIBLE_DOTS) {
return 0 to totalPages
}
val lowerBound = when {
currentIndex <= 1 -> 0
currentIndex >= totalPages - 2 -> totalPages - MAX_VISIBLE_DOTS
else -> currentIndex - 2
}
val upperBound = min(lowerBound + MAX_VISIBLE_DOTS, totalPages)
return lowerBound to upperBound
}
private fun getDotSize(index: Int, currentIndex: Int, totalPages: Int): DpSize {
if (index == currentIndex) {
return CURRENT_DOT_SIZE
}
if (totalPages <= MAX_VISIBLE_DOTS) {
return NORMAL_DOT_SIZE
}
val params = DotSizeParams.create(index, currentIndex, totalPages)
return params.calculateSize()
}
private class DotSizeParams private constructor(
val posInWindow: Int,
val currentPosInWindow: Int,
val hiddenLeft: Int,
val hiddenRight: Int,
val distanceFromCurrent: Int,
) {
private val lastPos = MAX_VISIBLE_DOTS - 1
private val isCentered = currentPosInWindow == 2 && hiddenLeft >= 1 && hiddenRight >= 1
fun calculateSize(): DpSize = when {
isCentered -> getCenteredSize()
hiddenRight >= 1 -> getRightEdgeSize()
hiddenLeft >= 1 -> getLeftEdgeSize()
else -> NORMAL_DOT_SIZE
}
private fun getCenteredSize(): DpSize = when (posInWindow) {
0, lastPos -> HINT_DOT_SIZE
else -> NORMAL_DOT_SIZE
}
private fun getRightEdgeSize(): DpSize {
val isLastPos = posInWindow == lastPos
val isSecondToLast = posInWindow == lastPos - 1
val hasExtraHidden = hiddenRight >= MIN_HIDDEN_FOR_SMALL_DOT
val isFarFromCurrent = distanceFromCurrent >= MIN_DISTANCE_FOR_SMALL_DOT
val isModerateDistance = distanceFromCurrent >= MIN_DISTANCE_FOR_HINT_DOT
return when {
isLastPos && hasExtraHidden && isFarFromCurrent -> SMALL_DOT_SIZE
isLastPos && isModerateDistance -> HINT_DOT_SIZE
isSecondToLast && hasExtraHidden && isModerateDistance -> HINT_DOT_SIZE
else -> NORMAL_DOT_SIZE
}
}
private fun getLeftEdgeSize(): DpSize {
val isFirstPos = posInWindow == 0
val isSecondPos = posInWindow == 1
val hasExtraHidden = hiddenLeft >= MIN_HIDDEN_FOR_SMALL_DOT
val isFarFromCurrent = distanceFromCurrent >= MIN_DISTANCE_FOR_SMALL_DOT
val isModerateDistance = distanceFromCurrent >= MIN_DISTANCE_FOR_HINT_DOT
return when {
isFirstPos && hasExtraHidden && isFarFromCurrent -> SMALL_DOT_SIZE
isFirstPos && isModerateDistance -> HINT_DOT_SIZE
isSecondPos && hasExtraHidden && isModerateDistance -> HINT_DOT_SIZE
else -> NORMAL_DOT_SIZE
}
}
companion object {
fun create(index: Int, currentIndex: Int, totalPages: Int): DotSizeParams {
val (windowStart, windowEnd) = getWindowBounds(totalPages, currentIndex)
val posInWindow = index - windowStart
val currentPosInWindow = currentIndex - windowStart
return DotSizeParams(
posInWindow = posInWindow,
currentPosInWindow = currentPosInWindow,
hiddenLeft = windowStart,
hiddenRight = totalPages - windowEnd,
distanceFromCurrent = abs(posInWindow - currentPosInWindow),
)
}
}
}
@Composable
private fun Dot(
index: Int,
currentIndex: Int,
totalPages: Int,
activeColor: Color,
inactiveColor: Color,
modifier: Modifier = Modifier,
) {
val isActive = index == currentIndex
val size = getDotSize(index, currentIndex, totalPages)
val animSpec = tween<Dp>(ANIMATION_DURATION)
val colorSpec = tween<Color>(ANIMATION_DURATION)
val animatedWidth by animateDpAsState(size.width, animSpec, label = "w$index")
val animatedHeight by animateDpAsState(size.height, animSpec, label = "h$index")
val animatedColor by animateColorAsState(
targetValue = if (isActive) activeColor else inactiveColor,
animationSpec = colorSpec,
label = "c$index",
)
val shape = RoundedCornerShape(animatedHeight / 2)
Box(
modifier = modifier
.width(animatedWidth)
.height(animatedHeight)
.background(animatedColor, shape),
)
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicatorPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4).forEach { page ->
TangemPagerIndicator(rememberPagerState(page) { 5 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicator6ItemsPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4, 5).forEach { page ->
TangemPagerIndicator(rememberPagerState(page) { 6 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicator7ItemsPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4, 5, 6).forEach { page ->
TangemPagerIndicator(rememberPagerState(page) { 7 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicator10ItemsPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).forEach { page ->
TangemPagerIndicator(rememberPagerState(page) { 10 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicatorSmallCountsPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
TangemPagerIndicator(rememberPagerState(0) { 1 })
TangemPagerIndicator(rememberPagerState(1) { 2 })
TangemPagerIndicator(rememberPagerState(1) { 3 })
}
}
}

View file

@ -72,14 +72,14 @@ fun TangemBadge(badgeUM: TangemBadgeUM, modifier: Modifier = Modifier) {
*/
@Composable
fun TangemBadge(
text: TextReference,
modifier: Modifier = Modifier,
text: TextReference? = null,
@DrawableRes iconRes: Int? = null,
size: TangemBadgeSize = X9,
shape: TangemBadgeShape = TangemBadgeShape.Default,
color: TangemBadgeColor = TangemBadgeColor.Gray,
type: TangemBadgeType = TangemBadgeType.Solid,
iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.Start,
iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.None,
onClick: (() -> Unit)? = null,
) {
val iconColor = getIconColor(type = type, color = color)
@ -94,7 +94,7 @@ fun TangemBadge(
.clickableSingle(enabled = onClick != null, onClick = { onClick?.invoke() }),
) {
AnimatedVisibility(
visible = iconRes != null && iconPosition == TangemBadgeIconPosition.Start,
visible = iconRes != null && iconPosition != TangemBadgeIconPosition.End,
modifier = Modifier.size(size = size.toContentSize()),
label = "Start Icon Visibility",
) {
@ -105,13 +105,18 @@ fun TangemBadge(
tint = iconColor,
)
}
Text(
text = text.resolveReference(),
style = size.toTextStyle(),
maxLines = 1,
color = getTextColor(type = type, color = color),
)
AnimatedVisibility(
visible = text != null,
label = "Text Visibility",
) {
val wrappedText = remember(this) { requireNotNull(text) }
Text(
text = wrappedText.resolveReference(),
style = size.toTextStyle(),
maxLines = 1,
color = getTextColor(type = type, color = color),
)
}
AnimatedVisibility(
visible = iconRes != null && iconPosition == TangemBadgeIconPosition.End,
modifier = Modifier.size(size = size.toContentSize()),
@ -178,14 +183,17 @@ enum class TangemBadgeSize {
X4 -> when (position) {
TangemBadgeIconPosition.Start -> PaddingValues(start = 4.dp, end = 6.dp)
TangemBadgeIconPosition.End -> PaddingValues(start = 6.dp, end = 4.dp)
TangemBadgeIconPosition.None -> PaddingValues(start = 6.dp, end = 6.dp)
}
X6 -> when (position) {
TangemBadgeIconPosition.Start -> PaddingValues(start = 8.dp, end = 12.dp)
TangemBadgeIconPosition.End -> PaddingValues(start = 12.dp, end = 8.dp)
TangemBadgeIconPosition.None -> PaddingValues(start = 12.dp, end = 12.dp)
}
X9 -> when (position) {
TangemBadgeIconPosition.Start -> PaddingValues(start = 12.dp, end = 16.dp)
TangemBadgeIconPosition.End -> PaddingValues(start = 16.dp, end = 12.dp)
TangemBadgeIconPosition.None -> PaddingValues(start = 16.dp, end = 16.dp)
}
}
@ -222,6 +230,7 @@ enum class TangemBadgeSize {
enum class TangemBadgeIconPosition {
Start,
End,
None,
}
/**
@ -240,6 +249,7 @@ enum class TangemBadgeColor {
Blue,
Red,
Gray,
Green,
}
@ReadOnlyComposable
@ -258,6 +268,12 @@ private fun getIconColor(type: TangemBadgeType, color: TangemBadgeColor) = when
-> TangemTheme.colors2.markers.iconRed
TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant
}
TangemBadgeColor.Green -> when (type) {
TangemBadgeType.Outline,
TangemBadgeType.Tinted,
-> TangemTheme.colors2.markers.iconGreen
TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant
}
}
@ReadOnlyComposable
@ -276,8 +292,15 @@ private fun getTextColor(type: TangemBadgeType, color: TangemBadgeColor) = when
-> TangemTheme.colors2.markers.textRed
TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant
}
TangemBadgeColor.Green -> when (type) {
TangemBadgeType.Outline,
TangemBadgeType.Tinted,
-> TangemTheme.colors2.markers.textGreen
TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant
}
}
@Suppress("CyclomaticComplexMethod")
@ReadOnlyComposable
@Composable
private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadgeColor, shape: Shape) = when (type) {
@ -286,6 +309,7 @@ private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadg
TangemBadgeColor.Gray -> TangemTheme.colors2.markers.backgroundSolidGray
TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundSolidBlue
TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundSolidRed
TangemBadgeColor.Green -> TangemTheme.colors2.markers.backgroundSolidGreen
},
)
TangemBadgeType.Tinted -> background(
@ -293,6 +317,7 @@ private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadg
TangemBadgeColor.Gray -> TangemTheme.colors2.markers.backgroundTintedGray
TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundTintedBlue
TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundTintedRed
TangemBadgeColor.Green -> TangemTheme.colors2.markers.backgroundTintedGreen
},
)
TangemBadgeType.Outline -> {
@ -301,6 +326,7 @@ private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadg
TangemBadgeColor.Gray -> TangemTheme.colors2.markers.borderGray
TangemBadgeColor.Blue -> TangemTheme.colors2.markers.borderTintedBlue
TangemBadgeColor.Red -> TangemTheme.colors2.markers.borderTintedRed
TangemBadgeColor.Green -> TangemTheme.colors2.markers.borderTintedGreen
},
shape = shape,
width = 1.dp,
@ -320,16 +346,16 @@ private fun TangemBadge_Preview(@PreviewParameter(TangemBadgePreviewProvider::cl
.background(TangemTheme.colors2.surface.level1)
.padding(8.dp),
) {
repeat(2) { yIndex ->
repeat(3) { yIndex ->
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
repeat(TangemBadgeType.entries.size) { index ->
TangemBadge(
text = stringReference("Title"),
text = stringReference("Title").takeIf { yIndex < 2 },
iconRes = R.drawable.ic_information_24,
type = TangemBadgeType.entries[index],
color = params,
shape = TangemBadgeShape.entries[yIndex % 2],
iconPosition = TangemBadgeIconPosition.entries[yIndex % 2],
iconPosition = TangemBadgeIconPosition.entries[yIndex],
)
}
}
@ -344,6 +370,7 @@ private class TangemBadgePreviewProvider : PreviewParameterProvider<TangemBadgeC
TangemBadgeColor.Gray,
TangemBadgeColor.Blue,
TangemBadgeColor.Red,
TangemBadgeColor.Green,
)
}
// endregion

View file

@ -22,11 +22,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
import com.tangem.core.ui.components.flicker
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -58,7 +58,15 @@ fun TangemMessage(
if (messageUM.iconUM != null) {
TangemIcon(
tangemIconUM = messageUM.iconUM,
modifier = Modifier.size(TangemTheme.dimens2.x8),
modifier = Modifier
.align(
if (messageUM.buttonsUM.isEmpty()) {
Alignment.CenterVertically
} else {
Alignment.Top
},
)
.size(TangemTheme.dimens2.x7),
)
}
},
@ -342,6 +350,7 @@ private class TangemMessagePreviewProvider : PreviewParameterProvider<TangemMess
id = "1",
title = stringReference("Title text"),
subtitle = stringReference("Subtext"),
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
messageEffect = TangemMessageEffect.None,
isCentered = true,
),
@ -350,6 +359,7 @@ private class TangemMessagePreviewProvider : PreviewParameterProvider<TangemMess
title = stringReference("Title text"),
subtitle = stringReference("Subtext"),
messageEffect = TangemMessageEffect.Magic,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
isCentered = false,
buttonsUM = persistentListOf(
TangemMessageButtonUM(
@ -405,9 +415,8 @@ private fun TangemMessage2_Preview() {
content = {
Box(
modifier = Modifier
.size(TangemTheme.dimens2.x10)
.size(TangemTheme.dimens2.x7)
.clip(RoundedCornerShape(TangemTheme.dimens2.x2))
.flicker(isFlickering = true)
.background(TangemTheme.colors2.text.neutral.primary),
)
},

View file

@ -0,0 +1,244 @@
package com.tangem.core.ui.ds.opportunities
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.res.LocalIsInDarkTheme
import com.tangem.core.ui.res.TangemThemePreview
import dev.chrisbanes.haze.HazeStyle
/**
* Container that draws a blurred background (from URL or solid color) and
* applies a semitransparent overlay on top of it, then renders foreground content.
*
* Figma https://www.figma.com/design/X0IMgSMOT5rWWgiSIeZQwC/Bottom-sheet--Redesign-?node-id=3360-64755&m=dev
*
* @param icon Background configuration (URL, solid color or none).
* @param modifier Modifier applied to the outer container.
* @param content Foreground content rendered on top of the overlay.
* @param shape Shape used for inner shadow and border (e.g. rounded corners).
*/
@Suppress("MagicNumber")
@Composable
fun OpportunitiesBG(
icon: TangemIconUM,
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(16.dp),
content: @Composable BoxScope.() -> Unit,
) {
val isInDarkTheme = LocalIsInDarkTheme.current
val overlayColor = remember(isInDarkTheme) {
if (isInDarkTheme) {
Color(OVERLAY_DARK)
} else {
Color.White
}
}
Box(modifier = modifier) {
BackgroundLayer(icon = icon)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.innerShadow(
shape = shape,
shadow = Shadow(
radius = 30.dp,
spread = 5.dp,
color = Color(INNER_SHADOW_COLOR_START).copy(alpha = .3f),
offset = DpOffset(0.dp, 0.dp),
),
)
.innerShadow(
shape = shape,
shadow = Shadow(
radius = 100.dp,
spread = (-39).dp,
color = Color(INNER_SHADOW_COLOR_END).copy(.3f),
offset = DpOffset(0.dp, (-56).dp),
),
)
.innerShadow(
shape = shape,
shadow = Shadow(
radius = 40.dp,
spread = (-19).dp,
color = Color(INNER_SHADOW_COLOR_END).copy(alpha = .25f),
offset = DpOffset(0.dp, (-16).dp),
),
)
.drawWithContent {
drawRect(color = overlayColor.copy(alpha = .7f))
drawContent()
val outline = shape.createOutline(size, layoutDirection, this)
drawOutline(outline, Color(BORDER_COLOR).copy(alpha = .1f), style = Stroke(width = 1.dp.toPx()))
},
content = content,
)
}
}
@Suppress("CyclomaticComplexMethod")
@Composable
private fun BoxScope.BackgroundLayer(icon: TangemIconUM, blurRadius: Dp = 26.dp) {
when (icon) {
is TangemIconUM.Currency -> CurrencyIconBackgroundLayer(icon.currencyIconState, blurRadius)
is TangemIconUM.Icon -> SolidColorBackground(icon.tintReference(), blurRadius)
is TangemIconUM.Ident -> Unit
is TangemIconUM.Image -> ResBackground(icon.imageRes, blurRadius)
}
}
@Suppress("CyclomaticComplexMethod")
@Composable
private fun BoxScope.CurrencyIconBackgroundLayer(state: CurrencyIconState, blurRadius: Dp) {
when (state) {
is CurrencyIconState.CryptoPortfolio.Icon -> SolidColorBackground(
color = state.color,
blurRadius = blurRadius,
)
is CurrencyIconState.CryptoPortfolio.Letter -> SolidColorBackground(
color = state.color,
blurRadius = blurRadius,
)
is CurrencyIconState.CustomTokenIcon -> SolidColorBackground(
color = state.background,
blurRadius = blurRadius,
)
is CurrencyIconState.Empty -> ResBackground(res = state.resId, blurRadius = blurRadius)
is CurrencyIconState.CoinIcon -> {
state.url?.let {
UrlBackground(imageUrl = state.url, blurRadius = blurRadius)
} ?: run {
ResBackground(res = state.fallbackResId, blurRadius = blurRadius)
}
}
is CurrencyIconState.FiatIcon -> state.url?.let {
UrlBackground(imageUrl = state.url, blurRadius = blurRadius)
} ?: run {
ResBackground(res = state.fallbackResId, blurRadius = blurRadius)
}
is CurrencyIconState.TokenIcon -> state.url?.let {
UrlBackground(imageUrl = state.url, blurRadius = blurRadius)
} ?: run {
SolidColorBackground(
color = state.fallbackBackground,
blurRadius = blurRadius,
)
}
CurrencyIconState.Loading -> Unit
CurrencyIconState.Locked -> Unit
}
}
@Composable
private fun BoxScope.UrlBackground(imageUrl: String?, blurRadius: Dp) {
val context = LocalContext.current
val imageRequest = remember(imageUrl) {
if (imageUrl.isNullOrBlank()) {
null
} else {
ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(true)
.build()
}
}
if (imageRequest != null) {
AsyncImage(
model = imageRequest,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.scale(SCALE_FACTOR)
.hazeForegroundEffectTangem(style = HazeStyle(blurRadius = blurRadius, tint = null)),
)
}
}
@Composable
private fun BoxScope.ResBackground(res: Int, blurRadius: Dp) {
Image(
painter = painterResource(res),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.scale(SCALE_FACTOR)
.hazeForegroundEffectTangem(style = HazeStyle(blurRadius = blurRadius, tint = null)),
)
}
@Composable
private fun BoxScope.SolidColorBackground(color: Color, blurRadius: Dp) {
Box(
modifier = Modifier
.matchParentSize()
.background(color = color)
.hazeForegroundEffectTangem(style = HazeStyle(blurRadius = blurRadius, tint = null)),
)
}
private const val SCALE_FACTOR = 1.5f
private const val INNER_SHADOW_COLOR_START = 0x00000000
private const val INNER_SHADOW_COLOR_END = 0xFFFFFFFF
private const val BORDER_COLOR = 0xFFF0F0F0
private const val OVERLAY_DARK = 0xFF141414
// region Previews
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun OpportunitiesBGPreview() {
TangemThemePreview {
OpportunitiesBG(
modifier = Modifier.size(400.dp),
icon = TangemIconUM.Currency(
CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_solana_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
content = {},
)
}
}
// endregion

View file

@ -16,8 +16,8 @@ import kotlin.math.max
/**
* A custom layout composable that arranges its children in a row with specific layout IDs.
*/
internal enum class TangemRowLayoutId {
HEAD, START_TOP, END_TOP, START_BOTTOM, END_BOTTOM, TAIL, EXTRA_TOP
enum class TangemRowLayoutId {
HEAD, START_TOP, END_TOP, START_BOTTOM, END_BOTTOM, TAIL, EXTRA_TOP, EXTRA_BOTTOM
}
/**
@ -29,7 +29,7 @@ internal enum class TangemRowLayoutId {
*/
@Suppress("LongMethod")
@Composable
internal fun TangemRowContainer(
fun TangemRowContainer(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(TangemTheme.dimens2.x3),
content: @Composable () -> Unit,
@ -37,6 +37,7 @@ internal fun TangemRowContainer(
val density = LocalDensity.current
val localDirection = LocalLayoutDirection.current
val verticalPadding = with(density) { TangemTheme.dimens2.x1.roundToPx() }
val extraContentPadding = with(density) { TangemTheme.dimens2.x2.roundToPx() }
val contentTopPadding = with(density) { contentPadding.calculateTopPadding().roundToPx() }
val contentBottomPadding = with(density) { contentPadding.calculateBottomPadding().roundToPx() }
val contentStartPadding = with(density) { contentPadding.calculateLeftPadding(localDirection).roundToPx() }
@ -110,6 +111,10 @@ internal fun TangemRowContainer(
layoutId = TangemRowLayoutId.EXTRA_TOP,
constraints = constraints,
)
val extraBottomPlaceable = measurables.measure(
layoutId = TangemRowLayoutId.EXTRA_BOTTOM,
constraints = constraints,
)
val mainLayoutHeight = maxOf(
headPlaceable.heightOrZero(),
@ -124,7 +129,13 @@ internal fun TangemRowContainer(
contentTopPadding
}
val layoutHeight = mainLayoutHeight + mainContentTopPadding + contentBottomPadding
val mainContentBottomPadding = if (extraBottomPlaceable != null) {
extraBottomPlaceable.heightOrZero() + contentBottomPadding
} else {
contentBottomPadding
}
val layoutHeight = mainLayoutHeight + mainContentTopPadding + mainContentBottomPadding
layout(width = constraints.maxWidth, height = layoutHeight) {
extraTopPlaceable?.placeRelative(x = 0, y = 0)
@ -174,6 +185,11 @@ internal fun TangemRowContainer(
x = layoutWidth - tailPlaceable.width + contentEndPadding,
y = mainContentTopPadding + (mainLayoutHeight - tailPlaceable.height).div(other = 2),
)
extraBottomPlaceable?.placeRelative(
x = 0,
y = mainContentTopPadding + mainLayoutHeight + extraContentPadding,
)
}
}
}

View file

@ -52,15 +52,6 @@ fun TangemTokenRow(
.testTag(tag = TokenElementsTestTags.TOKEN_ICON),
)
TokenRowPromoBanner(
promoBannerUM = tokenRowUM.promoBannerUM,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.EXTRA_TOP)
.testTag(tag = TokenElementsTestTags.TOKEN_YIELD_PROMO_BANNER)
.padding(horizontal = TangemTheme.dimens2.x3)
.fillMaxWidth(),
)
TokenRowTitle(
titleUM = tokenRowUM.titleUM,
modifier = Modifier
@ -77,17 +68,21 @@ fun TangemTokenRow(
.testTag(tag = TokenElementsTestTags.TOKEN_PRICE),
)
TokenRowEndTopContent(
TokenRowEndContent(
endContentUM = tokenRowUM.topEndContentUM,
isBalanceHidden = isBalanceHidden,
textStyle = TangemTheme.typography2.bodySemibold16,
textColor = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.END_TOP)
.testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT),
)
TokenRowEndBottomContent(
TokenRowEndContent(
endContentUM = tokenRowUM.bottomEndContentUM,
isBalanceHidden = isBalanceHidden,
textStyle = TangemTheme.typography2.captionSemibold12,
textColor = TangemTheme.colors2.text.neutral.secondary,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM)
.testTag(tag = TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT),
@ -100,6 +95,15 @@ fun TangemTokenRow(
.layoutId(layoutId = TangemRowLayoutId.TAIL)
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
)
TokenRowPromoBanner(
promoBannerUM = tokenRowUM.promoBannerUM,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.EXTRA_BOTTOM)
.testTag(tag = TokenElementsTestTags.TOKEN_YIELD_PROMO_BANNER)
.padding(start = TangemTheme.dimens2.x10, bottom = TangemTheme.dimens2.x2)
.fillMaxWidth(),
)
},
modifier = modifier.tokenClickable(tokenRowUM = tokenRowUM),
)
@ -159,17 +163,21 @@ fun TangemTokenRow(
.testTag(tag = TokenElementsTestTags.TOKEN_PRICE),
)
TokenRowEndTopContent(
TokenRowEndContent(
endContentUM = tokenRowUM.topEndContentUM,
isBalanceHidden = isBalanceHidden,
textStyle = TangemTheme.typography2.bodySemibold16,
textColor = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.END_TOP)
.testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT),
)
TokenRowEndBottomContent(
TokenRowEndContent(
endContentUM = tokenRowUM.bottomEndContentUM,
isBalanceHidden = isBalanceHidden,
textStyle = TangemTheme.typography2.captionSemibold12,
textColor = TangemTheme.colors2.text.neutral.secondary,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM)
.testTag(tag = TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT),

View file

@ -133,7 +133,8 @@ sealed class TangemTokenRowUM : TangemRowUM {
val text: TextReference,
val isAvailable: Boolean = true,
val isFlickering: Boolean = false,
val icons: ImmutableList<TangemIconUM.Icon> = persistentListOf(),
val startIcons: ImmutableList<TangemIconUM.Icon> = persistentListOf(),
val endIcons: ImmutableList<TangemIconUM.Icon> = persistentListOf(),
val priceChangeUM: PriceChangeState = PriceChangeState.Unknown,
) : EndContentUM()

View file

@ -142,7 +142,7 @@ internal object TangemTokenRowPreviewData {
)
}),
),
icons = persistentListOf(
startIcons = persistentListOf(
TangemIconUM.Icon(R.drawable.ic_staking_mini_10),
TangemIconUM.Icon(R.drawable.ic_attention_12),
TangemIconUM.Icon(R.drawable.ic_error_sync_24),

View file

@ -1,100 +0,0 @@
package com.tangem.core.ui.ds.row.token.internal
import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Composable
internal fun TokenRowEndBottomContent(
endContentUM: TangemTokenRowUM.EndContentUM,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
when (endContentUM) {
is TangemTokenRowUM.EndContentUM.Content -> Content(
modifier = modifier,
endContentUM = endContentUM,
isBalanceHidden = isBalanceHidden,
)
TangemTokenRowUM.EndContentUM.Empty -> Unit
TangemTokenRowUM.EndContentUM.Loading -> TextShimmer(
style = TangemTheme.typography2.captionSemibold12,
modifier = modifier.width(TangemTheme.dimens2.x10),
radius = TangemTheme.dimens2.x25,
)
}
}
@Composable
private fun Content(
endContentUM: TangemTokenRowUM.EndContentUM.Content,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = endContentUM.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TangemTheme.typography2.captionSemibold12.applyBladeBrush(
isEnabled = endContentUM.isFlickering,
textColor = if (endContentUM.isAvailable) {
TangemTheme.colors2.text.neutral.secondary
} else {
TangemTheme.colors2.text.status.disabled
},
),
)
when (val priceChangeUM = endContentUM.priceChangeUM) {
is PriceChangeState.Content -> TokenRowPriceChangeContent(
priceChangeState = priceChangeUM,
isFlickering = endContentUM.isFlickering,
isAvailable = endContentUM.isAvailable,
)
PriceChangeState.Unknown -> Unit
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TokenRowEndBottomContent_Preview(
@PreviewParameter(TokenRowEndBottomContentPreviewProvider::class) params: TangemTokenRowUM.EndContentUM,
) {
TangemThemePreviewRedesign {
TokenRowEndBottomContent(
endContentUM = params,
isBalanceHidden = false,
)
}
}
private class TokenRowEndBottomContentPreviewProvider : PreviewParameterProvider<TangemTokenRowUM.EndContentUM> {
override val values: Sequence<TangemTokenRowUM.EndContentUM>
get() = sequenceOf(
TangemTokenRowPreviewData.bottomEndContentUM,
)
}
// endregion

View file

@ -8,15 +8,18 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.orMaskWithStars
@ -25,9 +28,11 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Composable
internal fun TokenRowEndTopContent(
internal fun TokenRowEndContent(
endContentUM: TangemTokenRowUM.EndContentUM,
isBalanceHidden: Boolean,
textStyle: TextStyle,
textColor: Color,
modifier: Modifier = Modifier,
) {
when (endContentUM) {
@ -35,11 +40,13 @@ internal fun TokenRowEndTopContent(
modifier = modifier,
endContentUM = endContentUM,
isBalanceHidden = isBalanceHidden,
textStyle = textStyle,
textColor = textColor,
)
TangemTokenRowUM.EndContentUM.Empty -> Unit
TangemTokenRowUM.EndContentUM.Loading -> TextShimmer(
style = TangemTheme.typography2.bodySemibold16,
modifier = modifier.width(TangemTheme.dimens2.x18),
style = textStyle,
modifier = modifier.width(TangemTheme.dimens2.x10),
radius = TangemTheme.dimens2.x25,
)
}
@ -48,6 +55,8 @@ internal fun TokenRowEndTopContent(
@Composable
private fun Content(
endContentUM: TangemTokenRowUM.EndContentUM.Content,
textStyle: TextStyle,
textColor: Color,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
@ -56,14 +65,14 @@ private fun Content(
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedVisibility(
visible = endContentUM.icons.isNotEmpty(),
visible = endContentUM.startIcons.isNotEmpty(),
) {
Row(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x1),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
endContentUM.icons.fastForEach { icon ->
endContentUM.startIcons.fastForEach { icon ->
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x3),
painter = rememberVectorPainter(image = ImageVector.vectorResource(icon.iconRes)),
@ -75,11 +84,11 @@ private fun Content(
}
Text(
modifier = Modifier,
text = endContentUM.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TangemTheme.typography2.bodySemibold16.applyBladeBrush(
color = textColor,
style = textStyle.applyBladeBrush(
isEnabled = endContentUM.isFlickering,
textColor = if (endContentUM.isAvailable) {
TangemTheme.colors2.text.neutral.primary
@ -88,6 +97,34 @@ private fun Content(
},
),
)
AnimatedVisibility(
visible = endContentUM.endIcons.isNotEmpty(),
) {
Row(
modifier = Modifier.padding(start = TangemTheme.dimens2.x0_5),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
endContentUM.endIcons.fastForEach { icon ->
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x3),
painter = rememberVectorPainter(image = ImageVector.vectorResource(icon.iconRes)),
tint = icon.tintReference(),
contentDescription = null,
)
}
}
}
when (val priceChangeUM = endContentUM.priceChangeUM) {
is PriceChangeState.Content -> TokenRowPriceChangeContent(
priceChangeState = priceChangeUM,
isFlickering = endContentUM.isFlickering,
isAvailable = endContentUM.isAvailable,
)
PriceChangeState.Unknown -> Unit
}
}
}
@ -95,13 +132,15 @@ private fun Content(
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TokenRowEndTopContent_Preview(
private fun TokenRowEndContent_Preview(
@PreviewParameter(TokenRowEndContentPreviewProvider::class) params: TangemTokenRowUM.EndContentUM,
) {
TangemThemePreviewRedesign {
TokenRowEndTopContent(
TokenRowEndContent(
endContentUM = params,
isBalanceHidden = false,
textColor = TangemTheme.colors2.text.neutral.primary,
textStyle = TangemTheme.typography2.captionSemibold12,
)
}
}
@ -109,7 +148,7 @@ private fun TokenRowEndTopContent_Preview(
private class TokenRowEndContentPreviewProvider : PreviewParameterProvider<TangemTokenRowUM.EndContentUM> {
override val values: Sequence<TangemTokenRowUM.EndContentUM>
get() = sequenceOf(
TangemTokenRowPreviewData.topEndContentUM,
TangemTokenRowPreviewData.bottomEndContentUM,
)
}
// endregion

View file

@ -3,15 +3,12 @@ package com.tangem.core.ui.ds.row.token.internal
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@ -19,6 +16,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.badge.*
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@ -40,55 +38,52 @@ internal fun TokenRowPromoBanner(promoBannerUM: TangemTokenRowUM.PromoBannerUM.C
LaunchedEffect(promoBannerUM) {
promoBannerUM.onPromoShown()
}
val bgColor = TangemTheme.colors.control.default
Column(modifier = modifier) {
val bgColor = TangemTheme.colors2.markers.backgroundTintedGreen
Column(
modifier = modifier,
) {
Icon(
painter = painterResource(id = R.drawable.shape_triangular),
contentDescription = null,
tint = bgColor,
modifier = Modifier.padding(start = TangemTheme.dimens2.x5),
)
Row(
modifier = Modifier
.background(color = bgColor, shape = RoundedCornerShape(TangemTheme.dimens2.x4))
.clickable(onClick = promoBannerUM.onPromoBannerClick)
.padding(horizontal = TangemTheme.dimens2.x3, vertical = TangemTheme.dimens2.x2)
.fillMaxWidth(),
.padding(
start = TangemTheme.dimens2.x2_5,
end = TangemTheme.dimens2.x0_5,
top = TangemTheme.dimens2.x0_5,
bottom = TangemTheme.dimens2.x0_5,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_analytics_up_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
tint = TangemTheme.colors2.markers.textGreen,
modifier = Modifier
.padding(end = TangemTheme.dimens2.x2)
.size(TangemTheme.dimens2.x4),
.padding(vertical = TangemTheme.dimens2.x0_5)
.size(TangemTheme.dimens2.x3),
)
Text(
text = promoBannerUM.title.resolveReference(),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.captionSemibold11,
color = TangemTheme.colors2.markers.textGreen,
modifier = Modifier
.weight(1f)
.padding(end = TangemTheme.dimens2.x2),
.padding(vertical = TangemTheme.dimens2.x0_5),
)
Icon(
painter = painterResource(id = R.drawable.ic_close_24),
contentDescription = null,
tint = TangemTheme.colors2.text.neutral.secondary,
modifier = Modifier
.size(TangemTheme.dimens2.x4)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = { promoBannerUM.onCloseClick() },
),
)
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_rectangle_bottom),
contentDescription = null,
tint = bgColor,
modifier = Modifier
.size(width = TangemTheme.dimens2.x3, height = TangemTheme.dimens2.x2),
TangemBadge(
size = TangemBadgeSize.X4,
shape = TangemBadgeShape.Rounded,
color = TangemBadgeColor.Green,
type = TangemBadgeType.Tinted,
iconRes = R.drawable.ic_close_24,
iconPosition = TangemBadgeIconPosition.None,
onClick = promoBannerUM.onCloseClick,
)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.core.ui.format.bigdecimal
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CAN_BE_LOWER_SIGN
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CRYPTO_FEE_FORMAT_THRESHOLD
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CURRENCY_SPACE
@ -9,6 +10,7 @@ import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE
import com.tangem.utils.extensions.isNotWhitespace
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
@ -34,6 +36,17 @@ class BigDecimalCryptoFormatFull(
override fun invoke(value: BigDecimal): String = defaultAmount()(value)
}
open class BigDecimalCryptoFormatStyled(
val symbol: String,
val decimals: Int,
val spanStyleReference: SpanStyleReference,
val locale: Locale = Locale.getDefault(),
val shouldIgnoreSymbolPosition: Boolean = false,
) : BigDecimalFormatStyled {
override fun invoke(value: BigDecimal): TextReference = defaultAmount(spanStyleReference)(value)
}
// == Initializers ==
fun BigDecimalFormatScope.crypto(
@ -61,6 +74,35 @@ fun BigDecimalFormatScope.crypto(
)
}
fun BigDecimalFormatScope.cryptoStyled(
symbol: String,
decimals: Int,
spanStyleReference: SpanStyleReference,
locale: Locale = Locale.getDefault(),
): BigDecimalCryptoFormatStyled {
return BigDecimalCryptoFormatStyled(
symbol = symbol,
decimals = decimals,
spanStyleReference = spanStyleReference,
locale = locale,
)
}
fun BigDecimalFormatScope.cryptoStyled(
cryptoCurrency: CryptoCurrency,
spanStyleReference: SpanStyleReference,
ignoreSymbolPosition: Boolean = false,
locale: Locale = Locale.getDefault(),
): BigDecimalCryptoFormatStyled {
return BigDecimalCryptoFormatStyled(
symbol = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
spanStyleReference = spanStyleReference,
shouldIgnoreSymbolPosition = ignoreSymbolPosition,
locale = locale,
)
}
// == Formatters ==
fun BigDecimalCryptoFormat.defaultAmount() = BigDecimalFormat { value ->
@ -88,6 +130,51 @@ fun BigDecimalCryptoFormat.defaultAmount() = BigDecimalFormat { value ->
}
}
fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleReference) =
BigDecimalFormatStyled { value ->
if (shouldIgnoreSymbolPosition) {
val formatter = NumberFormat.getInstance(locale).apply {
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
val formattedAmount = formatter.format(value)
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
combinedReference(
stringReference(formattedAmount.take(separatorIndex)),
styledStringReference(formattedAmount.drop(separatorIndex), spanStyleReference),
stringReference(NON_BREAKING_SPACE + symbol),
)
} else {
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
val formattedAmount = formatter.format(value)
.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.getSymbol(locale),
cryptoCurrencySymbol = symbol,
)
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
combinedReference(
stringReference(formattedAmount.take(separatorIndex)),
styledStringReference(formattedAmount.drop(separatorIndex), spanStyleReference),
)
}
}
fun BigDecimalCryptoFormat.shorted() = BigDecimalFormat { value ->
val formatter = if (value.isMoreThanThreshold()) {
NumberFormat.getCurrencyInstance(locale).apply {

View file

@ -1,9 +1,11 @@
package com.tangem.core.ui.format.bigdecimal
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CAN_BE_LOWER_SIGN
import com.tangem.utils.StringsSigns.TILDE_SIGN
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
@ -15,8 +17,16 @@ open class BigDecimalFiatFormat(
override fun invoke(value: BigDecimal): String = defaultAmount()(value)
}
// == Initializers ==
open class BigDecimalFiatFormatStyled(
val fiatCurrencyCode: String,
val fiatCurrencySymbol: String,
val spanStyleReference: SpanStyleReference,
val locale: Locale = Locale.getDefault(),
) : BigDecimalFormatStyled {
override fun invoke(value: BigDecimal): TextReference = defaultAmount(spanStyleReference)(value)
}
//region == Initializers ==
fun BigDecimalFormatScope.fiat(
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
@ -29,7 +39,20 @@ fun BigDecimalFormatScope.fiat(
)
}
// == Formatters ==
fun BigDecimalFormatScope.fiat(
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
spanStyleReference: SpanStyleReference,
locale: Locale = Locale.getDefault(),
): BigDecimalFiatFormatStyled {
return BigDecimalFiatFormatStyled(
fiatCurrencyCode = fiatCurrencyCode,
fiatCurrencySymbol = fiatCurrencySymbol,
spanStyleReference = spanStyleReference,
locale = locale,
)
}
// endregion == Formatters ==
/**
* Formats fiat amount with default precision.
@ -58,6 +81,38 @@ fun BigDecimalFiatFormat.defaultAmount(): BigDecimalFormat = BigDecimalFormat {
}
}
fun BigDecimalFiatFormatStyled.defaultAmount(spanStyleReference: SpanStyleReference) = BigDecimalFormatStyled { value ->
val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode)
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency
maximumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS
minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS
roundingMode = RoundingMode.HALF_UP
}
val formattingAmount = if (value.isLessThanThreshold()) {
FIAT_FORMAT_THRESHOLD
} else {
value
}
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val formattedAmount = formatter.format(formattingAmount)
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
val wholePart = formattedAmount.take(separatorIndex)
val fractionalPart = formattedAmount.drop(separatorIndex)
combinedReference(
if (formattingAmount.isLessThanThreshold()) stringReference(CAN_BE_LOWER_SIGN) else TextReference.EMPTY,
stringReference(wholePart),
styledStringReference(fractionalPart, spanStyleReference),
)
}
/**
* Formats fiat amount with default precision and adds tilde sign
*/

View file

@ -1,17 +1,27 @@
package com.tangem.core.ui.format.bigdecimal
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import java.math.BigDecimal
interface BigDecimalFormatScope {
companion object { val Empty = object : BigDecimalFormatScope {} }
companion object {
val Empty = object : BigDecimalFormatScope {}
}
}
fun interface BigDecimalFormat : (BigDecimal) -> String, BigDecimalFormatScope
fun interface BigDecimalFormatStyled : (BigDecimal) -> TextReference, BigDecimalFormatScope
inline fun BigDecimal.format(block: BigDecimalFormatScope.() -> BigDecimalFormat): String {
return BigDecimalFormatScope.Empty.block()(this)
}
inline fun BigDecimal.formatStyled(block: BigDecimalFormatScope.() -> BigDecimalFormatStyled): TextReference {
return BigDecimalFormatScope.Empty.block()(this)
}
inline fun BigDecimal?.format(
fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN,
block: BigDecimalFormatScope.() -> BigDecimalFormat,
@ -20,10 +30,26 @@ inline fun BigDecimal?.format(
return BigDecimalFormatScope.Empty.block()(this)
}
inline fun BigDecimal?.formatStyled(
fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN,
block: BigDecimalFormatScope.() -> BigDecimalFormatStyled,
): TextReference {
if (this == null) return stringReference(fallbackString)
return BigDecimalFormatScope.Empty.block()(this)
}
fun BigDecimal?.format(
format: BigDecimalFormat,
fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN,
): String {
if (this == null) return fallbackString
return format(this)
}
fun BigDecimal?.format(
format: BigDecimalFormatStyled,
fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN,
): TextReference {
if (this == null) return stringReference(fallbackString)
return format(this)
}

View file

@ -16,6 +16,7 @@ object TangemColorPalette {
val Dark4 = Color(0xFF3B3B3B)
val Dark5 = Color(0xFF303030)
val Dark6 = Color(0xFF1E1E1E)
val Dark7 = Color(0xFF171717)
// endregion Dark
// region Dark Alpha
@ -58,20 +59,45 @@ object TangemColorPalette {
val DarkGreen = Color(0xFF06311F)
// endregion Green
// region Blue
// region Azure
val Azure = Color(0xFF0099FF)
// endregion Blue
val Azure_50 = Color(0x800099FF)
val Azure_10 = Color(0x1A0099FF)
// endregion Azure
// region Red
// region Amaranth
val Amaranth = Color(0xFFFF3333)
val Amaranth_50 = Color(0x80FF3333)
val Amaranth_20 = Color(0x33FF3333)
val Amaranth_10 = Color(0x1AFF3333)
// endregion Amaranth
// region Flamingo
val Flamingo = Color(0xFFFF5B5B)
// endregion Red
val Flamingo_50 = Color(0x80FF5B5B)
val Flamingo_20 = Color(0x33FF5B5B)
val Flamingo_10 = Color(0x1AFF5B5B)
// endregion Flamingo
// region Yellow
val Tangerine = Color(0xFFFFB71B)
val Mustard = Color(0xFFFDDE55)
// endregion Yellow
// region Emerald
val Emerald = Color(0xFF34DF12)
val Emerald_50 = Color(0x8034DF12)
val Emerald_20 = Color(0x3334DF12)
val Emerald_10 = Color(0x1A34DF12)
// endregion Emerald
// region Eucalyptus
val Eucalyptus = Color(0xFF0C9F3D)
val Eucalyptus_50 = Color(0x800C9F3D)
val Eucalyptus_20 = Color(0x330C9F3D)
val Eucalyptus_10 = Color(0x1A0C9F3D)
// endregion Eucalyptus
// region Overlay
val Overlay1 = Color(0x66000000)
val Overlay2 = Color(0xB2000000)

View file

@ -448,24 +448,36 @@ class TangemColors2 internal constructor(
@Stable
class Markers internal constructor(
backgroundSolidGray: Color,
backgroundDisabled: Color,
backgroundSolidBlue: Color,
textGray: Color,
textDisabled: Color,
iconGray: Color,
iconDisabled: Color,
backgroundDisabled: Color,
textGray: Color,
iconGray: Color,
borderGray: Color,
backgroundTintedBlue: Color,
backgroundSolidGray: Color,
backgroundTintedGray: Color,
textBlue: Color,
iconBlue: Color,
borderTintedBlue: Color,
backgroundSolidBlue: Color,
backgroundTintedBlue: Color,
textRed: Color,
iconRed: Color,
borderTintedRed: Color,
backgroundSolidRed: Color,
backgroundTintedRed: Color,
iconBlue: Color,
iconRed: Color,
textRed: Color,
backgroundTintedGray: Color,
borderTintedBlue: Color,
borderTintedRed: Color,
textGreen: Color,
iconGreen: Color,
borderTintedGreen: Color,
borderSolidColor: Color,
backgroundTintedGreen: Color,
backgroundSolidGreen: Color,
textGreenAlt: Color,
iconGreenAlt: Color,
borderTintedGreenAlt: Color,
borderSolidColorAlt: Color,
backgroundTintedGreenAlt: Color,
backgroundSolidGreenAlt: Color,
) {
var backgroundSolidGray by mutableStateOf(backgroundSolidGray)
private set
@ -504,6 +516,32 @@ class TangemColors2 internal constructor(
var borderTintedRed by mutableStateOf(borderTintedRed)
private set
var textGreen by mutableStateOf(textGreen)
private set
var iconGreen by mutableStateOf(iconGreen)
private set
var borderTintedGreen by mutableStateOf(borderTintedGreen)
private set
var borderSolidColor by mutableStateOf(borderSolidColor)
private set
var backgroundTintedGreen by mutableStateOf(backgroundTintedGreen)
private set
var backgroundSolidGreen by mutableStateOf(backgroundSolidGreen)
private set
var textGreenAlt by mutableStateOf(textGreenAlt)
private set
var iconGreenAlt by mutableStateOf(iconGreenAlt)
private set
var borderTintedGreenAlt by mutableStateOf(borderTintedGreenAlt)
private set
var borderSolidColorAlt by mutableStateOf(borderSolidColorAlt)
private set
var backgroundTintedGreenAlt by mutableStateOf(backgroundTintedGreenAlt)
private set
var backgroundSolidGreenAlt by mutableStateOf(backgroundSolidGreenAlt)
private set
fun update(other: Markers) {
backgroundSolidGray = other.backgroundSolidGray
backgroundDisabled = other.backgroundDisabled
@ -523,6 +561,18 @@ class TangemColors2 internal constructor(
backgroundTintedGray = other.backgroundTintedGray
borderTintedBlue = other.borderTintedBlue
borderTintedRed = other.borderTintedRed
textGreen = other.textGreen
iconGreen = other.iconGreen
borderTintedGreen = other.borderTintedGreen
borderSolidColor = other.borderSolidColor
backgroundTintedGreen = other.backgroundTintedGreen
backgroundSolidGreen = other.backgroundSolidGreen
textGreenAlt = other.textGreenAlt
iconGreenAlt = other.iconGreenAlt
borderTintedGreenAlt = other.borderTintedGreenAlt
borderSolidColorAlt = other.borderSolidColorAlt
backgroundTintedGreenAlt = other.backgroundTintedGreenAlt
backgroundSolidGreenAlt = other.backgroundSolidGreenAlt
}
}

View file

@ -122,8 +122,8 @@ private fun lightThemeColors2(): TangemColors2 {
val surface = TangemColors2.Surface(
level1 = TangemColorPalette.White,
level2 = TangemColorPalette.Light1V2,
level3 = TangemColorPalette.Light1V2,
level4 = TangemColorPalette.White,
level3 = TangemColorPalette.White,
level4 = TangemColorPalette.Light1V2,
)
val controls = TangemColors2.Controls(
backgroundChecked = TangemColorPalette.Dark6,
@ -154,16 +154,28 @@ private fun lightThemeColors2(): TangemColors2 {
iconGray = TangemColorPalette.Dark1,
iconDisabled = TangemColorPalette.Light2,
borderGray = TangemColorPalette.Light3,
backgroundTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f),
backgroundTintedBlue = TangemColorPalette.Azure_10,
textBlue = text.status.accent,
backgroundSolidRed = TangemColorPalette.Amaranth,
backgroundTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f),
backgroundTintedRed = TangemColorPalette.Amaranth_10,
iconBlue = TangemColorPalette.Azure,
iconRed = TangemColorPalette.Amaranth,
textRed = TangemColorPalette.Amaranth,
backgroundTintedGray = TangemColorPalette.Dark6.copy(alpha = 0.1f),
borderTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f),
borderTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f),
borderTintedBlue = TangemColorPalette.Azure_10,
borderTintedRed = TangemColorPalette.Amaranth_10,
textGreen = TangemColorPalette.Emerald,
iconGreen = TangemColorPalette.Emerald,
borderTintedGreen = TangemColorPalette.Emerald_10,
borderSolidColor = TangemColorPalette.Emerald_50,
backgroundTintedGreen = TangemColorPalette.Emerald_10,
backgroundSolidGreen = TangemColorPalette.Emerald,
textGreenAlt = TangemColorPalette.Eucalyptus,
iconGreenAlt = TangemColorPalette.Eucalyptus,
borderTintedGreenAlt = TangemColorPalette.Eucalyptus_10,
borderSolidColorAlt = TangemColorPalette.Eucalyptus_50,
backgroundTintedGreenAlt = TangemColorPalette.Eucalyptus_10,
backgroundSolidGreenAlt = TangemColorPalette.Eucalyptus,
)
val tabs = TangemColors2.Tabs(
textPrimary = TangemColorPalette.Light2,
@ -270,8 +282,8 @@ private fun darkThemeColors2(): TangemColors2 {
borderPrimary = TangemColorPalette.Light4,
)
val surface = TangemColors2.Surface(
level1 = TangemColorPalette.Dark6,
level2 = TangemColorPalette.Black,
level1 = TangemColorPalette.Black,
level2 = TangemColorPalette.Dark7,
level3 = TangemColorPalette.Dark6,
level4 = TangemColorPalette.Dark5,
)
@ -304,7 +316,7 @@ private fun darkThemeColors2(): TangemColors2 {
iconGray = TangemColorPalette.Dark2,
iconDisabled = TangemColorPalette.Dark5,
borderGray = TangemColorPalette.White.copy(alpha = 0.2f),
backgroundTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f),
backgroundTintedBlue = TangemColorPalette.Azure_10,
textBlue = text.status.accent,
backgroundSolidRed = TangemColorPalette.Amaranth,
backgroundTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f),
@ -312,8 +324,20 @@ private fun darkThemeColors2(): TangemColors2 {
iconRed = TangemColorPalette.Flamingo,
textRed = TangemColorPalette.Flamingo,
backgroundTintedGray = TangemColorPalette.White.copy(alpha = 0.1f),
borderTintedBlue = TangemColorPalette.Azure.copy(alpha = 0.1f),
borderTintedRed = TangemColorPalette.Amaranth.copy(alpha = 0.1f),
borderTintedBlue = TangemColorPalette.Azure_10,
borderTintedRed = TangemColorPalette.Amaranth_10,
textGreen = TangemColorPalette.Emerald,
iconGreen = TangemColorPalette.Emerald,
borderTintedGreen = TangemColorPalette.Emerald_10,
borderSolidColor = TangemColorPalette.Emerald_50,
backgroundTintedGreen = TangemColorPalette.Emerald_10,
backgroundSolidGreen = TangemColorPalette.Emerald,
textGreenAlt = TangemColorPalette.Emerald,
iconGreenAlt = TangemColorPalette.Emerald,
borderTintedGreenAlt = TangemColorPalette.Emerald_10,
borderSolidColorAlt = TangemColorPalette.Emerald_50,
backgroundTintedGreenAlt = TangemColorPalette.Emerald_10,
backgroundSolidGreenAlt = TangemColorPalette.Emerald,
)
val tabs = TangemColors2.Tabs(
textPrimary = TangemColorPalette.Dark4,

View file

@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemeRedesign
/**
* Interface representing a Compose screen with common theming and content composition properties.
@ -61,7 +62,9 @@ internal fun ComposeScreen.createComposeView(
uiDependencies = uiDependencies,
overrideSystemBarColors = overrideSystemBarColors,
) {
ScreenContent(modifier = screenModifier)
TangemThemeRedesign {
ScreenContent(modifier = screenModifier)
}
}
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.core.ui.shader
class GlossyShader : TangemShader {
override val sksl: String =
"""
// The MIT License
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
uniform float uTime;
uniform vec3 uResolution;
vec4 main( vec2 fragCoord )
{
float mr = min(uResolution.x, uResolution.y);
vec2 uv = (fragCoord * 2.0 - uResolution.xy) / mr;
float d = -uTime * 0.5;
float a = 0.0;
for (float i = 0.0; i < 8.0; ++i) {
a += cos(i - d - a * uv.x);
d += sin(uv.y * i + a);
}
d += uTime * 0.5;
vec3 col = vec3(cos(uv * vec2(d, a)) * 0.6 + 0.4, cos(a + d) * 0.5 + 0.5);
col = cos(col * cos(vec3(d, a, 2.5)) * 0.5 + 0.5);
return vec4(col,1.0);
}
"""
}

View file

@ -0,0 +1,193 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.shader
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.shader.runtime.RuntimeEffect
/**
* A shader that creates a colorful, flowing "northern lights" effect.
* @param colors The colors to display. The last provided color acts like a "background"
* @param speed Adjust the speed of the movement
* @param scale Adjusts the scale of the board. Higher number -> larger billboard -> smaller color blobs
*
[REDACTED_AUTHOR]
*/
class NorthernLightsMeshGradientShader(
colors: Array<Color>,
speed: Float = 1f,
scale: Float = 2f,
) : TangemShader {
private val colorCount = colors.size
private val colorUniforms = colors.flatMap {
listOf(it.red, it.green, it.blue)
}.toTypedArray().toFloatArray()
private val ambientUniform = FloatArray(3)
init {
recomputeAmbient()
}
override val sksl = """
uniform float uTime;
uniform vec3 uResolution;
uniform vec3 uAmbient;
const int MAX_COLORS = $colorCount;
uniform vec3 uColor[MAX_COLORS];
// Simplex 3D Noise
// by Ian McEwan, Ashima Arts
// https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83
//
vec4 permute(vec4 x) {
return mod(((x * 34.0) + 1.0) * x, 289.0);
}
vec4 taylorInvSqrt(vec4 r) {
return 1.79284291400159 - 0.85373472095314 * r;
}
float snoise(vec3 v) {
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
// First corner
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
// Other corners
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
// x0 = x0 - 0. + 0.0 * C
vec3 x1 = x0 - i1 + 1.0 * C.xxx;
vec3 x2 = x0 - i2 + 2.0 * C.xxx;
vec3 x3 = x0 - 1. + 3.0 * C.xxx;
// Permutations
i = mod(i, 289.0);
vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
// Gradients
// ( N*N points uniformly over a square, mapped onto an octahedron.)
float n_ = 1.0 / 7.0; // N=7
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z); // mod(p,N*N)
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_); // mod(j,N)
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
//Normalise gradients
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
// Mix final noise value
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m = m * m;
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
vec4 main( vec2 fragCoord ) {
float mr = min(uResolution.x, uResolution.y);
vec2 uv = (fragCoord * $scale - uResolution.xy) / mr;
vec2 base = uv / 2;
vec3 vColor = uColor[MAX_COLORS - 1];
const vec2 frequency = vec2(0.7, 0.3);
const float noiseFloor = 0.00001;
float t = uTime * 0.005;
for(int i = 0; i < MAX_COLORS - 1; i++) {
float fi = float(i);
float flow = 5. + fi * 0.3;
float speed = 6. * $speed + fi * 0.3;
float seed = 1. + fi * 4.;
float noiseCeil = 0.6 + fi * 0.07;
float noise = smoothstep(noiseFloor, noiseCeil, snoise(vec3(base.x * frequency.x, base.y * frequency.y - t * flow, t * speed + seed)));
vColor = mix(vColor, uColor[i], noise);
}
vColor = max(vColor, uAmbient);
// Elliptical falloff centred at the very top of the screen.
// Using fragCoord directly (pixels) and uResolution for screen size.
// Horizontal radius ~ 80 % of screen width → wide enough to cover corners.
// Vertical radius ~ 45 % of screen height → controls how far down the glow reaches.
vec2 topCenter = vec2(uResolution.x * 0.5, 0.0);
vec2 delta = fragCoord - topCenter;
vec2 radii = vec2(uResolution.x * 0.9, uResolution.y * 0.65);
float normDist = length(delta / radii);
float alpha = pow(1.0 - smoothstep(0.0, 1.0, normDist), 1.5);
// Pre-multiplied alpha so the shader composites correctly over the dark background.
return vec4(vColor * alpha, alpha);
}
"""
/** Updates the animated colors in-place without recreating the shader. */
fun updateColors(colors: Array<Color>) {
colors.forEachIndexed { i, color ->
colorUniforms[i * 3 + 0] = color.red
colorUniforms[i * 3 + 1] = color.green
colorUniforms[i * 3 + 2] = color.blue
}
recomputeAmbient()
}
private fun recomputeAmbient() {
val count = colorCount - 1
var r = 0f
var g = 0f
var b = 0f
for (i in 0 until count) {
r += colorUniforms[i * 3]
g += colorUniforms[i * 3 + 1]
b += colorUniforms[i * 3 + 2]
}
val scale = 0.5f / count
ambientUniform[0] = r * scale
ambientUniform[1] = g * scale
ambientUniform[2] = b * scale
}
override fun applyUniforms(runtimeEffect: RuntimeEffect, time: Float, width: Float, height: Float) {
super.applyUniforms(runtimeEffect = runtimeEffect, time = time, width = width, height = height)
runtimeEffect.setFloatUniform(name = "uColor", values = colorUniforms)
runtimeEffect.setFloatUniform(
name = "uAmbient",
value1 = ambientUniform[0],
value2 = ambientUniform[1],
value3 = ambientUniform[2],
)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.core.ui.shader
import com.tangem.core.ui.shader.runtime.RuntimeEffect
interface TangemShader {
val speedModifier: Float
get() = 0.5f
val sksl: String
/** Applies the uniforms required for this shader to the effect */
fun applyUniforms(runtimeEffect: RuntimeEffect, time: Float, width: Float, height: Float) {
runtimeEffect.setFloatUniform(name = "uResolution", value1 = width, value2 = height, value3 = width / height)
runtimeEffect.setFloatUniform(name = "uTime", value1 = time)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.core.ui.shader.runtime
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
internal class FallbackRuntimeEffect : RuntimeEffect {
override val isSupported: Boolean = false
override val isReady: Boolean = false
override fun build(): Brush {
return Brush.horizontalGradient(listOf(Color.White, Color.White))
}
}

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