Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-18 11:34:41 +03:00
commit e9e45f7550
44 changed files with 358 additions and 296 deletions

View file

@ -1,10 +1,7 @@
package com.tangem.tap.di.domain
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.usecase.AddCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -44,4 +41,12 @@ internal object AccountDomainModule {
): RecoverCryptoPortfolioUseCase {
return RecoverCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository)
}
@Provides
@Singleton
fun provideGetUnoccupiedAccountIndexUseCase(
accountsCRUDRepository: AccountsCRUDRepository,
): GetUnoccupiedAccountIndexUseCase {
return GetUnoccupiedAccountIndexUseCase(crudRepository = accountsCRUDRepository)
}
}

View file

@ -60,6 +60,7 @@ class AmountStateConverter(
return AmountState.Data(
title = value.title,
availableBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)),
availableBalanceShort = stringReference(crypto),
tokenName = stringReference(status.currency.name),
tokenIconState = iconStateConverter.convert(status),
amountTextField = amountFieldConverter.convert(value.value),
@ -130,6 +131,7 @@ class AmountStateConverterV2(
} else {
resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat))
},
availableBalanceShort = stringReference(crypto),
tokenName = stringReference(cryptoCurrencyStatus.currency.name),
tokenIconState = iconStateConverter.convert(cryptoCurrencyStatus.currency),
amountTextField = amountFieldConverter.convert(value.value),

View file

@ -48,6 +48,7 @@ class AmountBoundaryUpdateTransformer(
return prevState.copy(
availableBalance = availableBalance,
availableBalanceShort = stringReference(crypto),
)
}
}

View file

