Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-09 13:33:25 +03:00
commit a651da10e5
143 changed files with 3655 additions and 899 deletions

View file

@ -1,8 +1,6 @@
package com.tangem.tap.common.redux
import com.tangem.tap.common.redux.global.GlobalMiddleware
import com.tangem.tap.common.redux.global.GlobalState
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.proxy.redux.DaggerGraphMiddleware
@ -20,12 +18,10 @@ data class AppState(
fun getMiddleware(): List<Middleware<AppState>> {
return listOf(
logMiddleware,
GlobalMiddleware.handler,
DetailsMiddleware().detailsMiddleware,
LockUserWalletsTimerMiddleware().middleware,
AccessCodeRequestPolicyMiddleware().middleware,
DaggerGraphMiddleware.daggerGraphMiddleware,
LegacyMiddleware.legacyMiddleware,
)
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.tap.common.redux.global
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.Action
@ -8,10 +7,5 @@ sealed class GlobalAction : Action {
data class SaveScanResponse(val scanResponse: ScanResponse) : GlobalAction()
data class ChangeAppCurrency(val appCurrency: AppCurrency) : GlobalAction()
object RestoreAppCurrency : GlobalAction() {
data class Success(val appCurrency: AppCurrency) : GlobalAction()
}
data class IsSignWithRing(val isSignWithRing: Boolean) : GlobalAction()
}

View file

@ -1,43 +0,0 @@
package com.tangem.tap.common.redux.global
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
object GlobalMiddleware {
val handler = globalMiddlewareHandler
}
private val globalMiddlewareHandler: Middleware<AppState> = { _, _ ->
{ nextDispatch ->
{ action ->
handleAction(action)
nextDispatch(action)
}
}
}
private fun handleAction(action: Action) {
when (action) {
is GlobalAction.RestoreAppCurrency -> restoreAppCurrency()
}
}
private fun restoreAppCurrency() {
scope.launch {
val currency = store.inject(DaggerGraphState::appCurrencyRepository)
.getSelectedAppCurrency()
.firstOrNull()
?: AppCurrency.Default
store.dispatchWithMain(GlobalAction.RestoreAppCurrency.Success(currency))
}
}

View file

@ -13,13 +13,6 @@ fun globalReducer(action: Action, state: AppState): GlobalState {
is GlobalAction.SaveScanResponse -> {
globalState.copy(scanResponse = action.scanResponse)
}
is GlobalAction.ChangeAppCurrency -> {
globalState.copy(appCurrency = action.appCurrency)
}
is GlobalAction.RestoreAppCurrency.Success -> {
globalState.copy(appCurrency = action.appCurrency)
}
is GlobalAction.IsSignWithRing -> globalState.copy(isLastSignWithRing = action.isSignWithRing)
else -> globalState
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.tap.common.redux.global
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.TapWalletManager
import org.rekotlin.StateType
@ -9,7 +8,6 @@ data class GlobalState(
@Deprecated("Use scan response from selected user wallet")
val scanResponse: ScanResponse? = null,
val tapWalletManager: TapWalletManager = TapWalletManager(),
val appCurrency: AppCurrency = AppCurrency.Default,
val isLastSignWithRing: Boolean = false,
) : StateType

View file

@ -1,79 +0,0 @@
package com.tangem.tap.common.redux.legacy
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.redux.LegacyAction
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.details.redux.AppSettingsState
import com.tangem.tap.features.details.redux.DetailsAction
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import org.rekotlin.Middleware
@Suppress("MemberNameEqualsClassName")
internal object LegacyMiddleware {
private val prepareDetailsScreenJobHolder = JobHolder()
val legacyMiddleware: Middleware<AppState> = { _, _ ->
{ next ->
{ action ->
when (action) {
is LegacyAction.PrepareDetailsScreen -> {
selectedUserWallet()
.distinctUntilChanged { old, new ->
if (old is UserWallet.Cold && new is UserWallet.Cold) {
old.walletId == new.walletId &&
old.scanResponse == new.scanResponse
} else {
old.walletId == new.walletId
}
}
.onEach { selectedUserWallet ->
val initializedAppSettingsStateContent = initializeAppSettingsState()
store.dispatchWithMain(
DetailsAction.PrepareScreen(
scanResponse = (selectedUserWallet as? UserWallet.Cold)?.scanResponse,
initializedAppSettingsState = initializedAppSettingsStateContent,
),
)
}
.flowOn(Dispatchers.IO)
.launchIn(scope)
.saveIn(prepareDetailsScreenJobHolder)
}
}
next(action)
}
}
}
private fun selectedUserWallet(): Flow<UserWallet> {
return store.inject(DaggerGraphState::userWalletsListRepository).selectedUserWallet.filterNotNull()
}
/**
* LEGACY: We need to initialize [AppSettingsState] async to avoid drawing blocking
* previously it was initialized in runBlocking and blocked details screen
*/
private suspend fun initializeAppSettingsState(): AppSettingsState {
return AppSettingsState(
selectedAppCurrency = store.state.globalState.appCurrency,
selectedThemeMode = store.inject(DaggerGraphState::appThemeModeRepository).getAppThemeMode().firstOrNull()
?: AppThemeMode.DEFAULT,
requireAccessCode = store.inject(DaggerGraphState::walletsRepository).requireAccessCode(),
useBiometricAuthentication = store.inject(DaggerGraphState::walletsRepository).useBiometricAuthentication(),
isHidingEnabled = store.inject(DaggerGraphState::balanceHidingRepository)
.getBalanceHidingSettings().isHidingEnabledInSettings,
needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true,
hasSecuredWallets = store.inject(DaggerGraphState::userWalletsListRepository).hasSecuredWallets(),
)
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.tap.di.domain
import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object DynamicAddressesDomainModule {
@Provides
@Singleton
fun provideEnableDynamicAddressesUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): EnableDynamicAddressesUseCase {
return EnableDynamicAddressesUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideDisableDynamicAddressesUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): DisableDynamicAddressesUseCase {
return DisableDynamicAddressesUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideGetDynamicAddressesStatusUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): GetDynamicAddressesStatusUseCase {
return GetDynamicAddressesStatusUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideGetDynamicReceiveAddressUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): GetDynamicReceiveAddressUseCase {
return GetDynamicReceiveAddressUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideCreateConsolidationTransactionUseCase(
consolidationRepository: ConsolidationRepository,
): CreateConsolidationTransactionUseCase {
return CreateConsolidationTransactionUseCase(consolidationRepository)
}
@Provides
@Singleton
fun provideIsXpubSupportedUseCase(walletManagersFacade: WalletManagersFacade): IsXpubSupportedUseCase {
return IsXpubSupportedUseCase(walletManagersFacade)
}
@Provides
@Singleton
fun provideIsXpubDerivedUseCase(
walletManagersFacade: WalletManagersFacade,
derivationsRepository: DerivationsRepository,
): IsXpubDerivedUseCase {
return IsXpubDerivedUseCase(walletManagersFacade, derivationsRepository)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.domain.staking.*
import com.tangem.domain.staking.repositories.*
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.staking.single.SingleStakingBalanceFetcher
import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -226,8 +227,14 @@ internal object StakingDomainModule {
@Provides
@Singleton
fun provideStakingIdFactory(walletManagersFacade: WalletManagersFacade): StakingIdFactory {
return StakingIdFactory(walletManagersFacade = walletManagersFacade)
fun provideStakingIdFactory(
walletManagersFacade: WalletManagersFacade,
stakingFeatureToggles: StakingFeatureToggles,
): StakingIdFactory {
return StakingIdFactory(
walletManagersFacade = walletManagersFacade,
stakingFeatureToggles = stakingFeatureToggles,
)
}
@Provides

View file

@ -33,7 +33,7 @@ class FinalizeTwinTask(
visaCardScanHandler = null,
visaCoroutineScope = null,
shouldCheckIsAlreadyActivated = false,
isDynamicAddressesEnabled = false,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
onboardingV2FeatureToggles = null,
).run(session, callback)
is CompletionResult.Failure ->

View file

@ -2,18 +2,12 @@ package com.tangem.tap.features.details.redux
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.scan.ScanResponse
import kotlinx.coroutines.CoroutineScope
import org.rekotlin.Action
@Suppress("BooleanPropertyNaming")
sealed class DetailsAction : Action {
data class PrepareScreen(
val scanResponse: ScanResponse?,
val initializedAppSettingsState: AppSettingsState,
) : DetailsAction()
sealed class AppSettings : DetailsAction() {
data class SwitchPrivacySetting(
val enable: Boolean,
@ -50,6 +44,4 @@ sealed class DetailsAction : Action {
data class Prepare(val state: AppSettingsState) : AppSettings()
}
data class ChangeAppCurrency(val currency: AppCurrency) : DetailsAction()
}

View file

@ -12,7 +12,6 @@ import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
@ -78,10 +77,7 @@ class DetailsMiddleware {
is DetailsAction.AppSettings.ChangeBalanceHiding -> {
changeBalanceHiding(action.shouldHideBalance)
}
is DetailsAction.AppSettings.ChangeAppCurrency -> {
store.dispatch(GlobalAction.ChangeAppCurrency(action.currency))
store.dispatch(DetailsAction.ChangeAppCurrency(action.currency))
}
is DetailsAction.AppSettings.ChangeAppCurrency,
is DetailsAction.AppSettings.SwitchPrivacySetting.Success,
is DetailsAction.AppSettings.SwitchPrivacySetting.Failure,
is DetailsAction.AppSettings.BiometricsStatusChanged,

View file

@ -12,27 +12,12 @@ private fun internalReduce(action: Action, state: AppState): DetailsState {
if (action !is DetailsAction) return state.detailsState
val detailsState = state.detailsState
return when (action) {
is DetailsAction.PrepareScreen -> {
handlePrepareScreen(action)
}
is DetailsAction.AppSettings -> {
handlePrivacyAction(action, detailsState)
}
is DetailsAction.ChangeAppCurrency -> detailsState.copy(
appSettingsState = detailsState.appSettingsState.copy(
selectedAppCurrency = action.currency,
),
)
}
}
private fun handlePrepareScreen(action: DetailsAction.PrepareScreen): DetailsState {
return DetailsState(
scanResponse = action.scanResponse,
appSettingsState = action.initializedAppSettingsState,
)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: DetailsState): DetailsState {
return when (action) {
@ -94,6 +79,7 @@ private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: Detail
useBiometricAuthentication = action.state.useBiometricAuthentication,
requireAccessCode = action.state.requireAccessCode,
hasSecuredWallets = action.state.hasSecuredWallets,
needEnrollBiometrics = action.state.needEnrollBiometrics,
),
)
is DetailsAction.AppSettings.EnrollBiometrics,

View file

@ -2,12 +2,9 @@ package com.tangem.tap.features.details.redux
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.StateType
data class DetailsState(
@Deprecated("Delete after onboarding refactoring")
val scanResponse: ScanResponse? = null,
val appSettingsState: AppSettingsState = AppSettingsState(),
) : StateType

View file

@ -14,6 +14,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
@ -54,6 +55,7 @@ internal class AppSettingsModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val appThemeModeRepository: AppThemeModeRepository,
private val appSettingsItemsAnalyticsSender: AppSettingsItemsAnalyticsSender,
private val tangemSdkManager: TangemSdkManager,
private val uiMessageSender: UiMessageSender,
) : Model(), StoreSubscriber<DetailsState> {
@ -251,9 +253,8 @@ internal class AppSettingsModel @Inject constructor(
private fun bootstrapAppCurrencyUpdates() {
appCurrencyRepository
.getSelectedAppCurrency()
.distinctUntilChanged()
.onEach { appCurrency ->
if (appCurrency.code == store.state.globalState.appCurrency.code) return@onEach
store.dispatchWithMain(DetailsAction.AppSettings.ChangeAppCurrency(appCurrency))
}
.launchIn(scope)
@ -267,6 +268,7 @@ internal class AppSettingsModel @Inject constructor(
isHidingEnabled = balanceHidingRepository.getBalanceHidingSettings().isHidingEnabledInSettings,
selectedAppCurrency = appCurrencyRepository.getSelectedAppCurrency().firstOrNull() ?: AppCurrency.Default,
selectedThemeMode = appThemeModeRepository.getAppThemeMode().firstOrNull() ?: AppThemeMode.DEFAULT,
needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true,
hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(),
)

View file

@ -27,6 +27,8 @@ class AccountCryptoPortfolioItemStateConverter(
private val priceChangeLce: Lce<Unit, PriceChange>? = null,
private val onItemClick: ((Account.CryptoPortfolio) -> Unit)? = null,
private val onItemLongClick: ((Account.CryptoPortfolio) -> Unit)? = null,
private val fiatAmountStateProvider: ((TotalFiatBalance) -> FiatAmountState?)? = null,
private val subtitle2StateProvider: ((Lce<Unit, PriceChange>) -> Subtitle2State?)? = null,
) : Converter<TotalFiatBalance, TokenItemState> {
override fun convert(value: TotalFiatBalance): TokenItemState {
@ -40,11 +42,11 @@ class AccountCryptoPortfolioItemStateConverter(
private fun Account.CryptoPortfolio.mapToContentState(
fiatBalance: TotalFiatBalance.Loaded,
): TokenItemState.Content {
val subtitle2State = priceChangeLce?.fold(
ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading },
ifError = { null },
ifContent = { priceChange -> priceChange.toSubtitle2State() },
)
val subtitle2State = if (priceChangeLce != null && subtitle2StateProvider != null) {
subtitle2StateProvider(priceChangeLce)
} else {
createSubtitle2State(priceChangeLce)
}
return TokenItemState.Content(
id = account.accountId.toItemId(),
iconState = AccountIconItemStateConverter().convert(this),
@ -59,11 +61,8 @@ class AccountCryptoPortfolioItemStateConverter(
),
isAvailable = false,
),
fiatAmountState = FiatAmountState.Content(
text = fiatBalance.amount
.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) },
isFlickering = fiatBalance.source == StatusSource.CACHE,
),
fiatAmountState = fiatAmountStateProvider?.invoke(fiatBalance)
?: createFiatAmountState(fiatBalance, appCurrency),
subtitle2State = subtitle2State,
onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } },
onItemLongClick = onItemLongClick?.let { onItemLongClick -> { onItemLongClick(account) } },
@ -127,4 +126,30 @@ class AccountCryptoPortfolioItemStateConverter(
type = this.value.getPriceChangeType(),
isFlickering = this.source.isFlickering(),
)
private fun createFiatAmountState(fiatBalance: TotalFiatBalance, appCurrency: AppCurrency): FiatAmountState {
return when (fiatBalance) {
TotalFiatBalance.Failed,
TotalFiatBalance.Loading,
-> FiatAmountState.Empty
is TotalFiatBalance.Loaded -> FiatAmountState.Content(
text = fiatBalance.amount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
isFlickering = fiatBalance.source == StatusSource.CACHE,
)
}
}
private fun createSubtitle2State(priceChangeLce: Lce<Unit, PriceChange>?): Subtitle2State? {
return priceChangeLce?.fold(
ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading },
ifError = { null },
ifContent = { priceChange -> priceChange.toSubtitle2State() },
)
}
}

View file

@ -1,8 +1,10 @@
package com.tangem.common.ui.navigationButtons
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
sealed class NavigationButtonsState {
data object Empty : NavigationButtonsState()

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.R
import com.tangem.core.ui.components.account.AccountCharIcon
import com.tangem.core.ui.components.account.AccountResIcon
import com.tangem.core.ui.components.account.PaymentAccountIcon
import com.tangem.core.ui.components.currency.DefaultCurrencyIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@ -61,6 +62,7 @@ internal fun ContentIcon(
background = icon.background,
alpha = alpha,
)
is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(modifier = modifier, size = icon.size)
is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon(
modifier = modifier,
resId = icon.resId,

View file

@ -62,6 +62,7 @@ fun CurrencyIcon(
is CurrencyIconState.FiatIcon,
is CurrencyIconState.CustomTokenIcon,
is CurrencyIconState.TokenIcon,
is CurrencyIconState.PaymentAccount,
is CurrencyIconState.CryptoPortfolio.Icon,
is CurrencyIconState.CryptoPortfolio.Letter,
-> {

View file

@ -88,6 +88,12 @@ sealed class CurrencyIconState {
override val topBadgeIconResId: Int? = null
}
data class PaymentAccount(val size: AccountIconSize = AccountIconSize.Default) : CurrencyIconState() {
override val isGrayscale: Boolean = false
override val shouldShowCustomBadge: Boolean = false
override val topBadgeIconResId: Int? = null
}
@Immutable
sealed class CryptoPortfolio : CurrencyIconState() {
override val shouldShowCustomBadge: Boolean = false
@ -155,6 +161,7 @@ sealed class CurrencyIconState {
is CryptoPortfolio.Letter -> copy(
isGrayscale = isGrayscale,
)
is PaymentAccount,
is Loading,
is Locked,
is Empty,

View file

@ -9,4 +9,5 @@ data class SearchBarUM(
val isActive: Boolean,
val onActiveChange: (Boolean) -> Unit,
val onClearClick: () -> Unit = {},
val onCancelClick: (() -> Unit)? = null,
)

View file

@ -2,7 +2,8 @@ package com.tangem.core.ui.components.tokenlist
import androidx.compose.animation.*
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
import androidx.compose.animation.core.*
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
@ -24,6 +25,7 @@ import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.account.AccountCharIcon
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.account.AccountResIcon
import com.tangem.core.ui.components.account.PaymentAccountIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.SearchBar
@ -98,6 +100,9 @@ fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolea
icon.copy(
size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default,
)
is CurrencyIconState.PaymentAccount -> icon.copy(
size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default,
)
else -> icon
}
@ -183,7 +188,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B
}
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun ExpandedPortfolioHeader(
state: TokenItemState,
@ -209,6 +214,7 @@ fun ExpandedPortfolioHeader(
composables.icon.invoke(Modifier)
} else {
when (val icon = state.iconState) {
is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(size = icon.size)
is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon(
resId = icon.resId,
color = icon.color,

View file

@ -55,6 +55,12 @@ sealed interface TokensListItemUM {
is PortfolioItemContentUM.Tokens -> content.tokens
is PortfolioItemContentUM.Empty -> persistentListOf()
}
val tokensItemsList: List<Token>
get() = when (content) {
is PortfolioItemContentUM.Tokens -> content.tokens.filterIsInstance<Token>()
is PortfolioItemContentUM.Empty -> emptyList()
}
}
data class Text(override val id: Any, val text: TextReference) : TokensListItemUM

View file

@ -289,8 +289,13 @@ private fun CancelButton(
state.onQueryChange("")
}
keyboardController?.hide()
state.onClearClick()
focusManager.clearFocus()
val onCancel = state.onCancelClick
if (onCancel != null) {
onCancel()
} else {
state.onClearClick()
focusManager.clearFocus()
}
},
)
}

View file

@ -169,6 +169,7 @@ private fun BoxScope.CurrencyIconBackgroundLayer(state: CurrencyIconState, blurR
blurRadius = blurRadius,
)
}
is CurrencyIconState.PaymentAccount -> Unit
CurrencyIconState.Loading -> Unit
CurrencyIconState.Locked -> Unit
}
@ -193,7 +194,7 @@ private fun BoxScope.UrlBackground(imageUrl: String?, blurRadius: Dp) {
AsyncImage(
model = imageRequest,
contentDescription = null,
contentScale = ContentScale.Crop,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.matchParentSize()
.scale(SCALE_FACTOR)
@ -207,7 +208,7 @@ private fun BoxScope.ResBackground(res: Int, blurRadius: Dp) {
Image(
painter = painterResource(res),
contentDescription = null,
contentScale = ContentScale.Crop,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.matchParentSize()
.scale(SCALE_FACTOR)

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11.999,1.25C13.68,1.25 15.047,1.818 16.24,2.436C16.6,2.622 16.935,2.807 17.255,2.983C18.038,3.415 18.733,3.797 19.495,4.04C19.942,4.183 20.338,4.31 20.619,4.42C20.883,4.523 21.198,4.665 21.42,4.913C21.621,5.136 21.723,5.385 21.792,5.624C21.854,5.841 21.907,6.135 21.958,6.417C23.196,13.282 20.492,19.787 13.881,22.318C13.232,22.567 12.753,22.75 12.002,22.75C11.251,22.75 10.772,22.567 10.123,22.318C3.512,19.788 0.806,13.283 2.043,6.417C2.094,6.135 2.147,5.841 2.209,5.624C2.277,5.385 2.38,5.135 2.58,4.913C2.802,4.665 3.117,4.524 3.381,4.42C3.662,4.31 4.059,4.183 4.506,4.04C5.268,3.797 5.961,3.415 6.744,2.983C7.063,2.807 7.398,2.622 7.758,2.436C8.95,1.818 10.317,1.25 11.999,1.25ZM17.942,8.666C17.757,8.146 17.186,7.873 16.666,8.058C15.788,8.369 14.948,8.961 14.212,9.607C13.465,10.262 12.766,11.023 12.172,11.734C11.733,12.261 11.345,12.769 11.03,13.199C10.741,12.854 10.453,12.601 10.179,12.418C9.905,12.235 9.552,12 9,12C8.447,12 8,12.448 8,13C8,13.521 8.397,13.949 8.906,13.996C9.11,14.015 9.664,14.566 10.105,15.447C10.266,15.77 10.589,15.981 10.949,15.999C11.309,16.017 11.65,15.84 11.843,15.536C11.849,15.528 12.193,15.012 12.357,14.78C12.688,14.311 13.157,13.677 13.708,13.016C14.262,12.352 14.887,11.675 15.53,11.111C16.183,10.539 16.8,10.132 17.333,9.943C17.854,9.759 18.126,9.187 17.942,8.666Z"
android:fillColor="#0099FF"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="72dp"
android:height="72dp"
android:viewportWidth="72"
android:viewportHeight="72">
<group>
<clip-path
android:pathData="M36,0L36,0A36,36 0,0 1,72 36L72,36A36,36 0,0 1,36 72L36,72A36,36 0,0 1,0 36L0,36A36,36 0,0 1,36 0z"/>
<path
android:pathData="M0,0h72v72h-72z"
android:strokeAlpha="0.1"
android:fillColor="#0099FF"
android:fillAlpha="0.1"/>
<path
android:pathData="M24.607,31.142C23.778,32.206 22.682,31.815 22.291,30.454L20.319,23.678C20.006,22.598 20.663,21.753 21.79,21.8L28.848,22.035C30.256,22.081 30.882,23.067 30.069,24.116L28.66,25.915L28.723,25.947C31.602,27.856 35.123,31.831 35.968,34.46H36.031C36.86,31.846 40.397,27.856 43.276,25.947L43.354,25.9L41.915,23.912C41.132,22.833 41.774,21.862 43.198,21.878L50.256,21.847C51.382,21.847 52.008,22.723 51.664,23.803L49.473,30.501C49.051,31.846 47.924,32.206 47.126,31.095L45.608,28.998L45.264,29.217C42.118,31.22 37.893,36.15 37.893,39.734V48.106C37.893,49.467 37.22,50.203 36,50.203C34.779,50.203 34.122,49.467 34.122,48.106V39.734C34.122,36.15 29.865,31.22 26.735,29.217L26.313,28.951L24.607,31.142Z"
android:fillColor="#0099FF"/>
</group>
</vector>

View file

@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<group>
<clip-path
android:pathData="M28,0L28,0A28,28 0,0 1,56 28L56,28A28,28 0,0 1,28 56L28,56A28,28 0,0 1,0 28L0,28A28,28 0,0 1,28 0z"/>
<path
android:pathData="M0,0h56v56h-56z"
android:strokeAlpha="0.1"
android:fillColor="#FFB71B"
android:fillAlpha="0.1"/>
<path
android:pathData="M41.646,35.377C41.646,37.37 40.305,38.906 38.091,38.906H17.909C15.696,38.906 14.354,37.37 14.354,35.377C14.354,34.765 14.511,34.14 14.862,33.555L24.966,15.95C25.631,14.766 26.802,14.167 28,14.167C29.198,14.167 30.357,14.766 31.034,15.95L41.138,33.555C41.464,34.127 41.646,34.765 41.646,35.377ZM28.013,30.208C27.336,30.208 26.959,29.817 26.946,29.127L26.776,22.044C26.763,21.354 27.271,20.859 28,20.859C28.703,20.859 29.25,21.367 29.237,22.057L29.042,29.127C29.029,29.831 28.651,30.208 28.013,30.208ZM28.013,34.57C27.232,34.57 26.555,33.945 26.555,33.177C26.555,32.396 27.219,31.771 28.013,31.771C28.795,31.771 29.459,32.383 29.459,33.177C29.459,33.958 28.782,34.57 28.013,34.57Z"
android:fillColor="#FFB71B"
android:fillType="evenOdd"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.608,1.147C14.314,1.448 14.797,2.163 14.797,3.017L14.798,9.785C14.798,9.895 14.887,9.985 14.998,9.985H18.099C18.985,9.985 19.595,10.583 19.847,11.21C20.097,11.837 20.064,12.642 19.563,13.285L12.564,22.268C12.003,22.988 11.12,23.163 10.392,22.852C9.685,22.551 9.202,21.837 9.202,20.983L9.202,14.215C9.202,14.104 9.112,14.015 9.002,14.015H5.9C5.014,14.015 4.404,13.417 4.153,12.789C3.902,12.163 3.936,11.358 4.437,10.715L11.435,1.732C11.996,1.011 12.879,0.837 13.608,1.147Z"
android:fillColor="#0099FF"/>
</vector>

View file

@ -30,6 +30,7 @@ dependencies {
// region Project - Libs
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
implementation(tangemDeps.card.core)
// endregion
// region DI

View file

@ -1,5 +1,6 @@
package com.tangem.data.dynamicaddresses
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
@ -10,6 +11,7 @@ import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
@ -31,17 +33,20 @@ internal class DefaultDynamicAddressesRepository(
.map { response ->
val token = response.findToken(network)
when {
token?.dynamicAddressesEnabled == true -> DynamicAddressesStatus.ENABLED
else -> DynamicAddressesStatus.DISABLED
token?.dynamicAddressesEnabled != true -> DynamicAddressesStatus.DISABLED
!isXpubAvailable(userWalletId, network) -> DynamicAddressesStatus.ENABLED_REQUIRES_SETUP
else -> DynamicAddressesStatus.ENABLED
}
// TODO handle ENABLED_REQUIRES_SETUP when XPUB is not derived locally
}
.flowOn(dispatchers.io)
}
override suspend fun enable(userWalletId: UserWalletId, network: Network, xpub: String) {
withContext(dispatchers.io) {
walletManagersFacade.enableXpubMode(userWalletId, network, xpub)
val result = walletManagersFacade.enableXpubMode(userWalletId, network, xpub)
if (result is SimpleResult.Failure) {
error("Failed to enable xpub mode for $userWalletId / ${network.id}: ${result.error}")
}
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) }
@ -50,7 +55,10 @@ internal class DefaultDynamicAddressesRepository(
override suspend fun disable(userWalletId: UserWalletId, network: Network) {
withContext(dispatchers.io) {
walletManagersFacade.disableXpubMode(userWalletId, network)
val result = walletManagersFacade.disableXpubMode(userWalletId, network)
if (result is SimpleResult.Failure) {
error("Failed to disable xpub mode for $userWalletId / ${network.id}: ${result.error}")
}
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) }
@ -70,6 +78,45 @@ internal class DefaultDynamicAddressesRepository(
return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network)
}
override suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean {
return withContext(dispatchers.io) {
val response = walletAccountsFetcher.getSaved(userWalletId) ?: return@withContext false
val baseDerivationPath = network.derivationPath.value ?: return@withContext false
response.accounts
.flatMap { it.tokens.orEmpty() }
.any { token ->
val tokenDerivationPath = token.derivationPath ?: return@any false
token.networkId == network.backendId &&
tokenDerivationPath != baseDerivationPath &&
hasNonZeroChangeOrIndex(tokenDerivationPath, baseDerivationPath)
}
}
}
/**
* Checks if the token's derivation path has the same first 3 nodes (purpose/coin/account)
* as the base path but different change/index nodes (not both 0).
*/
private fun hasNonZeroChangeOrIndex(tokenPath: String, basePath: String): Boolean {
val tokenNodes = runCatching { DerivationPath(tokenPath).nodes }.getOrNull() ?: return false
val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false
if (tokenNodes.size < DERIVATION_NODE_COUNT || baseNodes.size < DERIVATION_NODE_COUNT) return false
// First 3 nodes must match (purpose/coin/account) by value, ignoring hardening
val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i ->
tokenNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false)
}
if (!isSameAccount) return false
// Check if change or index ≠ 0
val change = tokenNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)
val index = tokenNodes[INDEX_NODE_INDEX].getIndex(includeHardened = false)
return change != 0L || index != 0L
}
private suspend fun updateTokenDynamicAddressesFlag(
userWalletId: UserWalletId,
network: Network,
@ -92,6 +139,11 @@ internal class DefaultDynamicAddressesRepository(
}
}
private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean {
// Check if WalletManager is already in XPUB mode (DA was previously enabled on this device)
return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null
}
private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? {
return accounts
.flatMap { it.tokens.orEmpty() }
@ -103,4 +155,11 @@ internal class DefaultDynamicAddressesRepository(
derivationPath == network.derivationPath.value &&
contractAddress == null
}
private companion object {
const val DERIVATION_NODE_COUNT = 5
const val ACCOUNT_NODE_COUNT = 3
const val CHANGE_NODE_INDEX = 3
const val INDEX_NODE_INDEX = 4
}
}

View file

@ -22,6 +22,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
@ -65,7 +66,7 @@ internal class DefaultP2PEthPoolRepository(
}
override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
val vaults = if (stakingFeatureToggles.isEthStakingEnabled) {
val vaults = if (stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)) {
getVaults(network).getOrElse { error ->
TangemLogger.e("Error fetching P2PEthPool vaults: $error")
emptyList()

View file

@ -1,8 +1,6 @@
package com.tangem.data.staking
import arrow.core.getOrElse
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.staking.store.StakeKitBalancesStore
import com.tangem.domain.card.common.TapWorkarounds.isWallet2
import com.tangem.domain.models.currency.CryptoCurrency
@ -22,14 +20,13 @@ import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.withContext
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
internal class DefaultStakingRepository(
private val stakeKitRepository: StakeKitRepository,
private val p2pEthPoolRepository: P2PEthPoolRepository,
private val stakingBalanceStoreV2: StakeKitBalancesStore,
private val stakeKitBalancesStore: StakeKitBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val stakingFeatureToggles: StakingFeatureToggles,
@ -40,7 +37,8 @@ internal class DefaultStakingRepository(
cryptoCurrency: CryptoCurrency,
): Flow<StakingAvailability> {
return channelFlow {
if (!checkFeatureToggleEnabled(cryptoCurrency)) {
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
if (stakingIntegration == null || !stakingFeatureToggles.isIntegrationEnabled(stakingIntegration)) {
send(StakingAvailability.Unavailable)
return@channelFlow
}
@ -56,8 +54,6 @@ internal class DefaultStakingRepository(
return@channelFlow
}
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
val availabilityFlow = when (stakingIntegration) {
StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability()
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability(
@ -65,7 +61,6 @@ internal class DefaultStakingRepository(
rawCurrencyId,
cryptoCurrency.symbol,
)
null -> flowOf(StakingAvailability.Unavailable)
}
availabilityFlow.collect { send(it) }
@ -76,20 +71,15 @@ internal class DefaultStakingRepository(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): StakingAvailability {
if (!checkFeatureToggleEnabled(cryptoCurrency)) {
return StakingAvailability.Unavailable
}
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
?.takeIf(stakingFeatureToggles::isIntegrationEnabled)
?: return StakingAvailability.Unavailable
if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) {
return StakingAvailability.Unavailable
}
val rawCurrencyId = cryptoCurrency.id.rawCurrencyId
if (rawCurrencyId == null) {
return StakingAvailability.Unavailable
}
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
?: return StakingAvailability.Unavailable
return when (stakingIntegration) {
@ -104,7 +94,7 @@ internal class DefaultStakingRepository(
override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.default) {
val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false
val balances = stakeKitBalancesStore.getAllSyncOrNull(userWalletId) ?: return@withContext false
val hasDataStakingBalance by lazy {
balances.any { stakingBalance ->
@ -116,18 +106,6 @@ internal class DefaultStakingRepository(
}
}
private fun checkFeatureToggleEnabled(cryptoCurrency: CryptoCurrency): Boolean {
return when (cryptoCurrency.network.id.toBlockchain()) {
Blockchain.Ethereum -> {
when (cryptoCurrency) {
is CryptoCurrency.Coin -> stakingFeatureToggles.isEthStakingEnabled
is CryptoCurrency.Token -> true
}
}
else -> true
}
}
private fun checkForInvalidCardBatch(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
val userWallet = getUserWalletUseCase(userWalletId).getOrElse {
error("Failed to get user wallet")

View file

@ -8,11 +8,11 @@ import com.tangem.data.staking.store.StakeKitBalancesStore
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import com.tangem.domain.staking.single.SingleStakingBalanceSupplier
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -54,7 +54,7 @@ internal object StakingBalanceSupplierModule {
fun provideSingleStakingBalanceSupplier(
factory: SingleStakingBalanceProducer.Factory,
): SingleStakingBalanceSupplier {
return object : SingleStakingBalanceSupplier(
return SingleStakingBalanceSupplier(
factory = factory,
keyCreator = { params ->
listOf(
@ -65,15 +65,15 @@ internal object StakingBalanceSupplierModule {
)
.joinToString(separator = "_")
},
) {}
)
}
@Provides
@Singleton
fun provideMultiStakingBalanceSupplier(factory: MultiStakingBalanceProducer.Factory): MultiStakingBalanceSupplier {
return object : MultiStakingBalanceSupplier(
return MultiStakingBalanceSupplier(
factory = factory,
keyCreator = { "multi_staking_balances_${it.userWalletId.stringValue}" },
) {}
)
}
}

View file

@ -65,7 +65,7 @@ internal object StakingDataModule {
return DefaultStakingRepository(
stakeKitRepository = stakeKitRepository,
p2pEthPoolRepository = p2pEthPoolRepository,
stakingBalanceStoreV2 = stakeKitBalancesStore,
stakeKitBalancesStore = stakeKitBalancesStore,
dispatchers = dispatchers,
getUserWalletUseCase = getUserWalletUseCase,
stakingFeatureToggles = stakingFeatureToggles,

View file

@ -2,12 +2,35 @@ package com.tangem.data.staking.toggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
internal class DefaultStakingFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : StakingFeatureToggles {
override val isEthStakingEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED)
override fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean {
val toggle = integrationId.getFeatureToggle() ?: return true
return featureTogglesManager.isFeatureEnabled(toggle)
}
private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) {
is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED
is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle()
}
private fun StakingIntegrationID.StakeKit.getStakeKitFeatureToggle(): FeatureToggles? = when (this) {
is StakingIntegrationID.StakeKit.Coin -> when (this) {
StakingIntegrationID.StakeKit.Coin.Ton,
StakingIntegrationID.StakeKit.Coin.Solana,
StakingIntegrationID.StakeKit.Coin.Cosmos,
StakingIntegrationID.StakeKit.Coin.Tron,
StakingIntegrationID.StakeKit.Coin.BSC,
StakingIntegrationID.StakeKit.Coin.Cardano,
-> null
}
is StakingIntegrationID.StakeKit.EthereumToken -> when (this) {
StakingIntegrationID.StakeKit.EthereumToken.Polygon -> null
}
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.staking.toggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.staking.model.StakingIntegrationID
import com.google.common.truth.Truth.assertThat
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
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 DefaultStakingFeatureTogglesTest {
private val featureTogglesManager: FeatureTogglesManager = mockk()
private val toggles = DefaultStakingFeatureToggles(featureTogglesManager = featureTogglesManager)
@BeforeEach
fun resetMocks() {
clearMocks(featureTogglesManager)
}
@Test
fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
}
@Test
fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() {
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse()
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
}
@Test
fun `existing StakeKit Coin integrations are always enabled`() {
StakingIntegrationID.StakeKit.Coin.entries.forEach { coin ->
assertThat(toggles.isIntegrationEnabled(coin)).isTrue()
}
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
}
@Test
fun `existing StakeKit EthereumToken integrations are always enabled`() {
StakingIntegrationID.StakeKit.EthereumToken.entries.forEach { token ->
assertThat(toggles.isIntegrationEnabled(token)).isTrue()
}
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
}
}

View file

@ -7,8 +7,8 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.error.UniversalError
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
@ -16,14 +16,8 @@ import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
private const val TAG = "TangemPay: DefaultTangemPayCryptoCurrencyFactory"
/**
* Custom token parameters. Will be used only for F&F.
*/
private const val TOKEN_ID = "usd-coin"
private const val TOKEN_NAME = "USDC"
private const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
private const val TOKEN_DECIMALS = 6
@Deprecated("Use TangemPayCurrencyFactory instead")
internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor(
excludedBlockchains: ExcludedBlockchains,
private val errorConverter: TangemPayErrorConverter,
@ -47,32 +41,11 @@ internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor(
)
cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TOKEN_ID),
name = TOKEN_NAME,
symbol = TOKEN_NAME,
contractAddress = TOKEN_CONTRACT_ADDRESS,
decimals = TOKEN_DECIMALS,
)
}.mapLeft { exception ->
TangemLogger.withTag(TAG).e("Error", exception)
errorConverter.convert(exception)
}
}
override fun create(userWallet: UserWallet): Either<UniversalError, CryptoCurrency.Token> {
return catch {
val network = networkFactory.create(
blockchain = VisaUtilities.visaBlockchain,
extraDerivationPath = null,
userWallet = userWallet,
)
cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TOKEN_ID),
name = TOKEN_NAME,
symbol = TOKEN_NAME,
contractAddress = TOKEN_CONTRACT_ADDRESS,
decimals = TOKEN_DECIMALS,
rawId = CryptoCurrency.RawID(TangemPayCurrencyFactory.TOKEN_ID),
name = TangemPayCurrencyFactory.TOKEN_NAME,
symbol = TangemPayCurrencyFactory.TOKEN_NAME,
contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS,
decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS,
)
}.mapLeft { exception ->
TangemLogger.withTag(TAG).e("Error", exception)

View file

@ -1,11 +1,12 @@
package com.tangem.data.pay.converter
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convert
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convertBack
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.utils.converter.TwoWayConverter
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
import javax.inject.Singleton
/**
* Two-way converter between [PaymentAccountStatusValue] and [PaymentAccountStatusValueDM].
@ -15,10 +16,12 @@ import com.tangem.utils.converter.TwoWayConverter
*
* [convertBack] maps data model domain. All restored statuses have [StatusSource.CACHE] as source.
*/
internal object PaymentAccountStatusValueDMConverter :
TwoWayConverter<PaymentAccountStatusValue, PaymentAccountStatusValueDM?> {
@Singleton
internal class PaymentAccountStatusValueDMConverter @Inject constructor(
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
) {
override fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? {
fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? {
return when (value) {
is PaymentAccountStatusValue.NotCreated -> PaymentAccountStatusValueDM.NotCreated()
is PaymentAccountStatusValue.UnderReview -> PaymentAccountStatusValueDM.UnderReview(
@ -60,7 +63,7 @@ internal object PaymentAccountStatusValueDMConverter :
}
}
override fun convertBack(value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue {
fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue {
return when (value) {
is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated
is PaymentAccountStatusValueDM.CardIssueFailed -> PaymentAccountStatusValue.Error.CardIssueFailed(
@ -80,6 +83,7 @@ internal object PaymentAccountStatusValueDMConverter :
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
)
} else {
PaymentAccountStatusValue.Loaded(
@ -92,6 +96,7 @@ internal object PaymentAccountStatusValueDMConverter :
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
)
}
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(

View file

@ -6,6 +6,7 @@ import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer
import com.tangem.data.pay.repository.*
@ -24,6 +25,7 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
@ -112,6 +114,7 @@ internal interface TangemPayDataModule {
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
scope: AppCoroutineScope,
converter: PaymentAccountStatusValueDMConverter,
): PaymentAccountStatusesStore {
return PaymentAccountStatusesStore(
runtimeStore = RuntimeSharedStore(),
@ -124,6 +127,7 @@ internal interface TangemPayDataModule {
produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") },
scope = scope,
),
converter = converter,
scope = scope,
)
}
@ -139,6 +143,14 @@ internal interface TangemPayDataModule {
) {}
}
@Provides
@Singleton
fun provideGetTangemPayCryptoCurrencyStatusUseCase(
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
): GetPaymentAccountCryptoCurrencyStatusUseCase {
return GetPaymentAccountCryptoCurrencyStatusUseCase(paymentAccountStatusSupplier)
}
@Provides
@Singleton
fun provideTangemPayMainScreenCustomerInfoUseCase(

View file

@ -0,0 +1,49 @@
package com.tangem.data.pay.entity
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.requireUserWalletsSync
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class TangemPayCurrencyFactory @Inject constructor(
excludedBlockchains: ExcludedBlockchains,
private val userWalletsListRepository: UserWalletsListRepository,
private val networkFactory: NetworkFactory,
) {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)
}
fun create(userWalletId: UserWalletId): CryptoCurrency.Token {
val userWallet = userWalletsListRepository.requireUserWalletsSync()
.firstOrNull { it.walletId == userWalletId }
?: error("User wallet with id $userWalletId not found")
val network = networkFactory.create(
blockchain = VisaUtilities.visaBlockchain,
userWallet = userWallet,
extraDerivationPath = null,
)
return cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TOKEN_ID),
name = TOKEN_NAME,
symbol = TOKEN_NAME,
contractAddress = TOKEN_CONTRACT_ADDRESS,
decimals = TOKEN_DECIMALS,
)
}
companion object {
internal const val TOKEN_ID = "usd-coin"
internal const val TOKEN_NAME = "USDC"
internal const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
internal const val TOKEN_DECIMALS = 6
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.pay.flow
import arrow.core.Either
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.StatusSource
@ -8,6 +9,7 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderStatus
@ -29,6 +31,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val customerOrderRepository: CustomerOrderRepository,
private val deviceSecurity: DeviceSecurityInfoProvider,
private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -132,7 +135,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
},
ifRight = { customerInfo ->
logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}")
val status = customerInfo.mapToPaymentAccountStatus()
val status = customerInfo.mapToPaymentAccountStatus(account.userWalletId)
if (status is PaymentAccountStatusValue.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) {
// If order id wasn't saved -> start order creation and get customer info
onboardingRepository.createOrder(account.userWalletId)
@ -167,7 +170,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
},
ifRight = { customerInfo ->
if (customerInfo.kycStatus == KycStatus.REJECTED) {
customerInfo.mapToPaymentAccountStatus()
customerInfo.mapToPaymentAccountStatus(account.userWalletId)
} else {
PaymentAccountStatusValue.Error.CardIssueFailed(
customerId = orderData.customerId,
@ -182,7 +185,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId)
.fold(
ifLeft = { it.mapToPaymentAccountStatus() },
ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() },
ifRight = { it.mapToPaymentAccountStatus(account.userWalletId) },
)
}
OrderStatus.UNKNOWN -> PaymentAccountStatusValue.Error.Unavailable
@ -191,7 +194,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
)
}
private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatusValue {
private fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
val cardInfo = this.cardInfo
val productInstance = this.productInstance
return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) {
@ -202,6 +205,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
)
} else if (cardInfo != null && productInstance != null && !customerId.isNullOrEmpty()) {
convertToContentState(
userWalletId = userWalletId,
productInstance = productInstance,
cardInfo = cardInfo,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
@ -212,10 +216,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
}
private fun convertToContentState(
userWalletId: UserWalletId,
productInstance: CustomerInfo.ProductInstance,
cardInfo: CustomerInfo.CardInfo,
customerId: String,
): PaymentAccountStatusValue {
val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId)
return when (productInstance.frozenState) {
TangemPayCardFrozenState.Frozen -> PaymentAccountStatusValue.Locked(
source = StatusSource.ACTUAL,
@ -227,6 +233,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
isPinSet = cardInfo.isPinSet,
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
cryptoCurrency = cryptoCurrency,
)
else -> PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
@ -238,6 +245,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
isPinSet = cardInfo.isPinSet,
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
cryptoCurrency = cryptoCurrency,
)
}
}

View file

@ -29,6 +29,7 @@ internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatu
internal class PaymentAccountStatusesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithPaymentStatus>,
private val persistenceDataStore: DataStore<WalletIdWithPaymentStatusDM>,
private val converter: PaymentAccountStatusValueDMConverter,
scope: AppCoroutineScope,
) {
@ -39,7 +40,7 @@ internal class PaymentAccountStatusesStore(
runtimeStore.store(
value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) ->
val account = Account.Payment(userWalletId = UserWalletId(rawUserWalletId))
val statusValue = PaymentAccountStatusValueDMConverter.convertBack(value = statusDM)
val statusValue = converter.convertBack(userWalletId = account.userWalletId, value = statusDM)
AccountStatus.Payment(account = account, value = statusValue)
},
)
@ -87,7 +88,7 @@ internal class PaymentAccountStatusesStore(
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatusValue) {
val statusDM = PaymentAccountStatusValueDMConverter.convert(value = status) ?: return
val statusDM = converter.convert(value = status) ?: return
persistenceDataStore.updateData { storedStatuses ->
storedStatuses.toMutableMap().apply {
put(key = userWalletId.stringValue, value = statusDM)

View file

@ -2,6 +2,7 @@ package com.tangem.data.wallets.derivations
import arrow.core.getOrElse
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
@ -76,6 +77,23 @@ internal class DefaultDerivationsRepository @Inject constructor(
}
}
override suspend fun getExistingDerivedKeys(
userWalletId: UserWalletId,
seedKey: ByteArrayKey,
): ExtendedPublicKeysMap {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
return userWallet.getExistingDerivedKeys()[seedKey] ?: ExtendedPublicKeysMap(emptyMap())
}
private fun UserWallet.getExistingDerivedKeys(): Map<ByteArrayKey, ExtendedPublicKeysMap> {
return when (this) {
is UserWallet.Cold -> scanResponse.derivedKeys
is UserWallet.Hot -> wallets
?.associate { it.publicKey.toMapKey() to ExtendedPublicKeysMap(it.derivedKeys) }
.orEmpty()
}
}
override suspend fun hasMissedDerivations(
userWalletId: UserWalletId,
networksWithDerivationPath: Map<BackendId, String?>,

View file

@ -13,8 +13,12 @@ dependencies {
api(projects.domain.dynamicAddresses.models)
implementation(projects.domain.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
implementation(projects.libs.blockchainSdk)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
implementation(tangemDeps.card.core)
}

View file

@ -0,0 +1,53 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchain.common.Blockchain
/**
* List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode).
* Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
*
* Per ASMPT-005: DA is NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses.
* Only the default derivation style per blockchain is supported.
*/
object DynamicAddressesSupportedBlockchains {
private const val BIP44_PURPOSE = 44L
private const val BIP84_PURPOSE = 84L
private val supported = setOf(
Blockchain.Bitcoin,
Blockchain.BitcoinTestnet,
Blockchain.BitcoinCash,
Blockchain.BitcoinCashTestnet,
Blockchain.Litecoin,
Blockchain.Dogecoin,
Blockchain.Dash,
Blockchain.Ravencoin,
Blockchain.RavencoinTestnet,
)
private val supportedNetworkIds = supported.map { it.id }.toSet()
/**
* Allowed BIP purpose nodes per network ID.
* BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH).
*/
private val allowedPurposeByNetworkId: Map<String, Long> = buildMap {
put(Blockchain.Bitcoin.id, BIP84_PURPOSE)
put(Blockchain.BitcoinTestnet.id, BIP84_PURPOSE)
put(Blockchain.Litecoin.id, BIP84_PURPOSE)
put(Blockchain.BitcoinCash.id, BIP44_PURPOSE)
put(Blockchain.BitcoinCashTestnet.id, BIP44_PURPOSE)
put(Blockchain.Dogecoin.id, BIP44_PURPOSE)
put(Blockchain.Dash.id, BIP44_PURPOSE)
put(Blockchain.Ravencoin.id, BIP44_PURPOSE)
put(Blockchain.RavencoinTestnet.id, BIP44_PURPOSE)
}
fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported
fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds
/** Returns the allowed BIP purpose node for the given network, or null if not supported */
fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId]
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.dynamicaddresses
sealed class EnableDynamicAddressesError {
data object ConflictingCustomTokens : EnableDynamicAddressesError()
data class ServiceError(val cause: Throwable) : EnableDynamicAddressesError()
}

View file

@ -1,6 +1,8 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
@ -9,8 +11,19 @@ class EnableDynamicAddressesUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network, xpub: String): Either<Throwable, Unit> =
Either.catch {
suspend operator fun invoke(
userWalletId: UserWalletId,
network: Network,
xpub: String,
): Either<EnableDynamicAddressesError, Unit> {
return try {
if (dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)) {
return EnableDynamicAddressesError.ConflictingCustomTokens.left()
}
dynamicAddressesRepository.enable(userWalletId, network, xpub)
Unit.right()
} catch (e: Throwable) {
EnableDynamicAddressesError.ServiceError(e).left()
}
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
/**
* Checks if the account-level XPUB key is already derived (no card scan needed).
*/
class IsXpubDerivedUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val derivationsRepository: DerivationsRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean {
val blockchain = network.toBlockchain()
if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false
if (!blockchain.isBip44DerivationStyleXPUB()) return false
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false
val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return false
if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return false
val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT))
val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey)
val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey)
return existingKeys[accountPath] != null
}
private companion object {
const val ACCOUNT_PATH_DROP_COUNT = 2
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
/**
* Checks if XPUB generation is supported for the given wallet and network (hardware capability check).
*/
class IsXpubSupportedUseCase(
private val walletManagersFacade: WalletManagersFacade,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean {
val blockchain = network.toBlockchain()
if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false
return walletManager.wallet.publicKey.derivationType?.hdKey != null
}
}

View file

@ -19,4 +19,7 @@ interface DynamicAddressesRepository {
suspend fun getLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String?
suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean
/** Returns true if there are custom tokens with change/index ≠ 0 that conflict with DA */
suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean
}

View file

@ -1,8 +0,0 @@
package com.tangem.domain.redux
import org.rekotlin.Action
sealed interface LegacyAction : Action {
data object PrepareDetailsScreen : LegacyAction
}

View file

@ -2,9 +2,13 @@ package com.tangem.domain.models.account
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
import java.math.BigDecimal
/**
* Represents the various states a payment account can have, encapsulating different information based on the state.
@ -104,7 +108,29 @@ sealed class PaymentAccountStatusValue {
val isPinSet: Boolean,
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
) : PaymentAccountStatusValue()
val cryptoCurrency: CryptoCurrency.Token,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = CryptoCurrencyStatus.Loaded(
amount = cryptoBalance.balance,
fiatAmount = fiatBalance.availableBalance,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = cryptoBalance.depositAddress,
),
),
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
),
)
}
/**
* Represents a state where the payment account is successfully loaded with complete information.
@ -130,7 +156,29 @@ sealed class PaymentAccountStatusValue {
val isPinSet: Boolean,
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
) : PaymentAccountStatusValue()
val cryptoCurrency: CryptoCurrency.Token,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = CryptoCurrencyStatus.Loaded(
amount = cryptoBalance.balance,
fiatAmount = fiatBalance.availableBalance,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = cryptoBalance.depositAddress,
),
),
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
),
)
}
/** Represents an error state for the payment account status. */
@Serializable

View file

@ -3,5 +3,5 @@ package com.tangem.domain.search.model
data class SearchResult(
val textHints: List<SearchTextHint>,
val recentTokens: List<RecentSearchToken>,
val userAssets: List<UserAssetSearchEntry>,
val userAssets: List<UserAssetSearchItem>,
)

View file

@ -2,6 +2,7 @@ package com.tangem.domain.search.model
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
@ -10,5 +11,6 @@ data class UserAssetSearchEntry(
val userWalletName: String,
val accountId: AccountId,
val accountName: AccountName,
val accountIcon: CryptoPortfolioIcon,
val currencyStatus: CryptoCurrencyStatus,
)

View file

@ -0,0 +1,13 @@
package com.tangem.domain.search.model
sealed interface UserAssetSearchItem {
data class Single(val entry: UserAssetSearchEntry) : UserAssetSearchItem
data class Grouped(
val tokenName: String,
val tokenSymbol: String,
val tokenIconUrl: String?,
val entries: List<UserAssetSearchEntry>,
) : UserAssetSearchItem
}

View file

@ -9,10 +9,12 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.search.model.SearchResult
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.domain.search.repository.SearchRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
/**
* Primary search use case that produces [SearchResult] based on the current query.
@ -70,9 +72,12 @@ class GetSearchResultsUseCase(
if (unlockedWallets.isEmpty()) return@combine emptyList()
statusLists
val entries = statusLists
.filter { it.userWalletId in unlockedWallets }
.flatMap { statusList -> extractMatchingAssets(statusList, unlockedWallets, lowerQuery) }
val shouldGroup = needsGrouping(unlockedWallets.values, statusLists)
groupAndSort(entries, shouldGroup)
}.map { userAssets ->
SearchResult(
textHints = emptyList(),
@ -82,6 +87,44 @@ class GetSearchResultsUseCase(
}
}
private fun needsGrouping(unlockedWallets: Collection<UserWallet>, statusLists: List<AccountStatusList>): Boolean {
if (unlockedWallets.size > 1) return true
val totalAccounts = statusLists
.filter { sl -> unlockedWallets.any { it.walletId == sl.userWalletId } }
.sumOf { it.accountStatuses.filterCryptoPortfolio().size }
return totalAccounts > 1
}
private fun groupAndSort(entries: List<UserAssetSearchEntry>, shouldGroup: Boolean): List<UserAssetSearchItem> {
if (!shouldGroup) {
return entries
.map { UserAssetSearchItem.Single(it) }
.sortedByDescending { it.entry.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
}
val grouped = entries.groupBy { entry ->
val rawId = entry.currencyStatus.currency.id.rawCurrencyId
rawId?.value ?: "${entry.currencyStatus.currency.name}|${entry.currencyStatus.currency.symbol}"
}
return grouped.map { (_, groupEntries) ->
val assetInfo = groupEntries.first()
UserAssetSearchItem.Grouped(
tokenName = assetInfo.currencyStatus.currency.name,
tokenSymbol = assetInfo.currencyStatus.currency.symbol,
tokenIconUrl = assetInfo.currencyStatus.currency.iconUrl,
entries = groupEntries,
)
}.sortedByDescending { item ->
when (item) {
is UserAssetSearchItem.Grouped ->
item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
}
}
}
private fun extractMatchingAssets(
statusList: AccountStatusList,
wallets: Map<UserWalletId, UserWallet>,
@ -103,6 +146,7 @@ class GetSearchResultsUseCase(
userWalletName = wallet.name,
accountId = accountStatus.accountId,
accountName = accountStatus.account.accountName,
accountIcon = accountStatus.account.icon,
currencyStatus = currencyStatus,
)
}

View file

@ -1,13 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:FetchStakingYieldBalanceUseCase.kt$FetchStakingYieldBalanceUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -&gt; raise(StakingError.DomainError("$it")) StakingIdFactory.Error.UnsupportedCurrency -&gt; Unit.right() } return@either }</ID>
<ID>MultilineLambdaItParameter:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase${ !it.isPending &amp;&amp; action.amount &lt; it.amount &amp;&amp; it.type == BalanceType.STAKED &amp;&amp; it.validatorAddress == action.validatorAddress }</ID>
<ID>NamedArguments:GetConstructedStakingTransactionUseCase.kt$GetConstructedStakingTransactionUseCase$constructTransaction(networkId, fee, amount, transactionId)</ID>
<ID>UnnecessaryAbstractClass:MultiStakingBalanceSupplier.kt$MultiStakingBalanceSupplier$MultiStakingBalanceSupplier</ID>
<ID>UnnecessaryAbstractClass:SingleStakingBalanceSupplier.kt$SingleStakingBalanceSupplier$SingleStakingBalanceSupplier</ID>
<ID>UseEmptyCounterpart:StakingAnalyticsEvent.kt$StakingAnalyticsEvent$mapOf()</ID>
<ID>UseOrEmpty:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase$action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: ""</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -23,9 +23,9 @@ class FetchStakingYieldBalanceUseCase(
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
)
.getOrElse {
when (it) {
is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it"))
.getOrElse { error ->
when (error) {
is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$error"))
StakingIdFactory.Error.UnsupportedCurrency -> Unit.right()
}

View file

@ -20,7 +20,12 @@ class GetConstructedStakingTransactionUseCase(
amount: Amount,
transactionId: String,
): Either<StakingError, Pair<StakingTransaction, TransactionData.Compiled>> = Either.catch {
stakeKitRepository.constructTransaction(networkId, fee, amount, transactionId)
stakeKitRepository.constructTransaction(
networkId = networkId,
fee = fee,
amount = amount,
transactionId = transactionId,
)
}.mapLeft {
stakingErrorResolver.resolve(it)
}

View file

@ -100,7 +100,7 @@ class InvalidatePendingTransactionsUseCase(
type = BalanceType.STAKED,
amount = action.amount,
rawCurrencyId = null,
validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "",
validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0).orEmpty(),
date = null,
pendingActions = emptyList(),
pendingActionsConstraints = emptyList(),
@ -149,10 +149,10 @@ class InvalidatePendingTransactionsUseCase(
}
private fun findPartialUnstake(balances: MutableList<BalanceItem>, action: StakingAction): Pair<Int, BigDecimal> {
val index = balances.indexOfFirst {
!it.isPending && action.amount < it.amount &&
it.type == BalanceType.STAKED &&
it.validatorAddress == action.validatorAddress
val index = balances.indexOfFirst { balance ->
!balance.isPending && action.amount < balance.amount &&
balance.type == BalanceType.STAKED &&
balance.validatorAddress == action.validatorAddress
}
return index to action.amount
}

View file

@ -2,23 +2,27 @@ package com.tangem.domain.staking
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade
/**
* Factory class for creating instances of [StakingID]
*
* @property walletManagersFacade wallet manager facade
* @property walletManagersFacade wallet manager facade
* @property stakingFeatureToggles staking feature toggles
*
[REDACTED_AUTHOR]
*/
class StakingIdFactory(
private val walletManagersFacade: WalletManagersFacade,
private val stakingFeatureToggles: StakingFeatureToggles,
) {
/**
@ -72,6 +76,8 @@ class StakingIdFactory(
ensureNotNull(integrationId) { Error.UnsupportedCurrency }
ensure(stakingFeatureToggles.isIntegrationEnabled(integrationId)) { Error.UnsupportedCurrency }
val address = defaultAddressProvider().takeUnless { it.isNullOrEmpty() }
ensureNotNull(address) { Error.UnableToGetAddress(integrationId = integrationId) }

View file

@ -8,7 +8,7 @@ import com.tangem.domain.models.staking.action.StakingActionType
sealed class StakingAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(
category = "Staking",
event = event,

View file

@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance
*
[REDACTED_AUTHOR]
*/
abstract class MultiStakingBalanceSupplier(
open class MultiStakingBalanceSupplier(
override val factory: FlowProducer.Factory<MultiStakingBalanceProducer.Params, MultiStakingBalanceProducer>,
override val keyCreator: (MultiStakingBalanceProducer.Params) -> String,
) : FlowCachingSupplier<MultiStakingBalanceProducer, MultiStakingBalanceProducer.Params, Set<StakingBalance>>()

View file

@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance
*
[REDACTED_AUTHOR]
*/
abstract class SingleStakingBalanceSupplier(
open class SingleStakingBalanceSupplier(
override val factory: FlowProducer.Factory<SingleStakingBalanceProducer.Params, SingleStakingBalanceProducer>,
override val keyCreator: (SingleStakingBalanceProducer.Params) -> String,
) : FlowCachingSupplier<SingleStakingBalanceProducer, SingleStakingBalanceProducer.Params, StakingBalance>()

View file

@ -1,5 +1,8 @@
package com.tangem.domain.staking.toggles
import com.tangem.domain.staking.model.StakingIntegrationID
interface StakingFeatureToggles {
val isEthStakingEnabled: Boolean
fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean
}

View file

@ -10,11 +10,13 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.test.core.ProvideTestModels
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
@ -30,11 +32,16 @@ import org.junit.jupiter.params.ParameterizedTest
internal class StakingIdFactoryTest {
private val walletManagersFacade: WalletManagersFacade = mockk()
private val factory = StakingIdFactory(walletManagersFacade = walletManagersFacade)
private val stakingFeatureToggles: StakingFeatureToggles = mockk()
private val factory = StakingIdFactory(
walletManagersFacade = walletManagersFacade,
stakingFeatureToggles = stakingFeatureToggles,
)
@BeforeEach
fun resetMocks() {
clearMocks(walletManagersFacade)
clearMocks(walletManagersFacade, stakingFeatureToggles)
every { stakingFeatureToggles.isIntegrationEnabled(any()) } returns true
}
@Nested
@ -66,6 +73,33 @@ internal class StakingIdFactoryTest {
}
}
@Test
fun `create returns UnsupportedCurrency if integration is disabled by toggle`() = runTest {
// Arrange
val userWalletId = UserWalletId(stringValue = "011")
val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.TON)
every {
stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.StakeKit.Coin.Ton)
} returns false
// Act
val actual = factory.create(
userWalletId = userWalletId,
currencyId = currency.id,
network = currency.network,
)
// Assert
val expected = StakingIdFactory.Error.UnsupportedCurrency
Truth.assertThat(actual.leftOrNull()).isEqualTo(expected)
coVerify(inverse = true) {
walletManagersFacade.getDefaultAddress(userWalletId = any(), network = any())
}
}
@Test
fun `create returns UnableToGetAddress if address is null`() = runTest {
// Arrange

View file

@ -5,8 +5,8 @@ import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
@Deprecated("TangemPayCurrencyFactory")
interface TangemPayCryptoCurrencyFactory {
fun create(userWallet: UserWallet, chainId: Int): Either<UniversalError, CryptoCurrency>
fun create(userWallet: UserWallet): Either<UniversalError, CryptoCurrency.Token>
}

View file

@ -0,0 +1,34 @@
package com.tangem.domain.pay.usecase
import arrow.core.Option
import arrow.core.none
import arrow.core.some
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import kotlinx.coroutines.flow.firstOrNull
class GetPaymentAccountCryptoCurrencyStatusUseCase(
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Option<Pair<Account.Payment, CryptoCurrencyStatus>> {
val accountStatus = paymentAccountStatusSupplier.invoke(userWalletId).firstOrNull() ?: return none()
val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) {
is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus
is PaymentAccountStatusValue.Locked -> statusValue.cryptoCurrencyStatus
else -> return none()
}
return if (cryptoCurrencyStatus.currency == cryptoCurrency) {
(accountStatus.account to cryptoCurrencyStatus).some()
} else {
none()
}
}
}

View file

@ -29,6 +29,9 @@ interface DerivationsRepository {
derivations: Map<ByteArrayKey, List<DerivationPath>>,
): Map<ByteArrayKey, ExtendedPublicKeysMap>
/** Returns already derived extended public keys for the given [seedKey] */
suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap
/** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */
suspend fun hasMissedDerivations(
userWalletId: UserWalletId,

View file

@ -1,7 +1,6 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import arrow.core.right
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.extensions.ByteArrayKey
@ -38,23 +37,37 @@ class GetExtendedPublicKeyForCurrencyUseCase(
error("No derivation found")
}
val seedKey = walletManager.wallet.publicKey.seedKey
val existingKeys = derivationsRepository.getExistingDerivedKeys(
userWalletId = userWalletId,
seedKey = ByteArrayKey(seedKey),
)
var childKey = makeChildKey(
isBip44DerivationStyleXPUB = blockchain.isBip44DerivationStyleXPUB(),
extendedPublicKey = hdKey.extendedPublicKey,
derivationPath = hdKey.path,
)
// Fill from already derived keys if available
if (childKey.extendedPublicKey == null) {
existingKeys[childKey.derivationPath]?.let {
childKey = childKey.copy(extendedPublicKey = it)
}
}
val parentPath = childKey.derivationPath.dropLastNodes(1)
var parentKey = Key(
derivationPath = childKey.derivationPath.dropLastNodes(1),
extendedPublicKey = null,
derivationPath = parentPath,
extendedPublicKey = existingKeys[parentPath],
)
val pendingDerivations = getPendingDerivations(childKey, parentKey)
val derivedKeys = deriveKeys(
userWalletId = userWalletId,
seedKey = walletManager.wallet.publicKey.seedKey,
paths = pendingDerivations,
)
val derivedKeys = if (pendingDerivations.isNotEmpty()) {
deriveKeys(userWalletId = userWalletId, seedKey = seedKey, paths = pendingDerivations)
} else {
ExtendedPublicKeysMap(emptyMap())
}
if (childKey.extendedPublicKey == null) {
childKey = childKey.copy(
@ -72,22 +85,6 @@ class GetExtendedPublicKeyForCurrencyUseCase(
}
}
/**
* @return true if xpub generation is supported, false otherwise
*/
suspend fun isSupported(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> = Either.catch {
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
?: error("Wallet not found for user wallet $userWalletId and network ${network.id}")
val blockchain = network.toBlockchain()
val isSecp256k1Blockchain = Blockchain.secp256k1Blockchains(network.isTestnet).contains(blockchain)
val isHdKey = walletManager.wallet.publicKey.derivationType?.hdKey
val isSupported = isSecp256k1Blockchain && isHdKey != null
return isSupported.right()
}
private suspend fun deriveKeys(
userWalletId: UserWalletId,
seedKey: ByteArray,

View file

@ -3,9 +3,9 @@ package com.tangem.domain.wallets.usecase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.LinkedHashMap
/**
* Use case for getting list of user wallets
@ -22,9 +22,13 @@ class GetWalletsUseCase(
operator fun invoke(): Flow<List<UserWallet>> = userWalletsListRepository.userWallets.map { requireNotNull(it) }
@Throws(IllegalArgumentException::class)
fun invokeAsMap(): Flow<LinkedHashMap<UserWalletId, UserWallet>> = userWalletsListRepository.userWallets
.map { requireNotNull(it) }
.map { wallets ->
fun invokeAsMap(isOnlyMultiCurrency: Boolean = true): Flow<LinkedHashMap<UserWalletId, UserWallet>> = invoke()
.map { list ->
val wallets = if (isOnlyMultiCurrency) {
list.filter { wallet -> wallet.isMultiCurrency }
} else {
list
}
wallets.associateByTo(
destination = linkedMapOf(),
keySelector = { wallet -> wallet.walletId },

View file

@ -22,8 +22,6 @@ import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
@ -61,7 +59,6 @@ internal class DetailsModel @Inject constructor(
private val router: Router,
private val urlOpener: UrlOpener,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val appStateHolder: ReduxStateHolder,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
@ -79,9 +76,6 @@ internal class DetailsModel @Inject constructor(
val state: MutableStateFlow<DetailsUM>
init {
// Use to save compatibility with screens that using Redux states
bootstrapScreenState()
val isWalletConnectAvailable = runBlocking {
// danger region, this works immediately, but will be refactored later with WC
checkIsWalletConnectAvailableUseCase(params.userWalletId).getOrElse { throwable ->
@ -122,10 +116,6 @@ internal class DetailsModel @Inject constructor(
.launchIn(modelScope)
}
private fun bootstrapScreenState() {
appStateHolder.dispatch(LegacyAction.PrepareDetailsScreen)
}
private fun sendFeedback() {
modelScope.launch {
val userWallets = getWalletsUseCase.invokeSync()

View file

@ -148,6 +148,13 @@ internal class FeedEntryChildFactory @Inject constructor(
appComponentContext = appComponentContext,
params = DefaultSearchComponent.Params(
onBackClick = onBackClicked,
onMarketTokenClick = { token, currency ->
feedEntryClickIntents.onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.Market.value,
)
},
),
)
}

View file

@ -15,6 +15,8 @@ import com.tangem.core.ui.ds.field.search.TangemFieldShape
import com.tangem.core.ui.ds.field.search.TangemSearchField
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.search.SearchModel
import com.tangem.features.feed.ui.search.SearchContent
import com.tangem.features.feed.ui.search.state.SearchCallbacks
@ -74,6 +76,7 @@ internal class DefaultSearchComponent(
onClearHintsClick = model::clearSearchHistory,
onTextHintClick = model::onTextHintClick,
onResultMarketTokenClick = model::onResultMarketTokenClick,
onHistoryTokenClick = model::onHistoryTokenClick,
)
}
SearchContent(
@ -86,5 +89,6 @@ internal class DefaultSearchComponent(
data class Params(
val onBackClick: () -> Unit,
val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
)
}

View file

@ -1,19 +1,21 @@
package com.tangem.features.feed.model.search
import arrow.core.getOrElse
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.GetTokenPriceChartUseCase
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase
import com.tangem.domain.search.usecase.GetSearchResultsUseCase
import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase
@ -26,6 +28,7 @@ import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecent
import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency
import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter
import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency
import com.tangem.features.feed.model.search.converter.UserAssetSearchItemConverter
import com.tangem.features.feed.model.search.state.SearchStateController
import com.tangem.features.feed.model.search.state.transformers.*
import com.tangem.features.feed.ui.search.state.*
@ -35,13 +38,8 @@ import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L
@ -54,6 +52,7 @@ internal class SearchModel @Inject constructor(
paramsContainer: ParamsContainer,
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getSearchResultsUseCase: GetSearchResultsUseCase,
private val saveSearchQueryUseCase: SaveSearchQueryUseCase,
private val saveRecentSearchTokenUseCase: SaveRecentSearchTokenUseCase,
@ -77,6 +76,13 @@ internal class SearchModel @Inject constructor(
initialValue = AppCurrency.Default,
)
private val isBalanceHidden = getBalanceHidingSettingsUseCase.isBalanceHidden()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
private val marketsListItemToRecentSearchTokenConverter by lazy {
MarketsListItemUMToRecentSearchTokenConverter()
}
@ -143,17 +149,38 @@ internal class SearchModel @Inject constructor(
)
saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input))
saveSearchQueryUseCase(stateController.value.searchBar.query)
withContext(dispatchers.mainImmediate) {
searchMarketsListManager.getTokenById(item.id)?.let { found ->
params.onMarketTokenClick(found.toSerializableParam(), appCurrency)
}
}
}
}
fun onHistoryTokenClick(item: MarketsListItemUM) {
val tokenMarketParams = TokenMarketParams(
id = item.id,
name = item.name,
symbol = item.currencySymbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = item.price.fiatPrice,
h24Percent = null,
weekPercent = null,
monthPercent = null,
),
imageUrl = item.iconUrl,
)
params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value)
}
private fun initCallbacks() {
stateController.update(object : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(
onQueryChange = ::onQueryChange,
onActiveChange = ::onActiveChange,
onClearClick = ::onClearClick,
onCancelClick = params.onBackClick,
),
)
}
@ -164,10 +191,6 @@ internal class SearchModel @Inject constructor(
stateController.update(UpdateSearchBarQueryTransformer(query))
}
private fun onActiveChange(isActive: Boolean) {
if (!isActive) params.onBackClick()
}
private fun onClearClick() {
stateController.update(UpdateSearchBarQueryTransformer(""))
}
@ -200,20 +223,19 @@ internal class SearchModel @Inject constructor(
private fun subscribeToSearchResults(query: String) {
modelScope.launch {
getSearchResultsUseCase(query = query).collectLatest { searchResult ->
val userAssets = searchResult.userAssets.map { entry ->
UserAssetItemUM(
id = "${entry.userWalletId.stringValue}_${entry.accountId.value}" +
"_${entry.currencyStatus.currency.id.value}",
tokenIconUrl = entry.currencyStatus.currency.iconUrl,
tokenName = entry.currencyStatus.currency.name,
tokenSymbol = entry.currencyStatus.currency.symbol,
accountName = entry.accountName.toDisplayString(),
onClick = {
// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task.
},
)
}.toImmutableList()
combine(
getSearchResultsUseCase(query = query),
currentAppCurrency,
isBalanceHidden,
) { searchResult, appCurrency, balanceHidden ->
val converter = UserAssetSearchItemConverter(
appCurrency = appCurrency,
isBalanceHidden = balanceHidden,
)
searchResult.userAssets
.map(converter::convert)
.toImmutableList()
}.collectLatest { userAssets ->
stateController.update(UpdateUserAssetsTransformer(userAssets))
}
}.saveIn(searchResultsJob)
@ -334,13 +356,6 @@ internal class SearchModel @Inject constructor(
}
}
private fun AccountName.toDisplayString(): String {
return when (this) {
is AccountName.DefaultMain -> "Main" // TODO [REDACTED_TASK_KEY] localize
is AccountName.Custom -> value
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {

View file

@ -0,0 +1,92 @@
package com.tangem.features.feed.model.search.converter
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
internal class UserAssetSearchItemConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
) : Converter<UserAssetSearchItem, UserAssetItemUM> {
override fun convert(value: UserAssetSearchItem): UserAssetItemUM {
return when (value) {
is UserAssetSearchItem.Single -> convertSingle(value.entry)
is UserAssetSearchItem.Grouped -> convertGrouped(value)
}
}
private fun convertSingle(entry: UserAssetSearchEntry): UserAssetItemUM.Single {
val currency = entry.currencyStatus.currency
val value = entry.currencyStatus.value
return UserAssetItemUM.Single(
id = "${entry.userWalletId.stringValue}_${entry.accountId.value}_${currency.id.value}",
icon = TangemIconUM.Currency(
currencyIconState = CryptoCurrencyToIconStateConverter().convert(entry.currencyStatus),
),
tokenName = currency.name,
tokenSymbol = currency.symbol,
fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) },
cryptoBalance = formatCryptoAmount(value.amount, currency.symbol, currency.decimals),
fiatBalance = formatFiatAmount(value.fiatAmount),
isBalanceHidden = isBalanceHidden,
onClick = {},
)
}
private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped {
val totalFiat = item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
val totalCrypto = item.entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO }
val firstCurrency = item.entries.first().currencyStatus.currency
val children = item.entries.map { entry ->
UserAssetItemUM.GroupedChild(
walletName = entry.userWalletName,
accountName = entry.accountName.toUM(),
accountIcon = entry.accountIcon.value,
accountColor = entry.accountIcon.color,
cryptoBalance = formatCryptoAmount(
entry.currencyStatus.value.amount,
entry.currencyStatus.currency.symbol,
entry.currencyStatus.currency.decimals,
),
fiatBalance = formatFiatAmount(entry.currencyStatus.value.fiatAmount),
)
}.toImmutableList()
return UserAssetItemUM.Grouped(
id = "grouped_${item.tokenName}_${item.tokenSymbol}",
icon = TangemIconUM.Currency(
currencyIconState = CryptoCurrencyToIconStateConverter().convert(item.entries.first().currencyStatus),
),
tokenName = item.tokenName,
tokenSymbol = item.tokenSymbol,
tokensCount = item.entries.size,
totalCryptoBalance = formatCryptoAmount(totalCrypto, firstCurrency.symbol, firstCurrency.decimals),
totalFiatBalance = formatFiatAmount(totalFiat),
isBalanceHidden = isBalanceHidden,
children = children,
onClick = {},
)
}
private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String {
return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN
}
private fun formatFiatAmount(fiatAmount: BigDecimal?): String {
return fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } ?: StringsSigns.DASH_SIGN
}
}

View file

@ -3,20 +3,16 @@ package com.tangem.features.feed.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
@ -32,7 +28,6 @@ import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.list.InfiniteListHandler
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.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
@ -54,6 +49,15 @@ internal fun SearchContent(
val lazyListState = rememberLazyListState()
val background = LocalMainBottomSheetColor.current.value
val contentStructureKey = when (content) {
is SearchContentUM.InitialEmpty -> "empty"
is SearchContentUM.History -> "history"
is SearchContentUM.Results -> "results_${content.userAssets.isNotEmpty()}"
}
LaunchedEffect(contentStructureKey) {
lazyListState.scrollToItem(0)
}
LazyColumn(
state = lazyListState,
modifier = modifier
@ -72,6 +76,7 @@ internal fun SearchContent(
history = content,
onClearAllClick = searchCallbacks.onClearHintsClick,
onHintClick = searchCallbacks.onTextHintClick,
onHistoryTokenClick = searchCallbacks.onHistoryTokenClick,
)
is SearchContentUM.Results -> searchResultsItems(
results = content,
@ -98,6 +103,7 @@ private fun LazyListScope.searchHistoryItems(
history: SearchContentUM.History,
onClearAllClick: (() -> Unit),
onHintClick: (String) -> Unit,
onHistoryTokenClick: (MarketsListItemUM) -> Unit,
) {
if (!history.textHints.isEmpty() || !history.recentTokens.isEmpty()) {
item(key = "recents") {
@ -107,15 +113,20 @@ private fun LazyListScope.searchHistoryItems(
)
}
}
items(
itemsIndexed(
items = history.textHints,
key = { "hint_${it.text}" },
) { hint ->
key = { _, item -> "hint_${item.text}" },
) { index, hint ->
TextHintItem(hint = hint, onHintClick = { onHintClick(hint.text) })
HorizontalDivider(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2),
color = TangemTheme.colors2.border.neutral.primary,
)
if (index < history.textHints.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2),
color = TangemTheme.colors2.border.neutral.primary,
)
}
}
item {
SpacerH(TangemTheme.dimens2.x2)
}
items(
items = history.recentTokens,
@ -129,7 +140,7 @@ private fun LazyListScope.searchHistoryItems(
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
),
model = token,
onClick = {}, // TODO in [REDACTED_TASK_KEY]
onClick = { onHistoryTokenClick(token) },
)
}
}
@ -264,9 +275,16 @@ private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) {
}
}
// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task.
@Composable
private fun UserAssetItem(asset: UserAssetItemUM) {
when (asset) {
is UserAssetItemUM.Single -> SingleUserAssetItem(asset)
is UserAssetItemUM.Grouped -> GroupedUserAssetItem(asset)
}
}
@Composable
private fun SingleUserAssetItem(asset: UserAssetItemUM.Single) {
Row(
modifier = Modifier
.fillMaxWidth()
@ -276,10 +294,8 @@ private fun UserAssetItem(asset: UserAssetItemUM) {
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TangemIcon(
tangemIconUM = TangemIconUM.Url(asset.tokenIconUrl, fallbackRes = R.drawable.ic_custom_token_44),
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
modifier = Modifier.size(40.dp),
tangemIconUM = asset.icon,
)
Column(modifier = Modifier.weight(1f)) {
Text(
@ -289,12 +305,79 @@ private fun UserAssetItem(asset: UserAssetItemUM) {
maxLines = 1,
)
Text(
text = "${asset.tokenSymbol} · ${asset.accountName}",
text = asset.tokenSymbol,
style = TangemTheme.typography2.captionRegular13,
color = TangemTheme.colors2.text.neutral.tertiary,
maxLines = 1,
)
}
if (!asset.isBalanceHidden) {
Column(horizontalAlignment = Alignment.End) {
Text(
text = asset.fiatBalance,
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
Text(
text = asset.cryptoBalance,
style = TangemTheme.typography2.captionRegular13,
color = TangemTheme.colors2.text.neutral.tertiary,
maxLines = 1,
)
}
}
}
}
// TODO [REDACTED_JIRA] update ui item to Portfolio block item
@Composable
private fun GroupedUserAssetItem(asset: UserAssetItemUM.Grouped) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = asset.onClick)
.padding(horizontal = 12.dp, vertical = 14.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TangemIcon(
modifier = Modifier.size(40.dp),
tangemIconUM = asset.icon,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = asset.tokenName,
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
Text(
text = "${asset.tokenSymbol} · ${asset.tokensCount}",
style = TangemTheme.typography2.captionRegular13,
color = TangemTheme.colors2.text.neutral.tertiary,
maxLines = 1,
)
}
if (!asset.isBalanceHidden) {
Column(horizontalAlignment = Alignment.End) {
Text(
text = asset.totalFiatBalance,
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
Text(
text = asset.totalCryptoBalance,
style = TangemTheme.typography2.captionRegular13,
color = TangemTheme.colors2.text.neutral.tertiary,
maxLines = 1,
)
}
}
}
}
}

View file

@ -2,11 +2,7 @@ package com.tangem.features.feed.ui.search.preview
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@ -15,7 +11,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -206,18 +204,22 @@ internal object SearchContentPreviewFixtures {
updateTimestamp = updateTimestamp,
)
private fun userAsset(
id: String,
name: String,
symbol: String,
accountName: String,
iconUrl: String? = null,
): UserAssetItemUM = UserAssetItemUM(
private fun userAsset(id: String, name: String, symbol: String): UserAssetItemUM = UserAssetItemUM.Single(
id = id,
tokenIconUrl = iconUrl,
icon = TangemIconUM.Currency(
CurrencyIconState.CoinIcon(
url = null,
fallbackResId = com.tangem.core.ui.R.drawable.ic_ethereumpow_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
tokenName = name,
tokenSymbol = symbol,
accountName = accountName,
fiatRate = "$98,765.43",
cryptoBalance = "1.234 $symbol",
fiatBalance = "$121,876.50",
isBalanceHidden = false,
onClick = {},
)
@ -251,13 +253,8 @@ internal object SearchContentPreviewFixtures {
)
private fun portfolioTwo(): ImmutableList<UserAssetItemUM> = persistentListOf(
userAsset(id = "p1", name = "Ethereum", symbol = "ETH", accountName = "Main wallet"),
userAsset(
id = "p2",
name = "Polygon",
symbol = "POL",
accountName = "Account with a long label for preview",
),
userAsset(id = "p1", name = "Ethereum", symbol = "ETH"),
userAsset(id = "p2", name = "Polygon", symbol = "POL"),
)
private fun marketListShort(): ImmutableList<MarketsListItemUM> = persistentListOf(
@ -390,6 +387,7 @@ private val SearchContentPreviewCallbacks = SearchCallbacks(
onClearHintsClick = {},
onTextHintClick = { _ -> },
onResultMarketTokenClick = { _ -> },
onHistoryTokenClick = { _ -> },
)
/** All [SearchContentPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */

View file

@ -7,4 +7,5 @@ internal data class SearchCallbacks(
val onClearHintsClick: () -> Unit,
val onTextHintClick: (hint: String) -> Unit,
val onResultMarketTokenClick: (MarketsListItemUM) -> Unit,
val onHistoryTokenClick: (MarketsListItemUM) -> Unit,
)

View file

@ -1,8 +1,11 @@
package com.tangem.features.feed.ui.search.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList
data class SearchUM(
@ -42,11 +45,45 @@ sealed interface MarketSearchResultUM {
data class TextHintItemUM(val text: String)
data class UserAssetItemUM(
val id: String,
val tokenIconUrl: String?,
val tokenName: String,
val tokenSymbol: String,
val accountName: String,
val onClick: () -> Unit,
)
@Immutable
sealed interface UserAssetItemUM {
val id: String
val icon: TangemIconUM
val tokenName: String
val tokenSymbol: String
val onClick: () -> Unit
data class Single(
override val id: String,
override val icon: TangemIconUM,
override val tokenName: String,
override val tokenSymbol: String,
val fiatRate: String?,
val cryptoBalance: String,
val fiatBalance: String,
val isBalanceHidden: Boolean,
override val onClick: () -> Unit,
) : UserAssetItemUM
data class Grouped(
override val id: String,
override val icon: TangemIconUM,
override val tokenName: String,
override val tokenSymbol: String,
val tokensCount: Int,
val totalCryptoBalance: String,
val totalFiatBalance: String,
val isBalanceHidden: Boolean,
val children: ImmutableList<GroupedChild>,
override val onClick: () -> Unit,
) : UserAssetItemUM
data class GroupedChild(
val walletName: String,
val accountName: AccountNameUM,
val accountIcon: CryptoPortfolioIcon.Icon,
val accountColor: CryptoPortfolioIcon.Color,
val cryptoBalance: String,
val fiatBalance: String,
)
}

View file

@ -19,7 +19,6 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.CryptoCurrencyAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
@ -69,7 +68,6 @@ internal class SendDestinationModel @Inject constructor(
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val parseQrCodeUseCase: ParseQrCodeUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory,
private val analyticsEventHandler: AnalyticsEventHandler,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
) : Model(), SendDestinationClickIntents {
@ -260,16 +258,16 @@ internal class SendDestinationModel @Inject constructor(
private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? {
val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null
val address = when (val status = this.value) {
is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress
is PaymentAccountStatusValue.Locked -> status.cryptoBalance.depositAddress
val (paymentAccountAddress, currency) = when (val status = this.value) {
is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress to status.cryptoCurrency
is PaymentAccountStatusValue.Locked -> status.cryptoBalance.depositAddress to status.cryptoCurrency
else -> return null
}
val currency = tangemPayCryptoCurrencyFactory.create(wallet).getOrNull() ?: return null
return if (contractAddress.equals(currency.contractAddress, true)) {
DestinationWalletUM(
name = wallet.name,
address = address,
address = paymentAccountAddress,
cryptoCurrency = currency,
userWalletId = wallet.walletId,
account = account,

View file

@ -1,25 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer$val showNotification = sendingAmount + feeAmount &gt; balance</ID>
<ID>BooleanPropertyNaming:AmountCurrencyChangeStateTransformer.kt$AmountCurrencyChangeStateTransformer$private val value: Boolean</ID>
<ID>BooleanPropertyNaming:StakingUiState.kt$StakingStates.InitialInfoState.Data$val showBanner: Boolean</ID>
<ID>BooleanPropertyNaming:StakingUiState.kt$StakingUiState$val showColdWalletInteractionIcon: Boolean</ID>
<ID>CastNullableToNonNullableType:SetApprovalBottomSheetInProgressTransformer.kt$SetApprovalBottomSheetInProgressTransformer$as</ID>
<ID>CastNullableToNonNullableType:SetApprovalBottomSheetTypeChangeTransformer.kt$SetApprovalBottomSheetTypeChangeTransformer$as</ID>
<ID>MultilineLambdaItParameter:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer${ it is StakingNotification.Error || it is NotificationUM.Error || it is NotificationUM.Warning.NetworkFeeUnreachable || it is StakingNotification.Warning.TransactionInProgress || it is StakingNotification.Warning.InitializeTonAccount }</ID>
<ID>MultilineLambdaItParameter:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler${ val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork &amp;&amp; isCurrency }</ID>
<ID>MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.body1, ) } }</ID>
<ID>MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size90, ), ) } }</ID>
<ID>MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.PREPARING || it.type == BalanceType.STAKED || it.type == BalanceType.LOCKED }</ID>
<ID>MultilineLambdaItParameter:StakingStateController.kt$StakingStateController${ it.copy( showColdWalletInteractionIcon = userWallet is UserWallet.Cold, ) }</ID>
<ID>NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$networkId</ID>
<ID>NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$tokenId</ID>
<ID>PropertyUsedBeforeDeclaration:StakingFeeBlock.kt$FeeBlockPreviewProvider$contentState</ID>
<ID>PropertyUsedBeforeDeclaration:StakingStateController.kt$StakingStateController$uiState</ID>
<ID>UnnecessaryEventHandlerParameter:StakingInitialInfoContent.kt$onClick: (BalanceState) -&gt; Unit</ID>
<ID>UnnecessaryLet:StakingTosText.kt$let { onTextClick(PRIVACY_POLICY_URL) }</ID>
<ID>UnnecessaryLet:StakingTosText.kt$let { onTextClick(TERMS_OF_USE_URL) }</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -53,9 +53,9 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor(
params = MultiWalletCryptoCurrenciesProducer.Params(selectedUserWalletId),
)
.orEmpty()
.firstOrNull {
val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true)
val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
.firstOrNull { currency ->
val isNetwork = currency.network.backendId.equals(networkId, ignoreCase = true)
val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
isNetwork && isCurrency
}
@ -63,8 +63,8 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor(
TangemLogger.e(
"""
Could not get crypto currency for
|- $NETWORK_ID_KEY: $networkId
|- $TOKEN_ID_KEY: $tokenId
|- $NETWORK_ID_KEY: ${networkId.orEmpty()}
|- $TOKEN_ID_KEY: ${tokenId.orEmpty()}
""".trimIndent(),
)
return@launch

View file

@ -1,5 +1,6 @@
package com.tangem.features.staking.impl.presentation.model
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.notifications.NotificationUM
@ -11,6 +12,7 @@ import java.math.BigDecimal
// TODO split this interface to click intents and other interaction events
@Suppress("TooManyFunctions")
@Immutable
internal interface StakingClickIntents : AmountScreenClickIntents {
fun onBackClick()

View file

@ -26,19 +26,19 @@ internal class StakingStateController @Inject constructor(
private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles,
) {
val value: StakingUiState get() = uiState.value
private val mutableUiState: MutableStateFlow<StakingUiState> = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<StakingUiState> get() = mutableUiState.asStateFlow()
val value: StakingUiState get() = uiState.value
private val buttonsTransformer = SetButtonsStateTransformer(urlOpener)
private val titleTransformer = SetTitleTransformer
fun initializeWithUserWallet(userWallet: UserWallet) {
mutableUiState.update { state ->
state.copy(
showColdWalletInteractionIcon = userWallet.isColdWallet,
isColdWalletInteractionIconVisible = userWallet.isColdWallet,
shouldShowHoldToConfirmButton = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled &&
userWallet.isHotWallet,
)
@ -89,7 +89,7 @@ internal class StakingStateController @Inject constructor(
actionType = StakingActionCommonType.Enter(skipEnterAmount = false),
buttonsState = NavigationButtonsState.Empty,
balanceState = null,
showColdWalletInteractionIcon = true,
isColdWalletInteractionIconVisible = true,
shouldShowHoldToConfirmButton = false,
)
}

View file

@ -39,22 +39,9 @@ internal data class StakingUiState(
val actionType: StakingActionCommonType,
val buttonsState: NavigationButtonsState,
val balanceState: BalanceState?,
val showColdWalletInteractionIcon: Boolean,
val isColdWalletInteractionIconVisible: Boolean,
val shouldShowHoldToConfirmButton: Boolean,
) {
fun copyWrapped(
initialInfoState: StakingStates.InitialInfoState = this.initialInfoState,
amountState: AmountState = this.amountState,
confirmationState: StakingStates.ConfirmationState = this.confirmationState,
validatorState: StakingStates.ValidatorState = this.validatorState,
): StakingUiState = copy(
initialInfoState = initialInfoState,
amountState = amountState,
confirmationState = confirmationState,
validatorState = validatorState,
)
}
)
internal sealed class StakingStates {
@ -64,7 +51,7 @@ internal sealed class StakingStates {
sealed class InitialInfoState : StakingStates() {
data class Data(
override val isPrimaryButtonEnabled: Boolean,
val showBanner: Boolean,
val isBannerVisible: Boolean,
val infoItems: ImmutableList<RoundedListWithDividersItemData>,
val onInfoClick: (InfoType) -> Unit,
val yieldBalance: InnerYieldBalanceState,

View file

@ -18,7 +18,7 @@ import kotlinx.collections.immutable.persistentListOf
internal object InitialStakingStatePreview {
val defaultState = StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = true,
showBanner = true,
isBannerVisible = true,
infoItems = persistentListOf(
RoundedListWithDividersItemData(
id = R.string.staking_details_available,

View file

@ -37,7 +37,7 @@ internal class SetButtonsStateTransformer(
return prevState.copy(buttonsState = buttonsState)
}
private fun getPrimaryButton(prevState: StakingUiState): NavigationButton? {
private fun getPrimaryButton(prevState: StakingUiState): NavigationButton {
val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val innerConfirmState = confirmState?.innerState
@ -52,7 +52,7 @@ internal class SetButtonsStateTransformer(
val isPrimaryButtonDisabled = prevState.isPrimaryButtonDisabled()
return NavigationButton(
textReference = prevState.getButtonText(),
iconRes = R.drawable.ic_tangem_24.takeIf { prevState.showColdWalletInteractionIcon },
iconRes = R.drawable.ic_tangem_24.takeIf { prevState.isColdWalletInteractionIconVisible },
isDimmed = isPrimaryButtonDisabled,
isIconVisible = isIconVisible,
shouldShowProgress = isInProgress,

View file

@ -96,7 +96,7 @@ internal class SetInitialDataStateTransformer(
isPrimaryButtonEnabled = with(status) {
!amount.isNullOrZero() && sources.stakingBalanceSource.isActual() && sources.networkSource.isActual()
},
showBanner = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty,
isBannerVisible = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty,
infoItems = getInfoItems(),
onInfoClick = clickIntents::onInfoClick,
yieldBalance = yieldBalance,

View file

@ -7,11 +7,11 @@ import com.tangem.utils.transformer.Transformer
internal class AmountCurrencyChangeStateTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val value: Boolean,
private val isFiatValue: Boolean,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
amountState = AmountCurrencyTransformer(cryptoCurrencyStatus, value).transform(prevState.amountState),
amountState = AmountCurrencyTransformer(cryptoCurrencyStatus, isFiatValue).transform(prevState.amountState),
)
}
}

View file

@ -27,7 +27,7 @@ internal class SetApprovalBottomSheetInProgressTransformer(
),
onCancel = onDismiss,
)
} as TangemBottomSheetConfigContent,
} as? TangemBottomSheetConfigContent ?: return prevState,
),
)
}

View file

@ -16,7 +16,7 @@ internal class SetApprovalBottomSheetTypeChangeTransformer(
bottomSheetConfig = prevState.bottomSheetConfig?.copy(
content = approvalBottomSheetConfig?.copy(
data = approvalBottomSheetConfig.data.copy(approveType = approveType),
) as TangemBottomSheetConfigContent,
) as? TangemBottomSheetConfigContent ?: return prevState,
),
)
}

View file

@ -177,12 +177,12 @@ internal class AddStakingNotificationsTransformer(
}
private fun isPrimaryButtonEnabled(notifications: ImmutableList<NotificationUM>, isActualSources: Boolean) =
notifications.none {
it is StakingNotification.Error ||
it is NotificationUM.Error ||
it is NotificationUM.Warning.NetworkFeeUnreachable ||
it is StakingNotification.Warning.TransactionInProgress ||
it is StakingNotification.Warning.InitializeTonAccount
notifications.none { notification ->
notification is StakingNotification.Error ||
notification is NotificationUM.Error ||
notification is NotificationUM.Warning.NetworkFeeUnreachable ||
notification is StakingNotification.Warning.TransactionInProgress ||
notification is StakingNotification.Warning.InitializeTonAccount
} && isActualSources
private fun MutableList<NotificationUM>.addStakingErrorNotifications(
@ -302,8 +302,8 @@ internal class AddStakingNotificationsTransformer(
val balance = cryptoCurrencyStatus.value.amount.orZero()
if (!isSubtractionAvailable) return
val showNotification = sendingAmount + feeAmount > balance
if (showNotification) {
val isExceedsBalance = sendingAmount + feeAmount > balance
if (isExceedsBalance) {
onNotEnoughFeeNotificationShow()
val notification = if (actionType is StakingActionCommonType.Enter) {
NotificationUM.Error.TotalExceedsBalance

View file

@ -150,10 +150,10 @@ internal class StakingInfoNotificationsFactory(
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val isTron = isTron(cryptoCurrencyStatus.currency.network.rawId)
val hasStakedBalance = (cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit)?.balance
?.items?.any {
it.type == BalanceType.PREPARING ||
it.type == BalanceType.STAKED ||
it.type == BalanceType.LOCKED
?.items?.any { item ->
item.type == BalanceType.PREPARING ||
item.type == BalanceType.STAKED ||
item.type == BalanceType.LOCKED
} == true
if (isTron && hasStakedBalance) {
add(

View file

@ -87,7 +87,7 @@ internal fun StakingInitialInfoContent(
.background(TangemTheme.colors.background.secondary)
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
if (state.showBanner) {
if (state.isBannerVisible) {
item(key = BANNER_BLOCK_KEY) {
Column(
modifier = Modifier.animateItem(),
@ -175,7 +175,7 @@ private fun LazyListScope.activeStakingBlock(
ActiveStakingBlock(
balance = balance,
isBalanceHidden = isBalanceHidden,
onClick = clickIntents::onActiveStake,
onClick = { clickIntents.onActiveStake(balance) },
onAnalytic = clickIntents::onActiveStakeAnalytic,
modifier = Modifier
.animateItem()
@ -288,7 +288,7 @@ private fun StakingRewardBlock(
private fun ActiveStakingBlock(
balance: BalanceState,
isBalanceHidden: Boolean,
onClick: (BalanceState) -> Unit,
onClick: () -> Unit,
onAnalytic: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -304,7 +304,7 @@ private fun ActiveStakingBlock(
enabled = balance.isClickable,
onClick = {
onAnalytic()
onClick(balance)
onClick()
},
)
.padding(TangemTheme.dimens.spacing12),
@ -351,20 +351,18 @@ private fun ActiveStakingBlock(
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
if (balance.formattedCryptoAmount != null) {
Text(
text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing2),
)
}
Text(
text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing2),
)
}
}
}
@Composable
private fun RowScope.StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) {
private fun StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) {
if (balance.hasImage() || icon != null) {
StakingTargetIcon(
image = if (balance.hasImage()) balance.target?.image.toImageReference() else null,

View file

@ -109,8 +109,8 @@ private fun BoxScope.FeeLoading(feeState: FeeState) {
targetState = feeState,
label = "Fee Loading State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it == FeeState.Loading) {
) { state ->
if (state == FeeState.Loading) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier.size(
@ -128,8 +128,8 @@ private fun BoxScope.FeeError(feeState: FeeState) {
targetState = feeState,
label = "Fee Error State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it == FeeState.Error) {
) { state ->
if (state == FeeState.Error) {
Text(
text = DASH_SIGN,
color = TangemTheme.colors.text.primary1,
@ -151,13 +151,6 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va
private class FeeBlockPreviewProvider : PreviewParameterProvider<FeeState> {
override val values: Sequence<FeeState>
get() = sequenceOf(
contentState,
FeeState.Loading,
FeeState.Error,
)
private val fee = Fee.Common(
amount = Amount(
currencySymbol = "MATIC",
@ -174,6 +167,13 @@ private class FeeBlockPreviewProvider : PreviewParameterProvider<FeeState> {
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
)
override val values: Sequence<FeeState>
get() = sequenceOf(
contentState,
FeeState.Loading,
FeeState.Error,
)
}
// endregion

View file

@ -2,9 +2,7 @@ package com.tangem.features.swap
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
@ -17,8 +15,6 @@ interface SwapComponent : ComposableContentComponent {
val isInitialReverseOrder: Boolean = false,
val screenSource: String,
val tangemPayInput: TangemPayInput? = null,
val preselectedToToken: CryptoCurrencyStatus? = null,
val preselectedAccount: Account? = null,
) {
data class TangemPayInput(
val cryptoAmount: BigDecimal,

View file

@ -162,8 +162,12 @@ internal class SavedSwapTransactionListConverter(
}
private fun findAccountByDerivationIndex(accountList: AccountList?, derivationIndex: DerivationIndex?): Account? {
return accountList?.accounts?.asSequence()?.filterIsInstance<Account.CryptoPortfolio>()
?.firstOrNull { it.derivationIndex == derivationIndex }
val accounts = accountList?.accounts ?: return null
return accounts.asSequence()
.filterIsInstance<Account.CryptoPortfolio>()
.firstOrNull { it.derivationIndex == derivationIndex }
?: accounts.firstOrNull { it is Account.Payment }.takeIf { derivationIndex == null }
}
private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? {

View file

@ -38,6 +38,7 @@ dependencies {
implementation(projects.domain.express.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.visa)
implementation(projects.domain.visa.models)
implementation(projects.features.swap.domain.api)

View file

@ -49,9 +49,9 @@ interface SwapInteractor {
@Throws(IllegalStateException::class)
suspend fun findBestQuote(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
fromAccount: Account?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
toAccount: Account?,
providers: List<SwapProvider>,
amountToSwap: String,
reduceBalanceBy: BigDecimal,

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