Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-02 11:18:27 +02:00
commit 788d7064b5
217 changed files with 6061 additions and 1892 deletions

View file

@ -112,6 +112,7 @@ dependencies {
implementation(projects.features.sendV2.api)
implementation(projects.features.tokenRecieve.api)
implementation(projects.features.yieldSupply.api)
implementation(projects.features.commonFeatures.api)
implementation(deps.decompose.ext.compose)

View file

@ -21,8 +21,8 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.AddFundsBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent
@ -47,6 +47,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val yieldSupplyWarningComponentFactory: YieldSupplyDepositedWarningComponent.Factory,
private val addFundsComponentFactory: AddFundsComponent.Factory,
yieldSupplyComponentFactory: YieldSupplyComponent.Factory,
private val ratingComponentFactory: RatingComponent.Factory,
) : TokenDetailsComponent, AppComponentContext by appComponentContext {
@ -177,9 +178,15 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
dynamicAddressesDelegate = model.dynamicAddressesDelegate,
onDismiss = model.bottomSheetNavigation::dismiss,
)
is TokenDetailsBottomSheetConfig.AddFunds -> AddFundsBottomSheetComponent(
stateFlow = model.addFundsUiState,
onDismiss = model.bottomSheetNavigation::dismiss,
is TokenDetailsBottomSheetConfig.AddFunds -> addFundsComponentFactory.create(
context = childByContext(componentContext),
params = AddFundsComponent.Params(
launchMode = AddFundsComponent.LaunchMode.TokenActionsOnly(
userWalletId = route.userWalletId,
currency = route.currency,
),
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
is TokenDetailsBottomSheetConfig.Transfer -> TransferBottomSheetComponent(
stateFlow = model.transferUiState,

View file

@ -9,6 +9,7 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.currency.CryptoCurrency
@ -47,6 +48,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val walletBalanceFetcher: WalletBalanceFetcher,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val singleAccountListFetcher: SingleAccountListFetcher,
) : TokenDetailsDeepLinkHandler {
init {
@ -81,6 +83,9 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
}
}
// Refresh the portfolio before searching so a token just added on the backend is present locally.
refreshAccountsIfNeeded(userWallet)
val cryptoCurrency = findCryptoCurrency(userWallet = userWallet, networkId = networkId, tokenId = tokenId)
if (cryptoCurrency == null) {
@ -91,6 +96,8 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
|- $TOKEN_ID_KEY: $tokenId
""".trimIndent(),
)
// Token is not in the response (not indexed yet / backend error): go to main, do not add.
appRouter.popTo(AppRoute.Wallet)
return@launch
}
@ -123,6 +130,21 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
}
}
/**
* Refreshes wallet accounts so a token just added on the backend appears in the local portfolio.
*
* Only when the app was open on push tap ([isFromOnNewIntent]) and the wallet is multi-currency:
* on cold start the fresh list is already loaded by the regular auth flow, and single-currency
* wallets have a fixed token. The fetch is best-effort on failure we fall through and try the
* current cache, so existing tokens (e.g. swap/onramp pushes) still open without regression.
*/
private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet) {
if (isFromOnNewIntent && userWallet.isMultiCurrency) {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId))
.onLeft { TangemLogger.e("Error on refreshing wallet accounts", it) }
}
}
private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) {
val isMultiCurrency = userWallet.isMultiCurrency
when {

View file

@ -559,7 +559,12 @@ internal class TokenDetailsModel @Inject constructor(
}
override fun onAddFundsClick() {
bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.AddFunds)
bottomSheetNavigation.activate(
TokenDetailsBottomSheetConfig.AddFunds(
userWalletId = userWalletId,
currency = cryptoCurrency,
),
)
}
override fun onTransferClick() {

View file

@ -4,6 +4,7 @@ import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.details.TokenAction
import kotlinx.serialization.Serializable
@ -32,7 +33,10 @@ sealed class TokenDetailsBottomSheetConfig : Route {
data object DynamicAddresses : TokenDetailsBottomSheetConfig()
@Serializable
data object AddFunds : TokenDetailsBottomSheetConfig()
data class AddFunds(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
) : TokenDetailsBottomSheetConfig()
@Serializable
data object Transfer : TokenDetailsBottomSheetConfig()

View file

@ -1,59 +0,0 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import kotlinx.coroutines.flow.StateFlow
import com.tangem.core.ui.R as CoreR
internal class AddFundsBottomSheetComponent(
private val stateFlow: StateFlow<AddFundsUM>,
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() {
onDismiss()
}
@Composable
override fun BottomSheet() {
val state by stateFlow.collectAsStateWithLifecycle()
val config = remember(state) {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = state,
)
}
TangemModalBottomSheet<AddFundsUM>(
config = config,
containerColor = TangemTheme.colors2.surface.level2,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(CoreR.string.common_get_token),
endIconRes = CoreR.drawable.ic_close_24,
onEndClick = ::dismiss,
)
},
content = { contentState ->
AddFundsBottomSheetContent(
state = contentState,
onCloseClick = ::dismiss,
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
)
},
)
}
}

View file

@ -1,167 +0,0 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.tokenaction.TokenActionRow
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import dev.chrisbanes.haze.rememberHazeState
import com.tangem.core.ui.R as CoreR
@Composable
internal fun AddFundsBottomSheetContent(state: AddFundsUM, onCloseClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
BuyActionRow(state = state)
SwapActionRow(state = state)
ReceiveActionRow(state = state)
SpacerH(TangemTheme.dimens2.x2)
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
SecondaryTangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = onCloseClick,
text = resourceReference(CoreR.string.common_close),
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
}
SpacerH(TangemTheme.dimens2.x4)
}
}
@Composable
private fun BuyActionRow(state: AddFundsUM) {
val row = (state as? AddFundsUM.Content)?.buy
if (state is AddFundsUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_credit_card_20,
title = resourceReference(CoreR.string.common_buy),
description = resourceReference(CoreR.string.quick_action_buy_description),
row = row,
isLoading = state is AddFundsUM.Loading,
)
}
@Composable
private fun SwapActionRow(state: AddFundsUM) {
val row = (state as? AddFundsUM.Content)?.swap
if (state is AddFundsUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_exchange_mini_24,
title = resourceReference(CoreR.string.common_swap),
description = resourceReference(CoreR.string.quick_action_swap_description),
row = row,
isLoading = state is AddFundsUM.Loading,
)
}
@Composable
private fun ReceiveActionRow(state: AddFundsUM) {
val row = (state as? AddFundsUM.Content)?.receive
if (state is AddFundsUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_qrcode_new_24,
title = resourceReference(CoreR.string.common_receive),
description = resourceReference(CoreR.string.quick_action_receive_description),
row = row,
isLoading = state is AddFundsUM.Loading,
)
}
@Composable
private fun ActionRow(
iconRes: Int,
title: TextReference,
description: TextReference,
row: AddFundsUM.Row?,
isLoading: Boolean,
) {
if (isLoading || row?.isLoading == true) {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
tailContent = { TailLoader() },
)
} else {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
onClick = row?.onClick,
onLongClick = row?.onLongClick,
isEnabled = row?.isEnabled == true,
)
}
}
@Composable
private fun TailLoader() {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = TangemTheme.colors2.graphic.neutral.tertiary,
strokeWidth = 2.dp,
)
}
// region Preview
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview(@PreviewParameter(AddFundsPreviewProvider::class) state: AddFundsUM) {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
AddFundsBottomSheetContent(
state = state,
onCloseClick = {},
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
private class AddFundsPreviewProvider : PreviewParameterProvider<AddFundsUM> {
override val values: Sequence<AddFundsUM> = sequenceOf(
AddFundsUM.Loading,
AddFundsUM.Content(
buy = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}),
swap = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}),
receive = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
AddFundsUM.Content(
buy = AddFundsUM.Row(isLoading = false, isEnabled = false, onClick = {}),
swap = AddFundsUM.Row(isLoading = false, isEnabled = false, onClick = {}),
receive = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
AddFundsUM.Content(
buy = null,
swap = null,
receive = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
)
}
// endregion

View file

@ -10,6 +10,7 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.supplier.SingleAccountListSupplier
@ -50,6 +51,7 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
private val walletBalanceFetcher: WalletBalanceFetcher = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val singleAccountListFetcher: SingleAccountListFetcher = mockk()
@BeforeEach
fun setUp() {
@ -57,6 +59,8 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
mockkObject(TangemLogger)
every { analyticsEventHandler.send(any()) } just Runs
every { appRouter.push(any(), any()) } just Runs
every { appRouter.popTo(route = any(), onComplete = any()) } just Runs
coEvery { singleAccountListFetcher.invoke(any()) } returns Either.Right(Unit)
val userWallet: UserWallet = mockk()
every { userWallet.walletId } returns mockk()
every { getSelectedWalletSync() } returns Either.Right(
@ -461,6 +465,151 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
}
}
@Test
fun `GIVEN multicurrency wallet AND isFromOnNewIntent WHEN handle deeplink THEN refresh wallet accounts`() =
runTest {
val userWalletId = UserWalletId("011")
val cryptoCurrency = mockCryptoCurrency()
mockMultiCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(cryptoCurrency),
)
every {
cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = cryptoCurrency)
} just Runs
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
advanceUntilIdle()
coVerify { singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) }
}
@Test
fun `GIVEN multicurrency wallet AND NOT isFromOnNewIntent WHEN handle deeplink THEN do not refresh accounts`() =
runTest {
val userWalletId = UserWalletId("011")
val cryptoCurrency = mockCryptoCurrency()
mockMultiCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(cryptoCurrency),
)
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = false)
advanceUntilIdle()
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
@Test
fun `GIVEN single currency wallet AND isFromOnNewIntent WHEN handle deeplink THEN do not refresh accounts`() =
runTest {
val userWalletId = UserWalletId("011")
val cryptoCurrency = mockCryptoCurrency()
mockSingleCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(cryptoCurrency),
)
every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs
coEvery {
walletBalanceFetcher.invoke(WalletBalanceFetcher.Params(userWalletId = userWalletId))
} returns mockk()
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
advanceUntilIdle()
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
@Test
fun `GIVEN crypto not found WHEN handle deeplink THEN redirect to main`() = runTest {
val userWalletId = UserWalletId("011")
mockMultiCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
advanceUntilIdle()
verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) }
}
@Test
fun `GIVEN refresh failed AND token in cache WHEN handle deeplink THEN push new route`() = runTest {
val userWalletId = UserWalletId("011")
val cryptoCurrency = mockCryptoCurrency()
mockMultiCurrencyWallet(userWalletId)
mockSelectWallet(userWalletId)
coEvery {
singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId))
} returns Either.Left(IllegalStateException("service unavailable"))
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(cryptoCurrency),
)
every {
cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = cryptoCurrency)
} just Runs
val expectedRoute = AppRoute.CurrencyDetails(userWalletId = userWalletId, currency = cryptoCurrency)
createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true)
advanceUntilIdle()
verify {
appRouter.push(route = expectedRoute, onComplete = any())
}
}
private fun defaultQueryParams() = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777",
)
private fun mockCryptoCurrency() = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(rawId = "321", derivationPath = "777"),
suffix = CryptoCurrency.ID.Suffix.RawID("321"),
)
}
private fun mockMultiCurrencyWallet(userWalletId: UserWalletId) {
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns true
every { walletId } returns userWalletId
every { isLocked } returns false
},
)
}
private fun mockSingleCurrencyWallet(userWalletId: UserWalletId) {
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
},
)
}
private fun mockSelectWallet(userWalletId: UserWalletId) {
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk { every { walletId } returns userWalletId },
)
}
private fun createHandler(
scope: CoroutineScope,
queryParams: Map<String, String>,
@ -479,6 +628,7 @@ class DefaultTokenDetailsDeepLinkHandlerTest {
getUserWalletUseCase = getUserWalletUseCase,
walletBalanceFetcher = walletBalanceFetcher,
singleAccountListSupplier = singleAccountListSupplier,
singleAccountListFetcher = singleAccountListFetcher,
getSelectedWalletSyncUseCase = getSelectedWalletSync,
)
}