@ -17,7 +17,8 @@ sealed class AmountState {
/**
* @param isPrimaryButtonEnabled indicates if next state button enabled
* @param title title
* @param availableBalance user crypto currency balance
* @param availableBalance user crypto currency balance with fiat balance
* @param availableBalanceShort user crypto currency balance without fiat balance
* @param tokenIconState crypto currency icon state
* @param segmentedButtonConfig currency switcher config
* @param selectedButton selected currency index
@ -33,6 +34,7 @@ sealed class AmountState {
override val isRedesignEnabled: Boolean,
val title: TextReference,
val availableBalance: TextReference,
val availableBalanceShort: TextReference,
val tokenName: TextReference,
val tokenIconState: CurrencyIconState,
val segmentedButtonConfig: PersistentList<AmountSegmentedButtonsConfig>,

View file

@ -24,7 +24,8 @@ object AmountStatePreviewData {
val amountState = AmountState.Data(
isPrimaryButtonEnabled = false,
title = stringReference("Family Wallet"),
availableBalance = stringReference("2 130,88 USDT (2 129,92 \$)"),
availableBalance = stringReference("2 130,88 USDT • 2 129,92 \$)"),
availableBalanceShort = stringReference("2 130,88 USDT"),
tokenIconState = CurrencyIconState.Loading,
segmentedButtonConfig = persistentListOf(
AmountSegmentedButtonsConfig(

View file

@ -64,7 +64,7 @@ fun AmountBlockV2(
AmountBlockV2(
title = amountState.title,
balance = amountState.availableBalance,
balance = amountState.availableBalanceShort,
currencyTitle = currencyTitle,
currencyIconState = amountState.tokenIconState,
firstAmount = firstAmount,

View file

@ -15,12 +15,14 @@ import okio.IOException
* Switch api environment [Interceptor]
*
* @property id api config id [ApiConfig.ID]
* @property baseUrls base urls for all api config environments
* @property apiConfigsManager api configs manager
*
[REDACTED_AUTHOR]
*/
internal class SwitchEnvironmentInterceptor(
private val id: ApiConfig.ID,
private val baseUrls: Set<String>,
private val apiConfigsManager: ApiConfigsManager,
) : Interceptor {
@ -39,10 +41,13 @@ internal class SwitchEnvironmentInterceptor(
return chain.proceed(request)
}
private fun HttpUrl.adjustBaseUrl(url: String): HttpUrl {
return this.newBuilder()
.host(host = url.toHttpUrl().host)
.build()
private fun HttpUrl.adjustBaseUrl(newBaseUrl: String): HttpUrl {
val currentUrl = this.toString()
val currentBaseUrl = baseUrls.first { currentUrl.contains(it) }
return currentUrl
.replace(oldValue = currentBaseUrl, newValue = newBaseUrl)
.toHttpUrl()
}
private fun Request.Builder.addHeaders(headers: Map<String, ProviderSuspend<String>>): Request.Builder {

View file

@ -8,6 +8,7 @@ import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.SwitchEnvironmentInterceptor
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
@ -42,6 +43,7 @@ import javax.inject.Singleton
*/
@Singleton
internal class RetrofitApiBuilder @Inject constructor(
private val apiConfigs: ApiConfigs,
private val apiConfigsManager: ApiConfigsManager,
@NetworkMoshi private val moshi: Moshi,
private val analyticsErrorHandler: AnalyticsErrorHandler,
@ -49,6 +51,8 @@ internal class RetrofitApiBuilder @Inject constructor(
private val appLogsStore: AppLogsStore,
) {
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
/**
* Builds a Retrofit API instance for the specified API configuration ID
*
@ -95,13 +99,26 @@ internal class RetrofitApiBuilder @Inject constructor(
val writeTimeoutSeconds: Long? = null,
)
private fun getConfigsBaseUrls(): Map<ApiConfig.ID, Set<String>> {
return apiConfigs.associate { config ->
val allBaseUrls = config.environmentConfigs.mapTo(hashSetOf(), ApiEnvironmentConfig::baseUrl)
config.id to allBaseUrls
}
}
private fun OkHttpClient.Builder.applyApiConfig(
apiConfigId: ApiConfig.ID,
environmentConfig: ApiEnvironmentConfig,
): OkHttpClient.Builder {
return if (BuildConfig.TESTER_MENU_ENABLED || BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE) {
addInterceptor(
interceptor = SwitchEnvironmentInterceptor(id = apiConfigId, apiConfigsManager = apiConfigsManager),
interceptor = SwitchEnvironmentInterceptor(
id = apiConfigId,
baseUrls = configsBaseUrls[apiConfigId]
?: error("Base URLs for ApiConfig with id [$apiConfigId] not found"),
apiConfigsManager = apiConfigsManager,
),
)
} else {
val headers = environmentConfig.headers

View file

@ -118,6 +118,7 @@
<string name="common_access_denied">Zugang verweigert</string>
<string name="common_add_to_portfolio">Zum Portfolio hinzufügen</string>
<string name="common_add_token">Token hinzufügen</string>
<string name="common_address">Vertragsadresse</string>
<string name="common_all">Alle</string>
<string name="common_allow">Erlauben</string>
<string name="common_amount">Betrag</string>

View file

@ -115,6 +115,7 @@
<string name="common_access_denied">Acceso denegado</string>
<string name="common_add_to_portfolio">Añadir al portafolio</string>
<string name="common_add_token">Agregar token</string>
<string name="common_address">Dirección</string>
<string name="common_all">Todos</string>
<string name="common_allow">Autorizar</string>
<string name="common_amount">Montante</string>

View file

@ -97,6 +97,7 @@
<string name="common_access_denied">Accès refusé</string>
<string name="common_add_to_portfolio">Ajouter au portfolio</string>
<string name="common_add_token">Ajouter un jeton</string>
<string name="common_address">Adresse</string>
<string name="common_all">Tous</string>
<string name="common_allow">Permettre</string>
<string name="common_amount">Montant</string>

View file

@ -146,6 +146,7 @@
<string name="common_access_denied">アクセスが拒否されました</string>
<string name="common_add_to_portfolio">ポートフォリオに追加</string>
<string name="common_add_token">トークンを追加</string>
<string name="common_address">アドレス</string>
<string name="common_all">すべて</string>
<string name="common_allow">許可する</string>
<string name="common_amount">金額</string>

View file

@ -94,6 +94,7 @@
<string name="common_access_denied">Доступ запрещен</string>
<string name="common_add_to_portfolio">Добавить в портфель</string>
<string name="common_add_token">Добавить токен</string>
<string name="common_address">Адрес</string>
<string name="common_all">Все</string>
<string name="common_allow">Разрешить</string>
<string name="common_amount">Сумма</string>

View file

@ -94,6 +94,7 @@
<string name="common_access_denied">Доступ заборонено</string>
<string name="common_add_to_portfolio">Додати у портфель</string>
<string name="common_add_token">Додати токен</string>
<string name="common_address">Адреса</string>
<string name="common_all">Усе</string>
<string name="common_allow">Дозволити</string>
<string name="common_amount">Сума</string>

View file

@ -18,6 +18,7 @@
<string name="account_details_archive_action">Archive</string>
<string name="account_details_archive_description">You are archiving this account, but you can always get it back.</string>
<string name="account_details_title">Account</string>
<string name="account_form_account_index">Account #%s — used for address derivation.</string>
<string name="account_form_create_button">Add account</string>
<string name="account_form_edit_button">Save</string>
<string name="account_form_name">Account name</string>
@ -151,6 +152,7 @@
<string name="common_access_denied">Access denied</string>
<string name="common_add_to_portfolio">Add to portfolio</string>
<string name="common_add_token">Add token</string>
<string name="common_address">Address</string>
<string name="common_all">All</string>
<string name="common_allow">Allow</string>
<string name="common_amount">Amount</string>
@ -1001,6 +1003,8 @@
<string name="send_with_swap_confirm_title">Swap and send</string>
<string name="send_with_swap_convert_token_alert_message">Proceed with conversion? This will clear your previous data.</string>
<string name="send_with_swap_convert_token_alert_title">Confirm Conversion</string>
<string name="send_with_swap_correct_recipient_network_notification_message">Sending any other currency will result in its irreversible loss.</string>
<string name="send_with_swap_correct_recipient_network_notification_title">Select the correct recipient network</string>
<string name="send_with_swap_notification_text">Send any token, and well convert it on the way. Your recipient gets exactly what they need—seamlessly.</string>
<string name="send_with_swap_recipient_amount_success_title">Recipient will receive</string>
<string name="send_with_swap_recipient_amount_text">To recipient</string>

View file

@ -51,7 +51,6 @@ private const val DISABLED_ICON_ALPHA = 0.4f
fun ProviderChooseCrypto(providerChooseUM: ProviderChooseUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
ConstraintLayout(
modifier = modifier
.clip(RoundedCornerShape(14.dp))
.selectedBorder(isSelected = providerChooseUM.isSelected)
.clickable(
enabled = !providerChooseUM.hasError(),

View file

@ -1,49 +0,0 @@
package com.tangem.core.ui.extensions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import timber.log.Timber
/**
* The ViewModel is scoped to the parent route Navigation graph
* and is provided using the Hilt-generated ViewModel factory
*
* ```
* val navController = rememberNavController()
*
* navigation(
* route = "parent",
* startDestination = "parent/1"
* ) {
* composable("route/1") { entry ->
* val viewModel = entry.parentHiltViewModel(navController)
* }
* composable("route/2") { entry ->
* val viewModel = entry.parentHiltViewModel(navController)
* }
* composable("route/3") { entry ->
* val viewModel = entry.parentHiltViewModel(navController)
* }
* }
* ```
*
* @param navController NavController within the common NavGraph
* @throws Exception if there is no parent route
*/
@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.parentHiltViewModel(navController: NavController): T {
val viewModelStoreOwner = remember(this) {
try {
navController.getBackStackEntry(this.destination.parent!!.id)
} catch (e: Exception) {
Timber.tag("scopedViewModel").e(e, "There is no parent route'")
throw e
}
}
return hiltViewModel<T>(viewModelStoreOwner)
}

View file

@ -1,43 +0,0 @@
package com.tangem.core.ui.extensions
import android.R
import android.content.Context
import android.graphics.Color.*
import android.view.WindowManager
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment
import kotlin.math.sqrt
@Deprecated("Use only in legacy fragments")
fun Fragment.setStatusBarColor(@ColorRes colorResId: Int) {
with(requireActivity().window) {
clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
statusBarColor = ContextCompat.getColor(requireContext(), colorResId)
val view = view ?: return
val windowInsetsController = WindowCompat.getInsetsController(this, view)
windowInsetsController.isAppearanceLightStatusBars = luminance(requireContext(), colorResId)
}
}
// TODO replace by android.graphics.luminance() after bump min API to 24
@Suppress("MagicNumber")
fun luminance(context: Context, @ColorRes colorRes: Int): Boolean {
val color = context.resources.getColor(colorRes, null)
if (R.color.transparent == color) return true
var rtnValue = false
val rgb = intArrayOf(red(color), green(color), blue(color))
val brightness = sqrt(
rgb[0] * rgb[0] * .241 +
rgb[1] * rgb[1] * .691 +
rgb[2] * rgb[2] * .068,
).toInt()
// color is light
if (brightness >= 200) {
rtnValue = true
}
return rtnValue
}

View file

@ -4,7 +4,6 @@ import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@ -72,26 +71,24 @@ fun Modifier.conditionalCompose(
fun Modifier.selectedBorder(
isSelected: Boolean,
width: Dp = 2.5.dp,
color: Color = TangemTheme.colors.text.accent.copy(alpha = 0.1f),
color: Color = TangemTheme.colors.text.accent,
radius: Dp = 16.dp,
) = conditionalCompose(
condition = isSelected,
modifier = {
border(
outsetBorder(
width = width,
color = color,
shape = RoundedCornerShape(radius),
color = color.copy(alpha = 0.15f),
shape = RoundedCornerShape(radius + 2.dp),
)
.padding(width)
.border(
width = 1.dp,
color = TangemTheme.colors.text.accent,
shape = RoundedCornerShape(radius - 2.dp),
color = color,
shape = RoundedCornerShape(radius),
)
.clip(RoundedCornerShape(radius - 2.dp))
.clip(RoundedCornerShape(radius))
},
otherModifier = {
padding(width)
.clip(RoundedCornerShape(radius - 2.dp))
clip(RoundedCornerShape(radius))
},
)

View file

@ -1,72 +0,0 @@
package com.tangem.core.ui.screen
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.FloatRange
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Modifier
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
/**
* An abstract base class for bottom sheet dialogs that use Compose for UI rendering.
* Extends [BottomSheetDialogFragment] and implements [ComposeScreen] interface.
*/
abstract class ComposeBottomSheetFragment : BottomSheetDialogFragment(), ComposeScreen {
/**
* The initial state of the bottom sheet. Default is [BottomSheetBehavior.STATE_EXPANDED].
*/
open val initialBottomSheetState = BottomSheetBehavior.STATE_EXPANDED
/**
* The fraction of the screen height that the bottom sheet should take when expanded.
* Default is `null`, indicating that the height will be determined by the content.
*/
@FloatRange(from = 0.0, to = 1.0)
open val expandedHeightFraction: Float? = null
override val screenModifier: Modifier
@Composable
@ReadOnlyComposable
get() = Modifier
.fillMaxWidth()
.let {
if (expandedHeightFraction != null) it.fillMaxHeight(expandedHeightFraction!!) else it
}
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.bottomSheet,
)
override fun getTheme(): Int = R.style.AppTheme_TransparentBottomSheetDialog
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return createComposeView(
context = inflater.context,
activity = requireActivity(),
overrideSystemBarColors = false,
)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
(dialog as BottomSheetDialog).behavior.apply {
state = initialBottomSheetState
skipCollapsed = true
}
return dialog
}
}

View file

@ -1,50 +0,0 @@
package com.tangem.core.ui.screen
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.transition.TransitionInflater
import com.tangem.core.ui.R
/**
* An abstract base class for fragments that use Compose for UI rendering.
* Extends [Fragment] and implements [ComposeScreen] interface.
*/
abstract class ComposeFragment : Fragment(), ComposeScreen {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val isTransitionsInflated = TransitionInflater.from(requireContext()).inflateTransitions()
return createComposeView(inflater.context, requireActivity()).also {
it.isTransitionGroup = isTransitionsInflated
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
/*
* We need to manually dispatch configuration changes to the Compose view.
*
* `android:configChanges="uiMode"` is set in the manifest.
* */
view?.dispatchConfigurationChanged(newConfig)
}
/**
* Inflates transitions for the fragment. Override this method to customize
* enter and exit transitions for the fragment.
*
* @return `true` if transitions were inflated; `false` otherwise.
*/
protected open fun TransitionInflater.inflateTransitions(): Boolean {
enterTransition = inflateTransition(R.transition.fade)
exitTransition = inflateTransition(R.transition.fade)
return true
}
}

View file

@ -53,6 +53,12 @@ internal class DefaultAccountsCRUDRepository(
}
}
override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int {
val activeAccountsCount = runtimeStore.getSyncOrNull()?.size ?: 1
return activeAccountsCount + 1
}
override fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsStore.getSyncStrict(userWalletId)
}

View file

@ -43,15 +43,20 @@ interface AccountsCRUDRepository {
*
* @param accountList the list of accounts to be saved.
*/
@Throws
suspend fun saveAccounts(accountList: AccountList)
/**
* Retrieves the total count of accounts associated with a specific user wallet including archived accounts
*
* @param userWalletId the unique identifier of the user wallet
*/
suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int
/**
* Retrieves a user wallet by its unique identifier
*
* @param userWalletId the unique identifier of the user wallet
* @return the [UserWallet] associated with the given identifier
*/
@Throws
fun getUserWallet(userWalletId: UserWalletId): UserWallet
}

View file

@ -0,0 +1,61 @@
package com.tangem.domain.account.usecase
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
/**
* Use case for retrieving the next unoccupied account index
*
* @property crudRepository repository for performing CRUD operations on accounts
*
[REDACTED_AUTHOR]
*/
class GetUnoccupiedAccountIndexUseCase(
private val crudRepository: AccountsCRUDRepository,
) {
/**
* Invokes the use case to calculate the next unoccupied account index
*
* @param userWalletId the unique identifier of the user wallet
*/
suspend operator fun invoke(userWalletId: UserWalletId): Either<Error, DerivationIndex> = either {
val totalAccountsCount = getTotalAccountsCount(userWalletId = userWalletId)
DerivationIndex(totalAccountsCount + 1).getOrElse {
raise(Error.InvalidDerivationIndex(it))
}
}
private suspend fun Raise<Error>.getTotalAccountsCount(userWalletId: UserWalletId): Int {
return catch(
block = { crudRepository.getTotalAccountsCount(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
}
/**
* Represents possible errors that can occur in the use case
*/
sealed interface Error {
val tag: String
get() = this::class.simpleName ?: "GetUnoccupiedAccountIndexUseCase.Error"
/** Error indicating that the derivation index is invalid */
data class InvalidDerivationIndex(val cause: DerivationIndex.Error) : Error {
override fun toString(): String = "$tag: Invalid derivation index: $cause"
}
/** Error indicating that a data operation failed */
data class DataOperationFailed(val cause: Throwable) : Error {
override fun toString(): String = "$tag: Data operation failed: ${cause.message ?: "Unknown error"}"
}
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.domain.account.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetUnoccupiedAccountIndexUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = GetUnoccupiedAccountIndexUseCase(crudRepository)
private val userWalletId = UserWalletId("011")
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository)
}
@Test
fun `invoke should return next unoccupied index when repository returns count`() = runTest {
// Arrange
coEvery { crudRepository.getTotalAccountsCount(userWalletId) } returns 3
// Act
val actual = useCase(userWalletId = userWalletId)
// Assert
val expected = 4.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerify { crudRepository.getTotalAccountsCount(userWalletId) }
}
@Test
fun `invoke should return error if repository throws exception`() = runTest {
// Arrange
val exception = IllegalStateException("Test error")
coEvery { crudRepository.getTotalAccountsCount(userWalletId) } throws exception
// Act
val actual = useCase(userWalletId = userWalletId)
// Assert
val expected = GetUnoccupiedAccountIndexUseCase.Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerify { crudRepository.getTotalAccountsCount(userWalletId) }
}
}

View file

@ -20,6 +20,7 @@ dependencies {
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.error)
implementation(projects.core.res)
implementation(projects.core.decompose)
implementation(projects.core.navigation)

View file

@ -1,5 +1,7 @@
package com.tangem.features.account.createedit
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -9,11 +11,14 @@ import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.account.usecase.AddCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase
import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.common.toDomain
import com.tangem.features.account.createedit.entity.AccountCreateEditUM
@ -21,15 +26,20 @@ import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.portfolioIcon
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateButton
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateColorSelect
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateDerivationIndex
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateIconSelect
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateName
import com.tangem.features.account.createedit.error.AccountFeatureError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class AccountCreateEditModel @Inject constructor(
paramsContainer: ParamsContainer,
private val messageSender: UiMessageSender,
@ -37,13 +47,21 @@ internal class AccountCreateEditModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val updateCryptoPortfolioUseCase: UpdateCryptoPortfolioUseCase,
private val addCryptoPortfolioUseCase: AddCryptoPortfolioUseCase,
private val getUnoccupiedAccountIndexUseCase: GetUnoccupiedAccountIndexUseCase,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : Model() {
private val params = paramsContainer.require<AccountCreateEditComponent.Params>()
private val umBuilder = AccountCreateEditUMBuilder(params)
val uiState: StateFlow<AccountCreateEditUM> get() = _uiState
private val _uiState = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<AccountCreateEditUM>
field = MutableStateFlow(value = getInitialState())
init {
if (params is AccountCreateEditComponent.Params.Create) {
updateDerivationInfo(userWalletId = params.userWalletId)
}
}
private fun unsaveChangeDialog() {
val secondAction = EventMessageAction(
@ -74,13 +92,16 @@ internal class AccountCreateEditModel @Inject constructor(
private suspend fun createNewCryptoPortfolio(params: AccountCreateEditComponent.Params.Create) {
val state = uiState.value
val name = AccountName(state.account.name).getOrNull() ?: return
val name = AccountName(value = state.account.name).getOrNull() ?: return
val icon = state.account.portfolioIcon.toDomain()
val index = state.account.derivationInfo.index ?: return
val derivationIndex = DerivationIndex(value = index).getOrNull() ?: return
addCryptoPortfolioUseCase(
userWalletId = params.userWalletId,
accountName = name,
icon = icon,
derivationIndex = DerivationIndex.Main, // todo account
derivationIndex = derivationIndex,
)
}
@ -100,19 +121,19 @@ internal class AccountCreateEditModel @Inject constructor(
private fun onCloseClick() = unsaveChangeDialog()
private fun onIconSelect(icon: CryptoPortfolioIcon.Icon) {
_uiState.value = uiState.value
uiState.value = uiState.value
.updateIconSelect(icon)
.validateNewState()
}
private fun onColorSelect(color: CryptoPortfolioIcon.Color) {
_uiState.value = uiState.value
uiState.value = uiState.value
.updateColorSelect(color)
.validateNewState()
}
private fun onNameChange(name: String) {
_uiState.value = uiState.value
uiState.value = uiState.value
.updateName(name)
.validateNewState()
}
@ -140,4 +161,35 @@ internal class AccountCreateEditModel @Inject constructor(
onCloseClick = ::onCloseClick,
)
}
private fun updateDerivationInfo(userWalletId: UserWalletId) {
modelScope.launch(dispatchers.default) {
getUnoccupiedAccountIndexUseCase(userWalletId = userWalletId)
.onRight { derivationIndex ->
uiState.update {
it.updateDerivationIndex(derivationIndex = derivationIndex.value)
}
}
.onLeft {
handleError(
error = AccountFeatureError.CreateAccount.UnableToGetDerivationIndex,
params = mapOf("userWalletId" to userWalletId.stringValue),
)
return@launch
}
}
}
private fun handleError(error: AccountFeatureError, params: Map<String, String> = mapOf()) {
val exception = IllegalStateException(error.toString())
Timber.e(exception)
analyticsExceptionHandler.sendException(
event = ExceptionAnalyticsEvent(exception = exception, params = params),
)
messageSender.showErrorDialog(universalError = error, onDismiss = router::pop)
}
}

View file

@ -17,11 +17,23 @@ data class AccountCreateEditUM(
data class Account(
val name: String,
val portfolioIcon: CryptoPortfolioIconUM,
val derivationInfo: TextReference,
val derivationInfo: DerivationInfo,
val inputPlaceholder: TextReference,
val onNameChange: (String) -> Unit,
)
sealed interface DerivationInfo {
val text: TextReference
val index: Int?
data class Content(override val text: TextReference, override val index: Int) : DerivationInfo
data object Empty : DerivationInfo {
override val text: TextReference = TextReference.EMPTY
override val index: Int? = null
}
}
data class Colors(
val selected: CryptoPortfolioIcon.Color,
val list: ImmutableList<CryptoPortfolioIcon.Color>,

View file

@ -3,15 +3,15 @@ package com.tangem.features.account.createedit.entity
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.common.toUM
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
internal class AccountCreateEditUMBuilder @Inject constructor(
val params: AccountCreateEditComponent.Params,
internal class AccountCreateEditUMBuilder(
private val params: AccountCreateEditComponent.Params,
) {
private val accountColors = CryptoPortfolioIcon.Color.entries.toImmutableList()
@ -29,14 +29,16 @@ internal class AccountCreateEditUMBuilder @Inject constructor(
is AccountCreateEditComponent.Params.Create -> AccountCreateEditUM.Account(
name = "",
portfolioIcon = createIcon,
derivationInfo = TextReference.EMPTY,
derivationInfo = AccountCreateEditUM.DerivationInfo.Empty,
inputPlaceholder = resourceReference(R.string.account_form_placeholder_new_account),
onNameChange = onNameChange,
)
is AccountCreateEditComponent.Params.Edit -> AccountCreateEditUM.Account(
name = params.account.name.value,
portfolioIcon = params.account.portfolioIcon.toUM(),
derivationInfo = TextReference.EMPTY, // todo account use Account.CryptoPortfolio.derivationIndex ?
derivationInfo = createAccountDerivationInfo(
index = (params.account as Account.CryptoPortfolio).derivationIndex.value,
),
inputPlaceholder = resourceReference(R.string.account_form_placeholder_edit_account),
onNameChange = onNameChange,
)
@ -113,5 +115,25 @@ internal class AccountCreateEditUMBuilder @Inject constructor(
fun AccountCreateEditUM.updateButton(isButtonEnabled: Boolean): AccountCreateEditUM {
return this.copy(buttonState = this.buttonState.copy(isButtonEnabled = isButtonEnabled))
}
fun AccountCreateEditUM.updateDerivationIndex(derivationIndex: Int): AccountCreateEditUM {
return this.copy(
account = this.account.copy(
derivationInfo = createAccountDerivationInfo(index = derivationIndex),
),
)
}
private fun createAccountDerivationInfo(index: Int): AccountCreateEditUM.DerivationInfo {
val derivationIndexText = if (index.toString().length == 1) "0$index" else "$index"
return AccountCreateEditUM.DerivationInfo.Content(
text = resourceReference(
id = R.string.account_form_account_index,
formatArgs = wrappedList(derivationIndexText),
),
index = index,
)
}
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.account.createedit.error
import com.tangem.core.error.UniversalError
sealed interface AccountFeatureError : UniversalError {
val subsystemCode: String
val specificErrorCode: String
override val errorCode: Int
get() = "108$subsystemCode$specificErrorCode".toInt()
sealed interface CreateAccount : AccountFeatureError {
override val subsystemCode: String get() = "001"
data object UnableToGetDerivationIndex : CreateAccount {
override val specificErrorCode: String = "001"
}
}
sealed interface EditAccount : AccountFeatureError {
override val subsystemCode: String get() = "002"
data object RequiredCryptoPortfolio : EditAccount {
override val specificErrorCode: String = "001"
}
}
}

View file

@ -32,10 +32,7 @@ import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.fields.AutoSizeTextField
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.account.CryptoPortfolioIcon
@ -76,7 +73,7 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi
SpacerH8()
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = state.account.derivationInfo.resolveReference(),
text = state.account.derivationInfo.text.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
@ -93,7 +90,7 @@ internal fun AccountCreateEditContent(state: AccountCreateEditUM, modifier: Modi
}
@Composable
private fun AccountSummary(account: AccountCreateEditUM.Account) {
private fun AccountSummary(account: Account) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
@ -126,7 +123,7 @@ private fun AccountSummary(account: AccountCreateEditUM.Account) {
}
@Composable
private fun AccountIcon(account: AccountCreateEditUM.Account) {
private fun AccountIcon(account: Account) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
@ -308,7 +305,10 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountC
portfolioIcon = portfolioIcon,
inputPlaceholder = resourceReference(R.string.account_form_placeholder_new_account),
onNameChange = {},
derivationInfo = stringReference("Account #03 — used for address derivation."),
derivationInfo = AccountCreateEditUM.DerivationInfo.Content(
text = resourceReference(id = R.string.account_form_account_index, formatArgs = wrappedList(1)),
index = 1,
),
),
colorsState = AccountCreateEditUM.Colors(
selected = portfolioIcon.color,
@ -340,7 +340,10 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountC
name = "Main account",
inputPlaceholder = resourceReference(R.string.account_form_placeholder_edit_account),
onNameChange = {},
derivationInfo = stringReference("Account #03 — used for address derivation."),
derivationInfo = AccountCreateEditUM.DerivationInfo.Content(
text = resourceReference(id = R.string.account_form_account_index, formatArgs = wrappedList(1)),
index = 1,
),
),
colorsState = AccountCreateEditUM.Colors(
selected = portfolioIcon.color,

View file

@ -1,15 +1,11 @@
package com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
@ -19,9 +15,9 @@ import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.rows.RowContentContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.outsetBorder
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.selectedBorder
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -112,17 +108,7 @@ private fun SelectableChainRow(
RowContentContainer(
modifier = modifier
.heightIn(min = 48.dp)
.outsetBorder(
color = if (selected) TangemTheme.colors.icon.accent.copy(alpha = 0.15f) else Color.Transparent,
width = 5.dp,
shape = RoundedCornerShape(size = 18.dp),
)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.border(
width = 1.dp,
color = if (selected) TangemTheme.colors.icon.accent else Color.Transparent,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.selectedBorder(selected)
.clickable(onClick = onClick)
.padding(12.dp),
icon = {
@ -163,7 +149,7 @@ private fun Preview() {
),
),
selectedOption = SelectableChainRowUM(
event = OnboardingVisaChooseWalletComponent.Params.Event.OtherWallet,
event = OnboardingVisaChooseWalletComponent.Params.Event.TangemWallet,
icon = R.drawable.ic_tangem_24,
text = TextReference.Str("Tangem Wallet"),
),

View file

@ -79,7 +79,7 @@ internal fun FeeSelectorModalBottomSheet(
FeeSelectorItems(
state = state,
feeSelectorIntents = feeSelectorIntents,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 13.dp),
modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp),
)
},
footer = {
@ -141,7 +141,6 @@ private fun FeeSelectorItems(
)
val itemModifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors.background.primary)
.selectedBorder(isSelected = isSelected)
.clickableSingle(onClick = { feeSelectorIntents.onFeeItemSelected(item) })
when (item) {

View file

@ -156,7 +156,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
currentRoute = model.currentRoute.filterIsInstance<CommonSendRoute.Destination>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName,
title = resourceReference(R.string.send_recipient_label),
title = resourceReference(R.string.common_address),
userWalletId = params.userWalletId,
cryptoCurrency = params.currency,
callback = model,

View file

@ -10,4 +10,7 @@ internal enum class EnterAddressSource {
val isPasted: Boolean
get() = this != InputField
val isAutoNext: Boolean
get() = this == RecentAddress || this == MyWallets
}

View file

@ -286,8 +286,7 @@ internal class SendDestinationModel @Inject constructor(
}
private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean, isValidMemo: Boolean) {
val isRecent = type == EnterAddressSource.RecentAddress
if (isRecent && isValidAddress && isValidMemo) {
if (type?.isAutoNext == true && isValidAddress && isValidMemo) {
saveResult()
(params as? SendDestinationComponentParams.DestinationParams)?.callback?.onNextClick()
}

View file

@ -205,6 +205,7 @@ class SendConfirmationNotificationsTransformerTest {
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
availableBalanceShort = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),

View file

@ -204,6 +204,7 @@ class SendConfirmationNotificationsTransformerV2Test {
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
availableBalanceShort = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),

View file

@ -307,6 +307,7 @@ class TransformersComparisonTest {
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
availableBalanceShort = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),

View file

@ -72,14 +72,13 @@ internal fun SwapAmountBlockContent(
start.linkTo(parent.start)
end.linkTo(parent.end)
},
extraContent = {
SwapPriceImpact(amountFieldUM = amountUM.primaryAmount, onInfoClick = onInfoClick)
},
extraContent = { SwapPriceImpact(amountFieldUM = amountUM.primaryAmount, onInfoClick = onInfoClick) },
)
AmountBlockV2(
amountState = (amountUM.secondaryAmount.amountField as? AmountState.Data)?.copy(
title = resourceReference(R.string.send_with_swap_recipient_amount_title),
availableBalance = TextReference.EMPTY,
availableBalanceShort = TextReference.EMPTY,
) ?: amountUM.secondaryAmount.amountField,
isClickDisabled = true,
isEditingDisabled = false,

View file

@ -5,14 +5,12 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -65,7 +63,7 @@ internal fun SwapChooseProviderContent(
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.padding(horizontal = 13.dp),
modifier = modifier.padding(horizontal = 12.dp),
) {
Text(
text = stringResourceSafe(id = R.string.onramp_choose_provider_title_hint),
@ -89,7 +87,6 @@ internal fun SwapChooseProviderContent(
SwapProviderItem(
state = provider.swapProviderState,
modifier = Modifier
.clip(RoundedCornerShape(14.dp))
.selectedBorder(isSelected = provider.swapProviderState.isSelected)
.clickable(
enabled = provider.quote !is SwapQuoteUM.Error,

View file

@ -96,13 +96,13 @@ internal object SwapChooseProviderContentPreview {
),
quote = quote2,
swapProviderState = SwapProviderState.Content(
name = provider1.name,
type = provider1.type.typeName,
name = provider2.name,
type = provider2.type.typeName,
iconUrl = "",
subtitle = stringReference("1800 POL"),
additionalBadge = SwapProviderState.AdditionalBadge.BestTrade,
diffPercent = SwapQuoteUM.Content.DifferencePercent.Best,
isSelected = true,
isSelected = false,
),
),
),

View file

@ -160,7 +160,7 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
currentRoute = model.currentRoute.filterIsInstance<DestinationRoute>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName,
title = resourceReference(R.string.send_recipient_label),
title = resourceReference(R.string.common_address),
userWalletId = params.userWalletId,
cryptoCurrency = secondaryCryptoCurrency,
callback = model,

View file

@ -30,7 +30,7 @@ internal fun WcAddressItem(address: String, modifier: Modifier = Modifier) {
)
Text(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing8),
text = stringResourceSafe(R.string.wc_common_address),
text = stringResourceSafe(R.string.common_address),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
maxLines = 1,