From 0c5d018677198aca94e2df901169a6bb4c1d71d3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 21 Aug 2025 19:56:51 +0500 Subject: [PATCH 01/27] Updated on 2026-08-14 --- .../tap/di/domain/WalletsDomainModule.kt | 22 ++++++++++- .../components/notifications/Notification.kt | 4 +- .../notifications/NotificationConfig.kt | 3 ++ .../res/drawable/ic_hardware_backup_36.xml | 22 +++++++++++ .../data/wallets/DefaultWalletsRepository.kt | 14 +++++++ .../wallets/repository/WalletsRepository.kt | 4 ++ ...DismissUpgradeWalletNotificationUseCase.kt | 12 ++++++ ...UpgradeWalletNotificationEnabledUseCase.kt | 13 +++++++ .../preview/PreviewWalletSettingsComponent.kt | 3 ++ .../entity/WalletSettingsItemUM.kt | 8 ++++ .../walletsettings/entity/WalletSettingsUM.kt | 1 + .../model/WalletSettingsModel.kt | 23 +++++++++++- .../walletsettings/ui/WalletSettingsScreen.kt | 37 +++++++++++++++++++ .../walletsettings/utils/ItemsBuilder.kt | 33 +++++++++++++++++ .../wallet/state/model/WalletNotification.kt | 2 + .../components/common/WalletNotifications.kt | 6 --- 16 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_hardware_backup_36.xml create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index 44dd702fc3..911eededb2 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -25,7 +25,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") @Module @InstallIn(SingletonComponent::class) internal object WalletsDomainModule { @@ -352,4 +352,24 @@ internal object WalletsDomainModule { walletsRepository = walletsRepository, ) } + + @Provides + @Singleton + fun providesIsUpgradeWalletNotificationEnabledUseCase( + walletsRepository: WalletsRepository, + ): IsUpgradeWalletNotificationEnabledUseCase { + return IsUpgradeWalletNotificationEnabledUseCase( + walletsRepository = walletsRepository, + ) + } + + @Provides + @Singleton + fun providesDismissUpgradeWalletNotificationUseCase( + walletsRepository: WalletsRepository, + ): DismissUpgradeWalletNotificationUseCase { + return DismissUpgradeWalletNotificationUseCase( + walletsRepository = walletsRepository, + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index 7b1baa0109..06a2bc1d9e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.* import com.tangem.core.ui.components.buttons.common.TangemButtonSize @@ -64,7 +63,6 @@ fun Notification( NotificationConfig.IconTint.Accent -> TangemTheme.colors.icon.accent NotificationConfig.IconTint.Attention -> TangemTheme.colors.icon.attention }, - iconSize: Dp = 20.dp, isEnabled: Boolean = true, ) { NotificationBaseContainer( @@ -78,7 +76,7 @@ fun Notification( MainContent( iconResId = config.iconResId, iconTint = iconTint, - iconSize = iconSize, + iconSize = config.iconSize, title = config.title, titleColor = titleColor, subtitle = config.subtitle, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt index 4afe96144e..ca5cdc8e5a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt @@ -1,6 +1,8 @@ package com.tangem.core.ui.components.notifications import androidx.annotation.DrawableRes +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.tangem.core.ui.extensions.TextReference /** @@ -26,6 +28,7 @@ data class NotificationConfig( val onCloseClick: (() -> Unit)? = null, val showArrowIcon: Boolean = onClick != null, val iconTint: IconTint = IconTint.Unspecified, + val iconSize: Dp = 20.dp, ) { sealed class ButtonsState { diff --git a/core/ui/src/main/res/drawable/ic_hardware_backup_36.xml b/core/ui/src/main/res/drawable/ic_hardware_backup_36.xml new file mode 100644 index 0000000000..071683ab08 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_hardware_backup_36.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index 2127d9b80b..d2a4f5cea1 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -31,6 +31,7 @@ import com.tangem.utils.coroutines.runCatching import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.collections.mutableSetOf typealias SeedPhraseNotificationsStatuses = Map @@ -44,6 +45,9 @@ internal class DefaultWalletsRepository( private val authProvider: AuthProvider, ) : WalletsRepository { + private val upgradeWalletNotificationDisabled: MutableStateFlow> = + MutableStateFlow(mutableSetOf()) + override suspend fun shouldSaveUserWalletsSync(): Boolean { return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false) } @@ -327,6 +331,16 @@ internal class DefaultWalletsRepository( } } + override fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow { + return upgradeWalletNotificationDisabled.map { + it.contains(userWalletId) + } + } + + override suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) { + upgradeWalletNotificationDisabled.update { it.plus(userWalletId) } + } + override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) { tangemTechApi.updateWallet( walletId = walletId, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index a1eb1f0cd8..b65e7006cc 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -57,6 +57,10 @@ interface WalletsRepository { suspend fun setNotificationsEnabled(userWalletId: UserWalletId, isEnabled: Boolean) + fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow + + suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) + @Throws suspend fun setWalletName(walletId: String, walletName: String) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt new file mode 100644 index 0000000000..2aeb755fba --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DismissUpgradeWalletNotificationUseCase.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.wallets.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.repository.WalletsRepository + +class DismissUpgradeWalletNotificationUseCase( + private val walletsRepository: WalletsRepository, +) { + suspend operator fun invoke(userWalletId: UserWalletId) { + walletsRepository.dismissUpgradeWalletNotification(userWalletId) + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt new file mode 100644 index 0000000000..d38fec4fe2 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/IsUpgradeWalletNotificationEnabledUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.wallets.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.repository.WalletsRepository +import kotlinx.coroutines.flow.Flow + +class IsUpgradeWalletNotificationEnabledUseCase( + private val walletsRepository: WalletsRepository, +) { + operator fun invoke(userWalletId: UserWalletId): Flow { + return walletsRepository.isUpgradeWalletNotificationEnabled(userWalletId) + } +} \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index 453120249f..1ac9106632 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -45,6 +45,9 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { onNotificationsDescriptionClick = {}, isNotificationsPermissionGranted = false, onAccessCodeClick = {}, + walletUpgradeDismissed = false, + onUpgradeWalletClick = {}, + onDismissUpgradeWalletClick = {}, ), requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = {}, diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt index 490fb2b00e..91a77aec05 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt @@ -43,4 +43,12 @@ internal sealed class WalletSettingsItemUM { val title: TextReference, val description: TextReference, ) : WalletSettingsItemUM() + + data class UpgradeWallet( + override val id: String, + val title: TextReference, + val description: TextReference, + val onClick: () -> Unit, + val onDismissClick: () -> Unit, + ) : WalletSettingsItemUM() } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt index 39bd16ae56..04014e4662 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsUM.kt @@ -10,4 +10,5 @@ internal data class WalletSettingsUM( val requestPushNotificationsPermission: Boolean = false, val onPushNotificationPermissionGranted: (Boolean) -> Unit, val isWalletBackedUp: Boolean = true, + val walletUpgradeDismissed: Boolean = false, ) \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index f9d0b633b2..c190745304 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -56,7 +56,7 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @ModelScoped internal class WalletSettingsModel @Inject constructor( getWalletUseCase: GetUserWalletUseCase, @@ -80,6 +80,8 @@ internal class WalletSettingsModel @Inject constructor( private val permissionsRepository: PermissionRepository, private val notificationsRepository: NotificationsRepository, private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase, + private val isUpgradeWalletNotificationEnabledUseCase: IsUpgradeWalletNotificationEnabledUseCase, + private val dismissUpgradeWalletNotificationUseCase: DismissUpgradeWalletNotificationUseCase, ) : Model() { val params: WalletSettingsComponent.Params = paramsContainer.require() @@ -93,6 +95,7 @@ internal class WalletSettingsModel @Inject constructor( requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = ::onPushNotificationPermissionGranted, isWalletBackedUp = true, + walletUpgradeDismissed = false, ), ) @@ -120,7 +123,8 @@ internal class WalletSettingsModel @Inject constructor( getWalletUseCase.invokeFlow(params.userWalletId).distinctUntilChanged(), getWalletNFTEnabledUseCase.invoke(params.userWalletId), getWalletNotificationsEnabledUseCase(params.userWalletId), - ) { maybeWallet, nftEnabled, notificationsEnabled -> + isUpgradeWalletNotificationEnabledUseCase(params.userWalletId), + ) { maybeWallet, nftEnabled, notificationsEnabled, isUpgradeNotificationEnabled -> val wallet = maybeWallet.getOrNull() ?: return@combine val isRenameWalletAvailable = getShouldSaveUserWalletsSyncUseCase() val isWalletBackedUp = when (wallet) { @@ -139,6 +143,7 @@ internal class WalletSettingsModel @Inject constructor( isNotificationsEnabled = notificationsEnabled, isNotificationsFeatureEnabled = isNeedShowNotifications, isNotificationsPermissionGranted = isNotificationsPermissionGranted(), + isUpgradeNotificationEnabled = isUpgradeNotificationEnabled, ), isWalletBackedUp = isWalletBackedUp, ) @@ -165,6 +170,7 @@ internal class WalletSettingsModel @Inject constructor( isNotificationsFeatureEnabled: Boolean, isNotificationsEnabled: Boolean, isNotificationsPermissionGranted: Boolean, + isUpgradeNotificationEnabled: Boolean, ): PersistentList { val isMultiCurrency = when (userWallet) { is UserWallet.Cold -> userWallet.isMultiCurrency @@ -215,6 +221,9 @@ internal class WalletSettingsModel @Inject constructor( onCheckedNotificationsChanged = ::onCheckedNotificationsChange, onNotificationsDescriptionClick = ::onNotificationsDescriptionClick, onAccessCodeClick = ::onAccessCodeClick, + walletUpgradeDismissed = isUpgradeNotificationEnabled, + onUpgradeWalletClick = ::onUpgradeWalletClick, + onDismissUpgradeWalletClick = ::onDismissUpgradeWalletClick, ) } @@ -367,4 +376,14 @@ internal class WalletSettingsModel @Inject constructor( router.push(AppRoute.UpdateAccessCode(params.userWalletId)) } } + + private fun onUpgradeWalletClick() { + // TODO [REDACTED_TASK_KEY] + } + + private fun onDismissUpgradeWalletClick() { + modelScope.launch { + dismissUpgradeWalletNotificationUseCase.invoke(params.userWalletId) + } + } } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index fe95f8129a..8c4bbd6cf2 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -25,6 +25,7 @@ import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -116,6 +117,10 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { modifier = itemModifier, model = item, ) + is WalletSettingsItemUM.UpgradeWallet -> UpgradeWalletBlock( + modifier = itemModifier, + model = item, + ) } } } @@ -225,6 +230,21 @@ private fun SwitchBlock(model: WalletSettingsItemUM.WithSwitch, modifier: Modifi } } +@Composable +private fun UpgradeWalletBlock(model: WalletSettingsItemUM.UpgradeWallet, modifier: Modifier = Modifier) { + Notification( + config = NotificationConfig( + title = model.title, + subtitle = model.description, + iconResId = R.drawable.ic_hardware_backup_36, + iconSize = 36.dp, + onClick = model.onClick, + onCloseClick = model.onDismissClick, + ), + modifier = modifier, + ) +} + @Composable private fun NotificationAlertBlock(model: WalletSettingsItemUM.NotificationPermission, modifier: Modifier = Modifier) { Notification( @@ -266,4 +286,21 @@ private fun Preview_WalletSettingsScreen() { PreviewWalletSettingsComponent().Content(modifier = Modifier.fillMaxSize()) } } + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_WalletSettingsScreen1() { + TangemThemePreview { + UpgradeWalletBlock( + model = WalletSettingsItemUM.UpgradeWallet( + id = "upgrade_wallet", + title = stringReference("Upgrade wallet with a hardware backup"), + description = stringReference("Keep your crypto safe with Tangem’s best-in-class hardware wallet."), + onClick = {}, + onDismissClick = {}, + ), + ) + } +} // endregion Preview \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index 5086f22579..4189d78f2a 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -47,8 +47,19 @@ internal class ItemsBuilder @Inject constructor( onLinkMoreCardsClick: () -> Unit, onReferralClick: () -> Unit, onAccessCodeClick: () -> Unit, + walletUpgradeDismissed: Boolean, + onUpgradeWalletClick: () -> Unit, + onDismissUpgradeWalletClick: () -> Unit, ): PersistentList = persistentListOf() .add(buildNameItem(userWalletName, isRenameWalletAvailable, renameWallet)) + .addAll( + buildUpgradeWalletItem( + userWallet = userWallet, + walletUpgradeDismissed = walletUpgradeDismissed, + onUpgradeWalletClick = onUpgradeWalletClick, + onDismissUpgradeWalletClick = onDismissUpgradeWalletClick, + ), + ) .addAll(buildAccessCodeItem(userWallet, onAccessCodeClick)) .add( buildCardItem( @@ -124,6 +135,28 @@ internal class ItemsBuilder @Inject constructor( onCheckedChange = onCheckedNFTChange, ) + private fun buildUpgradeWalletItem( + userWallet: UserWallet, + walletUpgradeDismissed: Boolean, + onUpgradeWalletClick: () -> Unit, + onDismissUpgradeWalletClick: () -> Unit, + ): List = when (userWallet) { + is UserWallet.Cold -> emptyList() + is UserWallet.Hot -> if (!walletUpgradeDismissed) { + listOf( + WalletSettingsItemUM.UpgradeWallet( + id = "upgrade_wallet", + title = resourceReference(id = R.string.hw_upgrade_to_cold_banner_title), + description = resourceReference(id = R.string.hw_upgrade_to_cold_banner_description), + onClick = onUpgradeWalletClick, + onDismissClick = onDismissUpgradeWalletClick, + ), + ) + } else { + emptyList() + } + } + private fun buildNotificationsPermissionItem() = WalletSettingsItemUM.NotificationPermission( id = "notifications_permission", title = resourceReference(id = R.string.transaction_notifications_warning_title), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt index 965b55df30..3d23c93ae5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt @@ -1,6 +1,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.model import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.pluralReference @@ -282,6 +283,7 @@ sealed class WalletNotification(val config: NotificationConfig) { text = resourceReference(R.string.notification_referral_promo_button), onClick = onClick, ), + iconSize = 54.dp, ), ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index 0227a5339a..211ce57e2d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -3,7 +3,6 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.notifications.NoteMigrationNotification import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.res.TangemTheme @@ -39,11 +38,6 @@ internal fun LazyListScope.notifications(configs: ImmutableList TangemTheme.colors.icon.warning is WalletNotification.Informational -> TangemTheme.colors.icon.accent From af7caffba5c63f83648f2a0469fc80f10274ddae Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 22 Aug 2025 09:07:42 +0000 Subject: [PATCH 02/27] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 418aa40d43..f430a4dd17 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.28-1206" +tangemBlockchainSdk = "develop-1205" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.28-559" +tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 35fd75485cf1741a39249ce80832d7fb3801c6ef Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 22 Aug 2025 16:17:51 +0700 Subject: [PATCH 03/27] Updated on 2026-08-14 --- core/res/src/main/res/values-ja/strings.xml | 9 + core/res/src/main/res/values-ru/strings.xml | 10 + .../src/main/res/values-uk-rUA/strings.xml | 10 + core/res/src/main/res/values/strings.xml | 18 ++ .../archived/ArchivedAccountListModel.kt | 10 +- .../createedit/entity/AccountCreateEditUM.kt | 2 +- .../details/entity/AccountDetailsUM.kt | 2 +- .../wallet-settings/impl/build.gradle.kts | 1 + .../impl/DefaultWalletSettingsComponent.kt | 15 ++ .../preview/PreviewWalletSettingsComponent.kt | 37 ++++ .../entity/WalletSettingsItemUM.kt | 33 ++++ .../model/WalletSettingsModel.kt | 11 +- .../walletsettings/ui/WalletSettingsScreen.kt | 171 ++++++++++++++++-- .../utils/AccountItemsDelegate.kt | 111 ++++++++++++ .../walletsettings/utils/ItemsBuilder.kt | 3 + 15 files changed, 413 insertions(+), 30 deletions(-) create mode 100644 features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 2e061c0bce..43ea6c53bf 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -12,6 +12,7 @@ ウォレットのロックを解除するには、 %s桁のアクセスコードを設定します。 アクセスコードの作成 アクセスコード + アーカイブされたアカウント 回復する アーカイブ済み アカウントをアーカイブする @@ -26,6 +27,7 @@ 新しいアカウント アカウントを追加 アカウントを編集 + アカウントを長押しして並べ替える 編集を続ける 破棄 新しいアカウントを破棄してもよろしいですか? @@ -116,6 +118,12 @@ 30秒後に再試行するか、カードまたはリングをスキャンしてください 試行回数が多すぎます お使いの携帯電話で生体認証が無効になっているため、アプリにウォレットを保存できません。ウォレットを保存するには、携帯電話の設定で生体認証機能を有効にしてください。 + プロモーションコードの処理中にエラーが発生しました。しばらくしてからもう一度お試しください。 + プロモーションコードが正常に有効化されました。14日以内に10 USDT相当のビットコインボーナスが付与されます。 + プロモーションコードが有効になりました + このプロモーションコードは既に使用されているため、再度使うことはできません。 + このプロモーションコードは無効であり、有効化できません。 + ボーナスを受け取るにはビットコインアドレスが必要です。ウォレットにビットコインアドレスを追加し、再度アクティベーションをお試しください。 バックアップ処理を開始する 銀行カードまたはその他の支払い方法を使用する @@ -149,6 +157,7 @@ ADAが不足しています。 受け入れる アクセスが拒否されました + アカウント 追加 ポートフォリオに追加 トークンを追加 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index befe0fd2d7..899b661b01 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -59,6 +59,16 @@ Пожалуйста, попробуйте снова через 30 секунд или отсканируйте карту или кольцо Слишком много попыток Вы отключили биометрическую аутентификацию на вашем телефоне и не сможете сохранять кошельки в приложении. Для сохранения кошельков, пожалуйста, включите функцию биометрической аутентификации в настройках телефона. + При обработке промокода произошла ошибка. Пожалуйста, попробуйте позже. + Ошибка активации + Ваш промокод успешно активирован. Бонус 10 USDT в Bitcoin будет зачислен через 14 дней. + Промокод активирован + Этот промокод уже был использован и не может быть активирован повторно. + Код недоступен + Этот промокод недействителен и не может быть активирован. + Неверный код + Для зачисления бонуса нужен Bitcoin-адрес. Добавьте его в портфель и повторите активацию. + Требуется Bitcoin-адрес Начать резервное копирование Используйте банковскую карту или другие методы оплаты diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 5c7d9f4ef4..21880f895f 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -59,6 +59,16 @@ Будь ласка, спробуйте знову через 30 секунд або відскануйте картку або кільце Забагато спроб Ви вимкнули біометричну автентифікацію на своєму телефоні і не зможете зберігати гаманці в додатку. Щоб зберегти гаманці, будь ласка, увімкніть функцію біометричної автентифікації в налаштуваннях телефону. + Під час обробки промокоду сталася помилка. Будь ласка, спробуйте пізніше. + Помилка активації + Ваш промокод успішно активовано. Бонус 10 USDT у Bitcoin буде зараховано через 14 днів. + Промокод активовано + Цей промокод уже був використаний і не може бути активований повторно. + Код недоступний + Цей промокод недійсний і не може бути активований. + Невірний код + Для зарахування бонусу потрібна Bitcoin-адреса. Додайте її до портфеля та повторіть активацію. + Потрібна Bitcoin-адреса Почніть процес резервного копіювання Використовуйте банківську картку або інші способи оплати diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index dc9ced8f5c..0db4cc4be9 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -12,7 +12,12 @@ Set a %s-digit Access Code to unlock your wallet. Create Access Code Access code + You cannot create more than %1$s accounts. Archive one to add new. + Can’t add new account + Archived accounts Recover + You’re about to recover “%1$s”. + Recover account Archived Archive account Archive @@ -26,6 +31,8 @@ New account Add account Edit account + %1$s in %2$s + Long tap on an account to reorder accounts Keep Editing Discard Are you sure you want to discard new account? @@ -117,6 +124,12 @@ Please try again in 30 seconds or scan the card or ring Too many attempts You have disabled biometric authentication on your phone and will not be able to save wallets in the app. To save wallets, please enable the biometric authentication function in your phone settings. + An error occurred while processing your promo code. Please try again later. + Your promo code has been successfully activated. A bonus of 10 USDT in Bitcoin will be credited in 14 days. + Promo code activated + This promo code has already been used and cannot be activated again. + This promo code is not valid and cannot be activated. + A Bitcoin address is required to receive the bonus. Please add one to your wallet and retry the activation. Start backup process Use a bank card or other payment methods @@ -152,6 +165,7 @@ Not enough ADA Accept Access denied + Accounts Add Add to portfolio Add token @@ -239,6 +253,10 @@ month Network fee Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level + + %d network + %d networks + Next NFT No diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt index a93cdcca2b..95554e161b 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ArchivedAccountListModel.kt @@ -5,9 +5,8 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender 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.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase @@ -48,8 +47,11 @@ internal class ArchivedAccountListModel @Inject constructor( ) messageSender.send( DialogMessage( - title = stringReference(account.accountName.value), - message = TextReference.EMPTY, + title = resourceReference(R.string.account_archived_recover_dialog_title), + message = resourceReference( + id = R.string.account_archived_recover_dialog_description, + formatArgs = wrappedList(account.accountName.value), + ), firstActionBuilder = { firstAction }, secondActionBuilder = { secondAction }, ), diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt index 330fc2352f..4b4365ba95 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/entity/AccountCreateEditUM.kt @@ -5,7 +5,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.account.CryptoPortfolioIcon import kotlinx.collections.immutable.ImmutableList -data class AccountCreateEditUM( +internal data class AccountCreateEditUM( val title: TextReference, val account: Account, val colorsState: Colors, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt index c68a1f098a..799c295838 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/entity/AccountDetailsUM.kt @@ -2,7 +2,7 @@ package com.tangem.features.account.details.entity import com.tangem.common.ui.account.CryptoPortfolioIconUM -data class AccountDetailsUM( +internal data class AccountDetailsUM( val accountName: String, val accountIcon: CryptoPortfolioIconUM, val onCloseClick: () -> Unit, diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index b69105d2c8..3f34abd5a1 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.common.routing) + implementation(projects.common.ui) /* Project - Domain */ implementation(projects.domain.legacy) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt index 49f23d0f8c..1cb8c2c145 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/impl/DefaultWalletSettingsComponent.kt @@ -1,6 +1,7 @@ package com.tangem.feature.walletsettings.component.impl import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -13,6 +14,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableDialogComponent +import com.tangem.core.ui.utils.requestPermission import com.tangem.feature.walletsettings.component.NetworksAvailableForNotificationsComponent import com.tangem.feature.walletsettings.component.RenameWalletComponent import com.tangem.feature.walletsettings.component.WalletSettingsComponent @@ -20,6 +22,7 @@ import com.tangem.feature.walletsettings.entity.DialogConfig import com.tangem.feature.walletsettings.entity.NetworksAvailableForNotificationBSConfig import com.tangem.feature.walletsettings.model.WalletSettingsModel import com.tangem.feature.walletsettings.ui.WalletSettingsScreen +import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -61,6 +64,18 @@ internal class DefaultWalletSettingsComponent @AssistedInject constructor( dialog = { dialog.child?.instance?.Dialog() }, ) + val requestPushPermission = requestPermission( + onAllow = { state.onPushNotificationPermissionGranted(true) }, + onDeny = { state.onPushNotificationPermissionGranted(false) }, + permission = PUSH_PERMISSION, + ) + + if (state.requestPushNotificationsPermission) { + LaunchedEffect(Unit) { + requestPushPermission() + } + } + bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index 1ac9106632..69bf4cfb75 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -2,12 +2,19 @@ package com.tangem.feature.walletsettings.component.preview import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tangem.common.ui.account.AccountIconPreviewData import com.tangem.core.analytics.DummyAnalyticsEventHandler import com.tangem.core.decompose.navigation.DummyRouter +import com.tangem.core.ui.components.block.model.BlockUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.walletsettings.component.WalletSettingsComponent +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM.Footer.AddAccountUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM +import com.tangem.feature.walletsettings.impl.R import com.tangem.feature.walletsettings.ui.WalletSettingsScreen import com.tangem.feature.walletsettings.utils.ItemsBuilder import com.tangem.hot.sdk.model.HotWalletId @@ -48,11 +55,41 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { walletUpgradeDismissed = false, onUpgradeWalletClick = {}, onDismissUpgradeWalletClick = {}, + accountsUM = previewAccounts(), ), requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = {}, ) + private fun previewAccounts() = buildList { + WalletSettingsAccountsUM.Header( + id = "accounts_header", + text = resourceReference(R.string.common_accounts), + ).let(::add) + WalletSettingsAccountsUM.Account( + id = "accountId", + accountName = stringReference("Main account"), + accountIconUM = AccountIconPreviewData.randomAccountIcon(), + tokensInfo = stringReference("10 tokens"), + networksInfo = stringReference("2 networks"), + onClick = {}, + ).let(::add) + WalletSettingsAccountsUM.Footer( + id = "accounts_footer", + addAccount = AddAccountUM( + title = resourceReference(R.string.account_form_title_create), + addAccountEnabled = true, + onAddAccountClick = {}, + ), + archivedAccounts = BlockUM( + text = resourceReference(R.string.account_archived_accounts), + iconRes = R.drawable.ic_archive_24, + onClick = {}, + ), + description = resourceReference(R.string.account_reorder_description), + ).let(::add) + } + @Composable override fun Content(modifier: Modifier) { WalletSettingsScreen( diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt index 91a77aec05..85a6bcb784 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt @@ -1,6 +1,7 @@ package com.tangem.feature.walletsettings.entity import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -51,4 +52,36 @@ internal sealed class WalletSettingsItemUM { val onClick: () -> Unit, val onDismissClick: () -> Unit, ) : WalletSettingsItemUM() +} + +@Immutable +internal sealed class WalletSettingsAccountsUM : WalletSettingsItemUM() { + + data class Header( + override val id: String, + val text: TextReference, + ) : WalletSettingsAccountsUM() + + data class Account( + override val id: String, + val accountName: TextReference, + val accountIconUM: CryptoPortfolioIconUM, + val tokensInfo: TextReference, + val networksInfo: TextReference, + val onClick: () -> Unit, + ) : WalletSettingsAccountsUM() + + data class Footer( + override val id: String, + val addAccount: AddAccountUM, + val archivedAccounts: BlockUM, + val description: TextReference, + ) : WalletSettingsAccountsUM() { + + data class AddAccountUM( + val title: TextReference, + val addAccountEnabled: Boolean, + val onAddAccountClick: () -> Unit, + ) + } } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index c190745304..1a5e3ee067 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -15,11 +15,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 -import com.tangem.core.ui.components.bottomsheets.message.icon -import com.tangem.core.ui.components.bottomsheets.message.infoBlock -import com.tangem.core.ui.components.bottomsheets.message.onClick -import com.tangem.core.ui.components.bottomsheets.message.secondaryButton +import com.tangem.core.ui.components.bottomsheets.message.* import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction @@ -29,6 +25,7 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.nft.DisableWalletNFTUseCase import com.tangem.domain.nft.EnableWalletNFTUseCase import com.tangem.domain.nft.GetWalletNFTEnabledUseCase @@ -36,7 +33,6 @@ import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUse import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles import com.tangem.domain.settings.repositories.PermissionRepository -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.* import com.tangem.feature.walletsettings.analytics.Settings import com.tangem.feature.walletsettings.analytics.WalletSettingsAnalyticEvents @@ -46,6 +42,7 @@ import com.tangem.feature.walletsettings.entity.NetworksAvailableForNotification import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R +import com.tangem.feature.walletsettings.utils.AccountItemsDelegate import com.tangem.feature.walletsettings.utils.ItemsBuilder import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -65,6 +62,7 @@ internal class WalletSettingsModel @Inject constructor( private val messageSender: UiMessageSender, private val deleteWalletUseCase: DeleteWalletUseCase, private val itemsBuilder: ItemsBuilder, + private val accountItemsDelegate: AccountItemsDelegate, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsContextProxy: AnalyticsContextProxy, @@ -224,6 +222,7 @@ internal class WalletSettingsModel @Inject constructor( walletUpgradeDismissed = isUpgradeNotificationEnabled, onUpgradeWalletClick = ::onUpgradeWalletClick, onDismissUpgradeWalletClick = ::onDismissUpgradeWalletClick, + accountsUM = with(accountItemsDelegate) { listOf() }, // todo account ) } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index 8c4bbd6cf2..b18c80f1f0 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -2,20 +2,29 @@ package com.tangem.feature.walletsettings.ui import android.content.res.Configuration 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.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountRow +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.TangemSwitch import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM @@ -30,12 +39,11 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.WalletSettingsScreenTestTags -import com.tangem.core.ui.utils.requestPermission import com.tangem.feature.walletsettings.component.preview.PreviewWalletSettingsComponent +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R -import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION @Composable internal fun WalletSettingsScreen( @@ -71,7 +79,6 @@ internal fun WalletSettingsScreen( private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { LazyColumn( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), contentPadding = PaddingValues( top = TangemTheme.dimens.spacing16, bottom = TangemTheme.dimens.spacing16, @@ -89,8 +96,17 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { items = state.items, key = WalletSettingsItemUM::id, ) { item -> - val itemModifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) + val offsetModifier = when (item) { + is WalletSettingsAccountsUM.Account, + is WalletSettingsAccountsUM.Footer, + -> Modifier.padding(horizontal = TangemTheme.dimens.spacing16) + else -> Modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing16, + ) + } + val itemModifier = offsetModifier .fillMaxWidth() .testTag(WalletSettingsScreenTestTags.SCREEN_ITEM) @@ -121,21 +137,12 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { modifier = itemModifier, model = item, ) + is WalletSettingsAccountsUM.Header -> AccountsHeader(item, itemModifier) + is WalletSettingsAccountsUM.Account -> AccountItem(item, itemModifier) + is WalletSettingsAccountsUM.Footer -> AccountsFooter(item, itemModifier) } } } - - val requestPushPermission = requestPermission( - onAllow = { state.onPushNotificationPermissionGranted(true) }, - onDeny = { state.onPushNotificationPermissionGranted(false) }, - permission = PUSH_PERMISSION, - ) - - if (state.requestPushNotificationsPermission) { - LaunchedEffect(Unit) { - requestPushPermission() - } - } } @Composable @@ -257,6 +264,134 @@ private fun NotificationAlertBlock(model: WalletSettingsItemUM.NotificationPermi ) } +@Composable +private fun AccountsHeader(model: WalletSettingsAccountsUM.Header, modifier: Modifier = Modifier) { + Text( + modifier = modifier + .background( + shape = RoundedCornerShape( + topStart = TangemTheme.dimens.radius16, + topEnd = TangemTheme.dimens.radius16, + ), + color = TangemTheme.colors.background.primary, + ) + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing4, + ), + text = model.text.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun AccountItem(model: WalletSettingsAccountsUM.Account, modifier: Modifier = Modifier) { + val subtitle = stringResourceSafe( + id = R.string.account_label_tokens_info, + formatArgs = arrayOf( + model.tokensInfo.resolveReference(), + model.networksInfo.resolveReference(), + ), + ) + AccountRow( + modifier = modifier + .background(color = TangemTheme.colors.background.primary) + .clickable(onClick = model.onClick) + .padding(12.dp), + title = model.accountName, + subtitle = stringReference(subtitle), + icon = model.accountIconUM, + ) +} + +@Composable +private fun AccountsFooter(model: WalletSettingsAccountsUM.Footer, modifier: Modifier = Modifier) { + Column(modifier) { + Column( + modifier = Modifier.background( + shape = RoundedCornerShape( + bottomStart = TangemTheme.dimens.radius16, + bottomEnd = TangemTheme.dimens.radius16, + ), + color = TangemTheme.colors.background.primary, + ), + ) { + AddAccountRow(model.addAccount) + SpacerH( + height = TangemTheme.dimens.size0_5, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing12) + .background(TangemTheme.colors.stroke.primary), + ) + BlockItem( + modifier = Modifier.fillMaxWidth(), + model = model.archivedAccounts, + ) + } + SpacerH8() + Text( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing12), + text = model.description.resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun AddAccountRow(model: WalletSettingsAccountsUM.Footer.AddAccountUM, modifier: Modifier = Modifier) { + val iconTint: Color + val backgroundColor: Color + val textColor: Color + if (model.addAccountEnabled) { + iconTint = TangemTheme.colors.icon.accent + backgroundColor = TangemTheme.colors.icon.accent.copy(alpha = 0.1f) + textColor = TangemTheme.colors.text.accent + } else { + iconTint = TangemTheme.colors.icon.inactive + backgroundColor = TangemTheme.colors.field.primary + textColor = TangemTheme.colors.text.disabled + } + Row( + modifier = modifier + .clickable(onClick = model.onAddAccountClick) + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(backgroundColor) + .clickable(onClick = { model.onAddAccountClick() }), + ) { + Icon( + modifier = Modifier.size(18.dp), + tint = iconTint, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_plus_24), + contentDescription = null, + ) + } + + Text( + text = model.title.resolveReference(), + color = textColor, + style = TangemTheme.typography.subtitle1, + ) + } +} + @Composable private fun DescriptionWithMoreBlock( model: WalletSettingsItemUM.DescriptionWithMore, diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt new file mode 100644 index 0000000000..7e9939d2e3 --- /dev/null +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountItemsDelegate.kt @@ -0,0 +1,111 @@ +package com.tangem.feature.walletsettings.utils + +import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.account.toUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.block.model.BlockUM +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM.Footer.AddAccountUM +import com.tangem.feature.walletsettings.impl.R +import javax.inject.Inject + +@ModelScoped +internal class AccountItemsDelegate @Inject constructor( + private val router: Router, + private val messageSender: UiMessageSender, +) { + + fun buildUiList(userWalletId: UserWalletId, accounts: List): List = buildList { + WalletSettingsAccountsUM.Header( + id = "accounts_header", + text = resourceReference(R.string.common_accounts), + ).let(::add) + + addAll(accounts.map(::mapAccount)) + + val addAccountEnabled = true // todo account + WalletSettingsAccountsUM.Footer( + id = "accounts_footer", + addAccount = AddAccountUM( + title = resourceReference(R.string.account_form_title_create), + addAccountEnabled = addAccountEnabled, + onAddAccountClick = { + if (addAccountEnabled) openAddAccount(userWalletId) else canNotAddAccountDialog() + }, + ), + archivedAccounts = BlockUM( + text = resourceReference(R.string.account_archived_accounts), + iconRes = R.drawable.ic_archive_24, + onClick = { openArchivedAccounts(userWalletId) }, + ), + description = resourceReference(R.string.account_reorder_description), + ).let(::add) + } + + private fun mapAccount(account: Account): WalletSettingsAccountsUM = when (account) { + is Account.CryptoPortfolio -> account.mapCryptoPortfolio() + } + + private fun Account.CryptoPortfolio.mapCryptoPortfolio(): WalletSettingsAccountsUM { + return WalletSettingsAccountsUM.Account( + id = accountId.value, + accountName = stringReference(accountName.value), + accountIconUM = icon.toUM(), + tokensInfo = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + networksInfo = pluralReference( + R.plurals.common_networks_count, + count = networksCount, + formatArgs = wrappedList(networksCount), + ), + onClick = { openAccountDetails(this) }, + ) + } + + private fun openAccountDetails(account: Account) { + router.push(AppRoute.AccountDetails(account)) + } + + private fun openArchivedAccounts(userWalletId: UserWalletId) { + router.push(AppRoute.ArchivedAccountList(userWalletId)) + } + + private fun openAddAccount(userWalletId: UserWalletId) { + router.push(AppRoute.CreateAccount(userWalletId)) + } + + private fun canNotAddAccountDialog() { + val firstAction = EventMessageAction( + title = resourceReference(R.string.common_got_it), + onClick = { }, + ) + messageSender.send( + DialogMessage( + title = resourceReference(R.string.account_add_limit_dialog_title), + message = resourceReference( + id = R.string.account_add_limit_dialog_description, + formatArgs = wrappedList(MAX_ACCOUNT_COUNT.toString()), + ), + firstActionBuilder = { firstAction }, + ), + ) + } + + companion object { + // todo account use domain const? + private const val MAX_ACCOUNT_COUNT = 20 + } +} \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index 4189d78f2a..d26dc7fd98 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.walletsettings.analytics.Settings +import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.impl.R import com.tangem.hot.sdk.model.HotWalletId @@ -30,6 +31,7 @@ internal class ItemsBuilder @Inject constructor( fun buildItems( userWallet: UserWallet, userWalletName: String, + accountsUM: List, isLinkMoreCardsAvailable: Boolean, isReferralAvailable: Boolean, isManageTokensAvailable: Boolean, @@ -60,6 +62,7 @@ internal class ItemsBuilder @Inject constructor( onDismissUpgradeWalletClick = onDismissUpgradeWalletClick, ), ) + .addAll(accountsUM) .addAll(buildAccessCodeItem(userWallet, onAccessCodeClick)) .add( buildCardItem( From 3e018452037eddaf01e0df62c813d5aeb70cb384 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 19 Aug 2025 16:14:54 +0400 Subject: [PATCH 04/27] Updated on 2026-08-14 --- data/account/build.gradle.kts | 16 +- .../account/converter/AccountConvertersExt.kt | 33 ++++ .../account/converter/AccountListConverter.kt | 46 +++++ .../converter/ArchivedAccountConverter.kt | 29 ++++ .../converter/CryptoPortfolioConverter.kt | 61 +++++++ .../converter/CryptoPortfolioIconConverter.kt | 21 +++ .../GetWalletAccountsResponseConverter.kt | 44 +++++ .../SaveWalletAccountsResponseConverter.kt | 33 ++++ .../converter/TokensGroupTypeConverter.kt | 29 ++++ .../converter/TokensSortTypeConverter.kt | 29 ++++ .../account/converter/AccountConverterExt.kt | 85 ++++++++++ .../converter/AccountListConverterTest.kt | 159 ++++++++++++++++++ .../converter/ArchivedAccountConverterTest.kt | 144 ++++++++++++++++ .../converter/CryptoPortfolioConverterTest.kt | 155 +++++++++++++++++ .../CryptoPortfolioIconConverterTest.kt | 66 ++++++++ .../GetWalletAccountsResponseConverterTest.kt | 130 ++++++++++++++ ...SaveWalletAccountsResponseConverterTest.kt | 50 ++++++ .../converter/TokensGroupTypeConverterTest.kt | 85 ++++++++++ .../converter/TokensSortTypeConverterTest.kt | 81 +++++++++ .../currency/UserTokensResponseFactory.kt | 3 +- .../tangem/domain/models/account/AccountId.kt | 33 ++++ 21 files changed, 1330 insertions(+), 2 deletions(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 464ba8486e..287f2b190f 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -9,9 +9,14 @@ android { namespace = "com.tangem.data.account" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { // region Project - Core + implementation(projects.core.datasource) api(projects.core.utils) // endregion @@ -21,7 +26,7 @@ dependencies { // endregion // Project - Data - implementation(projects.core.datasource) + implementation(projects.data.common) // endregion // region DI @@ -34,4 +39,13 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.timber) // endregion + + // region Test + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) + // endregion } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt new file mode 100644 index 0000000000..ab8eef91a8 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt @@ -0,0 +1,33 @@ +package com.tangem.data.account.converter + +import arrow.core.getOrElse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +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.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId + +internal fun String.toAccountId(userWalletId: UserWalletId): AccountId { + return AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId).getOrElse { + error("Unable to create AccountId from value: $this. Cause: $it") + } +} + +internal fun String.toAccountName(): AccountName { + return AccountName(value = this).getOrElse { + error("Unable to create AccountName from value: $this. Cause: $it") + } +} + +internal fun WalletAccountDTO.toIcon(): CryptoPortfolioIcon { + return CryptoPortfolioIconConverter.convert( + value = CryptoPortfolioIconConverter.DataModel(icon = icon, color = iconColor), + ) +} + +internal fun Int.toDerivationIndex(): DerivationIndex { + return DerivationIndex(value = this).getOrElse { + error("Unable to create DerivationIndex from value: $this. Cause: $it") + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt new file mode 100644 index 0000000000..d4c993f59f --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountListConverter.kt @@ -0,0 +1,46 @@ +package com.tangem.data.account.converter + +import arrow.core.getOrElse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.converter.Converter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Converts a [GetWalletAccountsResponse] to an [AccountList] and vice versa + * + * @property userWallet the user wallet associated with the account list + * @param cryptoPortfolioConverterFactory factory to create [CryptoPortfolioConverter] instances + * +[REDACTED_AUTHOR] + */ +internal class AccountListConverter @AssistedInject constructor( + @Assisted private val userWallet: UserWallet, + cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory, +) : Converter { + + private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy { + cryptoPortfolioConverterFactory.create(userWallet) + } + + override fun convert(value: GetWalletAccountsResponse): AccountList { + return AccountList( + userWallet = userWallet, + accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(), + totalAccounts = value.wallet.totalAccounts, + sortType = TokensSortTypeConverter.convert(value.wallet.sort), + groupType = TokensGroupTypeConverter.convert(value.wallet.group), + ) + .getOrElse { + error("Failed to convert GetWalletAccountsResponse to AccountList: $it") + } + } + + @AssistedFactory + interface Factory { + fun create(userWallet: UserWallet): AccountListConverter + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt new file mode 100644 index 0000000000..072c82e3fc --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/ArchivedAccountConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.converter.Converter + +/** + * Converts a [WalletAccountDTO] to an [ArchivedAccount] + * + * @param userWalletId the ID of the user wallet associated with the account + * +[REDACTED_AUTHOR] + */ +internal class ArchivedAccountConverter( + private val userWalletId: UserWalletId, +) : Converter { + + override fun convert(value: WalletAccountDTO): ArchivedAccount { + return ArchivedAccount( + accountId = value.id.toAccountId(userWalletId = userWalletId), + name = value.name.toAccountName(), + icon = value.toIcon(), + derivationIndex = value.derivationIndex.toDerivationIndex(), + tokensCount = value.totalTokens ?: error("Total tokens should not be null"), + networksCount = value.totalNetworks ?: error("Total networks should not be null"), + ) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt new file mode 100644 index 0000000000..bae26707a6 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioConverter.kt @@ -0,0 +1,61 @@ +package com.tangem.data.account.converter + +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.converter.TwoWayConverter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Converts a [WalletAccountDTO] to an [Account.CryptoPortfolio] and vise versa + * + * @property userWallet the user wallet associated with the account list + * @property responseCryptoCurrenciesFactory factory to create crypto currencies from response tokens + * +[REDACTED_AUTHOR] + */ +internal class CryptoPortfolioConverter @AssistedInject constructor( + @Assisted private val userWallet: UserWallet, + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + private val userTokensResponseFactory: UserTokensResponseFactory, +) : TwoWayConverter { + + override fun convert(value: WalletAccountDTO): Account.CryptoPortfolio { + val tokens = value.tokens ?: error("Tokens should not be null") + + return Account.CryptoPortfolio( + accountId = value.id.toAccountId(userWallet.walletId), + accountName = value.name.toAccountName(), + icon = value.toIcon(), + derivationIndex = value.derivationIndex.toDerivationIndex(), + cryptoCurrencies = if (tokens.isNotEmpty()) { + responseCryptoCurrenciesFactory.createCurrencies( + tokens = tokens, + userWallet = userWallet, + ).toSet() + } else { + emptySet() + }, + ) + } + + override fun convertBack(value: Account.CryptoPortfolio): WalletAccountDTO { + return WalletAccountDTO( + id = value.accountId.value, + name = value.accountName.value, + derivationIndex = value.derivationIndex.value, + icon = value.icon.value.name, + iconColor = value.icon.color.name, + tokens = value.cryptoCurrencies.map(userTokensResponseFactory::createResponseToken), + ) + } + + @AssistedFactory + interface Factory { + fun create(userWallet: UserWallet): CryptoPortfolioConverter + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt new file mode 100644 index 0000000000..33b8b2ab20 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/CryptoPortfolioIconConverter.kt @@ -0,0 +1,21 @@ +package com.tangem.data.account.converter + +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.utils.converter.Converter + +/** + * Converts a [CryptoPortfolioIconConverter.DataModel] to a [CryptoPortfolioIcon] + * +[REDACTED_AUTHOR] + */ +internal object CryptoPortfolioIconConverter : Converter { + + override fun convert(value: DataModel): CryptoPortfolioIcon { + return CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.valueOf(value.icon), + color = CryptoPortfolioIcon.Color.valueOf(value.color), + ) + } + + data class DataModel(val icon: String, val color: String) +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt new file mode 100644 index 0000000000..4ae6bf8b41 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/GetWalletAccountsResponseConverter.kt @@ -0,0 +1,44 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.converter.Converter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** +[REDACTED_AUTHOR] + */ +internal class GetWalletAccountsResponseConverter @AssistedInject constructor( + @Assisted private val userWallet: UserWallet, + @Assisted val version: Int, + cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory, +) : Converter { + + private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy { + cryptoPortfolioConverterFactory.create(userWallet) + } + + override fun convert(value: AccountList): GetWalletAccountsResponse { + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = version, + group = TokensGroupTypeConverter.convertBack(value.groupType), + sort = TokensSortTypeConverter.convertBack(value.sortType), + totalAccounts = value.totalAccounts, + ), + accounts = value.accounts + .filterIsInstance() + .map(cryptoPortfolioConverter::convertBack), + unassignedTokens = emptyList(), + ) + } + + @AssistedFactory + interface Factory { + fun create(userWallet: UserWallet, version: Int): GetWalletAccountsResponseConverter + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt new file mode 100644 index 0000000000..25c5307b84 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/SaveWalletAccountsResponseConverter.kt @@ -0,0 +1,33 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.utils.converter.Converter + +/** + * Converts an [AccountList] to a [SaveWalletAccountsResponse] + * +[REDACTED_AUTHOR] + */ +internal object SaveWalletAccountsResponseConverter : Converter { + + override fun convert(value: AccountList): SaveWalletAccountsResponse { + return SaveWalletAccountsResponse( + accounts = value.accounts + .filterIsInstance() + .map(::toDTO), + ) + } + + private fun toDTO(account: Account.CryptoPortfolio): WalletAccountDTO { + return WalletAccountDTO( + id = account.accountId.value, + name = account.accountName.value, + derivationIndex = account.derivationIndex.value, + icon = account.icon.value.name, + iconColor = account.icon.color.name, + ) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt new file mode 100644 index 0000000000..9a9b92af1b --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensGroupTypeConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensGroupType +import com.tangem.utils.converter.TwoWayConverter + +/** + * Converts a [UserTokensResponse.GroupType] to a [TokensGroupType] and vice versa + * +[REDACTED_AUTHOR] + */ +internal object TokensGroupTypeConverter : TwoWayConverter { + + override fun convert(value: UserTokensResponse.GroupType): TokensGroupType { + return when (value) { + UserTokensResponse.GroupType.NETWORK -> TokensGroupType.NETWORK + UserTokensResponse.GroupType.NONE, + UserTokensResponse.GroupType.TOKEN, + -> TokensGroupType.NONE + } + } + + override fun convertBack(value: TokensGroupType): UserTokensResponse.GroupType { + return when (value) { + TokensGroupType.NONE -> UserTokensResponse.GroupType.NONE + TokensGroupType.NETWORK -> UserTokensResponse.GroupType.NETWORK + } + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt new file mode 100644 index 0000000000..91b2b2e88a --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/TokensSortTypeConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensSortType +import com.tangem.utils.converter.TwoWayConverter + +/** + * Converts a [UserTokensResponse.SortType] to a [TokensSortType] and vice versa + * +[REDACTED_AUTHOR] + */ +internal object TokensSortTypeConverter : TwoWayConverter { + + override fun convert(value: UserTokensResponse.SortType): TokensSortType { + return when (value) { + UserTokensResponse.SortType.BALANCE -> TokensSortType.BALANCE + UserTokensResponse.SortType.MANUAL, + UserTokensResponse.SortType.MARKETCAP, + -> TokensSortType.NONE + } + } + + override fun convertBack(value: TokensSortType): UserTokensResponse.SortType { + return when (value) { + TokensSortType.NONE -> UserTokensResponse.SortType.MANUAL + TokensSortType.BALANCE -> UserTokensResponse.SortType.BALANCE + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt new file mode 100644 index 0000000000..5670802776 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountConverterExt.kt @@ -0,0 +1,85 @@ +package com.tangem.data.account.converter + +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId + +internal fun createWalletAccountDTO( + userWalletId: UserWalletId, + accountId: String? = null, + accountName: String? = null, + icon: String? = null, + iconColor: String? = null, + derivationIndex: Int? = null, + tokens: List? = emptyList(), +): WalletAccountDTO { + val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) + + return WalletAccountDTO( + id = accountId ?: mainAccount.accountId.value, + name = accountName ?: mainAccount.accountName.value, + derivationIndex = derivationIndex ?: mainAccount.derivationIndex.value, + icon = icon ?: mainAccount.icon.value.name, + iconColor = iconColor ?: mainAccount.icon.color.name, + tokens = tokens, + ) +} + +internal fun createCryptoPortfolio(userWalletId: UserWalletId): Account.CryptoPortfolio { + return Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) +} + +internal fun createGetWalletAccountsResponse( + userWalletId: UserWalletId, + groupType: UserTokensResponse.GroupType = UserTokensResponse.GroupType.NETWORK, + sortType: UserTokensResponse.SortType = UserTokensResponse.SortType.BALANCE, + accountId: String? = null, + accountName: String? = null, + icon: String? = null, + iconColor: String? = null, + derivationIndex: Int? = null, + tokens: List? = emptyList(), +): GetWalletAccountsResponse { + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 0, + group = groupType, + sort = sortType, + totalAccounts = 1, + ), + accounts = buildList { + createWalletAccountDTO( + userWalletId = userWalletId, + accountId = accountId, + accountName = accountName, + icon = icon, + iconColor = iconColor, + derivationIndex = derivationIndex, + tokens = tokens, + ) + .let(::add) + }, + unassignedTokens = emptyList(), + ) +} + +internal fun createAccountList( + userWallet: UserWallet, + sortType: TokensSortType = TokensSortType.BALANCE, + groupType: TokensGroupType = TokensGroupType.NETWORK, +): AccountList { + return AccountList( + userWallet = userWallet, + accounts = setOf(createCryptoPortfolio(userWallet.walletId)), + totalAccounts = 1, + sortType = sortType, + groupType = groupType, + ) + .getOrNull()!! +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt new file mode 100644 index 0000000000..8b32adb5e5 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/AccountListConverterTest.kt @@ -0,0 +1,159 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AccountListConverterTest { + + private val userWallet = mockk { + every { walletId } returns UserWalletId("011") + } + private val cryptoPortfolioConverterFactory = mockk() + private val cryptoPortfolioConverter = mockk() + private val converter = AccountListConverter(userWallet, cryptoPortfolioConverterFactory) + + @BeforeAll + fun setupAll() { + every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter + } + + @BeforeEach + fun setupEach() { + clearMocks(cryptoPortfolioConverter) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @Test + fun `cryptoPortfolioConverter throws exception`() { + // Arrange + val dto = createGetWalletAccountsResponse(userWallet.walletId) + val exception = IllegalStateException("Test exception") + + every { cryptoPortfolioConverter.convert(any()) } throws exception + + // Act + val actual = runCatching { converter.convert(dto) }.exceptionOrNull()!! + + // Asset + val expected = exception + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual.message).isEqualTo(expected.message) + } + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Arrange + if (model.expected.isSuccess) { + model.value.accounts.forEach { dto -> + val account = model.expected.getOrNull()!!.accounts + .firstOrNull { it.accountId.value == dto.id } as? Account.CryptoPortfolio + + every { cryptoPortfolioConverter.convert(dto) } returns account!! + } + } + + // Act + val actual = runCatching { converter.convert(model.value) } + + // Asset + actual + .onSuccess { + val expected = model.expected.getOrNull() + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull() ?: throw it + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.BALANCE, + groupType = UserTokensResponse.GroupType.NETWORK, + ), + expected = Result.success( + createAccountList( + userWallet = userWallet, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ), + ), + ), + ConvertModel( + value = createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.MANUAL, + groupType = UserTokensResponse.GroupType.TOKEN, + ), + expected = Result.success( + createAccountList( + userWallet = userWallet, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ), + ), + ), + ConvertModel( + value = createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.MARKETCAP, + groupType = UserTokensResponse.GroupType.NONE, + ), + expected = Result.success( + createAccountList( + userWallet = userWallet, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ), + ), + ), + ConvertModel( + value = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 0, + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + totalAccounts = 1, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ), + expected = Result.failure( + IllegalStateException( + "Failed to convert GetWalletAccountsResponse to AccountList: EmptyAccountsList: " + + "The accounts list cannot be empty", + ), + ), + ), + ) + } + } + + data class ConvertModel( + val value: GetWalletAccountsResponse, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt new file mode 100644 index 0000000000..90831c1d3b --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/ArchivedAccountConverterTest.kt @@ -0,0 +1,144 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.ArchivedAccount +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.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchivedAccountConverterTest { + + private val userWalletId = UserWalletId("011") + private val converter = ArchivedAccountConverter(userWalletId = userWalletId) + + @ParameterizedTest + @ProvideTestModels + fun convert(model: TestModel) { + // Act + val actual = runCatching { converter.convert(value = model.value) } + + // Assert + actual + .onSuccess { + val expected = model.expected.getOrNull()!! + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull()!! + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + TestModel( + value = createDTO(), + expected = Result.success(createDomain()), + ), + TestModel( + value = createDTO(accountId = "123"), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}", + ), + ), + ), + TestModel( + value = createDTO(name = ""), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}", + ), + ), + ), + TestModel( + value = createDTO(icon = "INVALID_ICON"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON", + ), + ), + ), + TestModel( + value = createDTO(iconColor = "INVALID_COLOR"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR", + ), + ), + ), + TestModel( + value = createDTO(derivationIndex = -1), + expected = Result.failure( + IllegalStateException( + "Unable to create DerivationIndex from value: -1. " + + "Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1", + ), + ), + ), + TestModel( + value = createDTO(totalTokens = null), + expected = Result.failure( + IllegalStateException("Total tokens should not be null"), + ), + ), + TestModel( + value = createDTO(totalNetworks = null), + expected = Result.failure( + IllegalStateException("Total networks should not be null"), + ), + ), + ) + } + + private fun createDTO( + accountId: String = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027", + name: String = "Test Account", + icon: String = "Letter", + iconColor: String = "Azure", + derivationIndex: Int = 0, + totalTokens: Int? = 1, + totalNetworks: Int? = 1, + ): WalletAccountDTO { + return WalletAccountDTO( + id = accountId, + name = name, + derivationIndex = derivationIndex, + icon = icon, + iconColor = iconColor, + tokens = null, + totalTokens = totalTokens, + totalNetworks = totalNetworks, + ) + } + + private fun createDomain(): ArchivedAccount { + return ArchivedAccount( + accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(0).getOrNull()!!), + name = "Test Account".toAccountName(), + derivationIndex = 0.toDerivationIndex(), + icon = CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + tokensCount = 1, + networksCount = 1, + ) + } + + data class TestModel( + val value: WalletAccountDTO, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt new file mode 100644 index 0000000000..f046bae96a --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioConverterTest.kt @@ -0,0 +1,155 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoPortfolioConverterTest { + + private val userWallet = mockk { + every { walletId } returns UserWalletId("011") + } + + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk() + private val userTokensResponseFactory: UserTokensResponseFactory = mockk() + private val converter = CryptoPortfolioConverter( + userWallet = userWallet, + responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, + userTokensResponseFactory = userTokensResponseFactory, + ) + + @BeforeEach + fun setupEach() { + clearMocks(responseCryptoCurrenciesFactory, userTokensResponseFactory) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = runCatching { converter.convert(model.value) } + + // Asset + actual + .onSuccess { + val expected = model.expected.getOrNull() + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull() ?: throw it + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId), + expected = Result.success(createCryptoPortfolio(userWalletId = userWallet.walletId)), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountId = "123"), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountName = ""), + expected = Result.failure( + IllegalStateException( + "Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, icon = "INVALID_ICON"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, iconColor = "INVALID_COLOR"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, derivationIndex = -1), + expected = Result.failure( + IllegalStateException( + "Unable to create DerivationIndex from value: -1. " + + "Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1", + ), + ), + ), + ConvertModel( + value = createWalletAccountDTO(userWalletId = userWallet.walletId, tokens = null), + expected = Result.failure( + IllegalStateException("Tokens should not be null"), + ), + ), + ) + } + } + + data class ConvertModel( + val value: WalletAccountDTO, + val expected: Result, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = converter.convertBack(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertBackModel( + value = createCryptoPortfolio(userWalletId = userWallet.walletId), + expected = createWalletAccountDTO(userWalletId = userWallet.walletId), + ), + ) + } + } + + data class ConvertBackModel( + val value: Account.CryptoPortfolio, + val expected: WalletAccountDTO, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt new file mode 100644 index 0000000000..c6563f9fc8 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/CryptoPortfolioIconConverterTest.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.account.converter.CryptoPortfolioIconConverter.DataModel +import com.tangem.domain.models.account.CryptoPortfolioIcon +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CryptoPortfolioIconConverterTest { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: TestModel) { + // Act + val actual = runCatching { CryptoPortfolioIconConverter.convert(model.value) } + + // Assert + actual + .onSuccess { + val expected = model.expected.getOrNull()!! + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull()!! + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + TestModel( + value = DataModel(icon = "Letter", color = "Azure"), + expected = Result.success( + CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + ), + TestModel( + value = DataModel(icon = "INVALID_ICON", color = "Azure"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON", + ), + ), + ), + TestModel( + value = DataModel(icon = "Letter", color = "INVALID_COLOR"), + expected = Result.failure( + IllegalArgumentException( + "No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR", + ), + ), + ), + ) + } + + data class TestModel( + val value: DataModel, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt new file mode 100644 index 0000000000..b6dddb7aa5 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/GetWalletAccountsResponseConverterTest.kt @@ -0,0 +1,130 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetWalletAccountsResponseConverterTest { + + private val userWallet = mockk { + every { walletId } returns UserWalletId("011") + } + private val cryptoPortfolioConverterFactory = mockk() + private val cryptoPortfolioConverter = mockk() + private val converter = GetWalletAccountsResponseConverter( + userWallet = userWallet, + version = 0, + cryptoPortfolioConverterFactory = cryptoPortfolioConverterFactory, + ) + + @BeforeAll + fun setupAll() { + every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter + } + + @BeforeEach + fun setupEach() { + clearMocks(cryptoPortfolioConverter) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @Test + fun `cryptoPortfolioConverter throws exception`() { + // Arrange + val domain = createAccountList(userWallet = userWallet) + val exception = IllegalStateException("Test exception") + + every { cryptoPortfolioConverter.convertBack(any()) } throws exception + + // Act + val actual = runCatching { converter.convert(domain) }.exceptionOrNull()!! + + // Asset + val expected = exception + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual.message).isEqualTo(expected.message) + } + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Arrange + if (model.expected.isSuccess) { + model.value.accounts.forEach { domain -> + val dto = model.expected.getOrNull()!!.accounts.firstOrNull { it.id == domain.accountId.value } + + every { cryptoPortfolioConverter.convertBack(domain as Account.CryptoPortfolio) } returns dto!! + } + } + + // Act + val actual = runCatching { converter.convert(model.value) } + + // Asset + actual + .onSuccess { + val expected = model.expected.getOrNull() + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val expected = model.expected.exceptionOrNull() ?: throw it + Truth.assertThat(it).isInstanceOf(expected::class.java) + Truth.assertThat(it.message).isEqualTo(expected.message) + } + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = createAccountList( + userWallet = userWallet, + sortType = TokensSortType.BALANCE, + groupType = TokensGroupType.NETWORK, + ), + expected = Result.success( + createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.BALANCE, + groupType = UserTokensResponse.GroupType.NETWORK, + ), + ), + ), + ConvertModel( + value = createAccountList( + userWallet = userWallet, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ), + expected = Result.success( + createGetWalletAccountsResponse( + userWalletId = userWallet.walletId, + sortType = UserTokensResponse.SortType.MANUAL, + groupType = UserTokensResponse.GroupType.NONE, + ), + ), + ), + ) + } + } + + data class ConvertModel( + val value: AccountList, + val expected: Result, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt new file mode 100644 index 0000000000..13ad114fe4 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/SaveWalletAccountsResponseConverterTest.kt @@ -0,0 +1,50 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SaveWalletAccountsResponseConverterTest { + + @Test + fun convert() { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns UserWalletId("011") + } + + val accountList = AccountList( + userWallet = userWallet, + accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)), + totalAccounts = 1, + ) + .getOrNull()!! + + // Act + val actual = SaveWalletAccountsResponseConverter.convert(value = accountList) + + // Assert + val expected = SaveWalletAccountsResponse( + accounts = listOf( + WalletAccountDTO( + id = accountList.mainAccount.accountId.value, + name = accountList.mainAccount.accountName.value, + derivationIndex = accountList.mainAccount.derivationIndex.value, + icon = accountList.mainAccount.icon.value.name, + iconColor = accountList.mainAccount.icon.color.name, + ), + ), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt new file mode 100644 index 0000000000..62ba8b6f30 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/TokensGroupTypeConverterTest.kt @@ -0,0 +1,85 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensGroupType +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TokensGroupTypeConverterTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = TokensGroupTypeConverter.convert(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertModel( + value = UserTokensResponse.GroupType.NETWORK, + expected = TokensGroupType.NETWORK, + ), + ConvertModel( + value = UserTokensResponse.GroupType.NONE, + expected = TokensGroupType.NONE, + ), + ConvertModel( + value = UserTokensResponse.GroupType.TOKEN, + expected = TokensGroupType.NONE, + ), + ) + } + } + + data class ConvertModel( + val value: UserTokensResponse.GroupType, + val expected: TokensGroupType, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = TokensGroupTypeConverter.convertBack(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels(): List { + return listOf( + ConvertBackModel( + value = TokensGroupType.NETWORK, + expected = UserTokensResponse.GroupType.NETWORK, + ), + ConvertBackModel( + value = TokensGroupType.NONE, + expected = UserTokensResponse.GroupType.NONE, + ), + ) + } + } + + data class ConvertBackModel( + val value: TokensGroupType, + val expected: UserTokensResponse.GroupType, + ) +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt b/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt new file mode 100644 index 0000000000..e5476f5c49 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/converter/TokensSortTypeConverterTest.kt @@ -0,0 +1,81 @@ +package com.tangem.data.account.converter + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.models.TokensSortType +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TokensSortTypeConverterTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = TokensSortTypeConverter.convert(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + fun provideTestModels() = listOf( + ConvertModel( + value = UserTokensResponse.SortType.BALANCE, + expected = TokensSortType.BALANCE, + ), + ConvertModel( + value = UserTokensResponse.SortType.MANUAL, + expected = TokensSortType.NONE, + ), + ConvertModel( + value = UserTokensResponse.SortType.MARKETCAP, + expected = TokensSortType.NONE, + ), + ) + } + + data class ConvertModel( + val value: UserTokensResponse.SortType, + val expected: TokensSortType, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun convertBack(model: ConvertBackModel) { + // Act + val actual = TokensSortTypeConverter.convertBack(model.value) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + fun provideTestModels() = listOf( + ConvertBackModel( + value = TokensSortType.BALANCE, + expected = UserTokensResponse.SortType.BALANCE, + ), + ConvertBackModel( + value = TokensSortType.NONE, + expected = UserTokensResponse.SortType.MANUAL, + ), + ) + } + + data class ConvertBackModel( + val value: TokensSortType, + val expected: UserTokensResponse.SortType, + ) +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt index 4c2623149b..84b75233ac 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt @@ -2,8 +2,9 @@ package com.tangem.data.common.currency import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.domain.models.currency.CryptoCurrency +import javax.inject.Inject -class UserTokensResponseFactory { +class UserTokensResponseFactory @Inject constructor() { fun createUserTokensResponse( currencies: List, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt index a9cf874078..3133206dd3 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt @@ -1,5 +1,8 @@ package com.tangem.domain.models.account +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure import com.tangem.common.extensions.toByteArray import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.extensions.toHexString @@ -18,9 +21,39 @@ data class AccountId private constructor( val userWalletId: UserWalletId, ) { + sealed interface Error { + + val tag: String + get() = this::class.simpleName ?: "AccountId.Error" + + data object Empty : Error { + override fun toString(): String = "$tag: Account ID cannot be blank" + } + + data object InvalidFormat : Error { + override fun toString(): String = "$tag: Account ID must be a 64-character hexadecimal string" + } + } + companion object { private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") } + private val hexRegex = Regex("^[a-fA-F0-9]{64}$") + + /** + * Creates a unique account identifier for a crypto portfolio + * + * @param userWalletId the identifier of the user wallet + * @param value the unique string value representing the account + * + * @return an [Either] containing the [AccountId] on success, or an [Error] on failure + */ + fun forCryptoPortfolio(userWalletId: UserWalletId, value: String): Either = either { + ensure(value.isNotBlank()) { Error.Empty } + ensure(value.matches(hexRegex)) { Error.InvalidFormat } + + AccountId(value = value, userWalletId = userWalletId) + } /** * Creates a unique account identifier for a crypto portfolio From adfbf8d277b7afb54fc7d5eeaf2adc146741a23c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 10:38:49 +0500 Subject: [PATCH 05/27] Updated on 2026-08-14 --- .../modal/TangemModalBottomSheetWithFooter.kt | 24 +++++++++----- .../v2/feeselector/model/FeeSelectorModel.kt | 3 ++ .../ui/FeeSelectorModalBottomSheet.kt | 31 +++++++++++++------ 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt index 9964ac424d..3826cc503f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt @@ -1,6 +1,7 @@ package com.tangem.core.ui.components.bottomsheets.modal import android.content.res.Configuration +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -49,7 +50,7 @@ inline fun TangemModalBottomSheetWi noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable (BoxScope.(T) -> Unit), + noinline footer: @Composable (BoxScope.(T) -> Unit)?, ) { val isAlwaysVisible = LocalBottomSheetAlwaysVisible.current @@ -84,7 +85,7 @@ inline fun DefaultModalBottomSheetW noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable (BoxScope.(T) -> Unit), + noinline footer: @Composable (BoxScope.(T) -> Unit)?, ) { var isVisible by remember { mutableStateOf(value = config.isShown) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) @@ -118,7 +119,7 @@ inline fun PreviewModalBottomSheetW skipPartiallyExpanded: Boolean = true, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable BoxScope.(T) -> Unit, + noinline footer: @Composable (BoxScope.(T) -> Unit)?, ) { BasicModalBottomSheetWithFooter( config = config, @@ -145,7 +146,7 @@ inline fun BasicModalBottomSheetWit noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, - crossinline footer: @Composable (BoxScope.(T) -> Unit), + noinline footer: @Composable (BoxScope.(T) -> Unit)?, modifier: Modifier = Modifier, ) { val model = config.content as? T ?: return @@ -156,8 +157,13 @@ inline fun BasicModalBottomSheetWit val scrollState = rememberScrollState(initial = initial) val isKeyboardOpen by rememberIsKeyboardVisible() - val buttonHeight = TangemTheme.dimens.spacing80 - val contentBottomPadding = TangemTheme.dimens.spacing80 + val buttonHeight by animateDpAsState( + if (footer != null) { + 80.dp + } else { + 0.dp + }, + ) // Offset calculation for keyboard scroll adjustment: // 1) Button height (footer) // 2) Column content bottom padding @@ -202,7 +208,7 @@ inline fun BasicModalBottomSheetWit Column( modifier = Modifier .verticalScroll(state = scrollState) - .padding(bottom = contentBottomPadding), + .padding(bottom = buttonHeight), ) { content(model) } @@ -218,7 +224,9 @@ inline fun BasicModalBottomSheetWit .height(buttonHeight) .align(Alignment.BottomCenter), ) { - footer(model) + if (footer != null) { + footer(model) + } } } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt index 2784044d44..07e8e30c81 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/model/FeeSelectorModel.kt @@ -127,6 +127,9 @@ internal class FeeSelectorModel @Inject constructor( ) } uiState.update(FeeItemSelectedTransformer(feeItem)) + if (feeItem !is FeeItem.Custom) { + onDoneClick() + } } override fun onCustomFeeValueChange(index: Int, value: String) { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt index c99b647b80..d9aea80f8c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -82,15 +82,19 @@ internal fun FeeSelectorModalBottomSheet( modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), ) }, - footer = { - PrimaryButton( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - enabled = state.isPrimaryButtonEnabled, - text = stringResourceSafe(R.string.common_done), - onClick = feeSelectorIntents::onDoneClick, - ) + footer = if (state.selectedFeeItem is FeeItem.Custom) { + { + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + enabled = state.isPrimaryButtonEnabled, + text = stringResourceSafe(R.string.common_done), + onClick = feeSelectorIntents::onDoneClick, + ) + } + } else { + null }, ) } @@ -426,7 +430,14 @@ private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider< FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))), customFeeItem, ), - selectedFeeItem = customFeeItem, + selectedFeeItem = FeeItem.Slow( + fee = Fee.Common( + Amount( + value = BigDecimal("0.01"), + blockchain = Blockchain.Ethereum, + ), + ), + ), feeExtraInfo = FeeExtraInfo( isFeeApproximate = true, isFeeConvertibleToFiat = true, From 92b55cadbf7bd49ef0a31292b6085676c56e41ed Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 12:05:42 +0300 Subject: [PATCH 06/27] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index f430a4dd17..c57f7b4239 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -11,7 +11,7 @@ tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-461" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 1d97ce5203d8b25430a59d4d1e605182054aa713 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 14:35:20 +0500 Subject: [PATCH 07/27] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 11 ++ .../com/tangem/common/routing/AppRoute.kt | 5 + .../main/res/drawable/ic_knight_shield_24.xml | 9 + .../res/drawable/ic_mobile_security_24.xml | 12 ++ .../src/main/res/drawable/ic_protect_24.xml | 9 + .../ui/src/main/res/drawable/ic_tangem_64.xml | 18 ++ .../hotwallet/UpgradeWalletComponent.kt | 14 ++ .../DefaultUpgradeWalletComponent.kt | 39 +++++ .../upgradewallet/UpgradeWalletModel.kt | 41 +++++ .../upgradewallet/di/UpgradeWalletModule.kt | 25 +++ .../upgradewallet/entity/UpgradeWalletUM.kt | 7 + .../upgradewallet/ui/UpgradeWalletContent.kt | 161 ++++++++++++++++++ 12 files changed, 351 insertions(+) create mode 100644 core/ui/src/main/res/drawable/ic_knight_shield_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_mobile_security_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_protect_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_tangem_64.xml create mode 100644 features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 615b1c3778..9a80508e7b 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -17,6 +17,7 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.home.api.HomeComponent import com.tangem.features.hotwallet.AddExistingWalletComponent import com.tangem.features.hotwallet.CreateMobileWalletComponent +import com.tangem.features.hotwallet.UpgradeWalletComponent import com.tangem.features.hotwallet.WalletActivationComponent import com.tangem.features.hotwallet.CreateWalletBackupComponent import com.tangem.features.hotwallet.UpdateAccessCodeComponent @@ -103,6 +104,7 @@ internal class ChildFactory @Inject constructor( private val chooseManagedTokensComponentFactory: ChooseManagedTokensComponent.Factory, private val createWalletSelectionComponentFactory: CreateWalletSelectionComponent.Factory, private val createMobileWalletComponentFactory: CreateMobileWalletComponent.Factory, + private val upgradeWalletComponentFactory: UpgradeWalletComponent.Factory, private val addExistingWalletComponentFactory: AddExistingWalletComponent.Factory, private val walletActivationComponentFactory: WalletActivationComponent.Factory, private val createWalletBackupComponentFactory: CreateWalletBackupComponent.Factory, @@ -486,6 +488,15 @@ internal class ChildFactory @Inject constructor( componentFactory = createMobileWalletComponentFactory, ) } + is AppRoute.UpgradeWallet -> { + createComponentChild( + context = context, + params = UpgradeWalletComponent.Params( + userWalletId = route.userWalletId, + ), + componentFactory = upgradeWalletComponentFactory, + ) + } is AppRoute.AddExistingWallet -> { createComponentChild( context = context, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index d0ffcca403..cfa1070c25 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -311,6 +311,11 @@ sealed class AppRoute(val path: String) : Route { @Serializable object CreateMobileWallet : AppRoute(path = "/create_mobile_wallet") + @Serializable + data class UpgradeWallet( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/upgrade_wallet/${userWalletId.stringValue}") + @Serializable object AddExistingWallet : AppRoute(path = "/add_existing_wallet") diff --git a/core/ui/src/main/res/drawable/ic_knight_shield_24.xml b/core/ui/src/main/res/drawable/ic_knight_shield_24.xml new file mode 100644 index 0000000000..1a058ff367 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_knight_shield_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_mobile_security_24.xml b/core/ui/src/main/res/drawable/ic_mobile_security_24.xml new file mode 100644 index 0000000000..bbc69392d5 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_mobile_security_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_protect_24.xml b/core/ui/src/main/res/drawable/ic_protect_24.xml new file mode 100644 index 0000000000..5ad478076f --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_protect_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_tangem_64.xml b/core/ui/src/main/res/drawable/ic_tangem_64.xml new file mode 100644 index 0000000000..6c39da496c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_tangem_64.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt new file mode 100644 index 0000000000..8a9b7f6c14 --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/UpgradeWalletComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.hotwallet + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface UpgradeWalletComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt new file mode 100644 index 0000000000..2f0e91d1de --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/DefaultUpgradeWalletComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.hotwallet.upgradewallet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.hotwallet.UpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.ui.UpgradeWalletContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("UnusedPrivateMember") +internal class DefaultUpgradeWalletComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: UpgradeWalletComponent.Params, +) : UpgradeWalletComponent, AppComponentContext by context { + + private val model: UpgradeWalletModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + UpgradeWalletContent( + state = state, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : UpgradeWalletComponent.Factory { + override fun create( + context: AppComponentContext, + params: UpgradeWalletComponent.Params, + ): DefaultUpgradeWalletComponent + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt new file mode 100644 index 0000000000..bc03e54e43 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt @@ -0,0 +1,41 @@ +package com.tangem.features.hotwallet.upgradewallet + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +internal class UpgradeWalletModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, +) : Model() { + + internal val uiState: StateFlow + field = MutableStateFlow( + UpgradeWalletUM( + onBackClick = { router.pop() }, + onBuyTangemWalletClick = ::onBuyTangemWalletClick, + onScanDeviceClick = ::onScanDeviceClick, + ), + ) + + private fun onBuyTangemWalletClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + + private fun onScanDeviceClick() { + // TODO [REDACTED_TASK_KEY] + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt new file mode 100644 index 0000000000..3beb13bcab --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/di/UpgradeWalletModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.hotwallet.upgradewallet.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.hotwallet.UpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.DefaultUpgradeWalletComponent +import com.tangem.features.hotwallet.upgradewallet.UpgradeWalletModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(SingletonComponent::class) +internal interface UpgradeWalletModule { + + @Binds + fun bindUpgradeWalletComponentFactory(impl: DefaultUpgradeWalletComponent.Factory): UpgradeWalletComponent.Factory + + @Binds + @IntoMap + @ClassKey(UpgradeWalletModel::class) + fun bindUpgradeWalletModel(model: UpgradeWalletModel): Model +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt new file mode 100644 index 0000000000..8f700a27b9 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/entity/UpgradeWalletUM.kt @@ -0,0 +1,7 @@ +package com.tangem.features.hotwallet.upgradewallet.entity + +internal data class UpgradeWalletUM( + val onBackClick: () -> Unit, + val onBuyTangemWalletClick: () -> Unit, + val onScanDeviceClick: () -> Unit, +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt new file mode 100644 index 0000000000..2ae6ae5d0c --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/ui/UpgradeWalletContent.kt @@ -0,0 +1,161 @@ +package com.tangem.features.hotwallet.upgradewallet.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun UpgradeWalletContent(state: UpgradeWalletUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize() + .systemBarsPadding(), + ) { + TangemTopAppBar( + modifier = Modifier + .statusBarsPadding(), + startButton = TopAppBarButtonUM.Back(state.onBackClick), + title = TextReference.EMPTY, + ) + Column( + modifier = Modifier + .weight(1f) + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + ), + ) { + Icon( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(R.drawable.ic_tangem_64), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 20.dp, + end = 16.dp, + ), + text = stringResourceSafe(R.string.hw_upgrade_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 32.dp), + title = stringResourceSafe(R.string.hw_upgrade_key_migration_title), + description = stringResourceSafe(R.string.hw_upgrade_key_migration_description), + iconRes = R.drawable.ic_mobile_security_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_funds_access_title), + description = stringResourceSafe(R.string.hw_upgrade_funds_access_description), + iconRes = R.drawable.ic_knight_shield_24, + ) + FeatureBlock( + modifier = Modifier + .padding(top = 24.dp), + title = stringResourceSafe(R.string.hw_upgrade_general_security_title), + description = stringResourceSafe(R.string.hw_upgrade_general_security_description), + iconRes = R.drawable.ic_protect_24, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.details_buy_wallet), + onClick = state.onBuyTangemWalletClick, + ) + PrimaryButtonIconEnd( + modifier = Modifier + .fillMaxWidth(), + text = stringResourceSafe(R.string.hw_upgrade_scan_device), + onClick = state.onScanDeviceClick, + iconResId = R.drawable.ic_tangem_24, + ) + } + } +} + +@Composable +private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + ) { + Icon( + modifier = Modifier + .padding(horizontal = 12.dp), + painter = painterResource(iconRes), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .padding(top = 4.dp), + text = description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewUpgradeWalletContent() { + TangemThemePreview { + UpgradeWalletContent( + state = UpgradeWalletUM( + onBackClick = {}, + onBuyTangemWalletClick = {}, + onScanDeviceClick = {}, + ), + ) + } +} \ No newline at end of file From 220a43c1b9d38be882dd718a998f32cde379c6f5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 14:36:55 +0500 Subject: [PATCH 08/27] Updated on 2026-08-14 --- .../components/notifications/Notification.kt | 1 + .../notifications/NotificationConfig.kt | 1 + .../hotwallet/accesscode/ui/AccessCode.kt | 2 +- .../hotwallet/common/ui/OptionBlock.kt | 2 +- .../walletbackup/entity/WalletBackupUM.kt | 5 +- .../walletbackup/model/WalletBackupModel.kt | 10 ++-- .../walletbackup/ui/WalletBackupContent.kt | 19 ++++---- .../common/preview/WalletScreenPreviewData.kt | 11 ++++- .../domain/GetMultiWalletWarningsFactory.kt | 46 ++++++++++++++++--- .../wallet/state/model/WalletNotification.kt | 11 +++-- .../components/common/WalletNotifications.kt | 6 +++ 11 files changed, 86 insertions(+), 28 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index 06a2bc1d9e..a3e97923bd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -62,6 +62,7 @@ fun Notification( NotificationConfig.IconTint.Unspecified -> null NotificationConfig.IconTint.Accent -> TangemTheme.colors.icon.accent NotificationConfig.IconTint.Attention -> TangemTheme.colors.icon.attention + NotificationConfig.IconTint.Warning -> TangemTheme.colors.icon.warning }, isEnabled: Boolean = true, ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt index ca5cdc8e5a..bd4d9dacef 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt @@ -65,5 +65,6 @@ data class NotificationConfig( Unspecified, Accent, Attention, + Warning, } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt index 3924d978c7..4596985307 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt @@ -74,7 +74,7 @@ internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) { ) { PinTextField( length = state.accessCodeLength, - isPasswordVisual = !state.isConfirmMode, + isPasswordVisual = state.isConfirmMode, value = state.accessCode, pinTextColor = PinTextColor.Primary, onValueChange = state.onAccessCodeChange, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt index 8e90402e09..c41dba03e8 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt @@ -61,7 +61,7 @@ internal fun OptionBlock( color = backgroundColor, shape = TangemTheme.shapes.roundedCornersXMedium, ) - .conditional(onClick != null) { + .conditional(onClick != null && enabled) { onClick?.let { clickableSingle(onClick = it) } ?: Modifier } .padding(16.dp), diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt index f3f17cd9d3..3bc262d1a9 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt @@ -4,8 +4,9 @@ import com.tangem.core.ui.components.label.entity.LabelUM internal data class WalletBackupUM( val onBackClick: () -> Unit, - val recoveryPhraseStatus: LabelUM?, - val googleDriveStatus: LabelUM?, + val recoveryPhraseOption: LabelUM?, + val googleDriveOption: LabelUM?, + val googleDriveStatus: BackupStatus, val onRecoveryPhraseClick: () -> Unit, val onGoogleDriveClick: () -> Unit, val backedUp: Boolean, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt index b398ed047a..9996c7fb7a 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt @@ -20,6 +20,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.common.routing.AppRoute import com.tangem.features.hotwallet.WalletBackupComponent +import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* @@ -40,14 +41,15 @@ internal class WalletBackupModel @Inject constructor( field = MutableStateFlow( WalletBackupUM( onBackClick = { router.pop() }, - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_coming_soon), style = LabelStyle.REGULAR, ), + googleDriveStatus = BackupStatus.ComingSoon, onRecoveryPhraseClick = ::onRecoveryPhraseClick, onGoogleDriveClick = { }, backedUp = false, @@ -95,7 +97,7 @@ internal class WalletBackupModel @Inject constructor( } private fun WalletBackupUM.updateBackupStatusesHotWallet(userWallet: UserWallet.Hot): WalletBackupUM = copy( - recoveryPhraseStatus = if (userWallet.backedUp) { + recoveryPhraseOption = if (userWallet.backedUp) { LabelUM( text = resourceReference(R.string.common_done), style = LabelStyle.ACCENT, @@ -106,7 +108,7 @@ internal class WalletBackupModel @Inject constructor( style = LabelStyle.WARNING, ) }, - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_coming_soon), style = LabelStyle.REGULAR, ), diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt index f5d25b7280..671c8dd89a 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt @@ -51,7 +51,7 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod title = stringResourceSafe(R.string.hw_backup_seed_title), description = stringResourceSafe(R.string.hw_backup_seed_description), badge = { - state.recoveryPhraseStatus?.let { Label(it) } + state.recoveryPhraseOption?.let { Label(it) } }, onClick = state.onRecoveryPhraseClick, enabled = true, @@ -63,7 +63,7 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod title = stringResourceSafe(R.string.hw_backup_google_drive_title), description = stringResourceSafe(R.string.hw_backup_google_drive_description), badge = { - state.googleDriveStatus?.let { Label(it) } + state.googleDriveOption?.let { Label(it) } }, onClick = state.onGoogleDriveClick, enabled = state.googleDriveStatus != BackupStatus.ComingSoon, @@ -85,42 +85,45 @@ private fun WalletBackupContentPreview(@PreviewParameter(WalletBackupUMProvider: private class WalletBackupUMProvider : CollectionPreviewParameterProvider( collection = listOf( WalletBackupUM( - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_coming_soon), style = LabelStyle.REGULAR, ), + googleDriveStatus = BackupStatus.ComingSoon, onBackClick = {}, onRecoveryPhraseClick = {}, onGoogleDriveClick = {}, backedUp = false, ), WalletBackupUM( - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.hw_backup_no_backup), style = LabelStyle.WARNING, ), + googleDriveStatus = BackupStatus.NoBackup, onBackClick = {}, onRecoveryPhraseClick = {}, onGoogleDriveClick = {}, backedUp = false, ), WalletBackupUM( - recoveryPhraseStatus = LabelUM( + recoveryPhraseOption = LabelUM( text = resourceReference(R.string.common_done), style = LabelStyle.ACCENT, ), - googleDriveStatus = LabelUM( + googleDriveOption = LabelUM( text = resourceReference(R.string.common_done), style = LabelStyle.ACCENT, ), + googleDriveStatus = BackupStatus.Done, onBackClick = {}, onRecoveryPhraseClick = {}, onGoogleDriveClick = {}, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt index d57ed3079f..9e485dad2e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -3,10 +3,13 @@ package com.tangem.feature.wallet.presentation.common.preview import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo @@ -117,7 +120,13 @@ internal object WalletScreenPreviewData { buttons = persistentListOf(buyButton), warnings = persistentListOf( WalletNotification.Warning.SomeNetworksUnreachable, - WalletNotification.FinishWalletActivation { }, + WalletNotification.FinishWalletActivation( + iconTint = NotificationConfig.IconTint.Attention, + buttonsState = ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.hw_activation_need_finish), + onClick = { }, + ), + ), ), bottomSheetConfig = null, tokensListState = textContentTokensState, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 248f54a3e6..a7e173397b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -1,6 +1,9 @@ package com.tangem.feature.wallet.presentation.wallet.domain import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState +import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.core.lce.Lce @@ -19,7 +22,9 @@ import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.domain.wallets.usecase.SeedPhraseNotificationUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents +import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification +import com.tangem.utils.extensions.isPositive import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @@ -54,7 +59,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( addCriticalNotifications(userWallet, seedPhraseIssueStatus, clickIntents) - addFinishWalletActivationNotification(userWallet, clickIntents) + addFinishWalletActivationNotification(userWallet, maybeTokenList, clickIntents) addReferralPromoNotification(cardTypesResolver, clickIntents, shouldShowReferralPromo) @@ -256,26 +261,55 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( ) } - private fun MutableList.addIf(element: WalletNotification, condition: Boolean) { - if (condition) add(element = element) - } - private fun MutableList.addFinishWalletActivationNotification( userWallet: UserWallet, + maybeTokenList: Lce, clickIntents: WalletClickIntents, ) { if (userWallet !is UserWallet.Hot) return val shouldShowFinishActivation = !userWallet.backedUp + val iconTint = maybeTokenList.fold( + ifLoading = { + if ((it?.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isPositive() == true) { + IconTint.Warning + } else { + IconTint.Attention + } + }, + ifContent = { + if ((it.totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isPositive() == true) { + IconTint.Warning + } else { + IconTint.Attention + } + }, + ifError = { IconTint.Attention }, + ) + addIf( element = WalletNotification.FinishWalletActivation( - onFinishClick = clickIntents::onFinishWalletActivationClick, + iconTint = iconTint, + buttonsState = when (iconTint) { + IconTint.Warning -> ButtonsState.PrimaryButtonConfig( + text = resourceReference(R.string.hw_activation_need_finish), + onClick = clickIntents::onFinishWalletActivationClick, + ) + else -> ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.hw_activation_need_finish), + onClick = clickIntents::onFinishWalletActivationClick, + ) + }, ), condition = shouldShowFinishActivation, ) } + private fun MutableList.addIf(element: WalletNotification, condition: Boolean) { + if (condition) add(element = element) + } + private companion object { const val MAX_REMAINING_SIGNATURES_COUNT = 10 } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt index 3d23c93ae5..dd6ae1ec2b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt @@ -3,6 +3,8 @@ package com.tangem.feature.wallet.presentation.wallet.state.model import androidx.compose.runtime.Immutable import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState +import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference @@ -257,16 +259,15 @@ sealed class WalletNotification(val config: NotificationConfig) { ) data class FinishWalletActivation( - val onFinishClick: () -> Unit, + val iconTint: IconTint, + val buttonsState: ButtonsState, ) : WalletNotification( config = NotificationConfig( title = resourceReference(R.string.hw_activation_need_title), subtitle = resourceReference(R.string.hw_activation_need_description), iconResId = R.drawable.img_knight_shield_32, - buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( - text = resourceReference(R.string.hw_activation_need_finish), - onClick = onFinishClick, - ), + iconTint = iconTint, + buttonsState = buttonsState, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index 211ce57e2d..172ab84fbe 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -34,6 +34,12 @@ internal fun LazyListScope.notifications(configs: ImmutableList { + Notification( + config = it.config, + modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), + ) + } else -> { Notification( config = it.config, From 072290b144183fdd2151e9fc460c6360f7b8bf0c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 Aug 2025 11:05:10 +0500 Subject: [PATCH 09/27] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 ++ features/yield-lending/api/.gitignore | 1 + features/yield-lending/api/build.gradle.kts | 24 +++++++++++ .../api/YieldLendingFeatureToggles.kt | 6 +++ features/yield-lending/impl/.gitignore | 1 + features/yield-lending/impl/build.gradle.kts | 41 +++++++++++++++++++ .../impl/DefaultYieldLendingFeatureToggles.kt | 11 +++++ .../impl/di/YieldLendingFeatureModule.kt | 21 ++++++++++ settings.gradle.kts | 3 ++ 9 files changed, 112 insertions(+) create mode 100644 features/yield-lending/api/.gitignore create mode 100644 features/yield-lending/api/build.gradle.kts create mode 100644 features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt create mode 100644 features/yield-lending/impl/.gitignore create mode 100644 features/yield-lending/impl/build.gradle.kts create mode 100644 features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt create mode 100644 features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index bd1f7e6e67..318f39ce65 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -62,5 +62,9 @@ { "name": "NEW_TOKEN_RECEIVE_ENABLED", "version": "5.28.0" + }, + { + "name": "YIELD_LENDING_FEATURE_ENABLED", + "version": "undefined" } ] diff --git a/features/yield-lending/api/.gitignore b/features/yield-lending/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/yield-lending/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/yield-lending/api/build.gradle.kts b/features/yield-lending/api/build.gradle.kts new file mode 100644 index 0000000000..da7d806bb3 --- /dev/null +++ b/features/yield-lending/api/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.yieldlending.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.appCurrency.models) + + /** Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt b/features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt new file mode 100644 index 0000000000..3d1c8a10bd --- /dev/null +++ b/features/yield-lending/api/src/main/java/com/tangem/features/yieldlending/api/YieldLendingFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.yieldlending.api + +interface YieldLendingFeatureToggles { + + val isYieldLendingFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/yield-lending/impl/.gitignore b/features/yield-lending/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/yield-lending/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/yield-lending/impl/build.gradle.kts b/features/yield-lending/impl/build.gradle.kts new file mode 100644 index 0000000000..40ebde87d3 --- /dev/null +++ b/features/yield-lending/impl/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.yieldlending.impl" +} + +dependencies { + + /** Feature */ + implementation(projects.features.yieldLending.api) + + /** Core */ + implementation(projects.core.configToggles) + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.appCurrency.models) + + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.runtime) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.androidx.activity.compose) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt b/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt new file mode 100644 index 0000000000..4e780b4e96 --- /dev/null +++ b/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/DefaultYieldLendingFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.yieldlending.impl + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.yieldlending.api.YieldLendingFeatureToggles + +internal class DefaultYieldLendingFeatureToggles( + private val featureToggles: FeatureTogglesManager, +) : YieldLendingFeatureToggles { + override val isYieldLendingFeatureEnabled: Boolean + get() = featureToggles.isFeatureEnabled("YIELD_LENDING_FEATURE_ENABLED") +} \ No newline at end of file diff --git a/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt b/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt new file mode 100644 index 0000000000..d666cb36a1 --- /dev/null +++ b/features/yield-lending/impl/src/main/java/com/tangem/features/yieldlending/impl/di/YieldLendingFeatureModule.kt @@ -0,0 +1,21 @@ +package com.tangem.features.yieldlending.impl.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.yieldlending.api.YieldLendingFeatureToggles +import com.tangem.features.yieldlending.impl.DefaultYieldLendingFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +internal object YieldLendingFeatureModule { + + @Singleton + @Provides + fun provideYieldFeatureToggles(featureTogglesManager: FeatureTogglesManager): YieldLendingFeatureToggles { + return DefaultYieldLendingFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index d785e00c38..7c7251cb46 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -281,6 +281,9 @@ include(":features:account:impl") include(":features:token-recieve:api") include(":features:token-recieve:impl") + +include(":features:yield-lending:api") +include(":features:yield-lending:impl") // endregion Feature modules // region Domain modules From 3a96b203e0171f2375ddc0b31c1c74f30381055f Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 19:14:43 +0700 Subject: [PATCH 10/27] Updated on 2026-08-14 --- .../tangem/common/ui/account/AccountRow.kt | 5 ++ .../converter/UserWalletItemUMConverter.kt | 17 +--- features/markets/impl/build.gradle.kts | 1 + .../model/AddToPortfolioBSContentUMFactory.kt | 7 +- .../impl/model/MarketsPortfolioModel.kt | 59 ++++++------ .../impl/model/MyPortfolioUMFactory.kt | 6 +- .../wallet-settings/impl/build.gradle.kts | 2 + .../preview/PreviewWalletSettingsComponent.kt | 15 +++- .../entity/WalletSettingsItemUM.kt | 4 +- .../model/WalletSettingsModel.kt | 36 +++----- .../walletsettings/ui/WalletSettingsScreen.kt | 50 +++++++---- .../walletsettings/utils/ItemsBuilder.kt | 18 +--- .../utils/WalletCardItemDelegate.kt | 55 ++++++++++++ features/wallet/api/build.gradle.kts | 3 + .../wallet/utils/UserWalletImageFetcher.kt | 20 +++++ .../feature/wallet/di/WalletFeatureModule.kt | 7 ++ .../utils/DefaultUserWalletImageFetcher.kt | 89 +++++++++++++++++++ .../wallet/utils/DefaultUserWalletsFetcher.kt | 27 +----- 18 files changed, 285 insertions(+), 136 deletions(-) create mode 100644 features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt create mode 100644 features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt index c48fa40072..d02fe62c15 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.R import com.tangem.core.ui.extensions.TextReference @@ -73,6 +74,8 @@ private fun Title(title: TextReference) { text = title.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } @@ -82,6 +85,8 @@ private fun Subtitle(subtitle: TextReference) { color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.caption2, text = subtitle.resolveReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt index 41f2c3611e..41b0727d2d 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/converter/UserWalletItemUMConverter.kt @@ -2,8 +2,8 @@ package com.tangem.common.ui.userwallet.converter import com.tangem.common.ui.R import com.tangem.common.ui.userwallet.state.UserWalletItemUM -import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -12,7 +12,6 @@ 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.card.common.util.getCardsCount -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet @@ -37,10 +36,10 @@ class UserWalletItemUMConverter( private val isBalanceHidden: Boolean = false, private val authMode: Boolean = false, private val endIcon: UserWalletItemUM.EndIcon = UserWalletItemUM.EndIcon.None, - private val artwork: ArtworkModel? = null, + artwork: UserWalletItemUM.ImageState? = null, ) : Converter { - private val artworkUMConverter = ArtworkUMConverter() + private val artwork = artwork ?: UserWalletItemUM.ImageState.Loading override fun convert(value: UserWallet): UserWalletItemUM { return with(value) { @@ -52,7 +51,7 @@ class UserWalletItemUMConverter( isEnabled = isEnabled(userWallet = this), endIcon = endIcon, onClick = { onClick(value.walletId) }, - imageState = getImageState(userWallet = value), + imageState = artwork, label = getLabelOrNull(userWallet = this), ) } @@ -73,14 +72,6 @@ class UserWalletItemUMConverter( } } - private fun getImageState(userWallet: UserWallet): UserWalletItemUM.ImageState { - return when { - userWallet is UserWallet.Hot -> UserWalletItemUM.ImageState.MobileWallet - artwork != null -> UserWalletItemUM.ImageState.Image(artworkUMConverter.convert(artwork)) - else -> UserWalletItemUM.ImageState.Loading - } - } - private fun getInfo(userWallet: UserWallet): UserWalletItemUM.Information.Loaded { val text = when (userWallet) { is UserWallet.Cold -> { diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 1b9562b290..61d498c799 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(projects.features.onramp.api) api(projects.features.sendV2.api) api(projects.features.tokenRecieve.api) + api(projects.features.wallet.api) /* Data */ implementation(projects.data.common) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt index feef86486d..49b7fead68 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -7,7 +7,6 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -56,7 +55,7 @@ internal class AddToPortfolioBSContentUMFactory( portfolioUIData: PortfolioUIData, selectedWallet: UserWallet?, alreadyAddedNetworks: Set?, - artworks: HashMap, + artworks: Map, ): TangemBottomSheetConfig { return (currentState ?: TangemBottomSheetConfig.Empty).copy( isShown = portfolioUIData.portfolioBSVisibilityModel.addToPortfolioBSVisibility, @@ -110,7 +109,7 @@ internal class AddToPortfolioBSContentUMFactory( } private fun UserWallet.toSelectedUserWalletItemUM( - artwork: ArtworkModel? = null, + artwork: UserWalletItemUM.ImageState? = null, portfolioData: PortfolioData, balance: TotalFiatBalance?, ): UserWalletItemUM { @@ -128,7 +127,7 @@ internal class AddToPortfolioBSContentUMFactory( isShow: Boolean, portfolioData: PortfolioData, selectedWalletId: UserWalletId, - artworks: HashMap, + artworks: Map, ): TangemBottomSheetConfig { return TangemBottomSheetConfig( isShown = isShow, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt index 60673724c0..5b6bd84ca2 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MarketsPortfolioModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.markets.portfolio.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -21,7 +22,6 @@ import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.markets.SaveMarketTokensUseCase import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency @@ -31,7 +31,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.transaction.usecase.GetEnsNameUseCase -import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase import com.tangem.features.markets.impl.R @@ -42,13 +41,14 @@ import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject @@ -66,21 +66,17 @@ internal class MarketsPortfolioModel @Inject constructor( private val portfolioDataLoader: PortfolioDataLoader, private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase, private val saveMarketTokensUseCase: SaveMarketTokensUseCase, - private val getCardImageUseCase: GetCardImageUseCase, private val addToPortfolioManager: AddToPortfolioManager, private val analyticsEventHandler: AnalyticsEventHandler, private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, private val getEnsNameUseCase: GetEnsNameUseCase, + private val userWalletImageFetcher: UserWalletImageFetcher, ) : Model() { val state: StateFlow get() = _state private val _state: MutableStateFlow = MutableStateFlow(value = MyPortfolioUM.Loading) - private val loadedArtworks: HashMap = hashMapOf() - private val artworksState: MutableStateFlow> = MutableStateFlow(hashMapOf()) - private val loadArtworksMutex = Mutex() - private val params = paramsContainer.require() private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder( token = params.token, @@ -196,38 +192,33 @@ internal class MarketsPortfolioModel @Inject constructor( private fun subscribeOnStateUpdates() { combine( - flow = loadPortfolioData(params.token.id), + flow = loadPortfolioDataWithArtworks(params.token.id), flow2 = getPortfolioUIDataFlow(), - flow3 = artworksState, - transform = factory::create, + transform = { pair, portfolioUIData -> + val (portfolioData, artworks) = pair + factory.create(portfolioData, portfolioUIData, artworks) + }, ) .onEach { _state.value = it } .launchIn(modelScope) } - private fun loadPortfolioData(currencyRawId: CryptoCurrency.RawID): Flow { - portfolioDataLoader.load(currencyRawId).onEach { - loadArtworks(it.walletsWithCurrencies.keys.toList()) - }.also { return it } - } + private fun loadPortfolioDataWithArtworks( + currencyRawId: CryptoCurrency.RawID, + ): Flow>> { + val wallets = Channel>() + val portfolioFlow = portfolioDataLoader + .load(currencyRawId) + .onEach { wallets.trySend(it.walletsWithCurrencies.keys) } - private fun loadArtworks(wallets: List) { - modelScope.launch { - loadArtworksMutex.withLock { - wallets.filterIsInstance().forEach { wallet -> - if (!loadedArtworks.containsKey(wallet.walletId)) { - val artwork = getCardImageUseCase( - cardId = wallet.cardId, - manufacturerName = wallet.scanResponse.card.manufacturer.name, - firmwareVersion = wallet.scanResponse.card.firmwareVersion.toSdkFirmwareVersion(), - cardPublicKey = wallet.scanResponse.card.cardPublicKey, - ) - loadedArtworks[wallet.walletId] = artwork - artworksState.emit(loadedArtworks) - } - } - } - } + val artworksFlow = wallets.receiveAsFlow() + .distinctUntilChanged() + .flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) } + + return combine( + flow = portfolioFlow, + flow2 = artworksFlow, + ) { portfolioData, artworks -> portfolioData to artworks } } private fun getPortfolioUIDataFlow(): Flow { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt index 5c5bb59b96..44fd810a9a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -1,8 +1,8 @@ package com.tangem.features.markets.portfolio.impl.model +import com.tangem.common.ui.userwallet.state.UserWalletItemUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -36,7 +36,7 @@ internal class MyPortfolioUMFactory( fun create( portfolioData: PortfolioData, portfolioUIData: PortfolioUIData, - artworks: HashMap, + artworks: Map, ): MyPortfolioUM { val addToPortfolioData = portfolioUIData.addToPortfolioData @@ -89,7 +89,7 @@ internal class MyPortfolioUMFactory( private fun createAddToPortfolioBSConfig( portfolioData: PortfolioData, portfolioUIData: PortfolioUIData, - artworks: HashMap, + artworks: Map, ): TangemBottomSheetConfig { val selectedWallet = portfolioData.walletsWithCurrencies.keys .firstOrNull { it.walletId == portfolioUIData.selectedWalletId } diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index 3f34abd5a1..4ab6bc0a40 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(projects.features.onboardingV2.api) implementation(projects.features.pushNotifications.api) implementation(projects.features.hotWallet.api) + implementation(projects.features.wallet.api) /* Project - Core */ implementation(projects.core.decompose) @@ -69,4 +70,5 @@ dependencies { /** Tangem libraries */ implementation(tangemDeps.hot.core) + implementation(tangemDeps.card.core) } \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt index 69bf4cfb75..e716a35761 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/component/preview/PreviewWalletSettingsComponent.kt @@ -3,6 +3,7 @@ package com.tangem.feature.walletsettings.component.preview import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState import com.tangem.core.analytics.DummyAnalyticsEventHandler import com.tangem.core.decompose.navigation.DummyRouter import com.tangem.core.ui.components.block.model.BlockUM @@ -13,6 +14,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.walletsettings.component.WalletSettingsComponent import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM.Footer.AddAccountUM +import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R import com.tangem.feature.walletsettings.ui.WalletSettingsScreen @@ -34,14 +36,11 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { wallets = null, backedUp = false, ), - userWalletName = "My Wallet", isReferralAvailable = true, isLinkMoreCardsAvailable = true, - isRenameWalletAvailable = false, isNFTFeatureEnabled = true, isNFTEnabled = true, onCheckedNFTChange = {}, - renameWallet = {}, forgetWallet = {}, onLinkMoreCardsClick = {}, onReferralClick = {}, @@ -56,6 +55,7 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { onUpgradeWalletClick = {}, onDismissUpgradeWalletClick = {}, accountsUM = previewAccounts(), + cardItem = previewCardBlock(), ), requestPushNotificationsPermission = false, onPushNotificationPermissionGranted = {}, @@ -90,6 +90,15 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { ).let(::add) } + private fun previewCardBlock() = WalletSettingsItemUM.CardBlock( + id = "wallet_name", + title = resourceReference(id = R.string.user_wallet_list_rename_popup_placeholder), + text = stringReference("Wallet Name"), + isEnabled = true, + onClick = { }, + imageState = ImageState.MobileWallet, + ) + @Composable override fun Content(modifier: Modifier) { WalletSettingsScreen( diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt index 85a6bcb784..d2cb70fe36 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/entity/WalletSettingsItemUM.kt @@ -2,6 +2,7 @@ package com.tangem.feature.walletsettings.entity import androidx.compose.runtime.Immutable import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -24,11 +25,12 @@ internal sealed class WalletSettingsItemUM { val onCheckedChange: (Boolean) -> Unit, ) : WalletSettingsItemUM() - data class WithText( + data class CardBlock( override val id: String, val title: TextReference, val text: TextReference, val isEnabled: Boolean, + val imageState: ImageState, val onClick: () -> Unit, ) : WalletSettingsItemUM() diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index 1a5e3ee067..66ddf7f18f 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -1,6 +1,7 @@ package com.tangem.feature.walletsettings.model import android.os.Build +import arrow.core.Either import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate @@ -44,6 +45,7 @@ import com.tangem.feature.walletsettings.entity.WalletSettingsUM import com.tangem.feature.walletsettings.impl.R import com.tangem.feature.walletsettings.utils.AccountItemsDelegate import com.tangem.feature.walletsettings.utils.ItemsBuilder +import com.tangem.feature.walletsettings.utils.WalletCardItemDelegate import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.PersistentList @@ -66,7 +68,7 @@ internal class WalletSettingsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsContextProxy: AnalyticsContextProxy, - private val getShouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, + walletCardItemDelegateFactory: WalletCardItemDelegate.Factory, private val isDemoCardUseCase: IsDemoCardUseCase, getWalletNFTEnabledUseCase: GetWalletNFTEnabledUseCase, private val enableWalletNFTUseCase: EnableWalletNFTUseCase, @@ -85,6 +87,7 @@ internal class WalletSettingsModel @Inject constructor( val params: WalletSettingsComponent.Params = paramsContainer.require() val dialogNavigation = SlotNavigation() val bottomSheetNavigation: SlotNavigation = SlotNavigation() + private val walletCardItemDelegate = walletCardItemDelegateFactory.create(dialogNavigation) val state: MutableStateFlow = MutableStateFlow( value = WalletSettingsUM( @@ -117,14 +120,12 @@ internal class WalletSettingsModel @Inject constructor( } init { - combine( - getWalletUseCase.invokeFlow(params.userWalletId).distinctUntilChanged(), + fun combineUI(wallet: UserWallet) = combine( getWalletNFTEnabledUseCase.invoke(params.userWalletId), getWalletNotificationsEnabledUseCase(params.userWalletId), isUpgradeWalletNotificationEnabledUseCase(params.userWalletId), - ) { maybeWallet, nftEnabled, notificationsEnabled, isUpgradeNotificationEnabled -> - val wallet = maybeWallet.getOrNull() ?: return@combine - val isRenameWalletAvailable = getShouldSaveUserWalletsSyncUseCase() + walletCardItemDelegate.cardItemFlow(wallet), + ) { nftEnabled, notificationsEnabled, isUpgradeNotificationEnabled, cardItem -> val isWalletBackedUp = when (wallet) { is UserWallet.Hot -> wallet.backedUp is UserWallet.Cold -> true @@ -135,8 +136,7 @@ internal class WalletSettingsModel @Inject constructor( value.copy( items = buildItems( userWallet = wallet, - dialogNavigation = dialogNavigation, - isRenameWalletAvailable = isRenameWalletAvailable, + cardItem = cardItem, isNFTEnabled = nftEnabled, isNotificationsEnabled = notificationsEnabled, isNotificationsFeatureEnabled = isNeedShowNotifications, @@ -147,6 +147,10 @@ internal class WalletSettingsModel @Inject constructor( ) } } + getWalletUseCase.invokeFlow(params.userWalletId) + .distinctUntilChanged() + .filterIsInstance>() + .flatMapLatest { combineUI(it.value) } .launchIn(modelScope) } @@ -162,8 +166,7 @@ internal class WalletSettingsModel @Inject constructor( private fun buildItems( userWallet: UserWallet, - dialogNavigation: SlotNavigation, - isRenameWalletAvailable: Boolean, + cardItem: WalletSettingsItemUM.CardBlock, isNFTEnabled: Boolean, isNotificationsFeatureEnabled: Boolean, isNotificationsEnabled: Boolean, @@ -176,7 +179,7 @@ internal class WalletSettingsModel @Inject constructor( } return itemsBuilder.buildItems( userWallet = userWallet, - userWalletName = userWallet.name, + cardItem = cardItem, isReferralAvailable = when (userWallet) { is UserWallet.Cold -> userWallet.cardTypesResolver.isTangemWallet() is UserWallet.Hot -> false @@ -186,8 +189,6 @@ internal class WalletSettingsModel @Inject constructor( is UserWallet.Hot -> false }, isManageTokensAvailable = isMultiCurrency, - isRenameWalletAvailable = isRenameWalletAvailable, - renameWallet = { openRenameWalletDialog(userWallet, dialogNavigation) }, isNFTFeatureEnabled = isMultiCurrency, isNFTEnabled = isNFTEnabled, onCheckedNFTChange = ::onCheckedNFTChange, @@ -226,15 +227,6 @@ internal class WalletSettingsModel @Inject constructor( ) } - private fun openRenameWalletDialog(userWallet: UserWallet, dialogNavigation: SlotNavigation) { - val config = DialogConfig.RenameWallet( - userWalletId = userWallet.walletId, - currentName = userWallet.name, - ) - - dialogNavigation.activate(config) - } - private fun forgetWallet() = modelScope.launch { val hasUserWallets = deleteWalletUseCase(params.userWalletId).getOrElse { Timber.e("Unable to delete wallet: $it") diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt index b18c80f1f0..c0d5e0cd86 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/ui/WalletSettingsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.common.ui.account.AccountRow +import com.tangem.common.ui.userwallet.CardImage import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.TangemSwitch @@ -30,10 +31,13 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.BlockItem +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig 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.res.TangemTheme @@ -115,7 +119,7 @@ private fun Content(state: WalletSettingsUM, modifier: Modifier = Modifier) { modifier = itemModifier, model = item, ) - is WalletSettingsItemUM.WithText -> TextBlock( + is WalletSettingsItemUM.CardBlock -> CardBlock( modifier = itemModifier, model = item, ) @@ -180,30 +184,40 @@ private fun ItemsBlock(model: WalletSettingsItemUM.WithItems, modifier: Modifier } @Composable -private fun TextBlock(model: WalletSettingsItemUM.WithText, modifier: Modifier = Modifier) { +private fun CardBlock(model: WalletSettingsItemUM.CardBlock, modifier: Modifier = Modifier) { BlockCard( modifier = modifier.fillMaxWidth(), enabled = model.isEnabled, onClick = model.onClick, ) { - Column( + Row( modifier = Modifier.padding(all = TangemTheme.dimens.spacing12), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = model.title.resolveReference(), - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.subtitle2, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Text( - text = model.text.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.body1, - overflow = TextOverflow.Ellipsis, + CardImage(model.imageState) + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.title.resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = model.text.resolveReference(), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + SecondarySmallButton( + config = SmallButtonConfig( + enabled = model.isEnabled, + text = resourceReference(R.string.common_rename), + onClick = model.onClick, + ), ) } } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt index d26dc7fd98..6d2bf36754 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/ItemsBuilder.kt @@ -9,7 +9,6 @@ import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.walletsettings.analytics.Settings import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM @@ -30,12 +29,11 @@ internal class ItemsBuilder @Inject constructor( @Suppress("LongParameterList") fun buildItems( userWallet: UserWallet, - userWalletName: String, + cardItem: WalletSettingsItemUM.CardBlock, accountsUM: List, isLinkMoreCardsAvailable: Boolean, isReferralAvailable: Boolean, isManageTokensAvailable: Boolean, - isRenameWalletAvailable: Boolean, isNFTFeatureEnabled: Boolean, isNFTEnabled: Boolean, onCheckedNFTChange: (Boolean) -> Unit, @@ -45,7 +43,6 @@ internal class ItemsBuilder @Inject constructor( onCheckedNotificationsChanged: (Boolean) -> Unit, onNotificationsDescriptionClick: () -> Unit, forgetWallet: () -> Unit, - renameWallet: () -> Unit, onLinkMoreCardsClick: () -> Unit, onReferralClick: () -> Unit, onAccessCodeClick: () -> Unit, @@ -53,7 +50,7 @@ internal class ItemsBuilder @Inject constructor( onUpgradeWalletClick: () -> Unit, onDismissUpgradeWalletClick: () -> Unit, ): PersistentList = persistentListOf() - .add(buildNameItem(userWalletName, isRenameWalletAvailable, renameWallet)) + .add(cardItem) .addAll( buildUpgradeWalletItem( userWallet = userWallet, @@ -62,8 +59,8 @@ internal class ItemsBuilder @Inject constructor( onDismissUpgradeWalletClick = onDismissUpgradeWalletClick, ), ) - .addAll(accountsUM) .addAll(buildAccessCodeItem(userWallet, onAccessCodeClick)) + .addAll(accountsUM) .add( buildCardItem( userWallet = userWallet, @@ -121,15 +118,6 @@ internal class ItemsBuilder @Inject constructor( } } - private fun buildNameItem(walletName: String, isRenameWalletAvailable: Boolean, renameWallet: () -> Unit) = - WalletSettingsItemUM.WithText( - id = "wallet_name", - title = resourceReference(id = R.string.settings_wallet_name_title), - text = stringReference(walletName), - isEnabled = isRenameWalletAvailable, - onClick = renameWallet, - ) - private fun buildNFTItem(isNFTEnabled: Boolean, onCheckedNFTChange: (Boolean) -> Unit) = WalletSettingsItemUM.WithSwitch( id = "nft", diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt new file mode 100644 index 0000000000..322eaaac62 --- /dev/null +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/WalletCardItemDelegate.kt @@ -0,0 +1,55 @@ +package com.tangem.feature.walletsettings.utils + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase +import com.tangem.feature.walletsettings.entity.DialogConfig +import com.tangem.feature.walletsettings.entity.WalletSettingsItemUM +import com.tangem.feature.walletsettings.impl.R +import com.tangem.features.wallet.utils.UserWalletImageFetcher +import com.tangem.operations.attestation.ArtworkSize +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow + +internal class WalletCardItemDelegate @AssistedInject constructor( + private val getShouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, + private val walletImageFetcher: UserWalletImageFetcher, + @Assisted private val dialogNavigation: SlotNavigation, +) { + + fun cardItemFlow(wallet: UserWallet): Flow = combine( + flow = walletImageFetcher.walletImage(wallet, ArtworkSize.SMALL), + flow2 = flow { emit(getShouldSaveUserWalletsSyncUseCase()) }, + transform = { imageState, isRenameAvailable -> + val walletName = wallet.name + WalletSettingsItemUM.CardBlock( + id = "wallet_name", + title = resourceReference(id = R.string.user_wallet_list_rename_popup_placeholder), + text = stringReference(walletName), + isEnabled = isRenameAvailable, + onClick = { openRenameWalletDialog(wallet) }, + imageState = imageState, + ) + }, + ) + + private fun openRenameWalletDialog(userWallet: UserWallet) { + val config = DialogConfig.RenameWallet( + userWalletId = userWallet.walletId, + currentName = userWallet.name, + ) + dialogNavigation.activate(config) + } + + @AssistedFactory + interface Factory { + fun create(dialogNavigation: SlotNavigation): WalletCardItemDelegate + } +} \ No newline at end of file diff --git a/features/wallet/api/build.gradle.kts b/features/wallet/api/build.gradle.kts index 3b1f5eeea2..4c23e065f7 100644 --- a/features/wallet/api/build.gradle.kts +++ b/features/wallet/api/build.gradle.kts @@ -15,6 +15,9 @@ dependencies { /** Project - Domain */ implementation(projects.domain.models) + /** Tangem libraries */ + implementation(tangemDeps.card.core) + /** Core */ implementation(projects.core.ui) implementation(projects.core.decompose) diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt new file mode 100644 index 0000000000..42738f4e11 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/utils/UserWalletImageFetcher.kt @@ -0,0 +1,20 @@ +package com.tangem.features.wallet.utils + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.operations.attestation.ArtworkSize +import kotlinx.coroutines.flow.Flow + +interface UserWalletImageFetcher { + + fun walletImage(walletId: UserWalletId, size: ArtworkSize): Flow + fun walletImage(cardDTO: CardDTO, size: ArtworkSize): Flow + fun walletImage(wallet: UserWallet, size: ArtworkSize): Flow + + fun walletsImage( + wallets: Collection, + size: ArtworkSize, + ): Flow> +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt index 74a02c34b7..1a925f467e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletFeatureModule.kt @@ -4,8 +4,10 @@ import com.tangem.core.decompose.model.Model import com.tangem.feature.wallet.DefaultWalletEntryComponent import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModel import com.tangem.feature.wallet.child.wallet.model.WalletModel +import com.tangem.feature.wallet.utils.DefaultUserWalletImageFetcher import com.tangem.feature.wallet.utils.DefaultUserWalletsFetcher import com.tangem.features.wallet.WalletEntryComponent +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.features.wallet.utils.UserWalletsFetcher import dagger.Binds import dagger.Module @@ -13,6 +15,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey import dagger.multibindings.IntoMap +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -24,6 +27,10 @@ internal interface WalletFeatureModule { @Binds fun bindUserWalletsFetcher(impl: DefaultUserWalletsFetcher.Factory): UserWalletsFetcher.Factory + @Binds + @Singleton + fun bindUserWalletImageFetcher(impl: DefaultUserWalletImageFetcher): UserWalletImageFetcher + @Binds @IntoMap @ClassKey(WalletModel::class) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt new file mode 100644 index 0000000000..f6f11319dd --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletImageFetcher.kt @@ -0,0 +1,89 @@ +package com.tangem.feature.wallet.utils + +import arrow.core.Either +import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.artwork.ArtworkUM +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetCardImageUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.wallet.utils.UserWalletImageFetcher +import com.tangem.operations.attestation.ArtworkSize +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +class DefaultUserWalletImageFetcher @Inject constructor( + private val getCardImageUseCase: GetCardImageUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val artworkUMConverter: ArtworkUMConverter, +) : UserWalletImageFetcher { + + private val smallCache = MutableStateFlow(mapOf()) + private val largeCache = MutableStateFlow(mapOf()) + + override fun walletImage(wallet: UserWallet, size: ArtworkSize): Flow = when (wallet) { + is UserWallet.Cold -> walletImage(wallet.scanResponse.card, size) + is UserWallet.Hot -> flowOf(UserWalletItemUM.ImageState.MobileWallet) + } + + override fun walletsImage( + wallets: Collection, + size: ArtworkSize, + ): Flow> = wallets + .map { userWallet -> walletImage(userWallet, size).map { imageState -> userWallet.walletId to imageState } } + .merge() + .runningFold(mapOf()) { map, newState -> map.plus(newState) } + .filter { it.size >= wallets.size } // prevent spam, waiting full map + .distinctUntilChanged() + + override fun walletImage(walletId: UserWalletId, size: ArtworkSize): Flow = flow { + val imagesFlow = getUserWalletUseCase.invokeFlow(walletId) + // emit Loading and wait wallet + .onEach { if (it.isLeft()) emit(UserWalletItemUM.ImageState.Loading) } + .filterIsInstance>() + .map { it.value } + .distinctUntilChanged() + .flatMapLatest { wallet -> walletImage(wallet, size) } + emitAll(imagesFlow) + }.distinctUntilChanged() + + override fun walletImage(cardDTO: CardDTO, size: ArtworkSize): Flow = + internalGetCardImage( + cardInfo = cardDTO, + size = size, + ).distinctUntilChanged() + + private fun internalGetCardImage(cardInfo: CardDTO, size: ArtworkSize): Flow = flow { + emit(cacheOrLoading(cardInfo.cardId, size)) + + val artwork = getCardImageUseCase.invoke( + cardId = cardInfo.cardId, + cardPublicKey = cardInfo.cardPublicKey, + size = size, + manufacturerName = cardInfo.manufacturer.name, + firmwareVersion = cardInfo.firmwareVersion.toSdkFirmwareVersion(), + ) + .let { artworkUMConverter.convert(it) } + .also { save(cardInfo.cardId, size, it) } + emit(UserWalletItemUM.ImageState.Image(artwork)) + } + + private fun cacheOrLoading(cardId: String, size: ArtworkSize): UserWalletItemUM.ImageState { + val artwork = when (size) { + ArtworkSize.LARGE -> largeCache.value[cardId] + ArtworkSize.SMALL -> smallCache.value[cardId] + } + return artwork + ?.let { UserWalletItemUM.ImageState.Image(artwork) } + ?: UserWalletItemUM.ImageState.Loading + } + + private fun save(cardId: String, size: ArtworkSize, artwork: ArtworkUM) { + when (size) { + ArtworkSize.LARGE -> largeCache.update { it.plus(cardId to artwork) } + ArtworkSize.SMALL -> smallCache.update { it.plus(cardId to artwork) } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt index 7a3b9accba..6a310e5c31 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/utils/DefaultUserWalletsFetcher.kt @@ -14,16 +14,15 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.lce import com.tangem.domain.core.utils.toLce -import com.tangem.domain.models.ArtworkModel import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.wallet.impl.R +import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -45,11 +44,10 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( @Assisted private val messageSender: UiMessageSender, @Assisted("onlyMultiCurrency") private val onlyMultiCurrency: Boolean, @Assisted("authMode") private val authMode: Boolean, - private val getCardImageUseCase: GetCardImageUseCase, + private val userWalletImageFetcher: UserWalletImageFetcher, dispatchers: CoroutineDispatcherProvider, ) : UserWalletsFetcher { - private var loadedArtworks: HashMap = hashMapOf() private val walletsFlow = if (onlyMultiCurrency) getWalletsUseCase().map { it.filter { it.isMultiCurrency } } else getWalletsUseCase() @@ -67,7 +65,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( flow = getSelectedAppCurrencyUseCase().distinctUntilChanged(), flow2 = getBalanceHidingSettingsUseCase().distinctUntilChanged(), flow3 = getWalletTotalBalanceUseCase(wallets.map(UserWallet::walletId)).distinctUntilChanged(), - flow4 = loadArtworks(wallets), + flow4 = userWalletImageFetcher.walletsImage(wallets, ArtworkSize.SMALL), ) { maybeAppCurrency, balanceHidingSettings, maybeBalances, artworks -> createUiModels( wallets = wallets, @@ -90,29 +88,12 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( } .flowOn(dispatchers.default) - private fun loadArtworks(wallets: List): Flow> { - return flow { - emit(hashMapOf()) // emits right away so the transform doesn't wait for the images' loading to finish - wallets.filterIsInstance().forEach { wallet -> - val artwork = getCardImageUseCase( - cardId = wallet.cardId, - manufacturerName = wallet.scanResponse.card.manufacturer.name, - firmwareVersion = wallet.scanResponse.card.firmwareVersion.toSdkFirmwareVersion(), - cardPublicKey = wallet.scanResponse.card.cardPublicKey, - size = ArtworkSize.SMALL, - ) - loadedArtworks[wallet.walletId] = artwork - emit(loadedArtworks) - } - } - } - private fun createUiModels( wallets: List, maybeAppCurrency: Either, maybeBalances: Lce>, balanceHidingSettings: BalanceHidingSettings, - artworks: HashMap, + artworks: Map, ): Lce> = lce { val balances = withError( transform = { Error.UnableToGetBalances }, From 710002e992ce3e3026e83315d8bbaa5035193a8f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 25 Aug 2025 19:31:47 +0400 Subject: [PATCH 11/27] Updated on 2026-08-14 --- data/account/build.gradle.kts | 10 +- .../AccountConverterFactoryContainer.kt | 20 ++++ .../store/AccountsResponseStoreFactory.kt | 66 ++++++++++++++ .../store/AccountsResponseStoreFactoryTest.kt | 91 +++++++++++++++++++ .../createedit/AccountCreateEditModel.kt | 18 +++- 5 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 287f2b190f..90800c1228 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -25,18 +25,24 @@ dependencies { api(projects.domain.models) // endregion - // Project - Data + // region Project - Data implementation(projects.data.common) // endregion // region DI - implementation(deps.hilt.core) + implementation(deps.hilt.android) kapt(deps.hilt.kapt) // endregion + // region AndroidX libraries + implementation(deps.androidx.datastore) + // endregion + // region Other Dependencies implementation(deps.arrow.core) implementation(deps.kotlin.coroutines) + implementation(deps.moshi) + implementation(deps.moshi.kotlin) implementation(deps.timber) // endregion diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt new file mode 100644 index 0000000000..28cfd01b9e --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt @@ -0,0 +1,20 @@ +package com.tangem.data.account.converter + +import javax.inject.Inject + +/** + * Container for converter factories related to accounts. + * + * @property accountsListCF factory for creating an account list converter + * @property getWalletAccountsResponseCF factory for creating a wallet accounts response converter + * @property cryptoPortfolioCF factory for creating a crypto portfolio converter + * + * @constructor Creates an instance of the container with injected factories. + * +[REDACTED_AUTHOR] + */ +internal class AccountConverterFactoryContainer @Inject constructor( + val accountsListCF: AccountListConverter.Factory, + val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory, + val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, +) \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt new file mode 100644 index 0000000000..a8ffea4d37 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.store + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +typealias AccountsResponseStore = DataStore + +/** + * Factory class for creating and managing instances of [AccountsResponseStore]. + * This class is responsible for creating a [DataStore] for each unique [UserWalletId]. + * + * @property context application context used to access the file system + * @property moshi moshi instance for JSON serialization and deserialization + * @property dispatchers coroutine dispatcher provider + * +[REDACTED_AUTHOR] + */ +internal class AccountsResponseStoreFactory @Inject constructor( + @ApplicationContext private val context: Context, + @NetworkMoshi private val moshi: Moshi, + private val dispatchers: CoroutineDispatcherProvider, +) { + + @OptIn(ExperimentalStdlibApi::class) + private val adapter by lazy { moshi.adapter() } + + private val createdDataStores = ConcurrentHashMap() + + /** + * Creates or retrieves an [AccountsResponseStore] for the given [UserWalletId]. + * + * @param userWalletId the unique identifier of the user's wallet + */ + fun create(userWalletId: UserWalletId): AccountsResponseStore { + return createdDataStores.computeIfAbsent(userWalletId) { + DataStoreFactory.create( + serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter), + produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + } + } + + @VisibleForTesting + fun getAllStores(): Map = createdDataStores.toMap() + + @VisibleForTesting + fun clearStores() { + createdDataStores.clear() + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt new file mode 100644 index 0000000000..6c39f7181c --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/AccountsResponseStoreFactoryTest.kt @@ -0,0 +1,91 @@ +package com.tangem.data.account.store + +import android.content.Context +import com.google.common.truth.Truth +import com.squareup.moshi.Moshi +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.mockk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AccountsResponseStoreFactoryTest { + + private val context: Context = mockk() + private val moshi: Moshi = Moshi.Builder().build() + private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory( + context = context, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun setup() { + clearMocks(context) + factory.clearStores() + } + + @Test + fun `creates new data store for unique userWalletId`() { + // Arrange + val userWalletId = UserWalletId("011") + val createdStore = factory.create(userWalletId = userWalletId) + + // Actual + val actual = factory.getAllStores() + + // Assert + Truth.assertThat(actual).containsExactly(userWalletId, createdStore) + } + + @Test + fun `reuses existing data store for same userWalletId`() { + val userWalletId = UserWalletId("011") + + // Arrange (first creation) + val firstStore = factory.create(userWalletId = userWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(userWalletId, firstStore) + + // Arrange (second creation) + val secondStore = factory.create(userWalletId = userWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + Truth.assertThat(actual2).containsExactly(userWalletId, secondStore) + Truth.assertThat(firstStore).isSameInstanceAs(secondStore) + } + + @Test + fun `creates separate data stores for different userWalletIds`() { + // Arrange (first creation) + val firstWalletId = UserWalletId("011") + val firstStore = factory.create(userWalletId = firstWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore) + + // Arrange (second creation) + val secondWalletId = UserWalletId("011") + val secondStore = factory.create(userWalletId = secondWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore) + Truth.assertThat(actual2).containsExactlyEntriesIn(expected) + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt index 5c61ebc3f4..03934e051e 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/AccountCreateEditModel.kt @@ -1,8 +1,8 @@ package com.tangem.features.account.createedit +import com.tangem.common.ui.account.toDomain import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.ExceptionAnalyticsEvent -import com.tangem.common.ui.account.toDomain import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -170,10 +170,14 @@ internal class AccountCreateEditModel @Inject constructor( it.updateDerivationIndex(derivationIndex = derivationIndex.value) } } - .onLeft { + .onLeft { cause -> handleError( error = AccountFeatureError.CreateAccount.UnableToGetDerivationIndex, - params = mapOf("userWalletId" to userWalletId.stringValue), + message = cause.toString(), + params = mapOf( + "userWalletId" to userWalletId.stringValue, + "cause" to cause.toString(), + ), ) return@launch @@ -181,8 +185,12 @@ internal class AccountCreateEditModel @Inject constructor( } } - private fun handleError(error: AccountFeatureError, params: Map = mapOf()) { - val exception = IllegalStateException(error.toString()) + private fun handleError( + error: AccountFeatureError, + message: String? = null, + params: Map = mapOf(), + ) { + val exception = IllegalStateException("$error. Cause: $message") Timber.e(exception) From 8ee35d12f8b97ac2abd473c88cb22b1e4eb02428 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 Aug 2025 09:26:25 +0300 Subject: [PATCH 12/27] Updated on 2026-08-14 --- .../common/ui/userwallet/UserWalletItem.kt | 82 +++++++++++++------ .../converter/UserWalletItemUMConverter.kt | 3 +- .../ui/userwallet/state/UserWalletItemUM.kt | 2 + 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt index 31c66358b4..ad44dcf7dd 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt @@ -139,33 +139,59 @@ private fun NameAndInfo( ) } } - Text( - text = " $DOT ", - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - maxLines = 1, - ) - AnimatedContent( - targetState = balance, - label = "Balance content", - ) { balance -> - val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance) + BalanceContent(balance) + } + } +} - if (balanceValue == null) { - TextShimmer( - style = TangemTheme.typography.caption2, - text = "aaaaa", - ) - } else { +@Composable +private fun BalanceContent(balance: UserWalletItemUM.Balance, modifier: Modifier = Modifier) { + AnimatedContent( + modifier = modifier, + targetState = balance, + label = "Balance content", + ) { balance -> + when (balance) { + UserWalletItemUM.Balance.Locked -> { + Icon( + modifier = Modifier + .padding(start = 4.dp, bottom = 2.dp, top = 2.dp) + .size(12.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_lock_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } + UserWalletItemUM.Balance.NotShowing -> { + /** No balance and no dot */ + } + else -> { + Row { Text( - text = balanceValue, - style = TangemTheme.typography.caption2.applyBladeBrush( - isEnabled = isFlickering, - textColor = TangemTheme.colors.text.tertiary, - ), + text = " $DOT ", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, maxLines = 1, ) + + val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance) + + if (balanceValue == null) { + TextShimmer( + style = TangemTheme.typography.caption2, + text = "aaaaa", + ) + } else { + Text( + text = balanceValue, + style = TangemTheme.typography.caption2.applyBladeBrush( + isEnabled = isFlickering, + textColor = TangemTheme.colors.text.tertiary, + ), + maxLines = 1, + ) + } } } } @@ -246,9 +272,8 @@ fun getBalanceValueAndFlickerState(balance: UserWalletItemUM.Balance): Pair DASH_SIGN to false is UserWalletItemUM.Balance.Hidden -> THREE_STARS to false - is UserWalletItemUM.Balance.Loading -> null to false - is UserWalletItemUM.Balance.Locked -> stringResourceSafe(R.string.common_locked) to false is UserWalletItemUM.Balance.Loaded -> balance.value to balance.isFlickering + else -> null to false } } @@ -392,6 +417,15 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider UserWalletItemUM.Balance.Hidden userWallet.isLocked -> UserWalletItemUM.Balance.Locked + authMode -> UserWalletItemUM.Balance.NotShowing + isBalanceHidden -> UserWalletItemUM.Balance.Hidden balance == null -> UserWalletItemUM.Balance.Loading else -> { when (balance) { diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt index c0abb2b485..b31b4d5ddb 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt @@ -28,6 +28,8 @@ data class UserWalletItemUM( data object Hidden : Balance() + data object NotShowing : Balance() + data object Locked : Balance() data object Failed : Balance() From df5af9afab63c68a435bc739e5d5964db317a57c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 Aug 2025 12:15:10 +0000 Subject: [PATCH 13/27] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 57e50cf4ac..c57f7b4239 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.27.0-1148" +tangemBlockchainSdk = "develop-1205" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.27.0-519" +tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-454" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 20dad876f08cf65244ef6a929859517b7bcc6235 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 Aug 2025 14:49:43 +0400 Subject: [PATCH 14/27] Updated on 2026-08-14 --- .../local/datastore/RuntimeStateStore.kt | 13 ++ .../account/store/ArchivedAccountsStore.kt | 68 +++++++++ .../store/ArchivedAccountsStoreFactory.kt | 37 +++++ .../store/ArchivedAccountsStoreFactoryTest.kt | 79 ++++++++++ .../store/ArchivedAccountsStoreTest.kt | 139 ++++++++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt index b28130b286..05f3c620af 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeStateStore.kt @@ -14,11 +14,18 @@ interface RuntimeStateStore { /** Get flow of elements [T] */ fun get(): StateFlow + /** Get element [T] synchronously or null */ + suspend fun getSyncOrNull(): T? + /** Store [value] */ suspend fun store(value: T) + /** Update current value by [function] */ suspend fun update(function: (T) -> T) + /** Clear stored value */ + fun clear() + companion object { /** @@ -32,6 +39,8 @@ interface RuntimeStateStore { override fun get(): StateFlow = flow + override suspend fun getSyncOrNull(): T? = flow.value + override suspend fun store(value: T) { flow.value = value } @@ -39,6 +48,10 @@ interface RuntimeStateStore { override suspend fun update(function: (T) -> T) { flow.update(function) } + + override fun clear() { + flow.value = defaultValue + } } } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt new file mode 100644 index 0000000000..cbbe1ca0b9 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStore.kt @@ -0,0 +1,68 @@ +package com.tangem.data.account.store + +import androidx.annotation.VisibleForTesting +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.account.models.ArchivedAccount +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.seconds + +/** + * Store for managing archived accounts with support for data expiration + * + * @property runtimeStore the underlying runtime shared store for storing the list of archived accounts + * +[REDACTED_AUTHOR] + */ +internal class ArchivedAccountsStore( + private val runtimeStore: RuntimeStateStore?>, +) { + + private var timestamp: Long? = null + + /** Retrieves a flow of archived accounts, filtering out null values */ + fun get(): Flow> { + return runtimeStore.get() + .map { + if (isDataExpired()) null else it + } + .filterNotNull() + } + + /** Retrieves the list of archived accounts synchronously, or null if the data is expired */ + suspend fun getSyncOrNull(): List? { + if (isDataExpired()) return null + + return runtimeStore.getSyncOrNull() + } + + /** Stores the provided list of archived accounts [value] */ + suspend fun store(value: List) { + timestamp = System.currentTimeMillis() + + runtimeStore.store(value) + } + + private fun isDataExpired(): Boolean { + val currentTime = System.currentTimeMillis() + val storedTime = timestamp ?: return true + + return currentTime - storedTime >= EXPIRATION_DURATION_MS + } + + @VisibleForTesting + fun setTimestamp(time: Long) { + timestamp = time + } + + @VisibleForTesting + fun clear() { + timestamp = null + runtimeStore.clear() + } + + private companion object Companion { + val EXPIRATION_DURATION_MS = 120.seconds.inWholeMicroseconds + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt new file mode 100644 index 0000000000..e89a9fa130 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt @@ -0,0 +1,37 @@ +package com.tangem.data.account.store + +import androidx.annotation.VisibleForTesting +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.models.wallet.UserWalletId +import java.util.concurrent.ConcurrentHashMap + +/** + * Factory for creating and managing instances of [ArchivedAccountsStore]. + + * and reused for each unique [UserWalletId]. + * +[REDACTED_AUTHOR] + */ +internal class ArchivedAccountsStoreFactory { + + private val createdRuntimeStores = ConcurrentHashMap() + + /** + * Creates or retrieves an existing instance of [ArchivedAccountsStore] for the given [userWalletId]. + * + * @param userWalletId the unique identifier for the user wallet + */ + fun create(userWalletId: UserWalletId): ArchivedAccountsStore { + return createdRuntimeStores.computeIfAbsent(userWalletId) { + ArchivedAccountsStore(runtimeStore = RuntimeStateStore(defaultValue = null)) + } + } + + @VisibleForTesting + fun getAllStores(): Map = createdRuntimeStores.toMap() + + @VisibleForTesting + fun clearStores() { + createdRuntimeStores.clear() + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt new file mode 100644 index 0000000000..977c3c4169 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt @@ -0,0 +1,79 @@ +package com.tangem.data.account.store + +import com.google.common.truth.Truth +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchivedAccountsStoreFactoryTest { + + private val factory = ArchivedAccountsStoreFactory() + + @AfterEach + fun tearDownEach() { + factory.clearStores() + } + + @Test + fun `creates new store for unique userWalletId`() { + // Arrange + val userWalletId = UserWalletId("001") + val createdStore = factory.create(userWalletId) + + // Act + val actual = factory.getAllStores() + + // Assert + Truth.assertThat(actual).containsExactly(userWalletId, createdStore) + } + + @Test + fun `reuses existing data store for same userWalletId`() { + val userWalletId = UserWalletId("011") + + // Arrange (first creation) + val firstStore = factory.create(userWalletId = userWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(userWalletId, firstStore) + + // Arrange (second creation) + val secondStore = factory.create(userWalletId = userWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + Truth.assertThat(actual2).containsExactly(userWalletId, secondStore) + Truth.assertThat(firstStore).isSameInstanceAs(secondStore) + } + + @Test + fun `creates separate data stores for different userWalletIds`() { + // Arrange (first creation) + val firstWalletId = UserWalletId("011") + val firstStore = factory.create(userWalletId = firstWalletId) + + // Act (first creation) + val actual1 = factory.getAllStores() + + // Assert (first creation) + Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore) + + // Arrange (second creation) + val secondWalletId = UserWalletId("011") + val secondStore = factory.create(userWalletId = secondWalletId) + + // Act (second creation) + val actual2 = factory.getAllStores() + + // Assert (second creation) + val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore) + Truth.assertThat(actual2).containsExactlyEntriesIn(expected) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt new file mode 100644 index 0000000000..70e6ddb2db --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreTest.kt @@ -0,0 +1,139 @@ +package com.tangem.data.account.store + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.account.models.ArchivedAccount +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.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import kotlin.time.Duration.Companion.seconds + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ArchivedAccountsStoreTest { + + private val runtimeStore: RuntimeStateStore?> = RuntimeStateStore(defaultValue = null) + private val archivedAccountsStore: ArchivedAccountsStore = ArchivedAccountsStore(runtimeStore = runtimeStore) + + @AfterEach + fun tearDown() { + archivedAccountsStore.clear() + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Get { + + @Test + fun `get returns empty flow`() = runTest { + // Act + val actual = getEmittedValues(archivedAccountsStore.get()) + + // Assert + Truth.assertThat(actual).isEmpty() // nothing emmited + } + + @Test + fun `get returns flow with not expired data`() = runTest { + // Arrange + val archivedAccount = createArchivedAccount() + archivedAccountsStore.store(value = listOf(archivedAccount)) + + // Act + val actual = getEmittedValues(archivedAccountsStore.get()) + + // Assert + val expected = listOf(archivedAccount) + Truth.assertThat(actual).containsExactly(expected) + } + + @Test + fun `get returns flow with expired data`() = runTest { + // Arrange + archivedAccountsStore.store(value = listOf(createArchivedAccount())) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds) + + // Act + val actual = getEmittedValues(archivedAccountsStore.get()) + + // Assert + Truth.assertThat(actual).isEmpty() // nothing emmited + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetSyncOrnNull { + + @Test + fun `getSyncOrNull returns null`() = runTest { + // Act + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + Truth.assertThat(actual).isNull() + } + + @Test + fun `get returns flow with not expired data`() = runTest { + // Arrange + val archivedAccount = createArchivedAccount() + archivedAccountsStore.store(value = listOf(archivedAccount)) + + // Act + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + val expected = archivedAccount + Truth.assertThat(actual).containsExactly(expected) + } + + @Test + fun `get returns flow with expired data`() = runTest { + // Arrange + archivedAccountsStore.store(value = listOf(createArchivedAccount())) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds) + + // Act + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + Truth.assertThat(actual).isNull() + } + } + + @Test + fun store() = runTest { + // Arrange + val archivedAccount = createArchivedAccount() + + // Act + archivedAccountsStore.store(value = listOf(archivedAccount)) + val actual = runtimeStore.getSyncOrNull() + + // Assert + val expected = archivedAccount + Truth.assertThat(actual).containsExactly(expected) + } + + private fun createArchivedAccount(): ArchivedAccount { + return ArchivedAccount( + accountId = AccountId.forCryptoPortfolio( + userWalletId = UserWalletId("011"), + derivationIndex = DerivationIndex.Main, + ), + name = AccountName("Archived Account").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = DerivationIndex.Main, + tokensCount = 2, + networksCount = 1, + ) + } +} \ No newline at end of file From b7eda75279589ab3c0532ef37e83d66fc4c81335 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 Aug 2025 11:52:14 +0300 Subject: [PATCH 15/27] Updated on 2026-08-14 --- .../tangem/features/biometry/impl/model/AskBiometryModel.kt | 2 +- .../tangem/feature/wallet/child/wallet/model/WalletModel.kt | 3 +++ .../child/wallet/model/WalletsUpdateActionResolver.kt | 6 ++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt index 614b462e24..195b2c9069 100644 --- a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt +++ b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/model/AskBiometryModel.kt @@ -111,7 +111,6 @@ internal class AskBiometryModel @Inject constructor( private suspend fun handleSuccessAllowing(userWallet: UserWallet) { walletsRepository.saveShouldSaveUserWallets(item = true) - settingsRepository.setShouldSaveAccessCodes(value = true) if (hotWalletFeatureToggles.isHotWalletEnabled) { walletsRepository.setUseBiometricAuthentication(value = true) @@ -120,6 +119,7 @@ internal class AskBiometryModel @Inject constructor( isBiometricsRequestPolicy = walletsRepository.requireAccessCode().not(), ) } else { + settingsRepository.setShouldSaveAccessCodes(value = true) if (userWallet is UserWallet.Cold) { cardSdkConfigRepository.setAccessCodeRequestPolicy( isBiometricsRequestPolicy = userWallet.hasAccessCode, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 14324845f1..e7a00027c6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -363,6 +363,9 @@ internal class WalletModel @Inject constructor( is WalletsUpdateActionResolver.Action.RenameWallets -> { stateHolder.update(transformer = RenameWalletsTransformer(renamedWallets = action.renamedWallets)) } + WalletsUpdateActionResolver.Action.EmptyWallets -> { + Timber.w("Wallets list is empty!") + } is WalletsUpdateActionResolver.Action.Unknown -> { Timber.w("Unable to perform action: $action") } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt index 48ee23027a..3e9a4d114b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt @@ -25,6 +25,10 @@ internal class WalletsUpdateActionResolver @Inject constructor( ) { fun resolve(wallets: List, currentState: WalletScreenState): Action { + if (wallets.isEmpty()) { + return Action.EmptyWallets + } + val selectedWallet = getSelectedWalletSyncUseCase().getOrElse { /* Selected user wallet can be null after reset if remaining user wallets is locked */ return Action.Unknown @@ -319,6 +323,8 @@ internal class WalletsUpdateActionResolver @Inject constructor( } } + data object EmptyWallets : Action() + data object Unknown : Action() } } \ No newline at end of file From c6b1638ea6412fbe4ddc9fbc4bf636839280ec8e Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 28 Aug 2025 14:33:14 +0500 Subject: [PATCH 16/27] Updated on 2026-08-14 --- .../start/ui/AddExistingWalletStartContent.kt | 6 +++- .../hotwallet/common/ui/OptionBlock.kt | 31 +++---------------- .../walletbackup/entity/WalletBackupUM.kt | 1 + .../walletbackup/model/WalletBackupModel.kt | 5 +++ .../walletbackup/ui/WalletBackupContent.kt | 31 +++++++++++++++++-- .../model/WalletSettingsModel.kt | 2 +- 6 files changed, 45 insertions(+), 31 deletions(-) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt index 6bf693e36b..184f5f924f 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/start/ui/AddExistingWalletStartContent.kt @@ -62,7 +62,7 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi ) OptionBlock( modifier = Modifier - .padding(top = 24.dp), + .padding(top = 32.dp), backgroundColor = TangemTheme.colors.background.secondary, title = stringResourceSafe(R.string.wallet_import_seed_title), description = stringResourceSafe(R.string.wallet_import_seed_description), @@ -71,6 +71,8 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi enabled = true, ) OptionBlock( + modifier = Modifier + .padding(top = 8.dp), backgroundColor = TangemTheme.colors.background.secondary, title = stringResourceSafe(R.string.wallet_import_scan_title), description = stringResourceSafe(R.string.wallet_import_scan_description), @@ -99,6 +101,8 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi enabled = true, ) OptionBlock( + modifier = Modifier + .padding(top = 8.dp), backgroundColor = TangemTheme.colors.background.secondary, title = stringResourceSafe(R.string.wallet_import_google_drive_title), description = stringResourceSafe(R.string.wallet_import_google_drive_description), diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt index c41dba03e8..f76ab84370 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/ui/OptionBlock.kt @@ -1,6 +1,5 @@ package com.tangem.features.hotwallet.common.ui -import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,8 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -30,33 +29,11 @@ internal fun OptionBlock( backgroundColor: Color, modifier: Modifier = Modifier, ) { - val backgroundColor by animateColorAsState( - targetValue = if (enabled) { - backgroundColor - } else { - backgroundColor.copy(alpha = DISABLED_COLORS_ALPHA) - }, - ) - val titleColor by animateColorAsState( - targetValue = if (enabled) { - TangemTheme.colors.text.primary1 - } else { - TangemTheme.colors.text.primary1.copy(alpha = DISABLED_COLORS_ALPHA) - }, - ) - val descriptionColor by animateColorAsState( - targetValue = if (enabled) { - TangemTheme.colors.text.tertiary - } else { - TangemTheme.colors.text.tertiary.copy(alpha = DISABLED_COLORS_ALPHA) - }, - ) - Column( modifier = modifier .fillMaxWidth() - .padding(top = 8.dp) .clip(TangemTheme.shapes.roundedCornersXMedium) + .alpha(if (enabled) 1f else DISABLED_COLORS_ALPHA) .background( color = backgroundColor, shape = TangemTheme.shapes.roundedCornersXMedium, @@ -73,7 +50,7 @@ internal fun OptionBlock( .padding(end = 4.dp), text = title, style = TangemTheme.typography.subtitle1, - color = titleColor, + color = TangemTheme.colors.text.primary1, ) badge?.invoke() } @@ -82,7 +59,7 @@ internal fun OptionBlock( .padding(top = 4.dp), text = description, style = TangemTheme.typography.body2, - color = descriptionColor, + color = TangemTheme.colors.text.tertiary, ) } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt index 3bc262d1a9..457edf278a 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt @@ -9,6 +9,7 @@ internal data class WalletBackupUM( val googleDriveStatus: BackupStatus, val onRecoveryPhraseClick: () -> Unit, val onGoogleDriveClick: () -> Unit, + val onHardwareWalletClick: () -> Unit, val backedUp: Boolean, ) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt index 9996c7fb7a..20c094ed28 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt @@ -52,6 +52,7 @@ internal class WalletBackupModel @Inject constructor( googleDriveStatus = BackupStatus.ComingSoon, onRecoveryPhraseClick = ::onRecoveryPhraseClick, onGoogleDriveClick = { }, + onHardwareWalletClick = ::onHardwareWalletClick, backedUp = false, ), ) @@ -122,4 +123,8 @@ internal class WalletBackupModel @Inject constructor( uiMessageSender.send(makeBackupAtFirstAlertBS) } } + + private fun onHardwareWalletClick() { + router.push(AppRoute.UpgradeWallet(params.userWalletId)) + } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt index 671c8dd89a..aa68dc3e7d 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -25,6 +26,7 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.label.Label import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.components.rows.NetworkTitle import com.tangem.core.ui.extensions.resourceReference @OptIn(ExperimentalMaterial3Api::class) @@ -47,7 +49,8 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod .padding(horizontal = 16.dp), ) { OptionBlock( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(top = 8.dp), title = stringResourceSafe(R.string.hw_backup_seed_title), description = stringResourceSafe(R.string.hw_backup_seed_description), badge = { @@ -59,7 +62,8 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod ) OptionBlock( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(top = 8.dp), title = stringResourceSafe(R.string.hw_backup_google_drive_title), description = stringResourceSafe(R.string.hw_backup_google_drive_description), badge = { @@ -69,6 +73,26 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod enabled = state.googleDriveStatus != BackupStatus.ComingSoon, backgroundColor = TangemTheme.colors.background.primary, ) + + NetworkTitle( + title = { + Text( + modifier = Modifier, + text = stringResourceSafe(R.string.express_provider_recommended), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + ) + OptionBlock( + modifier = Modifier.fillMaxWidth(), + title = stringResourceSafe(R.string.hw_backup_hardware_title), + description = stringResourceSafe(R.string.hw_backup_hardware_description), + badge = null, + onClick = state.onHardwareWalletClick, + enabled = true, + backgroundColor = TangemTheme.colors.background.primary, + ) } } } @@ -97,6 +121,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider Date: Thu, 28 Aug 2025 19:15:47 +0000 Subject: [PATCH 17/27] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 57e50cf4ac..c57f7b4239 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.27.0-1148" +tangemBlockchainSdk = "develop-1205" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.27.0-519" +tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-454" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 9e80fccbbda0bc411dcb2b1b8ea2b082921eff86 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 09:33:17 +0300 Subject: [PATCH 18/27] Updated on 2026-08-14 --- app/src/main/res/drawable/inset_splash.xml | 10 +++++----- app/src/main/res/values/colors.xml | 2 -- app/src/main/res/values/styles.xml | 2 +- .../core/ui/components/fields/PinTextField.kt | 18 ++++++++++-------- core/ui/src/main/res/drawable/ic_tangem_24.xml | 6 +++--- .../ui}/src/main/res/drawable/splash_logo.xml | 10 +++++----- core/ui/src/main/res/values-night/colors.xml | 4 ++++ core/ui/src/main/res/values/colors.xml | 4 ++++ .../features/welcome/impl/ui/WelcomePlain.kt | 4 ++-- 9 files changed, 34 insertions(+), 26 deletions(-) rename {app => core/ui}/src/main/res/drawable/splash_logo.xml (80%) create mode 100644 core/ui/src/main/res/values-night/colors.xml create mode 100644 core/ui/src/main/res/values/colors.xml diff --git a/app/src/main/res/drawable/inset_splash.xml b/app/src/main/res/drawable/inset_splash.xml index 1d66dc1301..2886ecbd00 100644 --- a/app/src/main/res/drawable/inset_splash.xml +++ b/app/src/main/res/drawable/inset_splash.xml @@ -1,10 +1,10 @@ + android:insetLeft="55dp" + android:insetRight="55dp" + android:insetTop="55dp" + android:insetBottom="55dp"/> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index db52974e28..bf4703e8e2 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -26,6 +26,4 @@ #1E1E1E #656565 - #000000 - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2b866d0894..d91dbb1dbe 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -16,7 +16,7 @@ diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt index 214da97109..a3226e0b46 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/PinTextField.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.utils.StringsSigns.PASSWORD_VISUAL_CHAR @@ -159,14 +158,17 @@ private fun CellDecoration( ) } } else { - Text( + Box( modifier = Modifier.sizeIn(minWidth = minWidth, minHeight = minHeight), - text = text, - style = TangemTheme.typography.h3, - color = color, - textAlign = TextAlign.Center, - lineHeight = 48.sp, - ) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = text, + style = TangemTheme.typography.h3, + color = color, + textAlign = TextAlign.Center, + ) + } } } } diff --git a/core/ui/src/main/res/drawable/ic_tangem_24.xml b/core/ui/src/main/res/drawable/ic_tangem_24.xml index b4ccb9cc5a..cd606d5acf 100644 --- a/core/ui/src/main/res/drawable/ic_tangem_24.xml +++ b/core/ui/src/main/res/drawable/ic_tangem_24.xml @@ -6,11 +6,11 @@ android:viewportHeight="24"> + android:fillColor="@color/icon_primary1"/> + android:fillColor="@color/icon_primary1"/> + android:fillColor="@color/icon_primary1"/> diff --git a/app/src/main/res/drawable/splash_logo.xml b/core/ui/src/main/res/drawable/splash_logo.xml similarity index 80% rename from app/src/main/res/drawable/splash_logo.xml rename to core/ui/src/main/res/drawable/splash_logo.xml index ef37dfda51..f95768bf95 100644 --- a/app/src/main/res/drawable/splash_logo.xml +++ b/core/ui/src/main/res/drawable/splash_logo.xml @@ -1,15 +1,15 @@ diff --git a/core/ui/src/main/res/values-night/colors.xml b/core/ui/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..6c7b1cabaa --- /dev/null +++ b/core/ui/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFFFF + \ No newline at end of file diff --git a/core/ui/src/main/res/values/colors.xml b/core/ui/src/main/res/values/colors.xml new file mode 100644 index 0000000000..8dd91f8060 --- /dev/null +++ b/core/ui/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FF1E1E1E + \ No newline at end of file diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt index 5b80e192ee..9da4e13724 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/ui/WelcomePlain.kt @@ -22,8 +22,8 @@ internal fun WelcomePlain(modifier: Modifier = Modifier) { contentAlignment = Alignment.Center, ) { Icon( - modifier = Modifier.size(84.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24), + modifier = Modifier.size(82.dp + 11.dp), + imageVector = ImageVector.vectorResource(R.drawable.splash_logo), tint = TangemTheme.colors.icon.primary1, contentDescription = null, ) From 665c0ff7631dd3ffbefec460fd067d008e50394b Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 14:35:24 +0500 Subject: [PATCH 19/27] Updated on 2026-08-14 --- .../tangem/tap/di/hot/TangemHotSdkModule.kt | 6 ++++++ .../hot/DefaultHotMapDerivationsRepository.kt | 1 + ...Accessor.kt => DefaultHotWalletAccessor.kt} | 18 ++++++++++-------- .../data/wallets/hot/TangemHotWalletSigner.kt | 1 + .../domain/wallets/hot/HotWalletAccessor.kt | 10 ++++++++++ 5 files changed, 28 insertions(+), 8 deletions(-) rename data/wallets/src/main/java/com/tangem/data/wallets/hot/{HotWalletAccessor.kt => DefaultHotWalletAccessor.kt} (92%) create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt diff --git a/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt b/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt index c21b55928b..53110dc9c9 100644 --- a/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt +++ b/app/src/main/java/com/tangem/tap/di/hot/TangemHotSdkModule.kt @@ -1,5 +1,7 @@ package com.tangem.tap.di.hot +import com.tangem.data.wallets.hot.DefaultHotWalletAccessor +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.TangemHotSdk import com.tangem.tap.features.hot.TangemHotSDKProxy import dagger.Binds @@ -15,4 +17,8 @@ internal interface TangemHotSdkModule { @Binds @Singleton fun bindTangemHotSdk(proxy: TangemHotSDKProxy): TangemHotSdk + + @Binds + @Singleton + fun bindHotWalletAccessor(default: DefaultHotWalletAccessor): HotWalletAccessor } \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt index 3eb9431fc3..bdc3f7fdf2 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.usecase.BackendId import com.tangem.hot.sdk.model.DeriveWalletRequest import com.tangem.operations.derivation.ExtendedPublicKeysMap diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt similarity index 92% rename from data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt rename to data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt index b5ca40b2de..796bc8d3ba 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessor.kt @@ -3,7 +3,7 @@ package com.tangem.data.wallets.hot import com.tangem.common.core.TangemSdkError import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.copy +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.hot.sdk.TangemHotSdk @@ -11,22 +11,24 @@ import com.tangem.hot.sdk.exception.WrongPasswordException import com.tangem.hot.sdk.model.* import javax.inject.Inject -class HotWalletAccessor @Inject constructor( +class DefaultHotWalletAccessor @Inject constructor( private val tangemHotSdk: TangemHotSdk, private val userWalletsListRepository: UserWalletsListRepository, private val hotWalletPasswordRequester: HotWalletPasswordRequester, private val walletsRepository: WalletsRepository, -) { +) : HotWalletAccessor { - suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List = + override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List = hotSdkRequest(hotWalletId) { unlock -> tangemHotSdk.signHashes(unlockHotWallet = unlock, dataToSign = dataToSign) } - suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse = - hotSdkRequest(hotWalletId) { unlock -> - tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request) - } + override suspend fun derivePublicKeys( + hotWalletId: HotWalletId, + request: DeriveWalletRequest, + ): DerivedPublicKeyResponse = hotSdkRequest(hotWalletId) { unlock -> + tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request) + } private suspend fun hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T { val isAccessCodeRequired = walletsRepository.requireAccessCode() diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt index b5bfc9da12..ccef106815 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/TangemHotWalletSigner.kt @@ -6,6 +6,7 @@ import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError import com.tangem.common.map import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.model.DataToSign import com.tangem.operations.sign.SignData import dagger.assisted.Assisted diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt new file mode 100644 index 0000000000..18d9d792fe --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessor.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.wallets.hot + +import com.tangem.hot.sdk.model.* + +interface HotWalletAccessor { + + suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List): List + + suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse +} \ No newline at end of file From d48ba8a8bdd704e04858db80712de9cf5aad7e07 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 14:52:25 +0400 Subject: [PATCH 20/27] Updated on 2026-08-14 --- .../java/com/tangem/tap/TangemApplication.kt | 7 +- core/config-toggles/build.gradle.kts | 87 +++- .../di/FeatureTogglesManagerModule.kt | 20 +- .../feature/FeatureTogglesManager.kt | 3 - .../feature/impl/DevFeatureTogglesManager.kt | 80 +-- .../feature/impl/ProdFeatureTogglesManager.kt | 31 +- .../storage/FeatureTogglesLocalStorage.kt | 31 ++ .../core/configtoggle/utils/CollectionExt.kt | 10 + .../manager/DevFeatureTogglesManagerTest.kt | 489 ++++++++++++++++++ .../manager/DevTogglesManagerTest.kt | 237 --------- .../manager/ProdFeatureTogglesManagerTest.kt | 155 ++++++ .../manager/ProdTogglesManagerTest.kt | 136 ----- .../local/preferences/PreferencesKeys.kt | 2 - 13 files changed, 829 insertions(+), 459 deletions(-) create mode 100644 core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt create mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt delete mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt create mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt delete mode 100644 core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 09c556cae1..a413d65b34 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -77,6 +77,7 @@ import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints import kotlinx.coroutines.* import org.rekotlin.Store +import timber.log.Timber import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository lateinit var store: Store @@ -289,13 +290,17 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat tangemAppLoggerInitializer.initialize() + Timber.i("APP STARTED") + if (BuildConfig.TESTER_MENU_ENABLED) { + Timber.i(featureTogglesManager.toString()) + } + foregroundActivityObserver = ForegroundActivityObserver() registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) // We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them. runBlocking { awaitAll( - async { featureTogglesManager.init() }, async { excludedBlockchainsManager.init() }, ) initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) diff --git a/core/config-toggles/build.gradle.kts b/core/config-toggles/build.gradle.kts index 96fb87b639..368af3bc77 100644 --- a/core/config-toggles/build.gradle.kts +++ b/core/config-toggles/build.gradle.kts @@ -1,4 +1,6 @@ -import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants +import com.squareup.kotlinpoet.* +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import org.json.JSONArray plugins { alias(deps.plugins.android.library) @@ -9,8 +11,24 @@ plugins { id("configuration") } +buildscript { + dependencies { + classpath("com.squareup:kotlinpoet:1.15.0") + classpath("org.json:json:20231013") + } +} + android { namespace = "com.tangem.core.configtoggle" + sourceSets["main"].java.srcDir("build/generated/source/toggles") +} + +tasks.named("preBuild") { + dependsOn(generateFeatureToggles /*generateExcludedBlockchainToggles*/) +} + +tasks.withType().configureEach { + useJUnitPlatform() } dependencies { @@ -32,7 +50,72 @@ dependencies { implementation(projects.core.utils) testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) + testImplementation(projects.common.test) +} + +val generateFeatureToggles by tasks.registering { + generateToggles( + inputFilePath = "src/main/assets/configs/feature_toggles_config.json", + generatedFileName = "FeatureToggles", + ) +} + +val generateExcludedBlockchainToggles by tasks.registering { + generateToggles( + inputFilePath = "src/main/assets/configs/excluded_blockchains_config.json", + generatedFileName = "ExcludedBlockchainToggles", + ) +} + +fun Task.generateToggles(inputFilePath: String, generatedFileName: String) { + val inputFile = file(inputFilePath) + val outputDir = file("build/generated/source/toggles") + + inputs.file(inputFile) + outputs.dir(outputDir) + + doLast { + val jsonText = inputFile.readText() + val jsonArray = JSONArray(jsonText) + + val entries = (0 until jsonArray.length()).map { i -> + val obj = jsonArray.getJSONObject(i) + val name = obj.getString("name") + val version = obj.getString("version") + CodeBlock.of("%S to %S", name, version) + } + + val mapInitializer = CodeBlock.builder() + .add("mapOf(\n") + .indent() + .apply { + entries.forEachIndexed { index, entry -> + add(entry) + if (index != entries.lastIndex) add(",\n") else add("\n") + } + } + .unindent() + .add(")") + .build() + + val objectBuilder = TypeSpec.objectBuilder(name = generatedFileName) + .addKdoc("Generated from $inputFilePath") + .addProperty( + PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING)) + .initializer(mapInitializer) + .build() + ) + + val fileSpec = FileSpec.builder(packageName = "com.tangem.core.configtoggle", fileName = generatedFileName) + .addType(objectBuilder.build()) + .build() + + val outputPackageDir = File(outputDir, "") + outputPackageDir.mkdirs() + fileSpec.writeTo(outputPackageDir) + } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt index fa5c04f6a1..79ad0221fc 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/di/FeatureTogglesManagerModule.kt @@ -5,16 +5,14 @@ import com.tangem.core.configtoggle.BuildConfig import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager -import com.tangem.core.configtoggle.storage.LocalTogglesStorage +import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage import com.tangem.core.configtoggle.version.DefaultVersionProvider -import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.local.preferences.AppPreferencesStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.runBlocking import javax.inject.Singleton @Module @@ -25,29 +23,17 @@ internal object FeatureTogglesManagerModule { @Singleton fun provideFeatureTogglesManager( @ApplicationContext context: Context, - assetLoader: AssetLoader, appPreferencesStore: AppPreferencesStore, ): FeatureTogglesManager { - val localTogglesStorage = LocalTogglesStorage(assetLoader) val versionProvider = DefaultVersionProvider(context) return if (BuildConfig.TESTER_MENU_ENABLED) { DevFeatureTogglesManager( - localTogglesStorage = localTogglesStorage, - appPreferencesStore = appPreferencesStore, versionProvider = versionProvider, + featureTogglesLocalStorage = FeatureTogglesLocalStorage(appPreferencesStore), ) } else { - ProdFeatureTogglesManager( - localTogglesStorage = localTogglesStorage, - versionProvider = versionProvider, - ) - }.also { - // We need to initialize during the hilt graph creation - // in order to provide the feature toggles correctly to other dependencies. - runBlocking { - it.init() - } + ProdFeatureTogglesManager(versionProvider = versionProvider) } } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt index 86a027924c..0296565727 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt @@ -7,9 +7,6 @@ package com.tangem.core.configtoggle.feature */ interface FeatureTogglesManager { - /** Initialize manager */ - suspend fun init() - /** Check feature toggle availability by name [name] */ fun isFeatureEnabled(name: String): Boolean } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt index ce0d035439..6facf5dc8b 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt @@ -1,77 +1,77 @@ package com.tangem.core.configtoggle.feature.impl import androidx.annotation.VisibleForTesting +import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles +import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage +import com.tangem.core.configtoggle.utils.defineTogglesAvailability import com.tangem.core.configtoggle.version.VersionProvider -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull -import com.tangem.datasource.local.preferences.utils.storeObject +import kotlinx.coroutines.runBlocking +import java.util.Locale +import kotlin.properties.Delegates /** * Feature toggles manager implementation in DEV build * - * @property localTogglesStorage local feature toggles storage - * @property appPreferencesStore application local store * @property versionProvider application version provider + * @property featureTogglesLocalStorage local storage for feature toggles */ internal class DevFeatureTogglesManager( - private val localTogglesStorage: TogglesStorage, - private val appPreferencesStore: AppPreferencesStore, private val versionProvider: VersionProvider, + private val featureTogglesLocalStorage: FeatureTogglesLocalStorage, ) : MutableFeatureTogglesManager { - private var featureTogglesMap: MutableMap? = null - private var localFeatureTogglesMap: Map? = null + private var fileFeatureTogglesMap: Map = getFileFeatureToggles() + private var featureTogglesMap: MutableMap by Delegates.notNull() - override suspend fun init() { - if (featureTogglesMap != null && localFeatureTogglesMap != null) { - return // Already initialized - } + init { + val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() } - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - - val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull>( - key = PreferencesKeys.FEATURE_TOGGLES_KEY, - ) ?: emptyMap() - - val localFeatureToggles = localTogglesStorage.toggles - .associateToggles(currentVersion = versionProvider.get().orEmpty()) - - localFeatureTogglesMap = localFeatureToggles - - featureTogglesMap = localFeatureToggles + featureTogglesMap = fileFeatureTogglesMap .mapValues { resultToggle -> savedFeatureToggles[resultToggle.key] ?: resultToggle.value } .toMutableMap() } - override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap!![name] ?: false + override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap[name] == true - override fun isMatchLocalConfig(): Boolean = featureTogglesMap == localFeatureTogglesMap + override fun getFeatureToggles(): Map = featureTogglesMap - override fun getFeatureToggles(): Map = featureTogglesMap!! + override fun isMatchLocalConfig(): Boolean = featureTogglesMap == fileFeatureTogglesMap override suspend fun changeToggle(name: String, isEnabled: Boolean) { - featureTogglesMap!![name] ?: return - featureTogglesMap!![name] = isEnabled - appPreferencesStore.storeFeatureToggles(value = featureTogglesMap!!) + featureTogglesMap[name] ?: return + featureTogglesMap[name] = isEnabled + featureTogglesLocalStorage.store(value = featureTogglesMap) } override suspend fun recoverLocalConfig() { - featureTogglesMap = localFeatureTogglesMap!!.toMutableMap() - appPreferencesStore.storeFeatureToggles(value = localFeatureTogglesMap!!) + featureTogglesMap = fileFeatureTogglesMap.toMutableMap() + featureTogglesLocalStorage.store(value = fileFeatureTogglesMap) + } + + override fun toString(): String { + return buildString { + append("DevFeatureTogglesManager:\n") + append("|------------------------------------------|-----------|\n") + append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", "name", "isEnabled")) + append("|------------------------------------------|-----------|\n") + featureTogglesMap.entries.forEachIndexed { index, (name, isEnabled) -> + append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", name, isEnabled)) + } + append("|------------------------------------------|-----------|") + } + } + + private fun getFileFeatureToggles(): Map { + val appVersion = versionProvider.get() + + return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion) } @VisibleForTesting(otherwise = VisibleForTesting.NONE) fun setFeatureToggles(map: MutableMap) { featureTogglesMap = map } - - private suspend fun AppPreferencesStore.storeFeatureToggles(value: Map) { - storeObject(PreferencesKeys.FEATURE_TOGGLES_KEY, value) - } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt index 54fe7a011d..261ba22e8e 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/ProdFeatureTogglesManager.kt @@ -1,41 +1,30 @@ package com.tangem.core.configtoggle.feature.impl import androidx.annotation.VisibleForTesting +import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles +import com.tangem.core.configtoggle.utils.defineTogglesAvailability import com.tangem.core.configtoggle.version.VersionProvider /** * Feature toggles manager implementation in PROD build * - * @property localTogglesStorage local feature toggles storage - * @property versionProvider application version provider + * @property versionProvider application version provider */ internal class ProdFeatureTogglesManager( - private val localTogglesStorage: TogglesStorage, private val versionProvider: VersionProvider, ) : FeatureTogglesManager { - private var featureToggles: Map? = null + private val featureToggles: Map = getFileFeatureToggles() - override suspend fun init() { - if (featureToggles != null) { - return // Already initialized - } + override fun isFeatureEnabled(name: String): Boolean = featureToggles[name] == true - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - featureToggles = localTogglesStorage.toggles - .associateToggles(currentVersion = versionProvider.get() ?: "") + private fun getFileFeatureToggles(): Map { + val appVersion = versionProvider.get() + + return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion) } - override fun isFeatureEnabled(name: String): Boolean = featureToggles!![name] ?: false - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - fun getProdFeatureToggles() = featureToggles!! - - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - fun setProdFeatureToggles(map: Map) { - featureToggles = map - } + fun getProdFeatureToggles() = featureToggles } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt new file mode 100644 index 0000000000..7ab7d42891 --- /dev/null +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/storage/FeatureTogglesLocalStorage.kt @@ -0,0 +1,31 @@ +package com.tangem.core.configtoggle.storage + +import androidx.datastore.preferences.core.stringPreferencesKey +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getObjectMapSync +import com.tangem.datasource.local.preferences.utils.storeObjectMap + +/** + * Local storage for feature toggles + * + * @property appPreferencesStore app preferences store + * +[REDACTED_AUTHOR] + */ +internal class FeatureTogglesLocalStorage( + private val appPreferencesStore: AppPreferencesStore, +) { + + suspend fun getSyncOrEmpty(): Map { + return appPreferencesStore.getObjectMapSync(key = FEATURE_TOGGLES_KEY) + } + + suspend fun store(value: Map) { + appPreferencesStore.storeObjectMap(key = FEATURE_TOGGLES_KEY, value = value) + } + + private companion object { + + val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } + } +} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt index 68ee66b88c..d3e54bcac1 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/utils/CollectionExt.kt @@ -10,4 +10,14 @@ internal fun List.associateToggles(currentVersion: String): Map.defineTogglesAvailability(appVersion: String?): Map { + return if (appVersion == null) { + mapValues { false } + } else { + mapValues { (_, version) -> + VersionAvailabilityContract(currentVersion = appVersion, localVersion = version) + } + } } \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt new file mode 100644 index 0000000000..e91cc5d705 --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt @@ -0,0 +1,489 @@ +package com.tangem.core.configtoggle.manager + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager +import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel +import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DevFeatureTogglesManagerTest { + + private val versionProvider = mockk() + private val featureTogglesLocalStorage = mockk(relaxUnitFun = true) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0") + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(FeatureToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider, featureTogglesLocalStorage) + } + + @Test + fun `successfully initialize manager`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if versionProvider returns null`() = runTest { + // Arrange + val appVersion = null + val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if versionProvider returns empty string`() = runTest { + // Arrange + val appVersion = "" + val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `failure initialize manager if versionProvider throws exception`() = runTest { + // Arrange + val exception = Exception("Test exception") + every { versionProvider.get() } throws exception + + // Act + val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) } + .exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { versionProvider.get() } + coVerify(inverse = true) { featureTogglesLocalStorage.getSyncOrEmpty() } + } + + @Test + fun `successfully initialize manager if storage returns empty map`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedFeatureToggles = emptyMap() + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `successfully initialize manager if storage returns unknown toggles`() = runTest { + // Arrange + val appVersion = "1.0.0" + val savedFeatureToggles = mapOf("TOGGLE_3" to true) + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + // Act + val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + + @Test + fun `failure initialize manager if storage throws exception`() = runTest { + // Arrange + val appVersion = "1.0.0" + val exception = Exception("Test exception") + + every { versionProvider.get() } returns appVersion + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } throws exception + + // Act + val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) } + .exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsFeatureEnabled { + + private lateinit var manager: DevFeatureTogglesManager + + @BeforeAll + fun setupAll() { + every { versionProvider.get() } returns "1.0.0" + + val featureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + + val savedFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to true, + "ACTIVE2_TEST_FEATURE_ENABLED" to false, + ) + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + } + + @AfterAll + fun tearDownAll() { + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + + @ParameterizedTest + @ProvideTestModels + fun isFeatureEnabled(model: IsFeatureEnabledModel) { + // Act + val actual = manager.isFeatureEnabled(name = model.name) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = true), + IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "", expected = false), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsMatchLocalConfig { + + @ParameterizedTest + @ProvideTestModels + fun isMatchLocalConfig(model: IsMatchLocalConfigModel) = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns model.fileFeatureToggles + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).apply { + setFeatureToggles(model.storedFeatureToggles.toMutableMap()) + } + + // Act + val actual = manager.isMatchLocalConfig() + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + + private fun provideTestModels() = listOf( + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false), + expected = true, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true), + expected = false, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_1" to true), + expected = false, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"), + storedFeatureToggles = mapOf("TOGGLE_3" to true, "TOGGLE_4" to false), + expected = false, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = emptyMap(), + storedFeatureToggles = emptyMap(), + expected = true, + ), + IsMatchLocalConfigModel( + fileFeatureToggles = emptyMap(), + storedFeatureToggles = mapOf("TOGGLE_1" to true), + expected = false, + ), + ) + } + + data class IsMatchLocalConfigModel( + val fileFeatureToggles: Map, + val storedFeatureToggles: Map, + val expected: Boolean, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetFeatureToggles { + + @Test + fun getFeatureToggles() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + val fileFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns fileFeatureToggles + + val savedFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to true, + "ACTIVE2_TEST_FEATURE_ENABLED" to false, + ) + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + + // Act + val actual = manager.getFeatureToggles() + + // Assert + val expected = savedFeatureToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ChangeToggle { + + @ParameterizedTest + @ProvideTestModels + fun changeToggle(model: ChangeToggleModel) = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns model.initialToggles + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap() + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + + // Act + manager.changeToggle(name = model.name, isEnabled = model.isEnabled) + val actual = manager.getFeatureToggles() + + // Assert + val expected = model.expectedToggles + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + + if (model.expectedStoreSaving) { + featureTogglesLocalStorage.store(model.expectedToggles) + } + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + + private fun provideTestModels() = listOf( + ChangeToggleModel( + initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"), + name = "TOGGLE_1", + isEnabled = false, + expectedToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false), + expectedStoreSaving = true, + ), + ChangeToggleModel( + initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"), + name = "TOGGLE_2", + isEnabled = true, + expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true), + expectedStoreSaving = true, + ), + ChangeToggleModel( + initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"), + name = "TOGGLE_3", + isEnabled = true, + expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false), + expectedStoreSaving = false, + ), + ChangeToggleModel( + initialToggles = emptyMap(), + name = "TOGGLE_1", + isEnabled = true, + expectedToggles = emptyMap(), + expectedStoreSaving = false, + ), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class RecoverLocalConfig { + + @Test + fun recoverLocalConfig() = runTest { + // Arrange + every { versionProvider.get() } returns "1.0.0" + + val fileFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns fileFeatureToggles + + val savedFeatureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to true, + "ACTIVE2_TEST_FEATURE_ENABLED" to false, + ) + coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles + + val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) + + // Act + manager.recoverLocalConfig() + val actual = manager.getFeatureToggles() + + // Assert + val expected = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to false, + "ACTIVE2_TEST_FEATURE_ENABLED" to true, + ) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { + versionProvider.get() + featureTogglesLocalStorage.getSyncOrEmpty() + featureTogglesLocalStorage.store(expected) + } + + clearMocks(versionProvider, featureTogglesLocalStorage) + unmockkObject(FeatureToggles) + } + } + + data class ChangeToggleModel( + val initialToggles: Map, + val name: String, + val isEnabled: Boolean, + val expectedToggles: Map, + val expectedStoreSaving: Boolean, + ) +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt deleted file mode 100644 index a4c3d6d7aa..0000000000 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt +++ /dev/null @@ -1,237 +0,0 @@ -package com.tangem.core.configtoggle.manager - -import android.annotation.SuppressLint -import com.google.common.truth.Truth -import com.squareup.moshi.Moshi -import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager -import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants -import com.tangem.core.configtoggle.storage.ConfigToggle -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles -import com.tangem.core.configtoggle.version.VersionProvider -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull -import com.tangem.datasource.local.preferences.utils.getSyncOrNull -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.collections.set - -/** -[REDACTED_AUTHOR] - */ -@SuppressLint("CheckResult") -internal class DevTogglesManagerTest { - - private val localTogglesStorage = mockk() - private val appPreferenceStore = AppPreferencesStore( - moshi = Moshi.Builder().build(), - dispatchers = TestingCoroutineDispatcherProvider(), - preferencesDataStore = mockk(relaxed = true), - ) - private val versionProvider = mockk() - private val manager = DevFeatureTogglesManager( - localTogglesStorage = localTogglesStorage, - appPreferencesStore = appPreferenceStore, - versionProvider = versionProvider, - ) - - @Test - fun `successfully initialize storage if shared prefs kept feature toggles`() = runTest { - val currentVersion = "0.1.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { - appPreferenceStore.getObjectSyncOrNull>(PreferencesKeys.FEATURE_TOGGLES_KEY) - } returns savedFeatureTogglesMap - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion) - .mapValues { resultToggle -> - savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value - } - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if shared prefs kept empty list`() = runTest { - val currentVersion = "0.1.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { - appPreferenceStore.getObjectSyncOrNull>(PreferencesKeys.FEATURE_TOGGLES_KEY) - } returns emptyMap() - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion) - .mapValues { resultToggle -> - savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value - } - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if shared prefs didn't keep feature toggles`() = runTest { - val currentVersion = "0.1.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { - appPreferenceStore.getObjectSyncOrNull>(PreferencesKeys.FEATURE_TOGGLES_KEY) - } returns null - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion) - .mapValues(Map.Entry::value) - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if versionProvider returns null`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - coEvery { appPreferenceStore.getSyncOrNull(PreferencesKeys.FEATURE_TOGGLES_KEY) } returns savedFeatureToggles - coEvery { localTogglesStorage.toggles } returns localFeatureToggles - coEvery { versionProvider.get() } returns null - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles - .associateToggles(currentVersion = "") - .mapValues { resultToggle -> - savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value - } - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `get feature availability if feature toggle exists`() { - val featureToggles = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to true, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED") - - Truth.assertThat(actual).isTrue() - } - - @Test - fun `get feature availability if feature toggle doesn't exists`() { - val featureToggles = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "") - - Truth.assertThat(actual).isFalse() - } - - @Test - fun getFeatureToggles() { - val expected = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - manager.setFeatureToggles(expected) - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `change toggle that contains in map`() = runTest { - val changeableToggleName = "INACTIVE_TEST_FEATURE_ENABLED" - val resultMap = mutableMapOf( - changeableToggleName to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - manager.setFeatureToggles(resultMap) - - manager.changeToggle(changeableToggleName, true) - - resultMap[changeableToggleName] = true - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap) - } - - @Test - fun `change toggle that doesn't contains in map`() = runTest { - val resultMap = mutableMapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - manager.setFeatureToggles(resultMap) - - manager.changeToggle("FEATURE_TOGGLE", true) - - Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap) - } - - private companion object { - - val savedFeatureToggles = """ - [ - { - "name": "INACTIVE_TEST_FEATURE_ENABLED", - "version": "undefined" - }, - { - "name": "ACTIVE2_TEST_FEATURE_ENABLED", - "version": "1.0.0" - } - ] - """.trimIndent() - - val savedFeatureTogglesMap = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - - val localFeatureToggles = listOf( - ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"), - ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"), - ) - } -} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt new file mode 100644 index 0000000000..ffef24d22e --- /dev/null +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdFeatureTogglesManagerTest.kt @@ -0,0 +1,155 @@ +package com.tangem.core.configtoggle.manager + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager +import com.tangem.core.configtoggle.version.VersionProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ProdFeatureTogglesManagerTest { + + private val versionProvider = mockk() + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Initialization { + + @BeforeAll + fun setupAll() { + val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0") + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + } + + @AfterAll + fun tearDownAll() { + unmockkObject(FeatureToggles) + } + + @AfterEach + fun tearDownEach() { + clearMocks(versionProvider) + } + + @Test + fun `successfully initialize storage`() = runTest { + // Arrange + val appVersion = "1.0.0" + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `successfully initialize storage if versionProvider returns null`() = runTest { + // Arrange + val appVersion = null + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `successfully initialize storage if versionProvider returns empty string`() = runTest { + // Arrange + val appVersion = "" + every { versionProvider.get() } returns appVersion + + // Act + val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles() + + // Assert + val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false) + Truth.assertThat(actual).containsExactlyEntriesIn(expected) + + coVerifyOrder { versionProvider.get() } + } + + @Test + fun `failure initialize storage if versionProvider throws exception`() = runTest { + // Arrange + val exception = Exception("Test exception") + every { versionProvider.get() } throws exception + + // Act + val actual = runCatching { ProdFeatureTogglesManager(versionProvider) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isInstanceOf(exception::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { versionProvider.get() } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsFeatureEnabled { + + private lateinit var manager: ProdFeatureTogglesManager + + @BeforeAll + fun setupAll() { + every { versionProvider.get() } returns "1.0.0" + + val featureToggles = mapOf( + "INACTIVE_TEST_FEATURE_ENABLED" to "undefined", + "ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0", + ) + + mockkObject(FeatureToggles) + every { FeatureToggles.values } returns featureToggles + + manager = ProdFeatureTogglesManager(versionProvider) + } + + @AfterAll + fun tearDownAll() { + clearMocks(versionProvider) + unmockkObject(FeatureToggles) + } + + @ParameterizedTest + @ProvideTestModels + fun isFeatureEnabled(model: IsFeatureEnabledModel) { + // Act + val actual = manager.isFeatureEnabled(name = model.name) + + // Assert + val expected = model.expected + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = true), + IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false), + IsFeatureEnabledModel(name = "", expected = false), + ) + } + + data class IsFeatureEnabledModel(val name: String, val expected: Boolean) +} \ No newline at end of file diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt deleted file mode 100644 index 6453d3eb4d..0000000000 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/ProdTogglesManagerTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.tangem.core.configtoggle.manager - -import android.content.pm.PackageManager -import com.google.common.truth.Truth -import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants -import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager -import com.tangem.core.configtoggle.storage.ConfigToggle -import com.tangem.core.configtoggle.storage.TogglesStorage -import com.tangem.core.configtoggle.utils.associateToggles -import com.tangem.core.configtoggle.version.VersionProvider -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.Test - -/** -[REDACTED_AUTHOR] - */ -internal class ProdTogglesManagerTest { - - private val localTogglesStorage = mockk() - private val versionProvider = mockk() - private val manager = ProdFeatureTogglesManager(localTogglesStorage, versionProvider) - - @Test - fun `successfully initialize storage`() = runTest { - val currentVersion = "1.0.0" - - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } returns localFeatureToggles - every { versionProvider.get() } returns currentVersion - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - val expected = localFeatureToggles.associateToggles(currentVersion) - Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(expected) - } - - @Test - fun `successfully initialize storage if versionProvider returns null`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } returns localFeatureToggles - every { versionProvider.get() } returns null - - manager.init() - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - - Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(disabledFeatureToggles) - } - - @Test - fun `failure initialize storage if localFeatureTogglesStorage throws exception`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } throws IllegalStateException( - "Property featureToggles should be initialized before get.", - ) - - runCatching { manager.init() } - .onSuccess { throw IllegalStateException("localFeatureToggles shouldn't be initialized") } - .onFailure { - Truth - .assertThat(it) - .hasMessageThat() - .contains("Property featureToggles should be initialized before get.") - - Truth.assertThat(it).isInstanceOf(IllegalStateException::class.java) - } - - coVerifyOrder { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } - verifyAll(inverse = true) { versionProvider.get() } - } - - @Test - fun `failure initialize storage if versionProvider throws exception`() = runTest { - coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs - every { localTogglesStorage.toggles } returns localFeatureToggles - every { versionProvider.get() } throws PackageManager.NameNotFoundException() - - runCatching { manager.init() } - .onSuccess { throw IllegalStateException("versionProvider should throws exception") } - .onFailure { - Truth.assertThat(it).isInstanceOf(PackageManager.NameNotFoundException::class.java) - } - - coVerifyOrder { - localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) - versionProvider.get() - } - } - - @Test - fun `get feature availability if feature toggle exists`() { - val featureToggles = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to true, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setProdFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED") - - Truth.assertThat(actual).isTrue() - } - - @Test - fun `get feature availability if feature toggle doesn't exists`() { - val featureToggles = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to true, - ) - manager.setProdFeatureToggles(featureToggles) - - val actual = manager.isFeatureEnabled(name = "") - - Truth.assertThat(actual).isFalse() - } - - private companion object { - val localFeatureToggles = listOf( - ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"), - ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"), - ) - - val disabledFeatureToggles = mapOf( - "INACTIVE_TEST_FEATURE_ENABLED" to false, - "ACTIVE2_TEST_FEATURE_ENABLED" to false, - ) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 06a5f51102..162d75a40f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -56,8 +56,6 @@ object PreferencesKeys { val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") } - val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } - val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") } val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") } From 3d5a8a55ed9e060a9f1d0ed2f6a837d121ac0511 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 Aug 2025 14:03:04 +0400 Subject: [PATCH 21/27] Updated on 2026-08-14 --- .../api/tangemTech/TangemTechApi.kt | 3 +- .../AccountConverterFactoryContainer.kt | 22 +- .../data/account/di/AccountDataModule.kt | 20 +- .../DefaultAccountsCRUDRepository.kt | 150 ++-- .../store/ArchivedAccountsStoreFactory.kt | 2 +- .../DefaultAccountsCRUDRepositoryTest.kt | 664 ++++++++++++++++++ .../store/ArchivedAccountsStoreFactoryTest.kt | 2 +- .../repository/AccountsCRUDRepository.kt | 10 +- .../usecase/AddCryptoPortfolioUseCase.kt | 4 +- .../usecase/ArchiveCryptoPortfolioUseCase.kt | 2 +- .../usecase/GetArchivedAccountsUseCase.kt | 8 +- .../GetUnoccupiedAccountIndexUseCase.kt | 5 + .../usecase/RecoverCryptoPortfolioUseCase.kt | 4 +- .../usecase/UpdateCryptoPortfolioUseCase.kt | 2 +- .../usecase/AddCryptoPortfolioUseCaseTest.kt | 20 +- .../ArchiveCryptoPortfolioUseCaseTest.kt | 20 +- .../usecase/GetArchivedAccountsUseCaseTest.kt | 16 +- .../GetUnoccupiedAccountIndexUseCaseTest.kt | 3 +- .../RecoverCryptoPortfolioUseCaseTest.kt | 44 +- .../UpdateCryptoPortfolioUseCaseTest.kt | 32 +- 20 files changed, 899 insertions(+), 134 deletions(-) create mode 100644 data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index be6fe202e7..9a557cd922 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -154,7 +154,8 @@ interface TangemTechApi { suspend fun saveWalletAccounts( @Path("walletId") walletId: String, @Header("If-Match") ifMatch: String, - ): ApiResponse + @Body body: SaveWalletAccountsResponse, + ): ApiResponse @GET("/v1/wallets/{walletId}/accounts/archived") suspend fun getWalletArchivedAccounts( diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt index 28cfd01b9e..e8bcef8cce 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConverterFactoryContainer.kt @@ -1,5 +1,7 @@ package com.tangem.data.account.converter +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.wallet.UserWalletId import javax.inject.Inject /** @@ -14,7 +16,21 @@ import javax.inject.Inject [REDACTED_AUTHOR] */ internal class AccountConverterFactoryContainer @Inject constructor( - val accountsListCF: AccountListConverter.Factory, val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory, - val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, -) \ No newline at end of file + private val accountsListCF: AccountListConverter.Factory, + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, + private val userWalletsStore: UserWalletsStore, +) { + + fun createAccountListConverter(userWalletId: UserWalletId): AccountListConverter { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + return accountsListCF.create(userWallet) + } + + fun createCryptoPortfolioConverter(userWalletId: UserWalletId): CryptoPortfolioConverter { + val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + + return cryptoPortfolioCF.create(userWallet) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index e05c402f68..df872e31dc 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,9 +1,13 @@ package com.tangem.data.account.di +import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.repository.DefaultAccountsCRUDRepository -import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,10 +20,20 @@ internal object AccountDataModule { @Provides @Singleton - fun provideAccountsCRUDRepository(userWalletsStore: UserWalletsStore): AccountsCRUDRepository { + fun provideAccountsCRUDRepository( + tangemTechApi: TangemTechApi, + accountsResponseStoreFactory: AccountsResponseStoreFactory, + userWalletsStore: UserWalletsStore, + accountConverterFactoryContainer: AccountConverterFactoryContainer, + dispatchers: CoroutineDispatcherProvider, + ): AccountsCRUDRepository { return DefaultAccountsCRUDRepository( - runtimeStore = RuntimeSharedStore(), + tangemTechApi = tangemTechApi, + accountsResponseStoreFactory = accountsResponseStoreFactory, + archivedAccountsStoreFactory = ArchivedAccountsStoreFactory, userWalletsStore = userWalletsStore, + convertersContainer = accountConverterFactoryContainer, + dispatchers = dispatchers, ) } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index a9fba30f57..3d148930d5 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -1,90 +1,150 @@ package com.tangem.data.account.repository import arrow.core.Option -import arrow.core.Option.Companion.catch -import arrow.core.none import arrow.core.raise.option -import com.tangem.datasource.local.datastore.RuntimeSharedStore +import arrow.core.toOption +import com.tangem.data.account.converter.AccountConverterFactoryContainer +import com.tangem.data.account.converter.ArchivedAccountConverter +import com.tangem.data.account.converter.SaveWalletAccountsResponseConverter +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.store.ArchivedAccountsStore +import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.repository.AccountsCRUDRepository -import com.tangem.domain.models.account.* +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.extensions.addOrReplace +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext /** [REDACTED_AUTHOR] */ -// TODO: [REDACTED_JIRA] internal class DefaultAccountsCRUDRepository( - private val runtimeStore: RuntimeSharedStore>, + private val tangemTechApi: TangemTechApi, + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory, private val userWalletsStore: UserWalletsStore, + private val convertersContainer: AccountConverterFactoryContainer, + private val dispatchers: CoroutineDispatcherProvider, ) : AccountsCRUDRepository { - override suspend fun getAccounts(userWalletId: UserWalletId): Option = catch { - runtimeStore.getSyncOrNull() - ?.firstOrNull { it.userWallet.walletId == userWalletId } - ?: return none() + private val saveAccountsMutex = Mutex() + + override suspend fun getAccountListSync(userWalletId: UserWalletId): Option = option { + val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) + + ensureNotNull(accountListResponse) + + val converter = convertersContainer.createAccountListConverter(userWalletId = userWalletId) + converter.convert(value = accountListResponse) } - override suspend fun getAccount(accountId: AccountId): Option = catch { - runtimeStore.getSyncOrNull().orEmpty() - .flatMap { it.accounts } - .firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio - ?: return none() + override suspend fun getAccountSync(accountId: AccountId): Option = option { + val userWalletId = accountId.userWalletId + + val accountResponse = getAccountsResponseSync(userWalletId = userWalletId) + ?.accounts?.firstOrNull { it.id == accountId.value } + + ensureNotNull(accountResponse) + + val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = userWalletId) + converter.convert(value = accountResponse) } - override suspend fun getArchivedAccount(accountId: AccountId): Option = option { - createMockArchivedAccount(userWalletId = accountId.userWalletId) + override suspend fun getArchivedAccountSync(accountId: AccountId): Option { + val store = getArchivedAccountsStore(userWalletId = accountId.userWalletId) + + return store.getSyncOrNull() + ?.firstOrNull { it.accountId == accountId } + .toOption() } - override suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option> = option { - listOf( - createMockArchivedAccount(userWalletId), - ) + override suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option> { + val store = getArchivedAccountsStore(userWalletId = userWalletId) + + return store.getSyncOrNull().toOption() } override fun getArchivedAccounts(userWalletId: UserWalletId): Flow> { - return flow { - getArchivedAccountsSync(userWalletId).getOrNull().orEmpty() - } + val store = getArchivedAccountsStore(userWalletId = userWalletId) + + return store.get() } - override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) = Unit + override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) { + val response = withContext(dispatchers.io) { + tangemTechApi.getWalletArchivedAccounts(walletId = userWalletId.stringValue).getOrThrow() + } + + val store = getArchivedAccountsStore(userWalletId = userWalletId) + val converter = ArchivedAccountConverter(userWalletId = userWalletId) + + val archivedAccounts = converter.convertList(input = response.accounts) + + store.store(value = archivedAccounts) + } override suspend fun saveAccounts(accountList: AccountList) { - runtimeStore.update(emptyList()) { - it.addOrReplace(accountList) { it.userWallet.walletId == accountList.userWallet.walletId } + saveAccountsMutex.withLock { + val store = getAccountsResponseStore(userWalletId = accountList.userWallet.walletId) + + val version = store.data.firstOrNull()?.wallet?.version ?: 0 + val body = SaveWalletAccountsResponseConverter.convert(value = accountList) + + withContext(dispatchers.io) { + tangemTechApi.saveWalletAccounts( + walletId = accountList.userWallet.walletId.stringValue, + ifMatch = version.toString(), + body = body, + ) + .getOrThrow() + } + + val converter = convertersContainer.getWalletAccountsResponseCF.create( + userWallet = accountList.userWallet, + version = version, + ) + + val accountsResponse = converter.convert(value = accountList) + + store.updateData { accountsResponse } } } - override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int { - val activeAccountsCount = runtimeStore.getSyncOrNull()?.size ?: 1 + override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option = option { + val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) - return activeAccountsCount + 1 + ensureNotNull(accountListResponse) + + return accountListResponse.wallet.totalAccounts.toOption() } override fun getUserWallet(userWalletId: UserWalletId): UserWallet { return userWalletsStore.getSyncStrict(userWalletId) } - private fun createMockArchivedAccount(userWalletId: UserWalletId): ArchivedAccount { - val derivationIndex = DerivationIndex(value = 1000).getOrNull()!! + private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? { + val store = getAccountsResponseStore(userWalletId = userWalletId) + return store.data.firstOrNull() + } - return ArchivedAccount( - accountId = AccountId.forCryptoPortfolio( - userWalletId = userWalletId, - derivationIndex = derivationIndex, - ), - name = AccountName("Archived Account").getOrNull()!!, - icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), - derivationIndex = derivationIndex, - tokensCount = 2, - networksCount = 1, - ) + private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore { + return accountsResponseStoreFactory.create(userWalletId = userWalletId) + } + + private fun getArchivedAccountsStore(userWalletId: UserWalletId): ArchivedAccountsStore { + return archivedAccountsStoreFactory.create(userWalletId) } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt index e89a9fa130..96e522270b 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/ArchivedAccountsStoreFactory.kt @@ -12,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap * [REDACTED_AUTHOR] */ -internal class ArchivedAccountsStoreFactory { +internal object ArchivedAccountsStoreFactory { private val createdRuntimeStores = ConcurrentHashMap() diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt new file mode 100644 index 0000000000..0002eb553d --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsCRUDRepositoryTest.kt @@ -0,0 +1,664 @@ +package com.tangem.data.account.repository + +import arrow.core.None +import arrow.core.toOption +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.account.converter.* +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.store.ArchivedAccountsStore +import com.tangem.data.account.store.ArchivedAccountsStoreFactory +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletArchivedAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.models.ArchivedAccount +import com.tangem.domain.models.account.Account.CryptoPortfolio +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.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import kotlin.time.Duration.Companion.minutes + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultAccountsCRUDRepositoryTest { + + private val tangemTechApi: TangemTechApi = mockk() + + private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() + private val accountsResponseStore: AccountsResponseStore = mockk() + private val accountsResponseStoreFlow = MutableStateFlow(value = null) + + private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory = mockk() + private val archivedAccountsInnerStore = RuntimeStateStore?>(defaultValue = null) + private val archivedAccountsStore = ArchivedAccountsStore(runtimeStore = archivedAccountsInnerStore) + + private val userWalletsStore: UserWalletsStore = mockk() + + private val convertersContainer: AccountConverterFactoryContainer = mockk() + private val accountListConverter: AccountListConverter = mockk() + private val cryptoPortfolioConverter: CryptoPortfolioConverter = mockk() + + private val repository = DefaultAccountsCRUDRepository( + tangemTechApi = tangemTechApi, + accountsResponseStoreFactory = accountsResponseStoreFactory, + archivedAccountsStoreFactory = archivedAccountsStoreFactory, + userWalletsStore = userWalletsStore, + convertersContainer = convertersContainer, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + + @BeforeAll + fun setup() { + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + + every { convertersContainer.createAccountListConverter(userWalletId) } returns accountListConverter + every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns cryptoPortfolioConverter + } + + @BeforeEach + fun setupEach() { + every { archivedAccountsStoreFactory.create(userWalletId) } returns archivedAccountsStore + } + + @AfterEach + fun tearDownEach() { + accountsResponseStoreFlow.value = null + archivedAccountsInnerStore.clear() + + clearMocks( + tangemTechApi, + archivedAccountsStoreFactory, + accountListConverter, + cryptoPortfolioConverter, + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountListSync { + + @Test + fun `getAccounts should return None when account list response is null`() = runTest { + // Arrange + accountsResponseStoreFlow.value = null + + // Act + val actual = repository.getAccountListSync(userWalletId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + verify(inverse = true) { accountListConverter.convert(value = any()) } + } + + @Test + fun `getAccounts should return AccountList when account list response is not null`() = runTest { + // Arrange + val response = mockk() + val accountList = mockk() + + accountsResponseStoreFlow.value = response + + every { accountListConverter.convert(response) } returns accountList + + // Act + val actual = repository.getAccountListSync(userWalletId) + + // Assert + val expected = accountList.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createAccountListConverter(userWalletId = userWalletId) + accountListConverter.convert(response) + } + } + + @Test + fun `getAccounts should throw exception if converter throws exception`() = runTest { + // Arrange + val response = mockk() + mockk() + + accountsResponseStoreFlow.value = response + + val exception = Exception("Test error") + + every { accountListConverter.convert(response) } throws exception + + // Act + val actual = runCatching { repository.getAccountListSync(userWalletId) }.exceptionOrNull()!! + + // Assert + val expected = exception + Truth.assertThat(actual).isSameInstanceAs(expected) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createAccountListConverter(userWalletId = userWalletId) + accountListConverter.convert(response) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccountSync { + + private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main) + + @Test + fun `getAccount should return None when account response is null`() = runTest { + // Arrange + val response = null + + accountsResponseStoreFlow.value = response + + // Act + val actual = repository.getAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) } + } + + @Test + fun `getAccount should return None when accountDto is not found`() = runTest { + // Arrange + val response = mockk { + every { this@mockk.accounts } returns emptyList() + } + + accountsResponseStoreFlow.value = response + + // Act + val actual = repository.getAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + } + + verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) } + } + + @Test + fun `getAccount should return Account_CryptoPortfolio when account response is not null`() = runTest { + // Arrange + val accountDTO = mockk { + every { this@mockk.id } returns accountId.value + } + + val response = mockk { + every { this@mockk.accounts } returns listOf(accountDTO) + } + + accountsResponseStoreFlow.value = response + + val cryptoPortfolio = mockk() + + every { cryptoPortfolioConverter.convert(accountDTO) } returns cryptoPortfolio + + // Act + val actual = repository.getAccountSync(accountId) + + // Assert + val expected = cryptoPortfolio.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createCryptoPortfolioConverter(userWalletId) + cryptoPortfolioConverter.convert(accountDTO) + } + } + + @Test + fun `getAccount should throw exception if converter throws exception`() = runTest { + // Arrange + val accountDTO = mockk { + every { this@mockk.id } returns accountId.value + } + + val response = mockk { + every { this@mockk.accounts } returns listOf(accountDTO) + } + + accountsResponseStoreFlow.value = response + + val exception = Exception("Test error") + + every { cryptoPortfolioConverter.convert(accountDTO) } throws exception + + // Act + val actual = runCatching { repository.getAccountSync(accountId) }.exceptionOrNull()!! + + // Assert + val expected = exception + Truth.assertThat(actual).isSameInstanceAs(expected) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + verifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + convertersContainer.createCryptoPortfolioConverter(userWalletId) + cryptoPortfolioConverter.convert(accountDTO) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetArchivedAccountSync { + + private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main) + + @Test + fun `getArchivedAccount should return None when archived accounts are null`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = null) + + // Act + val actual = repository.getArchivedAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccount should return None when archived account not found`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = listOf()) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + // Act + val actual = repository.getArchivedAccountSync(accountId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccount should return ArchivedAccount when found`() = runTest { + // Arrange + val archivedAccount = ArchivedAccount( + accountId = accountId, + name = AccountName("Archived Account").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = DerivationIndex.Main, + tokensCount = 0, + networksCount = 0, + ) + + archivedAccountsInnerStore.store(value = listOf(archivedAccount)) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + // Act + val actual = repository.getArchivedAccountSync(accountId) + + // Assert + val expected = archivedAccount.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccount should throws exception when store throws exception`() = runTest { + // Arrange + val exception = Exception("Test error") + + coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception + + // Act + val actual = runCatching { repository.getArchivedAccountSync(accountId) }.exceptionOrNull()!! + + // Assert + val expected = exception + Truth.assertThat(actual).isSameInstanceAs(expected) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetArchivedAccountListSync { + + @Test + fun `getArchivedAccountListSync should return None when archived accounts are null`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = null) + + // Act + val actual = repository.getArchivedAccountListSync(userWalletId) + + // Assert + Truth.assertThat(actual).isEqualTo(None) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccountListSync should return Option with list when archived accounts exist`() = runTest { + // Arrange + val archivedAccount1 = mockk() + val archivedAccount2 = mockk() + val archivedAccounts = listOf(archivedAccount1, archivedAccount2) + archivedAccountsInnerStore.store(value = archivedAccounts) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + // Act + val actual = repository.getArchivedAccountListSync(userWalletId) + + // Assert + val expected = archivedAccounts.toOption() + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.getSyncOrNull() + } + } + + @Test + fun `getArchivedAccountListSync should throw exception when store throws exception`() = runTest { + // Arrange + val exception = Exception("Test error") + coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception + + // Act + val actual = runCatching { repository.getArchivedAccountListSync(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isSameInstanceAs(exception) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetArchivedAccounts { + + @Test + fun `getArchivedAccounts should emit empty list when no archived accounts`() = runTest { + // Arrange + archivedAccountsInnerStore.store(value = null) + + val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId) + + // Act + val actual = getEmittedValues(archivedAccountsFlow) + + // Assert + Truth.assertThat(actual).isEmpty() + + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.get() + } + } + + @Test + fun `getArchivedAccounts should emit list of archived accounts when present`() = runTest { + // Arrange + val archivedAccount1 = mockk() + val archivedAccount2 = mockk() + val archivedAccounts = listOf(archivedAccount1, archivedAccount2) + + archivedAccountsInnerStore.store(value = archivedAccounts) + archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds) + + val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId) + + // Act + val actual = getEmittedValues(archivedAccountsFlow) + + // Assert + val expected = listOf(archivedAccounts) + Truth.assertThat(actual).containsExactlyElementsIn(expected) + coVerifyOrder { + archivedAccountsStoreFactory.create(userWalletId) + archivedAccountsStore.get() + } + } + + @Test + fun `getArchivedAccounts should throw exception when store throws exception`() = runTest { + // Arrange + val exception = Exception("Test error") + coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception + + // Act + val actual = runCatching { repository.getArchivedAccounts(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isSameInstanceAs(exception) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + + coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class FetchArchivedAccounts { + + private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main) + + @Test + fun `fetchArchivedAccounts should store archived accounts in store`() = runTest { + // Arrange + val accountDTO = WalletAccountDTO( + id = accountId.value, + name = "Archived Account", + derivationIndex = 0, + icon = CryptoPortfolioIcon.Icon.Wallet.name, + iconColor = CryptoPortfolioIcon.Color.DullLavender.name, + totalNetworks = 0, + totalTokens = 0, + ) + + val apiResponse = mockk { + every { this@mockk.accounts } returns listOf(accountDTO) + } + + val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO) + + coEvery { + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) + } returns ApiResponse.Success(apiResponse) + + // Act + repository.fetchArchivedAccounts(userWalletId) + val actual = archivedAccountsStore.getSyncOrNull() + + // Assert + Truth.assertThat(actual).containsExactly(archivedAccount) + + coVerifyOrder { + tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) + archivedAccountsStoreFactory.create(userWalletId) + } + } + + @Test + fun `fetchArchivedAccounts should throw exception if API returns error`() = runTest { // Arrange + val exception = Exception("API error") + coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) } throws exception + + // Act + val actual = runCatching { repository.fetchArchivedAccounts(userWalletId) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isSameInstanceAs(exception) + Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) + Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull() + + coVerify { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class SaveAccounts { + + private val version = 1 + + @Test + fun `saveAccounts should call API and update store`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + val accountList = AccountList.empty(userWallet = userWallet) + + val accountsResponse = mockk { + every { this@mockk.wallet.version } returns version + } + + accountsResponseStoreFlow.value = accountsResponse + + val body = SaveWalletAccountsResponseConverter.convert(value = accountList) + + val apiResponse = ApiResponse.Success(Unit) + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + ifMatch = version.toString(), + body = body, + ) + } returns apiResponse + + val converter = mockk { + every { this@mockk.convert(accountList) } returns accountsResponse + } + + every { + convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet, version = version) + } returns converter + + coEvery { accountsResponseStore.updateData(transform = any()) } returns accountsResponse + + // Act + repository.saveAccounts(accountList) + + // Assert + Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse) + + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body) + convertersContainer.getWalletAccountsResponseCF.create(userWallet, version) + converter.convert(accountList) + accountsResponseStore.updateData(any()) + } + } + + @Test + fun `saveAccounts if API request is failed`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + val accountList = AccountList.empty(userWallet = userWallet) + + val accountsResponse = mockk { + every { this@mockk.wallet.version } returns version + } + + accountsResponseStoreFlow.value = accountsResponse + + val body = SaveWalletAccountsResponseConverter.convert(value = accountList) + + val apiResponse = ApiResponse.Error(cause = ApiResponseError.NetworkException) as ApiResponse + + coEvery { + tangemTechApi.saveWalletAccounts( + walletId = userWalletId.stringValue, + ifMatch = version.toString(), + body = body, + ) + } returns apiResponse + + // Act + val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!! + + // Assert + Truth.assertThat(actual).isEqualTo(ApiResponseError.NetworkException) + + coVerifyOrder { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body) + } + + coVerify(inverse = true) { + convertersContainer.getWalletAccountsResponseCF.create(any(), any()) + accountsResponseStore.updateData(any()) + } + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt index 977c3c4169..a7358ba43d 100644 --- a/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/store/ArchivedAccountsStoreFactoryTest.kt @@ -9,7 +9,7 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class ArchivedAccountsStoreFactoryTest { - private val factory = ArchivedAccountsStoreFactory() + private val factory = ArchivedAccountsStoreFactory @AfterEach fun tearDownEach() { diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt index ac6921e167..8904840bcb 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt @@ -22,7 +22,7 @@ interface AccountsCRUDRepository { * @param userWalletId the unique identifier of the user wallet * @return an [Option] containing the [AccountList] if found, or `Option.None` if not */ - suspend fun getAccounts(userWalletId: UserWalletId): Option + suspend fun getAccountListSync(userWalletId: UserWalletId): Option /** * Retrieves a specific account by its unique identifier @@ -30,14 +30,14 @@ interface AccountsCRUDRepository { * @param accountId the unique identifier of the account * @return an [Option] containing the [Account.CryptoPortfolio] if found, or `Option.None` if not */ - suspend fun getAccount(accountId: AccountId): Option + suspend fun getAccountSync(accountId: AccountId): Option /** * Retrieves a archived account by its unique identifier * * @param accountId the unique identifier of the account */ - suspend fun getArchivedAccount(accountId: AccountId): Option + suspend fun getArchivedAccountSync(accountId: AccountId): Option /** * Retrieves a list of archived accounts associated with a specific user wallet @@ -45,7 +45,7 @@ interface AccountsCRUDRepository { * @param userWalletId the unique identifier of the user wallet * @return an [Option] containing a list of [ArchivedAccount] if found, or `Option.None` if not */ - suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option> + suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option> /** * Provides a flow of archived accounts associated with a specific user wallet @@ -73,7 +73,7 @@ interface AccountsCRUDRepository { * * @param userWalletId the unique identifier of the user wallet */ - suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int + suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option /** * Retrieves a user wallet by its unique identifier diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt index 6a3866bc4b..6f233d2da7 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCase.kt @@ -55,7 +55,7 @@ class AddCryptoPortfolioUseCase( newAccount } - private fun Raise.createAccount( + private fun createAccount( userWalletId: UserWalletId, accountName: AccountName, icon: CryptoPortfolioIcon, @@ -72,7 +72,7 @@ class AddCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): Option { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt index 0611b106fb..75056d6eb0 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCase.kt @@ -40,7 +40,7 @@ class ArchiveCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt index cbcfb13168..afaa2a587e 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetArchivedAccountsUseCase.kt @@ -57,7 +57,7 @@ class GetArchivedAccountsUseCase( private suspend fun getArchivedAccounts(userWalletId: UserWalletId): Either { return Either.catch { - crudRepository.getArchivedAccountsSync(userWalletId = userWalletId).getOrElse { + crudRepository.getArchivedAccountListSync(userWalletId = userWalletId).getOrElse { error("Archived accounts not found for user wallet: $userWalletId") } } @@ -70,7 +70,11 @@ class GetArchivedAccountsUseCase( private suspend fun ProducerScope>.subscribeOnArchivedAccounts( userWalletId: UserWalletId, ) { - crudRepository.getArchivedAccounts(userWalletId) + runCatching { crudRepository.getArchivedAccounts(userWalletId) } + .getOrElse { + send(it.lceError()) + return + } .distinctUntilChanged() .retryWhen { cause, _ -> send(cause.lceError()) diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt index c34240e22b..f511ab03b8 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCase.kt @@ -38,6 +38,7 @@ class GetUnoccupiedAccountIndexUseCase( block = { crudRepository.getTotalAccountsCount(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) + .getOrElse { raise(Error.DataNotFound) } } /** @@ -48,6 +49,10 @@ class GetUnoccupiedAccountIndexUseCase( val tag: String get() = this::class.simpleName ?: "GetUnoccupiedAccountIndexUseCase.Error" + data object DataNotFound : Error { + override fun toString(): String = "$tag: Data not found" + } + /** 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" diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt index f5dcec41aa..1bff615daf 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt @@ -44,7 +44,7 @@ class RecoverCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } @@ -52,7 +52,7 @@ class RecoverCryptoPortfolioUseCase( private suspend fun Raise.getArchivedAccount(accountId: AccountId): ArchivedAccount { return catch( - block = { crudRepository.getArchivedAccount(accountId = accountId) }, + block = { crudRepository.getArchivedAccountSync(accountId = accountId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt index 4451a5f50d..2145e7ed15 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCase.kt @@ -61,7 +61,7 @@ class UpdateCryptoPortfolioUseCase( private suspend fun Raise.getAccountList(userWalletId: UserWalletId): AccountList { return catch( - block = { crudRepository.getAccounts(userWalletId = userWalletId) }, + block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt index f30bc1dfda..f677187f17 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/AddCryptoPortfolioUseCaseTest.kt @@ -41,7 +41,7 @@ class AddCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWallet) val updatedAccountList = (accountList + newAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase( @@ -56,7 +56,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } @@ -69,7 +69,7 @@ class AddCryptoPortfolioUseCaseTest { val newAccount = createNewAccount() val newAccountList = (AccountList.empty(userWallet) + newAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns None coEvery { crudRepository.getUserWallet(userWalletId) } returns userWallet // Act @@ -85,7 +85,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.getUserWallet(userWalletId) crudRepository.saveAccounts(newAccountList) } @@ -102,7 +102,7 @@ class AddCryptoPortfolioUseCaseTest { val newAccount = createNewAccount(derivationIndex = 21) - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase( @@ -119,7 +119,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getUserWallet(any()) @@ -133,7 +133,7 @@ class AddCryptoPortfolioUseCaseTest { val newAccount = createNewAccount() val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception // Act val actual = useCase( @@ -147,7 +147,7 @@ class AddCryptoPortfolioUseCaseTest { val expected = AddCryptoPortfolioUseCase.Error.DataOperationFailed(cause = exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getUserWallet(any()) @@ -164,7 +164,7 @@ class AddCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception // Act @@ -180,7 +180,7 @@ class AddCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt index 1e442938fa..fe67edc618 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ArchiveCryptoPortfolioUseCaseTest.kt @@ -41,7 +41,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val updatedAccountList = (accountList - account).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId) @@ -51,7 +51,7 @@ class ArchiveCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } } @@ -64,7 +64,7 @@ class ArchiveCryptoPortfolioUseCaseTest { derivationIndex = DerivationIndex.Main, ) - coEvery { crudRepository.getAccounts(userWalletId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns None // Act val actual = useCase(accountId) @@ -73,7 +73,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -87,7 +87,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception // Act val actual = useCase(accountId) @@ -96,7 +96,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -109,7 +109,7 @@ class ArchiveCryptoPortfolioUseCaseTest { derivationIndex = DerivationIndex(1).getOrNull()!!, ) - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId) @@ -118,7 +118,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountNotFound(accountId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -133,7 +133,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Save failed") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception // Act @@ -144,7 +144,7 @@ class ArchiveCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) + crudRepository.getAccountListSync(userWalletId) crudRepository.saveAccounts(updatedAccountList) } } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt index eb0019f93c..5fd7b2ccce 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetArchivedAccountsUseCaseTest.kt @@ -43,7 +43,7 @@ class GetArchivedAccountsUseCaseTest { mockk(), mockk(), ) - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } returns archivedAccounts.toOption() + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns archivedAccounts.toOption() every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act @@ -54,7 +54,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -69,7 +69,7 @@ class GetArchivedAccountsUseCaseTest { mockk(), ) - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } returns None + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns None every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act @@ -83,7 +83,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(exactly = 1) { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -98,7 +98,7 @@ class GetArchivedAccountsUseCaseTest { mockk(), ) - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } throws exception + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } throws exception every { crudRepository.getArchivedAccounts(userWalletId) } returns flowOf(archivedAccounts) // Act @@ -112,7 +112,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(exactly = 1) { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } @@ -123,7 +123,7 @@ class GetArchivedAccountsUseCaseTest { // Arrange val exception = IllegalStateException("Fetch error") - coEvery { crudRepository.getArchivedAccountsSync(userWalletId) } returns None + coEvery { crudRepository.getArchivedAccountListSync(userWalletId) } returns None every { crudRepository.getArchivedAccounts(userWalletId) } returns emptyFlow() coEvery { crudRepository.fetchArchivedAccounts(userWalletId) } throws exception @@ -139,7 +139,7 @@ class GetArchivedAccountsUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(exactly = 1) { - crudRepository.getArchivedAccountsSync(userWalletId) + crudRepository.getArchivedAccountListSync(userWalletId) crudRepository.fetchArchivedAccounts(userWalletId) crudRepository.getArchivedAccounts(userWalletId) } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt index 4c994aeb21..a0ba98b709 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/GetUnoccupiedAccountIndexUseCaseTest.kt @@ -1,6 +1,7 @@ package com.tangem.domain.account.usecase import arrow.core.left +import arrow.core.toOption import com.google.common.truth.Truth import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.models.account.DerivationIndex @@ -29,7 +30,7 @@ class GetUnoccupiedAccountIndexUseCaseTest { @Test fun `invoke should return next unoccupied index when repository returns count`() = runTest { // Arrange - coEvery { crudRepository.getTotalAccountsCount(userWalletId) } returns 3 + coEvery { crudRepository.getTotalAccountsCount(userWalletId) } returns 3.toOption() // Act val actual = useCase(userWalletId = userWalletId) diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt index 7c8f1a847f..eb4e423c11 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt @@ -52,8 +52,8 @@ class RecoverCryptoPortfolioUseCaseTest { val updatedAccountList = (accountList + account).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } returns archivedAccount.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns archivedAccount.toOption() // Act val actual = useCase(account.accountId) @@ -63,8 +63,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) } } @@ -77,7 +77,7 @@ class RecoverCryptoPortfolioUseCaseTest { derivationIndex = DerivationIndex.Main, ) - coEvery { crudRepository.getAccounts(userWalletId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns None // Act val actual = useCase(accountId) @@ -86,9 +86,9 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { - crudRepository.getArchivedAccount(any()) + crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) } } @@ -102,7 +102,7 @@ class RecoverCryptoPortfolioUseCaseTest { ) val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception // Act val actual = useCase(accountId) @@ -111,9 +111,9 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { - crudRepository.getArchivedAccount(any()) + crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) } } @@ -125,8 +125,8 @@ class RecoverCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWallet) val exception = IllegalStateException("Test error") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } throws exception // Act val actual = useCase(account.accountId) @@ -136,8 +136,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -148,8 +148,8 @@ class RecoverCryptoPortfolioUseCaseTest { val account = createAccount(userWalletId) val accountList = AccountList.empty(userWallet) - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } returns None + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns None // Act val actual = useCase(account.accountId) @@ -159,8 +159,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) } coVerify(inverse = true) { crudRepository.saveAccounts(any()) } } @@ -182,8 +182,8 @@ class RecoverCryptoPortfolioUseCaseTest { val updatedAccountList = (accountList + account).getOrNull()!! val exception = IllegalStateException("Save failed") - coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption() - coEvery { crudRepository.getArchivedAccount(account.accountId) } returns archivedAccount.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns archivedAccount.toOption() coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception // Act @@ -194,8 +194,8 @@ class RecoverCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId) - crudRepository.getArchivedAccount(account.accountId) + crudRepository.getAccountListSync(userWalletId) + crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) } } diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt index f1ba896a7c..5d6625fbe0 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/UpdateCryptoPortfolioUseCaseTest.kt @@ -48,7 +48,7 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(accountName = newAccountName) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -58,7 +58,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } @@ -76,7 +76,7 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(icon = newAccountIcon) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, icon = newAccountIcon) @@ -86,7 +86,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } @@ -105,7 +105,7 @@ class UpdateCryptoPortfolioUseCaseTest { val updatedAccount = accountList.mainAccount.copy(accountName = newAccountName, icon = newAccountIcon) val updatedAccountList = (accountList + updatedAccount).getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, accountName = newAccountName, icon = newAccountIcon) @@ -115,7 +115,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } @@ -126,7 +126,7 @@ class UpdateCryptoPortfolioUseCaseTest { val accountList = AccountList.empty(userWallet = userWallet) val accountId = accountList.mainAccount.accountId - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId) @@ -136,7 +136,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerify(inverse = true) { - crudRepository.getAccounts(userWalletId = any()) + crudRepository.getAccountListSync(userWalletId = any()) crudRepository.saveAccounts(accountList = any()) } } @@ -151,7 +151,7 @@ class UpdateCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Test exception") - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } throws exception + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } throws exception // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -160,7 +160,7 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(cause = exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -175,7 +175,7 @@ class UpdateCryptoPortfolioUseCaseTest { val newAccountName = AccountName("New name").getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -184,7 +184,7 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -199,7 +199,7 @@ class UpdateCryptoPortfolioUseCaseTest { val newAccountName = AccountName("New name").getOrNull()!! - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() // Act val actual = useCase(accountId = accountId, accountName = newAccountName) @@ -208,7 +208,7 @@ class UpdateCryptoPortfolioUseCaseTest { val expected = Error.CriticalTechError.AccountNotFound(accountId = accountId).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) } + coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) } coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) } } @@ -224,7 +224,7 @@ class UpdateCryptoPortfolioUseCaseTest { val exception = IllegalStateException("Save failed") - coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption() + coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() coEvery { crudRepository.saveAccounts(accountList = updatedAccountList) } throws exception // Act @@ -235,7 +235,7 @@ class UpdateCryptoPortfolioUseCaseTest { Truth.assertThat(actual).isEqualTo(expected) coVerifyOrder { - crudRepository.getAccounts(userWalletId = userWalletId) + crudRepository.getAccountListSync(userWalletId = userWalletId) crudRepository.saveAccounts(accountList = updatedAccountList) } } From 31558ee15d409ce1b242f67ded73cf2353a95882 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 11:32:46 +0000 Subject: [PATCH 22/27] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 57e50cf4ac..c57f7b4239 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.27.0-1148" +tangemBlockchainSdk = "develop-1205" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.27.0-519" +tangemCardSdk = "develop-557" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-454" +tangemHotSdk = "develop-525" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 2836d7bb2bc1a369fc8c9816d74d869e3a7a9a39 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 14:38:10 +0300 Subject: [PATCH 23/27] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 16 ++++++++++++++++ .../feature/impl/DevFeatureTogglesManager.kt | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 823d7df27c..7fd98efe81 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -14,6 +14,8 @@ import com.kaspersky.kaspresso.kaspresso.Kaspresso import com.kaspersky.kaspresso.testcases.api.testcase.TestCase import com.tangem.common.allure.FailedStepScreenshotInterceptor import com.tangem.common.rules.ApiEnvironmentRule +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys @@ -46,6 +48,9 @@ abstract class BaseTestCase : TestCase( @Inject lateinit var appPreferencesStore: AppPreferencesStore + @Inject + lateinit var featureTogglesManager: FeatureTogglesManager + private val hiltRule = HiltAndroidRule(this) private val apiEnvironmentRule = ApiEnvironmentRule() private val permissionRule = GrantPermissionRule.grant( @@ -90,6 +95,7 @@ abstract class BaseTestCase : TestCase( apiEnvironmentRule.setup(apiConfigsManager) ActivityScenario.launch(MainActivity::class.java) Intents.init() + setFeatureToggles() additionalBeforeSection() }.after { additionalAfterSection() @@ -113,4 +119,14 @@ abstract class BaseTestCase : TestCase( { composeTestRule.onRoot(useUnmergedTree = useUnmergedTree).printToLog(tag, maxDepth) } + + + private fun setFeatureToggles() { + runBlocking { + with(featureTogglesManager as MutableFeatureTogglesManager) { + changeToggle("WALLET_CONNECT_REDESIGN_ENABLED", true) + changeToggle("WALLET_BALANCE_FETCHER_ENABLED", true) + } + } + } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt index 6facf5dc8b..6677cae6d0 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt @@ -11,7 +11,7 @@ import java.util.Locale import kotlin.properties.Delegates /** - * Feature toggles manager implementation in DEV build + * Feature toggles manager implementation in dev or mocked build * * @property versionProvider application version provider * @property featureTogglesLocalStorage local storage for feature toggles From bc7d7ea7b6fdb80a6f8d400b766b7c3585fc0df2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 Aug 2025 15:41:41 +0200 Subject: [PATCH 24/27] Updated on 2026-08-14 --- .gitignore | 4 + app/build.gradle.kts | 33 ++++++ app/proguard-rules.pro | 7 ++ .../FirebasePushNotificationsTokenProvider.kt | 2 +- .../com/tangem/tap/di/GooglePushModule.kt} | 6 +- app/src/huawei/AndroidManifest.xml | 17 +++ .../HuaweiPushNotificationsTokenProvider.kt | 50 +++++++++ .../java/com/tangem/tap/HuaweiPushService.kt | 47 ++++++++ .../com/tangem/tap/di/HuaweiPushModule.kt | 18 ++++ .../AppConfigurationProviderImpl.kt | 13 +++ .../common/pushes/PushNotificationDelegate.kt | 102 ++++++++++++++++++ .../pushes/TangemPushNotificationService.kt | 92 +++------------- .../tangem/tap/di/AppConfigurationModule.kt | 18 ++++ build.gradle.kts | 7 ++ .../buildConfig/AppConfigurationProvider.kt | 6 ++ gradle/dependencies.toml | 16 +++ .../extension/AppExtensionConfigurations.kt | 1 + settings.gradle.kts | 2 + 18 files changed, 357 insertions(+), 84 deletions(-) rename app/src/{main/java/com/tangem/tap/data => google/java/com/tangem/tap}/FirebasePushNotificationsTokenProvider.kt (95%) rename app/src/{main/java/com/tangem/tap/di/data/PushNotificationsModule.kt => google/java/com/tangem/tap/di/GooglePushModule.kt} (74%) create mode 100644 app/src/huawei/AndroidManifest.xml create mode 100644 app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt create mode 100644 app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt create mode 100644 app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt create mode 100644 app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt create mode 100644 app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt create mode 100644 app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt create mode 100644 core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt diff --git a/.gitignore b/.gitignore index 6106311a37..0144ddc6e7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ local.properties # Google services + +# Huawei services +app/agconnect-services.json + app/src/debug/google-services.json app/src/internal/google-services.json app/src/external/google-services.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 17ee83e2e2..720052668f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,9 +11,14 @@ plugins { alias(deps.plugins.firebase.crashlytics) alias(deps.plugins.firebase.perf) alias(deps.plugins.ksp) + id(deps.plugins.agconnect.get().pluginId) id("configuration") } +agcp { + manifest = false +} + android { namespace = "com.tangem.wallet" testOptions { @@ -53,12 +58,35 @@ android { keyPassword = keystoreProperties["key_password"] as String } } + + flavorDimensions += "services" + + productFlavors { + create("google") { + dimension = "services" + buildConfigField("String", "FLAVOR_NAME", "\"google\"") + } + create("huawei") { + dimension = "services" + buildConfigField("String", "FLAVOR_NAME", "\"huawei\"") + } + } + + buildTypes { + debug { + buildConfigField("String", "BUILD_TYPE", "\"debug\"") + } + release { + buildConfigField("String", "BUILD_TYPE", "\"release\"") + } + } } configurations.all { exclude(group = "org.bouncycastle", module = "bcprov-jdk15to18") exclude(group = "com.github.komputing.kethereum") + exclude(group = "com.android.tools.build", module = "gradle") resolutionStrategy { dependencySubstitution { @@ -379,4 +407,9 @@ dependencies { // excludes version 9999.0-empty-to-avoid-conflict-with-guava exclude(group = "com.google.guava", module = "listenablefuture") } + + /** Huawei flavor-specific dependencies */ + "huaweiImplementation"(deps.huawei.push) + "huaweiImplementation"(deps.agconnect.agcp) + "huaweiImplementation"(deps.agconnect.core) } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 2a7b205c9d..4e36894e88 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,6 +7,13 @@ -keep class com.google.android.gms.internal.** { *; } -keepclasseswithmembers class com.google.firebase.FirebaseException +# huawei push kit +-ignorewarnings +-keepattributes SourceFile,LineNumberTable +-keep class com.huawei.hianalytics.**{*;} +-keep class com.huawei.updatesdk.**{*;} +-keep class com.huawei.hms.**{*;} + # hedera sdk -keep class com.hedera.hashgraph.sdk.** { *; } -keep interface com.hedera.hashgraph.sdk.** { *; } diff --git a/app/src/main/java/com/tangem/tap/data/FirebasePushNotificationsTokenProvider.kt b/app/src/google/java/com/tangem/tap/FirebasePushNotificationsTokenProvider.kt similarity index 95% rename from app/src/main/java/com/tangem/tap/data/FirebasePushNotificationsTokenProvider.kt rename to app/src/google/java/com/tangem/tap/FirebasePushNotificationsTokenProvider.kt index 358bdd3d61..627e41f2cc 100644 --- a/app/src/main/java/com/tangem/tap/data/FirebasePushNotificationsTokenProvider.kt +++ b/app/src/google/java/com/tangem/tap/FirebasePushNotificationsTokenProvider.kt @@ -1,4 +1,4 @@ -package com.tangem.tap.data +package com.tangem.tap import com.google.firebase.messaging.FirebaseMessaging import com.tangem.utils.notifications.PushNotificationsTokenProvider diff --git a/app/src/main/java/com/tangem/tap/di/data/PushNotificationsModule.kt b/app/src/google/java/com/tangem/tap/di/GooglePushModule.kt similarity index 74% rename from app/src/main/java/com/tangem/tap/di/data/PushNotificationsModule.kt rename to app/src/google/java/com/tangem/tap/di/GooglePushModule.kt index 063efaf3dc..a8e4e87c08 100644 --- a/app/src/main/java/com/tangem/tap/di/data/PushNotificationsModule.kt +++ b/app/src/google/java/com/tangem/tap/di/GooglePushModule.kt @@ -1,6 +1,6 @@ -package com.tangem.tap.di.data +package com.tangem.tap.di -import com.tangem.tap.data.FirebasePushNotificationsTokenProvider +import com.tangem.tap.FirebasePushNotificationsTokenProvider import com.tangem.utils.notifications.PushNotificationsTokenProvider import dagger.Binds import dagger.Module @@ -10,7 +10,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal interface PushNotificationsModule { +internal interface GooglePushModule { @Binds @Singleton diff --git a/app/src/huawei/AndroidManifest.xml b/app/src/huawei/AndroidManifest.xml new file mode 100644 index 0000000000..0c769dbe3e --- /dev/null +++ b/app/src/huawei/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt b/app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt new file mode 100644 index 0000000000..03c709cd6c --- /dev/null +++ b/app/src/huawei/java/com/tangem/tap/HuaweiPushNotificationsTokenProvider.kt @@ -0,0 +1,50 @@ +package com.tangem.tap + +import android.content.Context +import com.google.firebase.messaging.FirebaseMessaging +import com.huawei.agconnect.AGConnectOptionsBuilder +import com.huawei.hms.aaid.HmsInstanceId +import com.huawei.hms.common.ApiException +import com.tangem.google.GoogleServicesHelper +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.notifications.PushNotificationsTokenProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +internal class HuaweiPushNotificationsTokenProvider @Inject constructor( + @ApplicationContext private val context: Context, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, +) : PushNotificationsTokenProvider { + + override suspend fun getToken(): String { + val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(context) + return if (isGoogleServicesAvailable) { + try { + FirebaseMessaging.getInstance().token.await() + } catch (ex: Exception) { + Timber.e(ex) + "" + } + } else { + withContext(coroutineDispatcherProvider.io) { + try { + val appId = AGConnectOptionsBuilder().build(context).getString(APP_ID_KEY) + val token = HmsInstanceId.getInstance(context).getToken(appId, TOKEN_REQUEST_MODE) + Timber.i("Requested token from HuaweiService: $token") + token + } catch (e: ApiException) { + Timber.i("Fetching token from HuaweiService failed cause: ${e.message}") + "" + } + } + } + } + + companion object { + private const val APP_ID_KEY = "client/app_id" + private const val TOKEN_REQUEST_MODE = "HCM" + } +} \ No newline at end of file diff --git a/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt b/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt new file mode 100644 index 0000000000..b7a1de528d --- /dev/null +++ b/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt @@ -0,0 +1,47 @@ +package com.tangem.tap + +import android.os.Bundle +import com.huawei.hms.push.HmsMessageService +import com.huawei.hms.push.RemoteMessage +import com.tangem.google.GoogleServicesHelper +import com.tangem.tap.common.pushes.PushNotificationDelegate +import timber.log.Timber + +class HuaweiPushService : HmsMessageService() { + + private val pushNotificationDelegate: PushNotificationDelegate by lazy { + PushNotificationDelegate(applicationContext) + } + + override fun onNewToken(token: String?, bundle: Bundle?) { + super.onNewToken(token, bundle) + Timber.i("HuaweiPushService: On new token from HuaweiService: $token") + } + + override fun onTokenError(e: Exception?, bundle: Bundle?) { + super.onTokenError(e, bundle) + Timber.i("HuaweiPushService: Fetching token from HuaweiService failed cause: ${e?.message}") + } + + override fun onMessageReceived(message: RemoteMessage?) { + super.onMessageReceived(message) + val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(this) + if (isGoogleServicesAvailable) return + val notification = message?.notification ?: return + val channelId = notification.channelId ?: TANGEM_CHANNEL_ID + + pushNotificationDelegate.showNotification( + dataMap = message.dataOfMap, + title = notification.title, + body = notification.body, + channelId = channelId, + priority = message.urgency, + imageUrl = notification.imageUrl, + vibratePattern = notification.vibrateConfig, + ) + } + + private companion object { + const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications + } +} \ No newline at end of file diff --git a/app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt b/app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt new file mode 100644 index 0000000000..f4379c5469 --- /dev/null +++ b/app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di + +import com.tangem.tap.HuaweiPushNotificationsTokenProvider +import com.tangem.utils.notifications.PushNotificationsTokenProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface HuaweiPushModule { + + @Binds + @Singleton + fun bindPushNotificationsTokenProvider(impl: HuaweiPushNotificationsTokenProvider): PushNotificationsTokenProvider +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt b/app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt new file mode 100644 index 0000000000..7a87d69e7c --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/buildconfig/AppConfigurationProviderImpl.kt @@ -0,0 +1,13 @@ +package com.tangem.tap.common.buildconfig + +import com.tangem.utils.buildConfig.AppConfigurationProvider +import com.tangem.wallet.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class AppConfigurationProviderImpl @Inject constructor() : AppConfigurationProvider { + + override fun isDebug(): Boolean = BuildConfig.BUILD_TYPE == "debug" + override fun isHuawei(): Boolean = BuildConfig.FLAVOR_NAME == "huawei" +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt b/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt new file mode 100644 index 0000000000..844eec86ff --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/pushes/PushNotificationDelegate.kt @@ -0,0 +1,102 @@ +package com.tangem.tap.common.pushes + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import coil.executeBlocking +import coil.request.ImageRequest +import com.tangem.domain.common.LogConfig +import com.tangem.tap.MainActivity +import com.tangem.tap.common.images.createCoilImageLoader +import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler +import com.tangem.wallet.R + +class PushNotificationDelegate(private val context: Context) { + + @Suppress("LongParameterList") + fun showNotification( + dataMap: Map, + title: String?, + body: String?, + channelId: String, + priority: Int, + imageUrl: Uri? = null, + vibratePattern: LongArray?, + ) { + val intent = Intent(context, MainActivity::class.java).apply { + dataMap.forEach { (key, value) -> + putExtra(key, value) + } + putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + val pendingIntent = PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE, + /* intent = */ intent, + /* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notificationBuilder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_tangem_24) + .setContentTitle(title) + .setContentText(body) + .setPriority(priority) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setVibrate(vibratePattern) + .apply { + imageUrl?.let { uri -> + val bitmap = getBitmapImageFromUrl(uri) + setStyle( + NotificationCompat + .BigPictureStyle() + .bigPicture(bitmap), + ).setLargeIcon(bitmap) + } + } + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannel = NotificationChannel( + channelId, + ContextCompat.getString(context, R.string.tangem_app_name), + NotificationManager.IMPORTANCE_HIGH, + ) + notificationManager.createNotificationChannel(notificationChannel) + } + + // Generating unique notification id + val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt() + + notificationManager.notify( + /* id = */ uniqueId, + /* notification = */ notificationBuilder.build(), + ) + } + + private fun getBitmapImageFromUrl(url: Uri): Bitmap? { + return createCoilImageLoader( + context, + logEnabled = LogConfig.imageLoader, + ).executeBlocking( + ImageRequest.Builder(context) + .data(url) + .build(), + ).drawable?.toBitmap() + } + + private companion object { + const val PUSH_NOTIFICATION_REQUEST_CODE = 123 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt index ebeec6dffc..97026725ea 100644 --- a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt +++ b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt @@ -1,30 +1,17 @@ package com.tangem.tap.common.pushes import android.annotation.SuppressLint -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Intent -import android.graphics.Bitmap -import android.net.Uri -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toBitmap -import coil.executeBlocking -import coil.request.ImageRequest import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import com.tangem.domain.common.LogConfig -import com.tangem.tap.MainActivity -import com.tangem.tap.common.images.createCoilImageLoader -import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler -import com.tangem.wallet.R import timber.log.Timber @SuppressLint("MissingFirebaseInstanceTokenRefresh") internal class TangemPushNotificationService : FirebaseMessagingService() { + private val pushNotificationDelegate: PushNotificationDelegate by lazy { + PushNotificationDelegate(applicationContext) + } + override fun onNewToken(token: String) { super.onNewToken(token) Timber.d("New FCM token received: $token") @@ -36,73 +23,18 @@ internal class TangemPushNotificationService : FirebaseMessagingService() { val notification = message.notification ?: return val channelId = notification.channelId ?: TANGEM_CHANNEL_ID - val intent = Intent(applicationContext, MainActivity::class.java).apply { - message.data.forEach { - putExtra(it.key, it.value) - } - putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - val pendingIntent = PendingIntent.getActivity( - /* context = */ this, - /* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE, - /* intent = */ intent, - /* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, + pushNotificationDelegate.showNotification( + dataMap = message.data, + title = notification.title, + body = notification.body, + channelId = channelId, + priority = message.priority, + imageUrl = notification.imageUrl, + vibratePattern = notification.vibrateTimings, ) - - val notificationBuilder = - NotificationCompat.Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.ic_tangem_24) - .setContentTitle(notification.title) - .setContentText(notification.body) - .setPriority(message.priority) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .setVibrate(notification.vibrateTimings) - .apply { - notification.imageUrl?.let { uri -> - val bitmap = getBitmapImageFromUrl(uri) - setStyle( - NotificationCompat - .BigPictureStyle() - .bigPicture(bitmap), - ).setLargeIcon(bitmap) - } - } - - val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationChannel = NotificationChannel( - channelId, - ContextCompat.getString(applicationContext, R.string.tangem_app_name), - NotificationManager.IMPORTANCE_HIGH, - ) - notificationManager.createNotificationChannel(notificationChannel) - } - - // Generating unique notification id - val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt() - - notificationManager.notify( - /* id = */ uniqueId, - /* notification = */ notificationBuilder.build(), - ) - } - - private fun getBitmapImageFromUrl(url: Uri): Bitmap? { - return createCoilImageLoader( - applicationContext, - logEnabled = LogConfig.imageLoader, - ).executeBlocking( - ImageRequest.Builder(applicationContext) - .data(url) - .build(), - ).drawable?.toBitmap() } private companion object { const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications - const val PUSH_NOTIFICATION_REQUEST_CODE = 123 } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt b/app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt new file mode 100644 index 0000000000..42148c10d5 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/AppConfigurationModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di + +import com.tangem.tap.common.buildconfig.AppConfigurationProviderImpl +import com.tangem.utils.buildConfig.AppConfigurationProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface AppConfigurationModule { + + @Binds + @Singleton + fun bindAppConfigurationProvider(impl: AppConfigurationProviderImpl): AppConfigurationProvider +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index c490aef8c2..5d73b767fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,13 @@ plugins { alias(deps.plugins.ksp) apply false } +buildscript { + dependencies { + classpath(deps.gradle.android) + classpath(deps.agconnect.agcp) + } +} + val clean by tasks.registering { delete(rootProject.buildDir) } diff --git a/core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt b/core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt new file mode 100644 index 0000000000..863864842e --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/buildConfig/AppConfigurationProvider.kt @@ -0,0 +1,6 @@ +package com.tangem.utils.buildConfig + +interface AppConfigurationProvider { + fun isDebug(): Boolean + fun isHuawei(): Boolean +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index f9ef6078d0..37e38fce69 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -11,6 +11,12 @@ ksp = "2.1.10-1.0.30" firebasePerf = "1.4.2" # endregion Classpath +# region AppGallery +agconnect = "1.9.1.304" +huaweiServices = "6.9.0.301" +huaweiPush = "6.11.0.300" +# endregion AppGallery + # region AndroidX androidxActivityCompose = "1.8.0" androidxAppCompat = "1.5.1" @@ -130,6 +136,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } room = { id = "androidx.room", version.ref = "room" } kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +agconnect = { id = "com.huawei.agconnect", version.ref = "agconnect" } [libraries] # region Classpath @@ -190,6 +197,15 @@ firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" } firebase-perf = { module = "com.google.firebase:firebase-perf" } # endregion Firebase +# region AppGallery +agconnect-agcp = { module = "com.huawei.agconnect:agcp", version.ref = "agconnect" } +agconnect-core = { module = "com.huawei.agconnect:agconnect-core", version.ref = "agconnect" } +agconnect-crash = { module = "com.huawei.agconnect:agconnect-crash", version.ref = "agconnect" } +huawei-base = { module = "com.huawei.hms:base", version.ref = "huaweiServices" } +huawei-analytics = { module = "com.huawei.hms:hianalytics", version.ref = "huaweiServices" } +huawei-push = { module = "com.huawei.hms:push", version.ref = "huaweiPush" } +# endregion AppGallery + # region Detekt detekt-compose = { module = "ru.kode:detekt-rules-compose", version.ref = "detektComposeRules" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt index 6f03ce1a3a..88e9d25480 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt @@ -75,6 +75,7 @@ private fun AndroidBuildType.configureBuildVariant(appExtension: AppExtension, b } BuildType.Debug -> { isDebuggable = true + signingConfig = appExtension.signingConfigs.getByName(BuildType.Debug.id) } BuildType.Internal, BuildType.External diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c7251cb46..1511ce8a21 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ pluginManagement { gradlePluginPortal() google() mavenCentral() + maven { url = uri("https://developer.huawei.com/repo/") } } includeBuild("plugins/configuration") @@ -35,6 +36,7 @@ dependencyResolutionManagement { } } mavenCentral() + maven { url = uri("https://developer.huawei.com/repo/") } mavenLocal { content { includeGroupAndSubgroups("com.tangem.tangem-sdk-kotlin") From 4c2ce375e919a26b942a0a46d47fc6ac7eed1967 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 08:53:06 +0200 Subject: [PATCH 25/27] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 ++++ .../appbar/models/TopAppBarButtonUM.kt | 6 ++++++ .../onramp/main/entity/OnrampIntents.kt | 1 + .../main/model/OnrampMainComponentModel.kt | 2 ++ .../DefaultOnrampNewMainFeatureToggle.kt | 10 +++++++++ .../onramp/newmain/OnrampNewMainComponent.kt | 21 +++++++++++++++++++ .../newmain/OnrampNewMainFeatureToggle.kt | 5 +++++ 7 files changed, 49 insertions(+) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 318f39ce65..9d99dceed5 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -66,5 +66,9 @@ { "name": "YIELD_LENDING_FEATURE_ENABLED", "version": "undefined" + }, + { + "name": "NEW_ONRAMP_MAIN_ENABLED", + "version": "undefined" } ] diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt index aa06b614a0..3d1549e138 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/models/TopAppBarButtonUM.kt @@ -32,6 +32,12 @@ sealed class TopAppBarButtonUM( enabled = enabled, ) + fun Close(enabled: Boolean = true, onCloseClick: () -> Unit) = Icon( + iconRes = R.drawable.ic_close_24, + onClicked = onCloseClick, + enabled = enabled, + ) + fun Text(text: TextReference, onTextClicked: () -> Unit, enabled: Boolean = true) = Text( text = text, onClicked = onTextClicked, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt index 5654a17174..6ed094c0cb 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampIntents.kt @@ -10,4 +10,5 @@ interface OnrampIntents { fun openProviders() fun onRefresh() fun onLinkClick(link: String) + fun onContinueClick() } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 97f9d147c7..50acb8a5a6 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -281,6 +281,8 @@ internal class OnrampMainComponentModel @Inject constructor( override fun onLinkClick(link: String) = urlOpener.openUrl(link) + override fun onContinueClick() = Unit + override fun onDestroy() { modelScope.launch { clearOnrampCacheUseCase.invoke() } quotesTaskScheduler.cancelTask() diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt new file mode 100644 index 0000000000..e73a8570bd --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/DefaultOnrampNewMainFeatureToggle.kt @@ -0,0 +1,10 @@ +package com.tangem.features.onramp.newmain + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager + +class DefaultOnrampNewMainFeatureToggle( + private val featureTogglesManager: FeatureTogglesManager, +) : OnrampNewMainFeatureToggle { + override val isOnrampNewMainEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_RECEIVE_ENABLED") +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt new file mode 100644 index 0000000000..d30d134f81 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainComponent.kt @@ -0,0 +1,21 @@ +package com.tangem.features.onramp.newmain + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampProviderWithQuote +import com.tangem.domain.onramp.model.OnrampSource + +internal interface OnrampNewMainComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, + val source: OnrampSource, + val openSettings: () -> Unit, + val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt new file mode 100644 index 0000000000..5eab7b94c5 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/newmain/OnrampNewMainFeatureToggle.kt @@ -0,0 +1,5 @@ +package com.tangem.features.onramp.newmain + +internal interface OnrampNewMainFeatureToggle { + val isOnrampNewMainEnabled: Boolean +} \ No newline at end of file From 2cd8786c752059177b1a178ac2d4220ca6bd39d2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 13:30:29 +0500 Subject: [PATCH 26/27] Updated on 2026-08-14 --- .../grid/EnumeratedTwoColumnGrid.kt | 104 ++++++++++++++++++ .../entity/EnumeratedTwoColumnGridItem.kt | 6 + .../phrase/entity/ManualBackupPhraseUM.kt | 10 +- .../phrase/model/ManualBackupPhraseModel.kt | 3 +- .../phrase/ui/ManualBackupPhraseContent.kt | 76 +------------ .../GenerateSeedPhraseUiStateBuilder.kt | 9 +- .../ui/MultiWalletSeedPhraseWords.kt | 74 +------------ .../ui/state/MultiWalletSeedPhraseUM.kt | 10 +- 8 files changed, 133 insertions(+), 159 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt b/core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt new file mode 100644 index 0000000000..f3c88b8cde --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/grid/EnumeratedTwoColumnGrid.kt @@ -0,0 +1,104 @@ +package com.tangem.core.ui.components.grid + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * EnumeratedTwoColumnGrid component + * + * @param items component items + * @param modifier composable modifier + * + */ +@Composable +fun EnumeratedTwoColumnGrid(items: ImmutableList, modifier: Modifier = Modifier) { + VerticalGrid( + modifier = modifier, + items = items, + ) { item -> + Row( + modifier = Modifier.padding(all = TangemTheme.dimens.size8), + verticalAlignment = Alignment.CenterVertically, + ) { + if (LocalLayoutDirection.current == LayoutDirection.Ltr) { + Text( + modifier = Modifier.width(TangemTheme.dimens.size40), + text = "${item.index}.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + Text( + text = item.mnemonic, + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.primary1, + ) + } else { + Text( + text = item.mnemonic, + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier.width(TangemTheme.dimens.size40), + text = "${item.index}.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } + } +} + +@Composable +private inline fun VerticalGrid( + items: ImmutableList, + modifier: Modifier = Modifier, + crossinline content: @Composable (T) -> Unit, +) { + val columnLength = items.size / 2 + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + repeat(2) { index -> + Column { + for (i in 0 until columnLength) { + val item = items[index * columnLength + i] + content(item) + } + } + } + } +} + +@Preview(widthDp = 360, heightDp = 640, showBackground = true) +@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + EnumeratedTwoColumnGrid( + items = List(24) { + EnumeratedTwoColumnGridItem( + index = it + 1, + mnemonic = "word${it + 1}", + ) + }.toImmutableList(), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt new file mode 100644 index 0000000000..770855af97 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/grid/entity/EnumeratedTwoColumnGridItem.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.components.grid.entity + +data class EnumeratedTwoColumnGridItem( + val index: Int, + val mnemonic: String, +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt index 3a799f4f31..a2b29d0220 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/entity/ManualBackupPhraseUM.kt @@ -1,14 +1,10 @@ package com.tangem.features.hotwallet.manualbackup.phrase.entity +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf internal data class ManualBackupPhraseUM( val onContinueClick: () -> Unit, - val words: ImmutableList = persistentListOf(), -) { - data class MnemonicGridItem( - val index: Int, - val mnemonic: String, - ) -} \ No newline at end of file + val words: ImmutableList = persistentListOf(), +) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt index e9625260ad..0c99cd5606 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/model/ManualBackupPhraseModel.kt @@ -5,6 +5,7 @@ import arrow.core.getOrElse import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent @@ -51,7 +52,7 @@ internal class ManualBackupPhraseModel @Inject constructor( uiState.update { it.copy( words = seedPhrasePrivateInfo.mnemonic.mnemonicComponents.mapIndexed { index, s -> - ManualBackupPhraseUM.MnemonicGridItem(index + 1, s) + EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList(), ) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt index 3bba1be463..86458b8395 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/manualbackup/phrase/ui/ManualBackupPhraseContent.kt @@ -7,20 +7,18 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.hotwallet.impl.R import com.tangem.features.hotwallet.manualbackup.phrase.entity.ManualBackupPhraseUM -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @Composable @@ -41,8 +39,8 @@ internal fun ManualBackupPhraseContent(state: ManualBackupPhraseUM, modifier: Mo modifier = Modifier.padding(top = 20.dp), ) - SeedPhraseGridBlock( - mnemonicGridItems = state.words, + EnumeratedTwoColumnGrid( + items = state.words, modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 32.dp), @@ -97,70 +95,6 @@ private fun TitleBlock(state: ManualBackupPhraseUM, modifier: Modifier = Modifie } } -@Composable -private fun SeedPhraseGridBlock( - mnemonicGridItems: ImmutableList, - modifier: Modifier = Modifier, -) { - VerticalGrid( - modifier = modifier, - items = mnemonicGridItems, - ) { item -> - Row( - modifier = Modifier.padding(all = TangemTheme.dimens.size8), - verticalAlignment = Alignment.CenterVertically, - ) { - if (LocalLayoutDirection.current == LayoutDirection.Ltr) { - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - } else { - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } - } -} - -@Composable -private inline fun VerticalGrid( - items: ImmutableList, - modifier: Modifier = Modifier, - crossinline content: @Composable (T) -> Unit, -) { - val columnLength = items.size / 2 - Row( - modifier = modifier, - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - repeat(2) { index -> - Column { - for (i in 0 until columnLength) { - val item = items[index * columnLength + i] - content(item) - } - } - } - } -} - @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Preview(showBackground = true, widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -170,7 +104,7 @@ private fun Preview() { state = ManualBackupPhraseUM( onContinueClick = {}, words = List(12) { - ManualBackupPhraseUM.MnemonicGridItem( + EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word${it + 1}", ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt index e7c4efd3b2..d5c6e72ba3 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/GenerateSeedPhraseUiStateBuilder.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.crypto.bip39.Mnemonic import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM @@ -20,10 +21,10 @@ internal class GenerateSeedPhraseUiStateBuilder( option: GeneratedWordsType, ): MultiWalletSeedPhraseUM.GenerateSeedPhrase { val words12 = generatedWords12.mnemonicComponents.mapIndexed { index, s -> - MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s) + EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList() val words24 = generatedWords24.mnemonicComponents.mapIndexed { index, s -> - MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s) + EnumeratedTwoColumnGridItem(index + 1, s) }.toImmutableList() return MultiWalletSeedPhraseUM.GenerateSeedPhrase( @@ -45,8 +46,8 @@ internal class GenerateSeedPhraseUiStateBuilder( private fun switchType( newType: GeneratedWordsType, - generatedWords12: ImmutableList, - generatedWords24: ImmutableList, + generatedWords12: ImmutableList, + generatedWords24: ImmutableList, ) { updateUiState { uiSt -> changeGeneratedWordsType(newType) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt index 1099d8bcc1..039ddff2e5 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhraseWords.kt @@ -5,15 +5,14 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.core.ui.extensions.pluralStringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -21,8 +20,6 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM -import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -45,8 +42,8 @@ internal fun MultiWalletSeedPhraseWords( TitleBlock(state) - SeedPhraseGridBlock( - mnemonicGridItems = state.words, + EnumeratedTwoColumnGrid( + items = state.words, modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 32.dp), @@ -127,67 +124,6 @@ private fun TitleBlock(state: MultiWalletSeedPhraseUM.GenerateSeedPhrase, modifi } } -@Composable -private fun SeedPhraseGridBlock(mnemonicGridItems: ImmutableList, modifier: Modifier = Modifier) { - VerticalGrid( - modifier = modifier, - items = mnemonicGridItems, - ) { item -> - Row( - modifier = Modifier.padding(all = TangemTheme.dimens.size8), - verticalAlignment = Alignment.CenterVertically, - ) { - if (LocalLayoutDirection.current == LayoutDirection.Ltr) { - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - } else { - Text( - text = item.mnemonic, - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - ) - Text( - modifier = Modifier.width(TangemTheme.dimens.size40), - text = "${item.index}.", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - } - } - } -} - -@Composable -private inline fun VerticalGrid( - items: ImmutableList, - modifier: Modifier = Modifier, - crossinline content: @Composable (T) -> Unit, -) { - val columnLength = items.size / 2 - Row( - modifier = modifier, - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - repeat(2) { index -> - Column { - for (i in 0 until columnLength) { - val item = items[index * columnLength + i] - content(item) - } - } - } - } -} - @Preview(showBackground = true, heightDp = 640) @Composable private fun Preview() { @@ -195,7 +131,7 @@ private fun Preview() { MultiWalletSeedPhraseWords( state = MultiWalletSeedPhraseUM.GenerateSeedPhrase( words = List(24) { - MnemonicGridItem( + EnumeratedTwoColumnGridItem( index = it + 1, mnemonic = "word1", ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt index c167a59481..af9e46b850 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.s import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem import com.tangem.core.ui.extensions.TextReference import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType @@ -22,15 +23,10 @@ internal sealed class MultiWalletSeedPhraseUM( data class GenerateSeedPhrase( val option: GeneratedWordsType = GeneratedWordsType.Words12, - val words: ImmutableList = persistentListOf(), + val words: ImmutableList = persistentListOf(), val onOptionChange: (GeneratedWordsType) -> Unit = {}, val onContinueClick: () -> Unit = {}, - ) : MultiWalletSeedPhraseUM(order = 1) { - data class MnemonicGridItem( - val index: Int, - val mnemonic: String, - ) - } + ) : MultiWalletSeedPhraseUM(order = 1) data class GeneratedWordsCheck( val wordFields: ImmutableList = persistentListOf(), From 450db48cedc8fbe93b2ecc3d01b9458df442dc9c Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Sep 2025 11:56:40 +0300 Subject: [PATCH 27/27] Updated on 2026-08-14 --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0dd13a52a2..09eae1b14d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -74,7 +74,7 @@ platform :android do gradle( task: "assemble", - build_type: "Internal", + build_type: "GoogleInternal", properties: { 'versionCode' => ENV['version_code'], 'versionName' => ENV['version_name'],