From 99dfa2a392f3a2acd7034e4608d05039ee413435 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Oct 2025 22:38:15 +0500 Subject: [PATCH 01/46] Updated on 2026-08-14 --- .../tangem/datasource/di/YieldSupplyModule.kt | 4 +- .../yieldsupply/DefaultYieldMarketsStore.kt | 10 ++-- .../local/yieldsupply/YieldMarketsStore.kt | 8 +-- .../DefaultYieldSupplyMarketRepository.kt | 8 +-- .../impl/main/entity/LoadingStatusMode.kt | 6 +++ .../impl/main/model/YieldSupplyModel.kt | 39 +++++++------- ...ieldSupplyTokenStatusFailureTransformer.kt | 17 ++++++ ...ieldSupplyTokenStatusSuccessTransformer.kt | 42 +++++++++++++++ .../active/YieldSupplyActiveComponent.kt | 16 +++++- .../entity/YieldSupplyActiveContentUM.kt | 1 + .../active/model/YieldSupplyActiveModel.kt | 22 ++++++++ .../active/ui/YieldSupplyActiveContent.kt | 52 ++++++++++++++++++- 12 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/LoadingStatusMode.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusFailureTransformer.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt index 8b174ebbc1..2b7cfecd0d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt @@ -4,11 +4,11 @@ import android.content.Context import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi +import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse import com.tangem.datasource.local.yieldsupply.DefaultYieldMarketsStore import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes -import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -34,7 +34,7 @@ object YieldSupplyModule { persistenceStore = DataStoreFactory.create( serializer = MoshiDataStoreSerializer( moshi = moshi, - types = listTypes(), + types = listTypes(), defaultValue = emptyList(), ), produceFile = { context.dataStoreFile(fileName = "yield_markets_cache") }, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt index b282890523..7f4dbc5eb7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt @@ -1,21 +1,21 @@ package com.tangem.datasource.local.yieldsupply import androidx.datastore.core.DataStore -import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull internal class DefaultYieldMarketsStore( - private val persistenceStore: DataStore>, + private val persistenceStore: DataStore>, ) : YieldMarketsStore { - override fun get(): Flow> = persistenceStore.data + override fun get(): Flow> = persistenceStore.data - override suspend fun getSyncOrNull(): List? { + override suspend fun getSyncOrNull(): List? { return persistenceStore.data.firstOrNull() } - override suspend fun store(items: List) { + override suspend fun store(items: List) { persistenceStore.updateData { _ -> items } } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt index c78c131d7a..8857e6f40a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt @@ -1,13 +1,13 @@ package com.tangem.datasource.local.yieldsupply -import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse import kotlinx.coroutines.flow.Flow interface YieldMarketsStore { - fun get(): Flow> + fun get(): Flow> - suspend fun getSyncOrNull(): List? + suspend fun getSyncOrNull(): List? - suspend fun store(items: List) + suspend fun store(items: List) } \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt index 8abd538e91..641a04923e 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt @@ -27,18 +27,20 @@ internal class DefaultYieldSupplyMarketRepository( ) : YieldSupplyMarketRepository { override suspend fun getCachedMarkets(): List? = withContext(dispatchers.io) { - store.getSyncOrNull()?.enrichNetworkIds() + val cache = store.getSyncOrNull().orEmpty() + val domain = cache.map(YieldMarketTokenConverter::convert) + domain.enrichNetworkIds() } override suspend fun updateMarkets(): List = withContext(dispatchers.io) { val response = yieldSupplyApi.getYieldMarkets().getOrThrow() val domain = response.marketDtos.map(YieldMarketTokenConverter::convert) - store.store(domain) + store.store(response.marketDtos) domain } override fun getMarketsFlow(): Flow> = store.get().map { - it.enrichNetworkIds() + it.map(YieldMarketTokenConverter::convert).enrichNetworkIds() } override suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketTokenStatus { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/LoadingStatusMode.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/LoadingStatusMode.kt new file mode 100644 index 0000000000..9480ba16ad --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/LoadingStatusMode.kt @@ -0,0 +1,6 @@ +package com.tangem.features.yield.supply.impl.main.entity + +internal enum class LoadingStatusMode { + Initial, + LoadApy, +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index d4c2c34325..829f65d97f 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -8,8 +8,6 @@ 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.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -20,8 +18,10 @@ import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.features.yield.supply.api.YieldSupplyComponent -import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.features.yield.supply.impl.main.entity.LoadingStatusMode +import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusFailureTransformer +import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.DelayedWork import kotlinx.coroutines.CoroutineScope @@ -29,6 +29,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber +import com.tangem.utils.transformer.update import javax.inject.Inject import kotlin.properties.Delegates @@ -102,24 +103,20 @@ internal class YieldSupplyModel @Inject constructor( } } - private fun loadTokenStatus(cryptoCurrency: CryptoCurrency.Token) { + private fun loadTokenStatus(mode: LoadingStatusMode) { + val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return modelScope.launch(dispatchers.default) { - yieldSupplyGetTokenStatusUseCase(cryptoCurrency) + yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) .onRight { tokenStatus -> - val newState = if (tokenStatus.isActive) { - YieldSupplyUM.Initial( - title = resourceReference( - id = R.string.yield_module_token_details_earn_notification_title, - formatArgs = wrappedList(tokenStatus.apy), - ), - onClick = ::onStartEarningClick, - ) - } else { - YieldSupplyUM.Unavailable - } - uiState.update { newState } + uiState.update( + YieldSupplyTokenStatusSuccessTransformer( + tokenStatus = tokenStatus, + onStartEarningClick = ::onStartEarningClick, + mode = mode, + ), + ) }.onLeft { - uiState.update { YieldSupplyUM.Unavailable } + uiState.update(YieldSupplyTokenStatusFailureTransformer(mode)) } } } @@ -178,8 +175,10 @@ internal class YieldSupplyModel @Inject constructor( uiState.update { yieldSupplyUM } - if (yieldSupplyUM is YieldSupplyUM.Loading) { - (cryptoCurrency as? CryptoCurrency.Token)?.let(::loadTokenStatus) + when (yieldSupplyUM) { + is YieldSupplyUM.Loading -> loadTokenStatus(LoadingStatusMode.Initial) + is YieldSupplyUM.Content -> loadTokenStatus(LoadingStatusMode.LoadApy) + else -> Unit } } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusFailureTransformer.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusFailureTransformer.kt new file mode 100644 index 0000000000..0154fa52ef --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusFailureTransformer.kt @@ -0,0 +1,17 @@ +package com.tangem.features.yield.supply.impl.main.model.transformers + +import com.tangem.features.yield.supply.impl.main.entity.LoadingStatusMode +import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.utils.transformer.Transformer + +internal class YieldSupplyTokenStatusFailureTransformer( + private val mode: LoadingStatusMode, +) : Transformer { + + override fun transform(prevState: YieldSupplyUM): YieldSupplyUM { + return when (mode) { + LoadingStatusMode.Initial -> YieldSupplyUM.Unavailable + LoadingStatusMode.LoadApy -> prevState // TODO apply correct UI + } + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt new file mode 100644 index 0000000000..42549b2060 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt @@ -0,0 +1,42 @@ +package com.tangem.features.yield.supply.impl.main.model.transformers + +import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.main.entity.LoadingStatusMode +import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.utils.transformer.Transformer + +internal class YieldSupplyTokenStatusSuccessTransformer( + private val tokenStatus: YieldMarketTokenStatus, + private val onStartEarningClick: () -> Unit, + private val mode: LoadingStatusMode, +) : Transformer { + + override fun transform(prevState: YieldSupplyUM): YieldSupplyUM { + if (!tokenStatus.isActive) return YieldSupplyUM.Unavailable + + return when (mode) { + LoadingStatusMode.Initial -> { + YieldSupplyUM.Initial( + title = resourceReference( + id = R.string.yield_module_token_details_earn_notification_title, + formatArgs = wrappedList(tokenStatus.apy), + ), + onClick = onStartEarningClick, + ) + } + LoadingStatusMode.LoadApy -> { + if (prevState is YieldSupplyUM.Content) { + prevState.copy( + rewardsApy = stringReference("${tokenStatus.apy}%"), + ) + } else { + prevState + } + } + } + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt index 4d7abbb513..db0f137890 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/YieldSupplyActiveComponent.kt @@ -8,16 +8,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.features.yield.supply.impl.subcomponents.active.model.YieldSupplyActiveModel import com.tangem.features.yield.supply.impl.subcomponents.active.ui.YieldSupplyActiveContent import com.tangem.features.yield.supply.impl.subcomponents.active.ui.YieldSupplyActiveTitle import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.chart.DefaultYieldSupplyChartComponent import kotlinx.coroutines.flow.StateFlow internal class YieldSupplyActiveComponent( @@ -26,6 +29,12 @@ internal class YieldSupplyActiveComponent( ) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: YieldSupplyActiveModel = getOrCreateModel(params = params) + private val chartComponent = DefaultYieldSupplyChartComponent( + appComponentContext = child("chartComponent"), + params = DefaultYieldSupplyChartComponent.Params( + cryptoCurrency = params.cryptoCurrencyStatusFlow.value.currency as CryptoCurrency.Token, + ), + ) @Composable override fun Title() { @@ -37,7 +46,12 @@ internal class YieldSupplyActiveComponent( val state by model.uiState.collectAsStateWithLifecycle() val isBalanceHidden by params.isBalanceHiddenFlow.collectAsStateWithLifecycle() - YieldSupplyActiveContent(state = state, isBalanceHidden = isBalanceHidden, modifier = Modifier) + YieldSupplyActiveContent( + state = state, + isBalanceHidden = isBalanceHidden, + chartComponent = chartComponent, + modifier = Modifier, + ) } @Composable diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt index 7cda12cb63..bf0fdc6e5b 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/entity/YieldSupplyActiveContentUM.kt @@ -10,4 +10,5 @@ internal data class YieldSupplyActiveContentUM( val subtitle: TextReference, val subtitleLink: TextReference, val notificationUM: NotificationUM?, + val apy: TextReference? = null, ) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt index 4b38fbb125..ae85f3f637 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/model/YieldSupplyActiveModel.kt @@ -5,18 +5,22 @@ 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.notifications.NotificationConfig +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.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.yield.supply.usecase.YieldSupplyGetProtocolBalanceUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.subcomponents.active.YieldSupplyActiveComponent import com.tangem.features.yield.supply.impl.subcomponents.active.entity.YieldSupplyActiveContentUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @ModelScoped @@ -24,6 +28,7 @@ internal class YieldSupplyActiveModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val yieldSupplyGetProtocolBalanceUseCase: YieldSupplyGetProtocolBalanceUseCase, + private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, ) : Model() { private val params: YieldSupplyActiveComponent.Params = paramsContainer.require() @@ -87,6 +92,8 @@ internal class YieldSupplyActiveModel @Inject constructor( null } + loadApy() + uiState.update { it.copy( notificationUM = approvalNotification, @@ -104,6 +111,21 @@ internal class YieldSupplyActiveModel @Inject constructor( .launchIn(modelScope) } + private fun loadApy() { + val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return + modelScope.launch(dispatchers.default) { + yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken).onRight { tokenStatus -> + uiState.update { + it.copy( + apy = TextReference.Str("${tokenStatus.apy}%"), + ) + } + }.onLeft { + Timber.e("Error loading token status") + } + } + } + private companion object { const val AAVEV3_PREFIX = "a" } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt index 0619e6b7a0..383a4b2231 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -15,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.withLink @@ -25,6 +27,7 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.* import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -35,6 +38,7 @@ import com.tangem.features.yield.supply.impl.subcomponents.active.entity.YieldSu internal fun YieldSupplyActiveContent( state: YieldSupplyActiveContentUM, isBalanceHidden: Boolean, + chartComponent: ComposableContentComponent, modifier: Modifier = Modifier, ) { Column( @@ -62,6 +66,9 @@ internal fun YieldSupplyActiveContent( style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, ) + + CurrentApy(state.apy) + chartComponent.Content(Modifier.padding(bottom = 12.dp)) } YieldSupplyActiveMyFunds(state = state, isBalanceHidden = isBalanceHidden) @@ -76,6 +83,45 @@ internal fun YieldSupplyActiveContent( } } +@Composable +private fun CurrentApy(apy: TextReference?, modifier: Modifier = Modifier) { + Row(modifier = modifier.padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = modifier.weight(1.0f), + text = stringResourceSafe(R.string.yield_module_earn_sheet_current_apy_title), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + ) + AnimatedContent( + targetState = apy?.resolveReference(), + label = "CurrentApy", + ) { apyText -> + if (apyText == null) { + TextShimmer( + modifier = modifier.width(56.dp), + text = "", + style = TangemTheme.typography.body1, + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(R.drawable.ic_arrow_up_8), + tint = TangemTheme.colors.text.accent, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + modifier = modifier, + text = apyText, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.accent, + ) + } + } + } + } +} + @Composable private fun YieldSupplyActiveMyFunds(state: YieldSupplyActiveContentUM, isBalanceHidden: Boolean) { Column( @@ -193,7 +239,11 @@ private fun YieldSupplyActiveBottomSheet_Preview( @PreviewParameter(YieldSupplyActiveBottomSheetPreviewProvider::class) params: YieldSupplyActiveContentUM, ) { TangemThemePreview { - YieldSupplyActiveContent(params, true) + YieldSupplyActiveContent( + state = params, + isBalanceHidden = true, + chartComponent = ComposableContentComponent.EMPTY, + ) } } From e511e845086a86ae76bdb3c067b6c0c3480a42e3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 14:56:51 +0500 Subject: [PATCH 02/46] Updated on 2026-08-14 --- .../ComposableListContentComponent.kt | 26 ++++ .../DefaultOnrampOperationComponent.kt | 7 +- .../selecttoken/ui/OnrampSelectToken.kt | 7 +- .../swap/DefaultSwapSelectTokensComponent.kt | 9 +- .../AvailableSwapPairsComponent.kt | 5 +- .../DefaultAvailableSwapPairsComponent.kt | 16 +- .../onramp/swap/ui/SwapSelectTokens.kt | 40 ++--- .../DefaultOnrampTokenListComponent.kt | 16 +- .../tokenlist/OnrampTokenListComponent.kt | 5 +- .../SetNothingToFoundStateTransformer.kt | 3 +- .../OnrampTokenItemStateConverterFactory.kt | 52 ++++++- .../onramp/tokenlist/ui/OnrampTokenList.kt | 144 +++++++++++++----- 12 files changed, 238 insertions(+), 92 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableListContentComponent.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableListContentComponent.kt b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableListContentComponent.kt new file mode 100644 index 0000000000..75f52c5be4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableListContentComponent.kt @@ -0,0 +1,26 @@ +package com.tangem.core.ui.decompose + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +@Stable +interface ComposableListContentComponent { + + val uiState: StateFlow + + fun LazyListScope.content(uiState: T, modifier: Modifier) + + companion object { + val EMPTY = EmptyComposableListContentComponent + } +} + +object EmptyComposableListContentComponent : ComposableListContentComponent { + override val uiState: StateFlow = MutableStateFlow(Unit) + + override fun LazyListScope.content(uiState: Unit, modifier: Modifier) { /* no-op */ + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt index f4fe8672a8..644770a58b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt @@ -1,6 +1,7 @@ package com.tangem.features.onramp.selecttoken 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 @@ -41,11 +42,13 @@ internal class DefaultOnrampOperationComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val state = model.state.collectAsStateWithLifecycle() + val state by model.state.collectAsStateWithLifecycle() + val onrampTokenListState by onrampTokenListComponent.uiState.collectAsStateWithLifecycle() OnrampSelectToken( - state = state.value, + state = state, onrampTokenListComponent = onrampTokenListComponent, + onrampTokenListState = onrampTokenListState, hotCryptoComponent = hotCryptoComponent, modifier = modifier, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/ui/OnrampSelectToken.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/ui/OnrampSelectToken.kt index 689cee2068..43ac75ccc7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/ui/OnrampSelectToken.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/ui/OnrampSelectToken.kt @@ -21,12 +21,14 @@ import com.tangem.features.onramp.hottokens.HotCryptoComponent import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.selecttoken.entity.OnrampOperationUM import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent +import com.tangem.features.onramp.tokenlist.entity.TokenListUM @OptIn(ExperimentalFoundationApi::class) @Composable internal fun OnrampSelectToken( state: OnrampOperationUM, onrampTokenListComponent: OnrampTokenListComponent, + onrampTokenListState: TokenListUM, hotCryptoComponent: HotCryptoComponent?, modifier: Modifier = Modifier, ) { @@ -50,8 +52,9 @@ internal fun OnrampSelectToken( ) } - item(key = "token_list", contentType = "token_list") { - onrampTokenListComponent.Content( + with(onrampTokenListComponent) { + content( + uiState = onrampTokenListState, modifier = Modifier .padding(top = 8.dp) .padding(horizontal = 16.dp) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt index 0f8001df67..5040c07c57 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt @@ -2,6 +2,7 @@ package com.tangem.features.onramp.swap import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -54,12 +55,16 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val state = model.state.collectAsStateWithLifecycle() + val state by model.state.collectAsStateWithLifecycle() + val fromTokensState by selectFromTokenListComponent.uiState.collectAsStateWithLifecycle() + val toTokensState by selectToTokenListComponent.uiState.collectAsStateWithLifecycle() SwapSelectTokens( - state = state.value, + state = state, selectFromTokenListComponent = selectFromTokenListComponent, + selectFromTokenListState = fromTokensState, selectToTokenListComponent = selectToTokenListComponent, + selectToTokenListState = toTokensState, modifier = modifier, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt index 35f44bbb33..cb87a50dc2 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt @@ -3,14 +3,15 @@ package com.tangem.features.onramp.swap.availablepairs import androidx.compose.runtime.Stable import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableListContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.onramp.tokenlist.entity.TokenListUM import kotlinx.coroutines.flow.StateFlow /** Token list component that present list of available tokens for swap */ @Stable -internal interface AvailableSwapPairsComponent : ComposableContentComponent { +internal interface AvailableSwapPairsComponent : ComposableListContentComponent { /** Component factory */ interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt index fd77ee062f..eea5740b23 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt @@ -1,17 +1,17 @@ package com.tangem.features.onramp.swap.availablepairs -import androidx.compose.runtime.Composable +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Stable -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.onramp.swap.availablepairs.model.AvailableSwapPairsModel -import com.tangem.features.onramp.tokenlist.ui.TokenList +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.ui.onrampTokenList import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.StateFlow @Stable internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( @@ -21,11 +21,11 @@ internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( private val model: AvailableSwapPairsModel = getOrCreateModel(params) - @Composable - override fun Content(modifier: Modifier) { - val state by model.state.collectAsStateWithLifecycle() + override val uiState: StateFlow + get() = model.state - TokenList(state = state, modifier = modifier) + override fun LazyListScope.content(uiState: TokenListUM, modifier: Modifier) { + onrampTokenList(state = uiState) } @AssistedFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt index bf873aa6f5..346d2b498f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp +import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -23,6 +24,7 @@ import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponen import com.tangem.features.onramp.swap.entity.ExchangeCardUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent +import com.tangem.features.onramp.tokenlist.entity.TokenListUM /** * Swap select tokens @@ -39,7 +41,9 @@ import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent internal fun SwapSelectTokens( state: SwapSelectTokensUM, selectFromTokenListComponent: OnrampTokenListComponent, + selectFromTokenListState: TokenListUM, selectToTokenListComponent: AvailableSwapPairsComponent, + selectToTokenListState: TokenListUM, modifier: Modifier = Modifier, ) { BackHandler(onBack = state.onBackClick) @@ -77,33 +81,33 @@ internal fun SwapSelectTokens( } if (state.exchangeFrom is ExchangeCardUM.Empty) { - item(key = "select_from", contentType = "select_from") { - selectFromTokenListComponent.Content( - modifier = Modifier - .padding(horizontal = 16.dp) - .animateItem(), + with(selectFromTokenListComponent) { + content( + uiState = selectFromTokenListState, + modifier = Modifier, ) } } if (state.exchangeFrom is ExchangeCardUM.Filled) { item(key = "exchange_to", contentType = "exchange_to") { - ExchangeCard( - state = state.exchangeTo, - isBalanceHidden = state.isBalanceHidden, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp) - .animateItem(), - ) + if (selectToTokenListState.warning != NotificationUM.Warning.SwapNoAvailablePair) { + ExchangeCard( + state = state.exchangeTo, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp) + .animateItem(), + ) + } } if (state.exchangeTo is ExchangeCardUM.Empty) { - item(key = "select_to", contentType = "select_to") { - selectToTokenListComponent.Content( - modifier = Modifier - .padding(horizontal = 16.dp) - .animateItem(), + with(selectToTokenListComponent) { + content( + uiState = selectToTokenListState, + modifier = Modifier.padding(horizontal = 16.dp), ) } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/DefaultOnrampTokenListComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/DefaultOnrampTokenListComponent.kt index c538d475cd..d35442f63a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/DefaultOnrampTokenListComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/DefaultOnrampTokenListComponent.kt @@ -1,17 +1,17 @@ package com.tangem.features.onramp.tokenlist -import androidx.compose.runtime.Composable +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Stable -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.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.model.OnrampTokenListModel -import com.tangem.features.onramp.tokenlist.ui.TokenList +import com.tangem.features.onramp.tokenlist.ui.onrampTokenList import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.StateFlow @Stable internal class DefaultOnrampTokenListComponent @AssistedInject constructor( @@ -21,11 +21,11 @@ internal class DefaultOnrampTokenListComponent @AssistedInject constructor( private val model: OnrampTokenListModel = getOrCreateModel(params) - @Composable - override fun Content(modifier: Modifier) { - val state by model.state.collectAsStateWithLifecycle() + override val uiState: StateFlow + get() = model.state - TokenList(state = state, modifier = modifier) + override fun LazyListScope.content(uiState: TokenListUM, modifier: Modifier) { + onrampTokenList(state = uiState) } @AssistedFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/OnrampTokenListComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/OnrampTokenListComponent.kt index 94ba06e247..4490a37ec3 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/OnrampTokenListComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/OnrampTokenListComponent.kt @@ -3,14 +3,15 @@ package com.tangem.features.onramp.tokenlist import androidx.compose.runtime.Stable import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableListContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.onramp.tokenlist.entity.OnrampOperation +import com.tangem.features.onramp.tokenlist.entity.TokenListUM /** Token list component that present list of token for multi-currency wallet */ @Stable -internal interface OnrampTokenListComponent : ComposableContentComponent { +internal interface OnrampTokenListComponent : ComposableListContentComponent { /** Component factory */ interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt index e98b29bfaa..2b2c7963eb 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt @@ -26,8 +26,7 @@ internal class SetNothingToFoundStateTransformer( id = emptySearchMessageReference.hashCode(), text = emptySearchMessageReference, ).let(::add) - } - .toImmutableList(), + }.toImmutableList(), unavailableItems = persistentListOf(), isBalanceHidden = isBalanceHidden, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt index f693f02e79..67f0c13e19 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt @@ -6,6 +6,7 @@ import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.getFormatte import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -21,7 +22,13 @@ internal object OnrampTokenItemStateConverterFactory { ): TokenItemStateConverter { return TokenItemStateConverter( appCurrency = appCurrency, - subtitleStateProvider = { createSubtitleState(status = it, isAvailable = true) }, + subtitleStateProvider = { + createSubtitleState( + status = it, + isAvailable = true, + text = stringReference(value = it.currency.symbol), + ) + }, subtitle2StateProvider = ::createSubtitle2State, fiatAmountStateProvider = { createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true) @@ -40,7 +47,13 @@ internal object OnrampTokenItemStateConverterFactory { isAvailable = false, ) }, - subtitleStateProvider = { createSubtitleState(status = it, isAvailable = false) }, + subtitleStateProvider = { + createSubtitleState( + status = it, + text = stringReference(value = it.currency.symbol), + isAvailable = false, + ) + }, subtitle2StateProvider = ::createSubtitle2State, fiatAmountStateProvider = { createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = false) @@ -48,12 +61,43 @@ internal object OnrampTokenItemStateConverterFactory { ) } - private fun createSubtitleState(status: CryptoCurrencyStatus, isAvailable: Boolean): TokenItemState.SubtitleState { + fun createUnavailableItemConverterV2( + appCurrency: AppCurrency, + unavailableErrorText: TextReference, + ): TokenItemStateConverter { + return TokenItemStateConverter( + appCurrency = appCurrency, + iconStateProvider = { CryptoCurrencyToIconStateConverter(isAvailable = false).convert(it) }, + titleStateProvider = { + TokenItemState.TitleState.Content( + text = stringReference(value = it.currency.name), + isAvailable = false, + ) + }, + subtitleStateProvider = { + createSubtitleState( + status = it, + isAvailable = false, + text = unavailableErrorText, + ) + }, + subtitle2StateProvider = ::createSubtitle2State, + fiatAmountStateProvider = { + createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = false) + }, + ) + } + + private fun createSubtitleState( + status: CryptoCurrencyStatus, + isAvailable: Boolean, + text: TextReference, + ): TokenItemState.SubtitleState { return when (status.value) { CryptoCurrencyStatus.Loading -> TokenItemState.SubtitleState.Loading else -> { TokenItemState.SubtitleState.TextContent( - value = stringReference(value = status.currency.symbol), + value = text, isAvailable = isAvailable, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt index 27943a21c4..8aeb293ca5 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt @@ -3,27 +3,28 @@ package com.tangem.features.onramp.tokenlist.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEachIndexed import com.tangem.common.ui.notifications.NotificationUM -import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.TangemSearchBarDefaults import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.tokenlist.PortfolioListItem +import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem import com.tangem.core.ui.components.tokenlist.TokenListItem import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags @@ -36,17 +37,21 @@ import kotlinx.collections.immutable.ImmutableList * Token list * * @param state state - * @param modifier modifier * [REDACTED_AUTHOR] */ -@Composable -internal fun TokenList(state: TokenListUM, modifier: Modifier = Modifier) { - Column(modifier) { - if (state.warning == null) { - SearchBar(searchBarUM = state.searchBarUM) - } else { - AnimatedContent(targetState = state.warning, label = "") { warning -> +internal fun LazyListScope.onrampTokenList(state: TokenListUM) { + val itemModifier = Modifier.padding(horizontal = 16.dp) + + if (state.warning == null) { + searchBarItem(searchBarUM = state.searchBarUM, modifier = itemModifier) + } else { + item("NotificationsKey") { + AnimatedContent( + targetState = state.warning, + label = "", + modifier = itemModifier, + ) { warning -> when (warning) { is NotificationUM.Warning.OnrampErrorNotification -> { Notification( @@ -60,31 +65,29 @@ internal fun TokenList(state: TokenListUM, modifier: Modifier = Modifier) { } } } + } - if (state.availableItems.isNotEmpty()) { - SpacerH12() - ItemsBlock(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) - } + tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) - if (state.unavailableItems.isNotEmpty()) { - SpacerH12() - ItemsBlock(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden) - } + tokensList(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden) +} + +private fun LazyListScope.searchBarItem(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) { + item("SearchKey") { + SearchBar( + state = searchBarUM, + colors = TangemSearchBarDefaults.secondaryTextFieldColors, + modifier = modifier, + ) } } -@Composable -private fun SearchBar(searchBarUM: SearchBarUM) { - SearchBar( - state = searchBarUM, - colors = TangemSearchBarDefaults.secondaryTextFieldColors, - ) -} - -@Composable -private fun ItemsBlock(items: ImmutableList, isBalanceHidden: Boolean) { - items.fastForEachIndexed { index, item -> - key(item.id) { +private fun LazyListScope.tokensList(items: ImmutableList, isBalanceHidden: Boolean) { + itemsIndexed( + items = items, + key = { _, item -> item.id }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> TokenListItem( state = item, isBalanceHidden = isBalanceHidden, @@ -92,13 +95,70 @@ private fun ItemsBlock(items: ImmutableList, isBalanceHidden: .roundedShapeItemDecoration( currentIndex = index, lastIndex = items.lastIndex, - addDefaultPadding = false, backgroundColor = TangemTheme.colors.background.primary, ) .testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) .semantics { lazyListItemPosition = index }, ) - } + }, + ) +} + +internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portfolio, isBalanceHidden: Boolean) { + val tokens = portfolio.tokens + val isExpanded = portfolio.isExpanded + + portfolioItem( + portfolio = portfolio, + modifier = Modifier.padding(top = 8.dp), + isBalanceHidden = isBalanceHidden, + ) + if (!isExpanded) return + itemsIndexed( + items = tokens, + key = { _, item -> item.id }, + contentType = { _, item -> item::class.java }, + itemContent = { tokenIndex, token -> + val indexWithHeader = tokenIndex.inc() + PortfolioTokensListItem( + state = token, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .animateItem() + .roundedShapeItemDecoration( + currentIndex = indexWithHeader, + lastIndex = tokens.lastIndex.inc(), + backgroundColor = TangemTheme.colors.background.primary, + ) + .conditional(tokenIndex == tokens.lastIndex) { + Modifier.padding(bottom = 8.dp) + }, + ) + }, + ) +} + +private fun LazyListScope.portfolioItem( + portfolio: TokensListItemUM.Portfolio, + modifier: Modifier, + isBalanceHidden: Boolean, +) { + item( + key = "account-${portfolio.id}", + contentType = "account", + ) { + PortfolioListItem( + state = portfolio, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .animateItem() + .roundedShapeItemDecoration( + currentIndex = 0, + lastIndex = portfolio.tokens.lastIndex.inc(), + backgroundColor = TangemTheme.colors.background.primary, + ) + .then(modifier), + ) } } @@ -107,12 +167,12 @@ private fun ItemsBlock(items: ImmutableList, isBalanceHidden: @Composable private fun Preview_TokenList(@PreviewParameter(PreviewTokenListUMProvider::class) state: TokenListUM) { TangemThemePreview { - TokenList( - state = state, - modifier = Modifier - .fillMaxWidth() - .background(color = TangemTheme.colors.background.secondary) - .padding(16.dp), - ) + LazyColumn( + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) { + onrampTokenList( + state = state, + ) + } } } \ No newline at end of file From cb6fdddcde30252115591dde8023b962c881b3ba Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 14:51:32 +0400 Subject: [PATCH 03/46] Updated on 2026-08-14 --- gradle/dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 34e16dd458..35e4b6fd2f 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -96,7 +96,7 @@ room = "2.6.1" markdown = "0.7.2" markdownComposeView = "0.5.4" usedesk = "4.4.0" -sumsub = "1.37.1" +sumsub = "1.38.0" # endregion Other libraries # region Tools From 1ed65c48cc88f41e60803373dc73657a77941ee5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 16:02:16 +0400 Subject: [PATCH 04/46] Updated on 2026-08-14 --- .../account/converter/AccountListConverter.kt | 2 +- .../FetchWalletAccountsErrorHandler.kt | 2 +- .../DefaultMultiAccountListProducer.kt | 6 +- .../DefaultSingleAccountListProducer.kt | 11 +- .../producer/WalletAccountListFlowFactory.kt | 9 +- .../DefaultAccountsCRUDRepository.kt | 6 +- .../account/converter/AccountConverterExt.kt | 7 +- .../converter/AccountListConverterTest.kt | 6 +- .../GetWalletAccountsResponseConverterTest.kt | 6 +- ...SaveWalletAccountsResponseConverterTest.kt | 11 +- .../FetchWalletAccountsErrorHandlerTest.kt | 2 +- .../DefaultMultiAccountListProducerTest.kt | 42 +++---- .../DefaultSingleAccountListProducerTest.kt | 105 +++--------------- .../WalletAccountListFlowFactoryTest.kt | 22 +++- .../DefaultAccountsCRUDRepositoryTest.kt | 8 +- .../domain/account/models/AccountList.kt | 24 ++-- .../account/models/AccountStatusList.kt | 6 +- .../domain/account/models/AccountListTest.kt | 58 ++++------ .../usecase/AddCryptoPortfolioUseCaseTest.kt | 15 +-- .../ArchiveCryptoPortfolioUseCaseTest.kt | 11 +- .../RecoverCryptoPortfolioUseCaseTest.kt | 13 +-- .../UpdateCryptoPortfolioUseCaseTest.kt | 21 ++-- .../DefaultSingleAccountStatusListProducer.kt | 10 +- .../GetAccountCurrencyByAddressUseCase.kt | 2 +- ...aultSingleAccountStatusListProducerTest.kt | 25 +++-- .../GetAccountCurrencyByAddressUseCaseTest.kt | 4 +- .../features/account/PortfolioFetcher.kt | 2 +- .../accounts/viewmodel/AccountsViewModel.kt | 2 +- 28 files changed, 172 insertions(+), 266 deletions(-) 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 index d4c993f59f..b2744a7452 100644 --- 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 @@ -28,7 +28,7 @@ internal class AccountListConverter @AssistedInject constructor( override fun convert(value: GetWalletAccountsResponse): AccountList { return AccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(), totalAccounts = value.wallet.totalAccounts, sortType = TokensSortTypeConverter.convert(value.wallet.sort), diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 2acc302f24..3c7d1af215 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -87,7 +87,7 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( } private fun createDefaultAccountDTOs(userWallet: UserWallet): List { - val accounts = AccountList.empty(userWallet).accounts + val accounts = AccountList.empty(userWallet.walletId).accounts .filterIsInstance() val converter = cryptoPortfolioCF.create(userWallet = userWallet) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt index da4e0e7853..40e4fd5f47 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt @@ -5,6 +5,7 @@ import arrow.core.some import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.models.wallet.UserWallet import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -35,10 +36,11 @@ internal class DefaultMultiAccountListProducer @AssistedInject constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun produce(): Flow> { return userWalletsStore.userWallets + .map { it.map(UserWallet::walletId) } .distinctUntilChanged() - .flatMapLatest { userWallets -> + .flatMapLatest { ids -> combine( - flows = userWallets.map(walletAccountListFlowFactory::create), + flows = ids.map(walletAccountListFlowFactory::create), transform = ::listOf, ) } diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt index 43d0745ea2..6288d9036f 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt @@ -2,7 +2,6 @@ package com.tangem.data.account.producer import arrow.core.Option import arrow.core.none -import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -11,16 +10,13 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.mapNotNull /** * Default implementation of [SingleAccountListProducer]. * Produces a list of [AccountList] for a specific user wallet. * * @property params params containing the user wallet ID - * @property userWalletsStore store that provides user wallets * @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet * @property dispatchers coroutine dispatchers provider * @@ -28,7 +24,6 @@ import kotlinx.coroutines.flow.mapNotNull */ internal class DefaultSingleAccountListProducer @AssistedInject constructor( @Assisted val params: SingleAccountListProducer.Params, - private val userWalletsStore: UserWalletsStore, private val walletAccountListFlowFactory: WalletAccountListFlowFactory, private val dispatchers: CoroutineDispatcherProvider, ) : SingleAccountListProducer { @@ -37,11 +32,7 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun produce(): Flow { - return userWalletsStore.userWallets - .mapNotNull { userWallets -> - userWallets.firstOrNull { it.walletId == params.userWalletId } - } - .flatMapLatest(walletAccountListFlowFactory::create) + return walletAccountListFlowFactory.create(userWalletId = params.userWalletId) .flowOn(dispatchers.default) } diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt index 765d6afcc1..44aa8713fc 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt @@ -4,9 +4,11 @@ import com.tangem.data.account.converter.AccountListConverter import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.card.common.util.cardTypesResolver 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.models.wallet.requireColdWallet import kotlinx.coroutines.flow.* @@ -22,12 +24,15 @@ import javax.inject.Inject [REDACTED_AUTHOR] */ internal class WalletAccountListFlowFactory @Inject constructor( + private val userWalletsStore: UserWalletsStore, private val accountsResponseStoreFactory: AccountsResponseStoreFactory, private val accountListConverterFactory: AccountListConverter.Factory, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, ) { - fun create(userWallet: UserWallet): Flow { + fun create(userWalletId: UserWalletId): Flow { + val userWallet = userWalletsStore.getSyncStrict(userWalletId) + return if (userWallet.isMultiCurrency) { createForMultiWallet(userWallet) } else { @@ -53,6 +58,6 @@ internal class WalletAccountListFlowFactory @Inject constructor( setOf(cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet)) } - return AccountList.empty(userWallet = userWallet, cryptoCurrencies = currencies) + return AccountList.empty(userWalletId = userWallet.walletId, cryptoCurrencies = currencies) } } \ 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 a9ceb3026e..524e256767 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 @@ -101,12 +101,12 @@ internal class DefaultAccountsCRUDRepository( } override suspend fun saveAccounts(accountList: AccountList) { - val userWalletId = accountList.userWallet.walletId + val userWallet = userWalletsStore.getSyncStrict(accountList.userWalletId) - val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = accountList.userWallet) + val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) val accountsResponse = converter.convert(value = accountList) - walletAccountsSaver.pushAndStore(userWalletId = userWalletId, response = accountsResponse) + walletAccountsSaver.pushAndStore(userWalletId = userWallet.walletId, response = accountsResponse) } override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option = option { 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 index 880b20520e..12ac6dda89 100644 --- 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 @@ -8,7 +8,6 @@ import com.tangem.domain.models.TokensGroupType import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountName -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId internal fun createWalletAccountDTO( @@ -72,13 +71,13 @@ internal fun createGetWalletAccountsResponse( } internal fun createAccountList( - userWallet: UserWallet, + userWalletId: UserWalletId, sortType: TokensSortType = TokensSortType.BALANCE, groupType: TokensGroupType = TokensGroupType.NETWORK, ): AccountList { return AccountList( - userWallet = userWallet, - accounts = setOf(createCryptoPortfolio(userWallet.walletId)), + userWalletId = userWalletId, + accounts = setOf(createCryptoPortfolio(userWalletId)), totalAccounts = 1, sortType = sortType, groupType = groupType, 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 index 8b32adb5e5..1a7ae063f0 100644 --- 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 @@ -96,7 +96,7 @@ class AccountListConverterTest { ), expected = Result.success( createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.BALANCE, groupType = TokensGroupType.NETWORK, ), @@ -110,7 +110,7 @@ class AccountListConverterTest { ), expected = Result.success( createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.NONE, groupType = TokensGroupType.NONE, ), @@ -124,7 +124,7 @@ class AccountListConverterTest { ), expected = Result.success( createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.NONE, groupType = TokensGroupType.NONE, ), 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 index 766544b58a..2b09ada07f 100644 --- 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 @@ -46,7 +46,7 @@ class GetWalletAccountsResponseConverterTest { @Test fun `cryptoPortfolioConverter throws exception`() { // Arrange - val domain = createAccountList(userWallet = userWallet) + val domain = createAccountList(userWalletId = userWallet.walletId) val exception = IllegalStateException("Test exception") every { cryptoPortfolioConverter.convertBack(any()) } throws exception @@ -92,7 +92,7 @@ class GetWalletAccountsResponseConverterTest { return listOf( ConvertModel( value = createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.BALANCE, groupType = TokensGroupType.NETWORK, ), @@ -106,7 +106,7 @@ class GetWalletAccountsResponseConverterTest { ), ConvertModel( value = createAccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, sortType = TokensSortType.NONE, groupType = TokensGroupType.NONE, ), 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 index 6d5ebee814..d2a6742050 100644 --- 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 @@ -6,10 +6,7 @@ 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.account.AccountName -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 @@ -19,13 +16,11 @@ class SaveWalletAccountsResponseConverterTest { @Test fun convert() { // Arrange - val userWallet = mockk { - every { this@mockk.walletId } returns UserWalletId("011") - } + val userWalletId = UserWalletId("011") val accountList = AccountList( - userWallet = userWallet, - accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)), + userWalletId = userWalletId, + accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)), totalAccounts = 1, ) .getOrNull()!! diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index 422c9a7b8a..c174b4d463 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -160,7 +160,7 @@ class FetchWalletAccountsErrorHandlerTest { // Arrange val error = ApiResponseError.TimeoutException() - val accounts = AccountList.empty(userWallet).accounts + val accounts = AccountList.empty(userWalletId).accounts .filterIsInstance() val accountDTO = WalletAccountDTO( diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt index 3ca26b0198..05cb95cca1 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt @@ -51,8 +51,8 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) every { userWalletsStore.userWallets } returns userWalletsFlow - val accountList = AccountList.empty(userWallet) - every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + val accountList = AccountList.empty(userWalletId) + every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList) // Act val actual = producer.produce().let(::getEmittedValues) @@ -63,7 +63,7 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -73,11 +73,11 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) every { userWalletsStore.userWallets } returns userWalletsFlow - val accountList = AccountList.empty(userWallet) - val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val accountList = AccountList.empty(userWalletId) + val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.NONE) val factoryFlow = MutableStateFlow(null) - every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull() // Act (first emission) factoryFlow.value = accountList @@ -95,9 +95,9 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -107,10 +107,10 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) every { userWalletsStore.userWallets } returns userWalletsFlow - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val factoryFlow = MutableStateFlow(null) - every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull() // Act (first emission) factoryFlow.value = accountList @@ -128,9 +128,9 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -141,7 +141,7 @@ class DefaultMultiAccountListProducerTest { every { userWalletsStore.userWallets } returns userWalletsFlow val exception = RuntimeException("Converter error") - every { walletAccountListFlowFactory.create(userWallet) } throws exception + every { walletAccountListFlowFactory.create(userWalletId) } throws exception // Act val actual = producer.produceWithFallback().let(::getEmittedValues) @@ -152,7 +152,7 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -178,7 +178,7 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) every { userWalletsStore.userWallets } returns userWalletsFlow - every { walletAccountListFlowFactory.create(userWallet) } returns emptyFlow() + every { walletAccountListFlowFactory.create(userWalletId) } returns emptyFlow() // Act val actual = producer.produce().let(::getEmittedValues) @@ -188,7 +188,7 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @@ -203,9 +203,9 @@ class DefaultMultiAccountListProducerTest { val userWalletsFlow = MutableStateFlow(listOf(userWallet, userWallet2)) every { userWalletsStore.userWallets } returns userWalletsFlow - val accountList = AccountList.empty(userWallet) - every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) - every { walletAccountListFlowFactory.create(userWallet2) } returns emptyFlow() + val accountList = AccountList.empty(userWalletId) + every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList) + every { walletAccountListFlowFactory.create(userWalletId2) } returns emptyFlow() // Act val actual = producer.produce().let(::getEmittedValues) @@ -215,8 +215,8 @@ class DefaultMultiAccountListProducerTest { coVerify(ordering = Ordering.SEQUENCE) { userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) - walletAccountListFlowFactory.create(userWallet2) + walletAccountListFlowFactory.create(userWalletId) + walletAccountListFlowFactory.create(userWalletId2) } } } \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt index 846e46bfea..bc065b4578 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt @@ -2,7 +2,6 @@ package com.tangem.data.account.producer import com.google.common.truth.Truth import com.tangem.common.test.utils.getEmittedValues -import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.models.TokensSortType @@ -11,7 +10,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -26,7 +24,6 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultSingleAccountListProducerTest { - private val userWalletsStore: UserWalletsStore = mockk() private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk() private val userWalletId = UserWalletId("011") @@ -36,24 +33,22 @@ class DefaultSingleAccountListProducerTest { private val producer = DefaultSingleAccountListProducer( params = SingleAccountListProducer.Params(userWalletId = userWalletId), - userWalletsStore = userWalletsStore, walletAccountListFlowFactory = walletAccountListFlowFactory, dispatchers = TestingCoroutineDispatcherProvider(), ) @AfterEach fun tearDownEach() { - clearMocks(userWalletsStore, walletAccountListFlowFactory) + clearMocks(walletAccountListFlowFactory) } @Test fun produce() = runTest { // Arrange - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow + MutableStateFlow(listOf(userWallet)) - val accountList = AccountList.empty(userWallet) - every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + val accountList = AccountList.empty(userWalletId) + every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList) // Act val actual = producer.produce().let(::getEmittedValues) @@ -63,22 +58,18 @@ class DefaultSingleAccountListProducerTest { Truth.assertThat(actual).containsExactly(expected) coVerify(ordering = Ordering.SEQUENCE) { - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) } } @Test fun `flow will updated if factoryFlow is updated`() = runTest { // Arrange - val userWalletsFlow = MutableStateFlow(listOf(userWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow - - val accountList = AccountList.empty(userWallet) - val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val accountList = AccountList.empty(userWalletId) + val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.NONE) val factoryFlow = MutableStateFlow(null) - every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull() // Act (first emission) factoryFlow.value = accountList @@ -95,23 +86,18 @@ class DefaultSingleAccountListProducerTest { Truth.assertThat(secondEmission).containsExactly(updatedAccountList) coVerifyOrder { - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) + walletAccountListFlowFactory.create(userWalletId) } } @Test fun `flow is filtered the same response`() = runTest { // Arrange - val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow - - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val factoryFlow = MutableStateFlow(null) - every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull() // Act (first emission) factoryFlow.value = accountList @@ -128,71 +114,8 @@ class DefaultSingleAccountListProducerTest { Truth.assertThat(secondEmission).containsExactly(accountList) coVerify(ordering = Ordering.SEQUENCE) { - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWalletId) + walletAccountListFlowFactory.create(userWalletId) } } - - @Test - fun `flow is empty if factory throws exception`() = runTest { - // Arrange - val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow - - val exception = RuntimeException("Converter error") - every { walletAccountListFlowFactory.create(userWallet) } throws exception - - // Act - val actual = producer.produceWithFallback().let(::getEmittedValues) - - // Assert - Truth.assertThat(actual).isEmpty() // no emissions - - coVerify(ordering = Ordering.SEQUENCE) { - userWalletsStore.userWallets - walletAccountListFlowFactory.create(userWallet) - } - } - - @Test - fun `flow is empty if userWalletsFlow returns empty flow`() = runTest { - // Arrange - val userWalletsFlow = emptyFlow>() - every { userWalletsStore.userWallets } returns userWalletsFlow - - // Act - val actual = producer.produce().let(::getEmittedValues) - - // Assert - Truth.assertThat(actual).isEmpty() // no emissions - - coVerify(exactly = 1) { userWalletsStore.userWallets } - coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } - } - - @Test - fun `flow is empty if userWalletsFlow doesn't contains userWalletId from params`() = runTest { - // Arrange - val unknownId = UserWalletId("012") - val unknownWallet = mockk { - every { this@mockk.walletId } returns unknownId - } - - val userWalletsFlow = MutableStateFlow(listOf(unknownWallet)) - every { userWalletsStore.userWallets } returns userWalletsFlow - - // Act - val actual = producer.produce().let(::getEmittedValues) - - // Assert - Truth.assertThat(actual).isEmpty() // no emissions - - coVerify(ordering = Ordering.SEQUENCE) { - userWalletsStore.userWallets - } - - coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } - } } \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt index 6e13e22dad..47c2e8f6fc 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt @@ -10,6 +10,7 @@ import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.common.currency.CardCryptoCurrencyFactory 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.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -28,6 +29,7 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class WalletAccountListFlowFactoryTest { + private val userWalletsStore: UserWalletsStore = mockk() private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() private val accountsResponseStore: AccountsResponseStore = mockk() private val accountsResponseStoreFlow = MutableStateFlow(value = null) @@ -38,6 +40,7 @@ class WalletAccountListFlowFactoryTest { private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() private val factory = WalletAccountListFlowFactory( + userWalletsStore = userWalletsStore, accountsResponseStoreFactory = accountsResponseStoreFactory, accountListConverterFactory = accountListConverterFactory, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, @@ -49,6 +52,7 @@ class WalletAccountListFlowFactoryTest { @AfterEach fun tearDownEach() { clearMocks( + userWalletsStore, accountsResponseStoreFactory, accountsResponseStore, accountListConverterFactory, @@ -66,17 +70,19 @@ class WalletAccountListFlowFactoryTest { every { this@mockk.isMultiCurrency } returns true } + every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet + val accountsResponse = createGetWalletAccountsResponse(userWalletId) every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore every { accountsResponseStore.data } returns accountsResponseStoreFlow accountsResponseStoreFlow.value = accountsResponse - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) every { accountListConverterFactory.create(userWallet) } returns accountListConverter every { accountListConverter.convert(accountsResponse) } returns accountList // Act - val actual = factory.create(userWallet).let(::getEmittedValues) + val actual = factory.create(userWalletId).let(::getEmittedValues) // Assert val expected = accountList @@ -99,14 +105,16 @@ class WalletAccountListFlowFactoryTest { fun `create for single wallet`() = runTest { val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false) + every { userWalletsStore.getSyncStrict(userWallet.walletId) } returns userWallet + val currency = cryptoCurrencyFactory.ethereum every { cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) } returns currency // Act - val actual = factory.create(userWallet).let(::getEmittedValues) + val actual = factory.create(userWallet.walletId).let(::getEmittedValues) // Assert - val expected = AccountList.empty(userWallet = userWallet, cryptoCurrencies = setOf(currency)) + val expected = AccountList.empty(userWalletId = userWallet.walletId, cryptoCurrencies = setOf(currency)) Truth.assertThat(actual).containsExactly(expected) coVerify(ordering = Ordering.SEQUENCE) { @@ -126,16 +134,18 @@ class WalletAccountListFlowFactoryTest { fun `flow is created for single wallet with token`() = runTest { val nodl = MockUserWalletFactory.createSingleWalletWithToken() + every { userWalletsStore.getSyncStrict(nodl.walletId) } returns nodl + val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet() every { cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl) } returns currencies.toList() // Act - val actual = factory.create(nodl).let(::getEmittedValues) + val actual = factory.create(nodl.walletId).let(::getEmittedValues) // Assert - val expected = AccountList.empty(userWallet = nodl, cryptoCurrencies = currencies) + val expected = AccountList.empty(userWalletId = nodl.walletId, cryptoCurrencies = currencies) Truth.assertThat(actual).containsExactly(expected) coVerify(ordering = Ordering.SEQUENCE) { 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 index 1e7c97fc64..b830678839 100644 --- 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 @@ -584,7 +584,7 @@ class DefaultAccountsCRUDRepositoryTest { every { this@mockk.walletId } returns userWalletId } - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountsResponse = mockk() accountsResponseStoreFlow.value = accountsResponse @@ -593,6 +593,8 @@ class DefaultAccountsCRUDRepositoryTest { every { this@mockk.convert(accountList) } returns accountsResponse } + every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet + every { convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) } returns converter @@ -617,7 +619,7 @@ class DefaultAccountsCRUDRepositoryTest { every { this@mockk.walletId } returns userWalletId } - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountsResponse = mockk() accountsResponseStoreFlow.value = accountsResponse @@ -626,6 +628,8 @@ class DefaultAccountsCRUDRepositoryTest { every { this@mockk.convert(accountList) } returns accountsResponse } + every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet + every { convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) } returns converter diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt index 2f848f2f16..e305566047 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountList.kt @@ -8,14 +8,14 @@ import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.extensions.addOrReplace import kotlinx.serialization.Serializable /** * Represents a list of accounts associated with a user wallet * - * @property userWallet the user wallet associated with the account list + * @property userWalletId the user wallet id associated with the account list * @property accounts a set of accounts belonging to the user wallet * @property totalAccounts the total number of accounts * @@ -23,7 +23,7 @@ import kotlinx.serialization.Serializable */ @Serializable data class AccountList private constructor( - val userWallet: UserWallet, + val userWalletId: UserWalletId, val accounts: Set, val totalAccounts: Int, val sortType: TokensSortType, @@ -51,7 +51,7 @@ data class AccountList private constructor( val accounts = this.accounts.addOrReplace(other) { it.accountId == other.accountId } return invoke( - userWallet = this.userWallet, + userWalletId = this.userWalletId, accounts = accounts, totalAccounts = this.totalAccounts + if (isNewAccount) 1 else 0, sortType = this.sortType, @@ -73,7 +73,7 @@ data class AccountList private constructor( } return invoke( - userWallet = this.userWallet, + userWalletId = this.userWalletId, accounts = accounts, totalAccounts = this.totalAccounts - if (isExistingAccount) 1 else 0, sortType = this.sortType, @@ -134,12 +134,12 @@ data class AccountList private constructor( * Factory method to create an `AccountList` instance. * Validates the input to ensure the accounts list is not empty and contains exactly one main account. * - * @param userWallet the user wallet associated with the account list + * @param userWalletId the user wallet id associated with the account list * @param accounts a set of accounts belonging to the user wallet * @param totalAccounts the total number of accounts */ operator fun invoke( - userWallet: UserWallet, + userWalletId: UserWalletId, accounts: Set, totalAccounts: Int, sortType: TokensSortType = TokensSortType.NONE, @@ -169,7 +169,7 @@ data class AccountList private constructor( } AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = accounts, totalAccounts = totalAccounts, sortType = sortType, @@ -180,19 +180,19 @@ data class AccountList private constructor( /** * Factory method to create an empty [AccountList] with a main crypto portfolio account * - * @param userWallet the user wallet associated with the account list + * @param userWalletId the user wallet id associated with the account list */ fun empty( - userWallet: UserWallet, + userWalletId: UserWalletId, cryptoCurrencies: Set = emptySet(), sortType: TokensSortType = TokensSortType.NONE, groupType: TokensGroupType = TokensGroupType.NONE, ): AccountList { return AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf( Account.CryptoPortfolio.createMainAccount( - userWalletId = userWallet.walletId, + userWalletId = userWalletId, cryptoCurrencies = cryptoCurrencies, ), ), diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt index 9581d1a6fb..4d3b4dab0e 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountStatusList.kt @@ -3,13 +3,13 @@ package com.tangem.domain.account.models import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable /** * Represents a list of account statuses associated with a user wallet * - * @property userWallet the user wallet to which the account statuses belong + * @property userWalletId the user wallet id to which the account statuses belong * @property accountStatuses a set of account statuses associated with the user wallet * @property totalAccounts the total number of accounts (including archived ones) * @property totalFiatBalance the total fiat balance across all accounts @@ -18,7 +18,7 @@ import kotlinx.serialization.Serializable */ @Serializable data class AccountStatusList( - val userWallet: UserWallet, + val userWalletId: UserWalletId, val accountStatuses: Set, val totalAccounts: Int, val totalFiatBalance: TotalFiatBalance, diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt index 0c43198fa0..60eea11794 100644 --- a/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/models/AccountListTest.kt @@ -8,11 +8,7 @@ import com.tangem.domain.account.utils.createAccounts import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import io.mockk.clearMocks -import io.mockk.mockk -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -31,7 +27,7 @@ class AccountListTest { val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) val accountList = AccountList( - userWallet = mockk(), + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ) @@ -49,13 +45,13 @@ class AccountListTest { fun canAddMoreAccounts() { // Arrange val accountList = AccountList( - userWallet = mockk(), + userWalletId = userWalletId, accounts = createAccounts(userWalletId = userWalletId, count = 2), totalAccounts = 2, ).getOrNull()!! val fullAccountList = AccountList( - userWallet = mockk(), + userWalletId = userWalletId, accounts = createAccounts(userWalletId = userWalletId, count = 20), totalAccounts = 20, ).getOrNull()!! @@ -67,16 +63,13 @@ class AccountListTest { @Test fun empty() { - // Arrange - val userWallet = mockk(relaxed = true) - // Act - val actual = AccountList.empty(userWallet) + val actual = AccountList.empty(userWalletId) // Assert val expected = AccountList( - userWallet = userWallet, - accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)), + userWalletId = userWalletId, + accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)), totalAccounts = 1, ).getOrNull()!! @@ -87,19 +80,12 @@ class AccountListTest { @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Create { - private val userWallet = mockk() - - @BeforeEach - fun resetMocks() { - clearMocks(userWallet) - } - @ParameterizedTest @MethodSource("provideTestModels") fun invoke(model: CreateTestModel) { // Act val actual = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = model.accounts, totalAccounts = model.accounts.size, ) @@ -131,13 +117,13 @@ class AccountListTest { createAccounts(userWalletId = userWalletId, count = 1).let { CreateTestModel( accounts = it, - expected = AccountList(userWallet = userWallet, accounts = it, totalAccounts = 1), + expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 1), ) }, createAccounts(userWalletId = userWalletId, count = 20).let { CreateTestModel( accounts = it, - expected = AccountList(userWallet = userWallet, accounts = it, totalAccounts = 20), + expected = AccountList(userWalletId = userWalletId, accounts = it, totalAccounts = 20), ) }, CreateTestModel( @@ -171,8 +157,6 @@ class AccountListTest { @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Plus { - private val userWallet = mockk() - @ParameterizedTest @MethodSource("provideTestModels") fun invoke(model: PlusTestModel) { @@ -191,13 +175,13 @@ class AccountListTest { PlusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toAdd = newAccount, expected = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount, newAccount), totalAccounts = 2, ), @@ -211,13 +195,13 @@ class AccountListTest { PlusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toAdd = newAccount, expected = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(newAccount), totalAccounts = 1, ), @@ -226,7 +210,7 @@ class AccountListTest { // endregion PlusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = createAccounts(userWalletId = userWalletId, count = 20), totalAccounts = 20, ).getOrNull()!!, @@ -246,8 +230,6 @@ class AccountListTest { @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Minus { - private val userWallet = mockk() - @ParameterizedTest @MethodSource("provideTestModels") fun invoke(model: MinusTestModel) { @@ -266,13 +248,13 @@ class AccountListTest { MinusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount, secondaryAccount), totalAccounts = 2, ).getOrNull()!!, toRemove = secondaryAccount, expected = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ), @@ -286,13 +268,13 @@ class AccountListTest { MinusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, toRemove = notInList, expected = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ), @@ -305,7 +287,7 @@ class AccountListTest { MinusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount), totalAccounts = 1, ).getOrNull()!!, @@ -321,7 +303,7 @@ class AccountListTest { MinusTestModel( initial = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = setOf(mainAccount, secondaryAccount), totalAccounts = 2, ).getOrNull()!!, 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 4808e416a1..32e113b374 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 @@ -14,7 +14,6 @@ import com.tangem.domain.account.utils.createAccount import com.tangem.domain.account.utils.createAccounts import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* import kotlinx.coroutines.test.runTest @@ -35,20 +34,16 @@ class AddCryptoPortfolioUseCaseTest { mainAccountTokensMigration = mainAccountTokensMigration, ) - private val userWallet = mockk() - @BeforeEach fun resetMocks() { - clearMocks(crudRepository, singleAccountListFetcher, mainAccountTokensMigration, userWallet) - - every { userWallet.walletId } returns userWalletId + clearMocks(crudRepository, singleAccountListFetcher, mainAccountTokensMigration) } @Test fun `invoke should add new crypto portfolio account to existing list`() = runTest { // Arrange val newAccount = createNewAccount() - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val updatedAccountList = (accountList + newAccount).getOrNull()!! coEvery { @@ -152,7 +147,7 @@ class AddCryptoPortfolioUseCaseTest { fun `invoke should return error if account list requirements not met`() = runTest { // Arrange val accountList = AccountList( - userWallet = userWallet, + userWalletId = userWalletId, accounts = createAccounts(userWalletId = userWalletId, count = 20), totalAccounts = 20, ).getOrNull()!! @@ -228,7 +223,7 @@ class AddCryptoPortfolioUseCaseTest { fun `invoke should return error if saveAccounts throws exception`() = runTest { // Arrange val newAccount = createNewAccount() - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val updatedAccountList = (accountList + newAccount).getOrNull()!! val exception = IllegalStateException("Test error") @@ -266,7 +261,7 @@ class AddCryptoPortfolioUseCaseTest { fun `invoke should return new account if migrate returns error`() = runTest { // Arrange val newAccount = createNewAccount() - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val updatedAccountList = (accountList + newAccount).getOrNull()!! val exception = Exception("Migration error") 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 fe67edc618..b23bd9bfe0 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 @@ -11,7 +11,6 @@ import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase.Error import com.tangem.domain.account.utils.createAccount import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* import kotlinx.coroutines.test.runTest @@ -24,19 +23,17 @@ class ArchiveCryptoPortfolioUseCaseTest { private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) private val useCase = ArchiveCryptoPortfolioUseCase(crudRepository) - private val userWallet = mockk() @BeforeEach fun resetMocks() { - clearMocks(crudRepository, userWallet) - every { userWallet.walletId } returns userWalletId + clearMocks(crudRepository) } @Test fun `invoke should archive existing crypto portfolio account`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = (AccountList.empty(userWallet) + account).getOrNull()!! + val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!! val accountId = account.accountId val updatedAccountList = (accountList - account).getOrNull()!! @@ -103,7 +100,7 @@ class ArchiveCryptoPortfolioUseCaseTest { @Test fun `invoke should return error if account not found`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val accountId = AccountId.forCryptoPortfolio( userWalletId = userWalletId, derivationIndex = DerivationIndex(1).getOrNull()!!, @@ -126,7 +123,7 @@ class ArchiveCryptoPortfolioUseCaseTest { fun `invoke should return error if saveAccounts throws exception`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = (AccountList.empty(userWallet) + account).getOrNull()!! + val accountList = (AccountList.empty(userWalletId) + account).getOrNull()!! val accountId = account.accountId val updatedAccountList = (accountList - account).getOrNull()!! 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 eb4e423c11..f5ede48207 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 @@ -12,7 +12,6 @@ import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase.Error import com.tangem.domain.account.utils.createAccount import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* import kotlinx.coroutines.test.runTest @@ -28,19 +27,17 @@ class RecoverCryptoPortfolioUseCaseTest { private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) private val useCase = RecoverCryptoPortfolioUseCase(crudRepository) - private val userWallet = mockk() @BeforeEach fun resetMocks() { - clearMocks(crudRepository, userWallet) - every { userWallet.walletId } returns userWalletId + clearMocks(crudRepository) } @Test fun `invoke should recover archived crypto portfolio account`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val archivedAccount = ArchivedAccount( accountId = account.accountId, name = account.accountName, @@ -122,7 +119,7 @@ class RecoverCryptoPortfolioUseCaseTest { fun `invoke should return error if getArchivedAccount throws exception`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val exception = IllegalStateException("Test error") coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() @@ -146,7 +143,7 @@ class RecoverCryptoPortfolioUseCaseTest { fun `invoke should return error if getArchivedAccount returns null`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns None @@ -169,7 +166,7 @@ class RecoverCryptoPortfolioUseCaseTest { fun `invoke should return error if saveAccounts throws exception`() = runTest { // Arrange val account = createAccount(userWalletId) - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val archivedAccount = ArchivedAccount( accountId = account.accountId, name = account.accountName, 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 3d904d3117..724429e3d3 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 @@ -12,7 +12,6 @@ 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 io.mockk.* import kotlinx.coroutines.test.runTest @@ -29,19 +28,15 @@ class UpdateCryptoPortfolioUseCaseTest { private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) private val useCase = UpdateCryptoPortfolioUseCase(crudRepository = crudRepository) - private val userWallet = mockk() - @BeforeEach fun resetMocks() { - clearMocks(crudRepository, userWallet) - - every { userWallet.walletId } returns userWalletId + clearMocks(crudRepository) } @Test fun `invoke should update crypto portfolio account with new name`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountName = AccountName("New name").getOrNull()!! @@ -66,7 +61,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke should update crypto portfolio account with new icon`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountIcon = CryptoPortfolioIcon.ofCustomAccount( @@ -94,7 +89,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke should update crypto portfolio account with new name and icon`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountName = AccountName("New name").getOrNull()!! @@ -123,7 +118,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke if name and icon are null`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption() @@ -144,7 +139,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke if getAccounts throws exception`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountName = AccountName("New name").getOrNull()!! @@ -192,7 +187,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke if getAccounts does not contain accountId`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = AccountId.forCryptoPortfolio( userWalletId = userWalletId, derivationIndex = DerivationIndex(1).getOrNull()!!, @@ -217,7 +212,7 @@ class UpdateCryptoPortfolioUseCaseTest { @Test fun `invoke if saveAccounts throws exception`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet = userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) val accountId = accountList.mainAccount.accountId val newAccountName = AccountName("New name").getOrNull()!! diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt index 9cc97e0760..e8c9a00296 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt @@ -6,6 +6,7 @@ import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.core.utils.lceContent import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokensGroupType @@ -39,6 +40,7 @@ import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) internal class DefaultSingleAccountStatusListProducer @AssistedInject constructor( @Assisted private val params: SingleAccountStatusListProducer.Params, + private val userWalletsListRepository: UserWalletsListRepository, private val singleAccountListSupplier: SingleAccountListSupplier, private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory, private val dispatchers: CoroutineDispatcherProvider, @@ -58,8 +60,12 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo if (account.cryptoCurrencies.isEmpty()) { createEmptyAccountStatusFlow(account) } else { + val userWallet = userWalletsListRepository.userWalletsSync().first { + it.walletId == params.userWalletId + } + getAccountStatusFlow( - userWallet = accountList.userWallet, + userWallet = userWallet, account = account, groupType = accountList.groupType, sortType = accountList.sortType, @@ -71,7 +77,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo val balances = accountStatuses.map { it.tokenList.totalFiatBalance } AccountStatusList( - userWallet = accountList.userWallet, + userWalletId = accountList.userWalletId, accountStatuses = accountStatuses.toSet(), totalAccounts = accountList.totalAccounts, totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt index f038816b91..18abf00e6c 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCase.kt @@ -117,7 +117,7 @@ class GetAccountCurrencyByAddressUseCase( .firstOrNull() return ensureNotNull(result) { - "No account found for network: $networkId in walletId: ${accountList.userWallet.walletId}" + "No account found for network: $networkId in walletId: ${accountList.userWalletId}" } } diff --git a/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt b/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt index ca151e1478..6601e4c64e 100644 --- a/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt +++ b/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt @@ -9,6 +9,7 @@ import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceLoading import com.tangem.domain.models.StatusSource @@ -37,6 +38,7 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultSingleAccountStatusListProducerTest { + private val userWalletsListRepository: UserWalletsListRepository = mockk() private val singleAccountListSupplier: SingleAccountListSupplier = mockk() private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory = mockk() @@ -47,6 +49,7 @@ class DefaultSingleAccountStatusListProducerTest { private val producer = DefaultSingleAccountStatusListProducer( params = SingleAccountStatusListProducer.Params(userWalletId), + userWalletsListRepository = userWalletsListRepository, singleAccountListSupplier = singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory = cryptoCurrencyStatusesFlowFactory, dispatchers = TestingCoroutineDispatcherProvider(), @@ -60,7 +63,7 @@ class DefaultSingleAccountStatusListProducerTest { @Test fun `flow is mapped for user wallet id from params`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId = userWalletId) every { singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) @@ -71,7 +74,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert val expected = AccountStatusList( - userWallet = userWallet, + userWalletId = userWalletId, accountStatuses = setOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, @@ -92,8 +95,8 @@ class DefaultSingleAccountStatusListProducerTest { @Test fun `flow will updated if balances are updated`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet) - val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.BALANCE) + val accountList = AccountList.empty(userWalletId) + val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.BALANCE) val accountListFlow = MutableStateFlow(value = accountList) @@ -106,7 +109,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert (first emission) val expected = AccountStatusList( - userWallet = userWallet, + userWalletId = userWalletId, accountStatuses = setOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, @@ -125,7 +128,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert (second emission) val expected2 = AccountStatusList( - userWallet = userWallet, + userWalletId = userWalletId, accountStatuses = setOf( AccountStatus.CryptoPortfolio( account = updatedAccountList.mainAccount, @@ -147,7 +150,7 @@ class DefaultSingleAccountStatusListProducerTest { @Test fun `flow is filtered the same balance`() = runTest { // Arrange - val accountList = AccountList.empty(userWallet) + val accountList = AccountList.empty(userWalletId) val accountListFlow = MutableStateFlow(value = accountList) every { @@ -155,7 +158,7 @@ class DefaultSingleAccountStatusListProducerTest { } returns accountListFlow val expected = AccountStatusList( - userWallet = userWallet, + userWalletId = userWalletId, accountStatuses = setOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, @@ -191,10 +194,12 @@ class DefaultSingleAccountStatusListProducerTest { // Arrange val cryptoCurrencyFactory = MockCryptoCurrencyFactory() val accountList = AccountList.empty( - userWallet = userWallet, + userWalletId = userWalletId, cryptoCurrencies = cryptoCurrencyFactory.ethereumAndStellar.toSet(), ) + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + every { singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) } returns flowOf(accountList) @@ -220,7 +225,7 @@ class DefaultSingleAccountStatusListProducerTest { // Assert val expected = AccountStatusList( - userWallet = userWallet, + userWalletId = userWalletId, accountStatuses = setOf( AccountStatus.CryptoPortfolio( account = accountList.mainAccount, diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt index 829ce7b3ec..34b63f7842 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyByAddressUseCaseTest.kt @@ -218,7 +218,7 @@ class GetAccountCurrencyByAddressUseCaseTest { }, value = NetworkStatus.Unreachable(address = validNetworkAddress), ) - val accountList = AccountList.empty(multiUserWallet) + val accountList = AccountList.empty(userWalletId) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) coEvery { @@ -253,7 +253,7 @@ class GetAccountCurrencyByAddressUseCaseTest { network = currency.network, value = NetworkStatus.Unreachable(address = validNetworkAddress), ) - val accountList = AccountList.empty(userWallet = multiUserWallet, cryptoCurrencies = setOf(currency)) + val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = setOf(currency)) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet)) coEvery { diff --git a/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt b/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt index 0dc73a77b7..22fae1a67e 100644 --- a/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt +++ b/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt @@ -28,7 +28,7 @@ interface PortfolioFetcher { val walletBalance: Lce, val accountsBalance: AccountStatusList, ) { - val userWallet: UserWallet get() = accountsBalance.userWallet + val userWalletId: UserWalletId get() = accountsBalance.userWalletId } sealed interface Mode { diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt index 31be606a60..de73765db2 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt @@ -140,7 +140,7 @@ internal class AccountsViewModel @Inject constructor( // It's temporary solution to create main account for testing purposes val accountList = AccountList( - userWallet = userWallet, + userWalletId = userWallet.walletId, accounts = setOf( Account.CryptoPortfolio.createMainAccount(userWallet.walletId).copy( accountName = AccountName.invoke(value = "Main Account").getOrNull()!!, From e38f1e0a050953ab0bd591b3c787580ef14c1421 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 16:44:31 +0500 Subject: [PATCH 05/46] Updated on 2026-08-14 --- .../tap/di/domain/WalletsDomainModule.kt | 14 ++++------ .../tap/di/domain/YieldSupplyDomainModule.kt | 28 +++++++++++-------- ...ory.kt => DefaultYieldSupplyRepository.kt} | 22 +++++++++++++-- .../yield/supply/di/YieldSupplyDataModule.kt | 10 ++++--- ...Repository.kt => YieldSupplyRepository.kt} | 5 +++- .../usecase/YieldSupplyApyFlowUseCase.kt | 6 ++-- .../usecase/YieldSupplyApyUpdateUseCase.kt | 6 ++-- .../usecase/YieldSupplyGetApyUseCase.kt | 6 ++-- .../usecase/YieldSupplyGetChartUseCase.kt | 6 ++-- .../YieldSupplyGetTokenStatusUseCase.kt | 6 ++-- .../usecase/YieldSupplyIsAvailableUseCase.kt | 14 ++++++++++ .../supply/impl/main/entity/YieldSupplyUM.kt | 4 ++- .../impl/main/model/YieldSupplyModel.kt | 17 +++++++++-- ...ieldSupplyTokenStatusSuccessTransformer.kt | 2 +- .../impl/main/ui/YieldSupplyBlockContent.kt | 7 +++-- gradle/tangem_dependencies.toml | 2 +- 16 files changed, 103 insertions(+), 52 deletions(-) rename data/yield-supply/src/main/java/com/tangem/data/yield/supply/{DefaultYieldSupplyMarketRepository.kt => DefaultYieldSupplyRepository.kt} (76%) rename domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/{YieldSupplyMarketRepository.kt => YieldSupplyRepository.kt} (85%) create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyIsAvailableUseCase.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 0f7e759701..d1749cce71 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 @@ -16,7 +16,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.* -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase @@ -454,21 +454,17 @@ internal object WalletsDomainModule { @Provides @Singleton - fun provideYieldSupplyApyFlowUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, - ): YieldSupplyApyFlowUseCase { + fun provideYieldSupplyApyFlowUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyApyFlowUseCase { return YieldSupplyApyFlowUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, ) } @Provides @Singleton - fun provideYieldSupplyApyUpdateUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, - ): YieldSupplyApyUpdateUseCase { + fun provideYieldSupplyApyUpdateUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyApyUpdateUseCase { return YieldSupplyApyUpdateUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index 70ce92ea14..ccc23e94e3 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -4,7 +4,7 @@ import com.tangem.domain.blockaid.BlockAidGasEstimate import com.tangem.domain.transaction.FeeRepository import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.yield.supply.YieldSupplyErrorResolver -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository import com.tangem.domain.yield.supply.usecase.* import dagger.Module @@ -82,30 +82,36 @@ internal object YieldSupplyDomainModule { @Provides @Singleton fun provideYieldSupplyGetTokenStatusUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, + yieldSupplyRepository: YieldSupplyRepository, ): YieldSupplyGetTokenStatusUseCase { return YieldSupplyGetTokenStatusUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, ) } @Provides @Singleton - fun provideYieldSupplyGetApyUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, - ): YieldSupplyGetApyUseCase { + fun provideYieldSupplyGetApyUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyGetApyUseCase { return YieldSupplyGetApyUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, ) } @Provides @Singleton - fun provideYieldSupplyGetChartUseCase( - yieldSupplyMarketRepository: YieldSupplyMarketRepository, - ): YieldSupplyGetChartUseCase { + fun provideYieldSupplyGetChartUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyGetChartUseCase { return YieldSupplyGetChartUseCase( - yieldSupplyMarketRepository = yieldSupplyMarketRepository, + yieldSupplyRepository = yieldSupplyRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyIsAvailableUseCase( + yieldSupplyRepository: YieldSupplyRepository, + ): YieldSupplyIsAvailableUseCase { + return YieldSupplyIsAvailableUseCase( + yieldSupplyRepository = yieldSupplyRepository, ) } } \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt similarity index 76% rename from data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt rename to data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt index 641a04923e..304697ca8d 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyMarketRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -1,7 +1,9 @@ package com.tangem.data.yield.supply import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.yieldsupply.YieldSupplyProvider import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.local.yieldsupply.YieldMarketsStore @@ -10,7 +12,9 @@ import com.tangem.datasource.api.tangemTech.YieldSupplyApi import com.tangem.data.yield.supply.converters.YieldTokenStatusConverter import com.tangem.data.yield.supply.converters.YieldTokenChartConverter import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData @@ -20,11 +24,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlin.collections.map -internal class DefaultYieldSupplyMarketRepository( +internal class DefaultYieldSupplyRepository( private val yieldSupplyApi: YieldSupplyApi, private val store: YieldMarketsStore, + private val walletManagersFacade: WalletManagersFacade, private val dispatchers: CoroutineDispatcherProvider, -) : YieldSupplyMarketRepository { +) : YieldSupplyRepository { override suspend fun getCachedMarkets(): List? = withContext(dispatchers.io) { val cache = store.getSyncOrNull().orEmpty() @@ -57,6 +62,17 @@ internal class DefaultYieldSupplyMarketRepository( return YieldTokenChartConverter.convert(response) } + override suspend fun isYieldSupplySupported(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean = + withContext(dispatchers.io) { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + + (walletManager as? YieldSupplyProvider)?.isSupported() ?: false + } + private fun List.enrichNetworkIds(): List { val chainIdMap = Blockchain.entries.associate { it.getChainId() to it.toNetworkId() } return this.map { token -> diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt index 8888eba918..69ce23f67e 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -1,12 +1,12 @@ package com.tangem.data.yield.supply.di -import com.tangem.data.yield.supply.DefaultYieldSupplyMarketRepository +import com.tangem.data.yield.supply.DefaultYieldSupplyRepository import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository import com.tangem.datasource.api.tangemTech.YieldSupplyApi import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyErrorResolver import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -37,12 +37,14 @@ internal object YieldSupplyDataModule { fun provideYieldSupplyMarketRepository( yieldSupplyApi: YieldSupplyApi, store: YieldMarketsStore, + walletManagersFacade: WalletManagersFacade, dispatchers: CoroutineDispatcherProvider, - ): YieldSupplyMarketRepository { - return DefaultYieldSupplyMarketRepository( + ): YieldSupplyRepository { + return DefaultYieldSupplyRepository( yieldSupplyApi = yieldSupplyApi, store = store, dispatchers = dispatchers, + walletManagersFacade = walletManagersFacade, ) } diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyMarketRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt similarity index 85% rename from domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyMarketRepository.kt rename to domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt index 5d26c82073..e751e75a1c 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyMarketRepository.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt @@ -1,12 +1,13 @@ package com.tangem.domain.yield.supply import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData import kotlinx.coroutines.flow.Flow -interface YieldSupplyMarketRepository { +interface YieldSupplyRepository { /** * Get cached yield markets or null if nothing cached yet. @@ -35,4 +36,6 @@ interface YieldSupplyMarketRepository { */ @Throws suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData + + suspend fun isYieldSupplySupported(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyFlowUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyFlowUseCase.kt index 9efcab1ea6..d93fcca1a1 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyFlowUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyFlowUseCase.kt @@ -1,6 +1,6 @@ package com.tangem.domain.yield.supply.usecase -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -12,11 +12,11 @@ import kotlinx.coroutines.flow.map * - value: APY as string */ class YieldSupplyApyFlowUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { operator fun invoke(): Flow> { - return yieldSupplyMarketRepository.getMarketsFlow() + return yieldSupplyRepository.getMarketsFlow() .map { yieldMarketTokenList -> yieldMarketTokenList.filter { it.isActive }.associate { token -> token.yieldSupplyKey to token.apy.toString() diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyUpdateUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyUpdateUseCase.kt index a3d9200e44..d2dff0ea7f 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyUpdateUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyApyUpdateUseCase.kt @@ -1,7 +1,7 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import kotlin.collections.filter /** @@ -12,11 +12,11 @@ import kotlin.collections.filter * - value: APY as string */ class YieldSupplyApyUpdateUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { suspend operator fun invoke(): Either> = Either.catch { - yieldSupplyMarketRepository.updateMarkets() + yieldSupplyRepository.updateMarkets() .filter { it.isActive } .associate { it.tokenAddress to it.apy.toString() diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetApyUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetApyUseCase.kt index 6bb5070262..14f176b3ec 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetApyUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetApyUseCase.kt @@ -1,14 +1,14 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository class YieldSupplyGetApyUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { suspend operator fun invoke(tokenAddress: String): Either = Either.catch { - val apys = yieldSupplyMarketRepository.getCachedMarkets() ?: yieldSupplyMarketRepository.updateMarkets() + val apys = yieldSupplyRepository.getCachedMarkets() ?: yieldSupplyRepository.updateMarkets() apys.first { it.tokenAddress == tokenAddress }.apy.toString() } } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetChartUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetChartUseCase.kt index 06cb773d1c..3d1142fb98 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetChartUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetChartUseCase.kt @@ -2,15 +2,15 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData class YieldSupplyGetChartUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { suspend operator fun invoke(cryptoCurrency: CryptoCurrency.Token): Either = Either.catch { - yieldSupplyMarketRepository.getTokenChart(cryptoCurrency) + yieldSupplyRepository.getTokenChart(cryptoCurrency) } } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt index 3a6d689924..c76f4c8e71 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt @@ -2,14 +2,14 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.yield.supply.YieldSupplyMarketRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus class YieldSupplyGetTokenStatusUseCase( - private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, + private val yieldSupplyRepository: YieldSupplyRepository, ) { suspend operator fun invoke(token: CryptoCurrency.Token): Either = Either.catch { - yieldSupplyMarketRepository.getTokenStatus(token) + yieldSupplyRepository.getTokenStatus(token) } } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyIsAvailableUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyIsAvailableUseCase.kt new file mode 100644 index 0000000000..4b7fc6e168 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyIsAvailableUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.yield.supply.usecase + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.YieldSupplyRepository + +class YieldSupplyIsAvailableUseCase( + private val yieldSupplyRepository: YieldSupplyRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { + return yieldSupplyRepository.isYieldSupplySupported(userWalletId, cryptoCurrency) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt index de7a778530..192ea9551a 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt @@ -6,7 +6,9 @@ import com.tangem.core.ui.extensions.TextReference @Immutable internal sealed class YieldSupplyUM { - data class Initial( + data object Initial : YieldSupplyUM() + + data class Available( val title: TextReference, val onClick: () -> Unit, ) : YieldSupplyUM() diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 829f65d97f..fb7fbdf51f 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -17,6 +17,7 @@ import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyIsAvailableUseCase import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.features.yield.supply.impl.main.entity.LoadingStatusMode @@ -45,12 +46,13 @@ internal class YieldSupplyModel @Inject constructor( @DelayedWork private val coroutineScope: CoroutineScope, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, + private val yieldSupplyIsAvailableUseCase: YieldSupplyIsAvailableUseCase, ) : Model(), YieldSupplyClickIntents { private val params = paramsContainer.require() val uiState: StateFlow - field = MutableStateFlow(YieldSupplyUM.Loading) + field = MutableStateFlow(YieldSupplyUM.Initial) val bottomSheetNavigation: SlotNavigation = SlotNavigation() @@ -69,8 +71,17 @@ internal class YieldSupplyModel @Inject constructor( field = MutableStateFlow(false) init { - subscribeOnCurrencyStatusUpdates() - subscribeOnBalanceHidden() + checkIfYieldSupplyIsAvailable() + } + + private fun checkIfYieldSupplyIsAvailable() { + modelScope.launch(dispatchers.io) { + val isAvailable = yieldSupplyIsAvailableUseCase(params.userWalletId, params.cryptoCurrency) + if (isAvailable) { + subscribeOnCurrencyStatusUpdates() + subscribeOnBalanceHidden() + } + } } private fun subscribeOnCurrencyStatusUpdates() { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt index 42549b2060..f45986dc3c 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt @@ -20,7 +20,7 @@ internal class YieldSupplyTokenStatusSuccessTransformer( return when (mode) { LoadingStatusMode.Initial -> { - YieldSupplyUM.Initial( + YieldSupplyUM.Available( title = resourceReference( id = R.string.yield_module_token_details_earn_notification_title, formatArgs = wrappedList(tokenStatus.apy), diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt index df41b8fb28..e044b1c598 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt @@ -44,7 +44,7 @@ internal fun YieldSupplyBlockContent( modifier = modifier, ) { supplyUM -> when (supplyUM) { - is YieldSupplyUM.Initial -> SupplyInitial(supplyUM) + is YieldSupplyUM.Available -> SupplyAvailable(supplyUM) YieldSupplyUM.Loading -> SupplyLoading() is YieldSupplyUM.Content -> SupplyContent(supplyUM, isBalanceHidden) YieldSupplyUM.Processing.Enter -> SupplyProcessing( @@ -54,12 +54,13 @@ internal fun YieldSupplyBlockContent( resourceReference(R.string.yield_module_stop_earning), ) YieldSupplyUM.Unavailable -> SupplyUnavailable() + YieldSupplyUM.Initial -> {} } } } @Composable -private fun SupplyInitial(supplyUM: YieldSupplyUM.Initial) { +private fun SupplyAvailable(supplyUM: YieldSupplyUM.Available) { SupplyInfo( title = supplyUM.title, subtitle = resourceReference(R.string.yield_module_token_details_earn_notification_description), @@ -292,7 +293,7 @@ private fun YieldSupplyBlockContent_Preview(@PreviewParameter(PreviewProvider::c private class PreviewProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - YieldSupplyUM.Initial( + YieldSupplyUM.Available( title = TextReference.Res( R.string.yield_module_token_details_earn_notification_title, wrappedList("5.1"), diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 289f92fb06..9dabe4209d 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1254" +tangemBlockchainSdk = "develop-1256" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-564" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 5d21b088c49bda0640b13f05cb50db3d646ce383 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 15:30:14 +0300 Subject: [PATCH 06/46] Updated on 2026-08-14 --- .../com/tangem/common/utils/ClipboardUtils.kt | 20 ++ .../com/tangem/scenarios/BaseScenarios.kt | 9 + .../scenarios/WalletConnectScenarios.kt | 71 ++++++-- .../WalletConnectBottonSheetPageObject.kt | 2 - .../tangem/screens/WalletConnectPageObject.kt | 15 ++ .../screens/WalletConnectScanQrPageObject.kt | 23 +++ .../com/tangem/tests/WalletConnectTest.kt | 171 +++++++++++++----- .../ui/test/WalletConnectScreenTestTags.kt | 1 + .../connections/ui/WcConnectionsContent.kt | 4 +- 9 files changed, 243 insertions(+), 73 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt new file mode 100644 index 0000000000..f4648d1cc9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt @@ -0,0 +1,20 @@ +package com.tangem.common.utils + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context + +fun getClipboardText(context: Context): String? { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + return if (clipboard.hasPrimaryClip()) { + clipboard.primaryClip?.getItemAt(0)?.text?.toString() + } else { + null + } +} + +fun setClipboardText(context: Context, text: String?) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("label", text) + clipboard.setPrimaryClip(clip) +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 3212a90432..d07178f3cb 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -85,4 +85,13 @@ fun BaseTestCase.openDeviceSettingsScreen() { step("Click on 'Device settings' button") { onWalletSettingsScreen { deviceSettingsButton.clickWithAssertion() } } +} + +fun BaseTestCase.openWalletConnectScreen() { + step("Click 'More' button on TopBar") { + onTopBar { moreButton.clickWithAssertion() } + } + step("Click on 'Wallet Connect' button") { + onDetailsScreen { walletConnectButton.clickWithAssertion() } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt index 107806004a..facffbfd2d 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt @@ -7,6 +7,7 @@ import com.tangem.screens.onWalletConnectScreen import io.qameta.allure.kotlin.Allure.step fun BaseTestCase.checkWalletConnectBottomSheet() { + waitForIdle() step("Assert 'Wallet Connect' bottom sheet title is displayed") { onWalletConnectBottomSheet { title.assertIsDisplayed() } } @@ -60,34 +61,64 @@ fun BaseTestCase.checkWalletConnectBottomSheet() { } } -fun BaseTestCase.checkWalletConnectScreen() { +fun BaseTestCase.checkWalletConnectScreen(withConnections: Boolean) { + waitForIdle() step("Assert 'Wallet Connect' title is displayed") { onWalletConnectScreen { title.assertIsDisplayed() } } - step("Assert 'More' button is displayed") { - onWalletConnectScreen { moreButton.assertIsDisplayed() } - } - step("Assert wallet name is displayed") { - onWalletConnectScreen { walletName.assertIsDisplayed() } - } - step("Assert app icon is displayed") { - onWalletConnectScreen { appIcon.assertIsDisplayed() } - } - step("Assert app name is displayed") { - onWalletConnectScreen { appName.assertIsDisplayed() } - } - step("Assert approve icon is displayed") { - onWalletConnectScreen { approveIcon.assertIsDisplayed() } - } - step("Assert app URL is displayed") { - onWalletConnectScreen { appUrl.assertIsDisplayed() } - } step("Assert 'New Connection' button is displayed") { onWalletConnectScreen { newConnectionButton.assertIsDisplayed() } } + if (withConnections) { + step("Assert 'More' button is displayed") { + onWalletConnectScreen { moreButton.assertIsDisplayed() } + } + step("Assert wallet name is displayed") { + onWalletConnectScreen { walletName.assertIsDisplayed() } + } + step("Assert app icon is displayed") { + onWalletConnectScreen { appIcon.assertIsDisplayed() } + } + step("Assert app name is displayed") { + onWalletConnectScreen { appName.assertIsDisplayed() } + } + step("Assert approve icon is displayed") { + onWalletConnectScreen { approveIcon.assertIsDisplayed() } + } + step("Assert app URL is displayed") { + onWalletConnectScreen { appUrl.assertIsDisplayed() } + } + } else { + step("Assert wallet name is not displayed") { + onWalletConnectScreen { walletName.assertIsNotDisplayed() } + } + step("Assert app icon is not displayed") { + onWalletConnectScreen { appIcon.assertIsNotDisplayed() } + } + step("Assert app name is not displayed") { + onWalletConnectScreen { appName.assertIsNotDisplayed() } + } + step("Assert approve icon is not displayed") { + onWalletConnectScreen { approveIcon.assertIsNotDisplayed() } + } + step("Assert app URL is not displayed") { + onWalletConnectScreen { appUrl.assertIsNotDisplayed() } + } + step("Assert 'Wallet Connect' image is displayed") { + onWalletConnectScreen { walletConnectImage.assertIsDisplayed() } + } + step("Assert 'No session' title is displayed") { + onWalletConnectScreen { noSessionTitle.assertIsDisplayed() } + } + step("Assert 'No session' text is displayed") { + onWalletConnectScreen { noSessionText.assertIsDisplayed() } + } + } + } fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { + waitForIdle() step("Assert connection details title is displayed") { onWalletConnectDetailsBottomSheet { title.assertIsDisplayed() } } @@ -128,7 +159,7 @@ fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { onWalletConnectDetailsBottomSheet { connectedNetworkIcon.assertIsDisplayed() } } step("Assert connected dApp name: '$dAppName'") { - onWalletConnectDetailsBottomSheet { connectedNetworkName.assertTextContains(dAppName) } + onWalletConnectDetailsBottomSheet { appName.assertTextContains(dAppName) } } step("Assert connected network symbol is displayed") { onWalletConnectDetailsBottomSheet { connectedNetworkSymbol.assertIsDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt index c111e92406..8490c75e76 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectBottonSheetPageObject.kt @@ -9,13 +9,11 @@ import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString -import com.tangem.features.walletconnect.impl.R as WalletConnectImplR class WalletConnectBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { val title: KNode = child { - hasText(getResourceString(WalletConnectImplR.string.wc_wallet_connect)) hasTestTag(WalletConnectBottomSheetTestTags.TITLE) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt index b9da100325..cb84e644f1 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectPageObject.kt @@ -53,6 +53,21 @@ class WalletConnectPageObject(semanticsProvider: SemanticsNodeInteractionsProvid hasText(getResourceString(R.string.wc_new_connection)) useUnmergedTree = true } + + val walletConnectImage: KNode = child { + hasTestTag(WalletConnectScreenTestTags.WALLET_CONNECT_IMAGE) + useUnmergedTree = true + } + + val noSessionTitle: KNode = child { + hasText(getResourceString(R.string.wc_no_sessions_title)) + useUnmergedTree = true + } + + val noSessionText: KNode = child { + hasText(getResourceString(R.string.wc_no_sessions_desc)) + useUnmergedTree = true + } } internal fun BaseTestCase.onWalletConnectScreen(function: WalletConnectPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt new file mode 100644 index 0000000000..d48c6e7e67 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletConnectScanQrPageObject.kt @@ -0,0 +1,23 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class WalletConnectScanQrPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val pasteFromClipboardButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.wallet_connect_paste_from_clipboard)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onWalletConnectScanQrScreen(function: WalletConnectScanQrPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt index db5e364f20..13fa9b6a47 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletConnectTest.kt @@ -2,12 +2,13 @@ package com.tangem.tests import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE -import com.tangem.common.extensions.SwipeDirection +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeVertical import com.tangem.common.utils.getWcUri +import com.tangem.common.utils.setClipboardText import com.tangem.scenarios.* import com.tangem.screens.* +import com.tangem.wallet.BuildConfig import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -21,7 +22,7 @@ class WalletConnectTest : BaseTestCase() { @DisplayName("WC (React App): open session from deeplink on main screen") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test - fun openWalletConnectSessionOnMainScreen() { + fun openWalletConnectSessionOnMainScreenTest() { val balance = TOTAL_BALANCE val dAppName = "React App" val deepLinkUri = getWcUri() @@ -37,19 +38,28 @@ class WalletConnectTest : BaseTestCase() { openAppByDeepLink(deepLinkUri) } step("Check 'Wallet Connect' bottom sheet") { - checkWalletConnectBottomSheet() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } + } + step("Assert 'Connect' button is enabled") { + onWalletConnectBottomSheet { connectButton.assertIsEnabled() } } step("Click on 'Connect' button") { + waitForIdle() onWalletConnectBottomSheet { connectButton.performClick() } } - step("Click 'More' button on TopBar") { - onTopBar { moreButton.clickWithAssertion() } + step("Assert 'Connect' button is not displayed") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } } - step("Click on 'Wallet Connect' button") { - onDetailsScreen { walletConnectButton.clickWithAssertion() } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() } - step("Check 'Wallet Connect' screen") { - checkWalletConnectScreen() + step("Check 'Wallet Connect' screen with connections") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectScreen(withConnections = true) + } } step("Click on app icon") { onWalletConnectScreen { appIcon.performClick() } @@ -57,11 +67,11 @@ class WalletConnectTest : BaseTestCase() { step("Check 'Wallet Connect' details bottom sheet") { checkWalletConnectDetailsBottomSheet(dAppName) } - step("Click on 'Disconnect button' is displayed") { + step("Click on 'Disconnect' button") { onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } } - step("Assert connection is not displayed") { - onWalletConnectScreen { appName.assertIsNotDisplayed() } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) } } } @@ -70,7 +80,7 @@ class WalletConnectTest : BaseTestCase() { @DisplayName("WC (React App): open session from deeplink not on main screen") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test - fun openWalletConnectSessionNotOnMainScreen() { + fun openWalletConnectSessionNotOnMainScreenTest() { val balance = TOTAL_BALANCE val dAppName = "React App" val deepLinkUri = getWcUri() @@ -82,41 +92,44 @@ class WalletConnectTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses(balance) } - step("Click on 'Buy' button") { - onMainScreen { buyButton.clickWithAssertion() } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + checkWalletConnectScreen(false) } step("Create WC session buy deeplink") { openAppByDeepLink(deepLinkUri) } step("Check 'Wallet Connect' bottom sheet") { - checkWalletConnectBottomSheet() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } } step("Click on 'Connect' button") { + waitForIdle() onWalletConnectBottomSheet { connectButton.performClick() } } - step("Click 'More' button on TopBar") { - onTopBar { moreButton.clickWithAssertion() } + step("Assert 'Connect' button is not displayed") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } } - step("Click on 'Wallet Connect' button") { - onDetailsScreen { walletConnectButton.clickWithAssertion() } - } - step("Assert 'Wallet Connect' bottom sheet is displayed") { - onWalletConnectBottomSheet { connectButton.clickWithAssertion() } - } - step("Check 'Wallet Connect' screen") { - checkWalletConnectScreen() + step("Check 'Wallet Connect' screen with connections") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectScreen(withConnections = true) + } } step("Click on app icon") { onWalletConnectScreen { appIcon.performClick() } } step("Check 'Wallet Connect' details bottom sheet") { - checkWalletConnectDetailsBottomSheet(dAppName) + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectDetailsBottomSheet(dAppName) + } } - step("Click on 'Disconnect button' is displayed") { + step("Click on 'Disconnect' button") { onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } } - step("Assert connection is not displayed") { - onWalletConnectScreen { appName.assertIsNotDisplayed() } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) } } } @@ -125,9 +138,10 @@ class WalletConnectTest : BaseTestCase() { @DisplayName("WC (React App): open session from deeplink ") @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") @Test - fun openWalletConnectSession() { + fun openWalletConnectSessionTest() { val balance = TOTAL_BALANCE val dAppName = "React App" + val packageName = BuildConfig.APPLICATION_ID val deepLinkUri = getWcUri() setupHooks().run { @@ -137,32 +151,28 @@ class WalletConnectTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses(balance) } - step("Open recent apps") { - device.uiDevice.pressRecentApps() - } - step("Stop app by swipe") { - swipeVertical(SwipeDirection.UP, startHeightRatio = 0.8f) + step("Kill app") { + device.apps.kill(packageName) } step("Create WC session buy deeplink") { openAppByDeepLink(deepLinkUri) } - step("Open 'Main Screen'") { - openMainScreen() - } step("Check 'Wallet Connect' bottom sheet") { - checkWalletConnectBottomSheet() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } } step("Click on 'Connect' button") { onWalletConnectBottomSheet { connectButton.performClick() } } - step("Click 'More' button on TopBar") { - onTopBar { moreButton.clickWithAssertion() } + step("Assert 'Connect' button is not displayed") { + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } } - step("Click on 'Wallet Connect' button") { - onDetailsScreen { walletConnectButton.clickWithAssertion() } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() } - step("Check 'Wallet Connect' screen") { - checkWalletConnectScreen() + step("Check 'Wallet Connect' screen with connections") { + checkWalletConnectScreen(withConnections = true) } step("Click on app icon") { onWalletConnectScreen { appIcon.performClick() } @@ -170,11 +180,72 @@ class WalletConnectTest : BaseTestCase() { step("Check 'Wallet Connect' details bottom sheet") { checkWalletConnectDetailsBottomSheet(dAppName) } - step("Click on 'Disconnect button' is displayed") { + step("Click on 'Disconnect' button") { onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } } - step("Assert connection is not displayed") { - onWalletConnectScreen { appName.assertIsNotDisplayed() } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) + } + } + } + + @AllureId("887") + @DisplayName("WC: open session by 'Paste from clipboard' button") + @Ignore("TODO [REDACTED_JIRA] React app deeplink doesn't work") + @Test + fun openWalletConnectSessionByClipboardLinkTest() { + val balance = TOTAL_BALANCE + val dAppName = "React App" + val context = device.context + val deepLinkUri = getWcUri() + + setupHooks().run { + step("Set URI to clipboard") { + setClipboardText(context, deepLinkUri) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + } + step("Click 'New connection' button") { + onWalletConnectScreen { newConnectionButton.performClick() } + } + step("CLick 'Paste from clipboard' button") { + onWalletConnectScanQrScreen { pasteFromClipboardButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' bottom sheet") { + waitForIdle() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkWalletConnectBottomSheet() + } + } + step("Click on 'Connect' button") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.performClick() } + } + step("Assert 'Connect' button is not displayed") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + } + step("Check 'Wallet Connect' screen with connections") { + checkWalletConnectScreen(withConnections = true) + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + checkWalletConnectDetailsBottomSheet(dAppName) + } + step("Click on 'Disconnect' button") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) } } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt index 8280fc5021..53400f7588 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletConnectScreenTestTags.kt @@ -7,4 +7,5 @@ object WalletConnectScreenTestTags { const val APP_NAME = "WALLET_CONNECT_SCREEN_APP_NAME" const val APPROVE_ICON = "WALLET_CONNECT_SCREEN_APPROVE_ICON" const val APP_URL = "WALLET_CONNECT_SCREEN_APP_URL" + const val WALLET_CONNECT_IMAGE = "WALLET_CONNECT_SCREEN_WALLET_CONNECT_IMAGE" } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt index f079b379c0..8afd70342c 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt @@ -110,7 +110,9 @@ private fun EmptyConnectionsBlock(onNewConnectionClick: () -> Unit, modifier: Mo Image( painter = painterResource(R.drawable.img_wallet_connect_76), contentDescription = "Wallet Connect", - modifier = Modifier.size(76.dp), + modifier = Modifier + .size(76.dp) + .testTag(WalletConnectScreenTestTags.WALLET_CONNECT_IMAGE), ) Text( modifier = Modifier.padding(top = TangemTheme.dimens.spacing24), From 16156c559a66ed6434d85b17e13378b0a42c781e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 19:39:10 +0500 Subject: [PATCH 07/46] Updated on 2026-08-14 --- .../impl/common/entity/YieldSupplyFeeUM.kt | 2 ++ .../model/YieldSupplyStartEarningModel.kt | 25 ++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt index 14baa04a9a..d00eede2f9 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/entity/YieldSupplyFeeUM.kt @@ -5,6 +5,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal @Immutable internal sealed class YieldSupplyFeeUM { @@ -27,4 +28,5 @@ internal data class YieldSupplyActionUM( val yieldSupplyFeeUM: YieldSupplyFeeUM, val isPrimaryButtonEnabled: Boolean, val isTransactionSending: Boolean, + val maxFee: BigDecimal = BigDecimal.ZERO, ) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index a82ff670c8..e052894b7f 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -8,8 +8,10 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.FetchCurrencyStatusUseCase @@ -18,6 +20,7 @@ import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory @@ -55,6 +58,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( private val yieldSupplyNotificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger, private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, + private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require() @@ -106,13 +110,26 @@ internal class YieldSupplyStartEarningModel @Inject constructor( } } + private suspend fun getMaxFee(): BigDecimal? { + if (uiState.value.maxFee != BigDecimal.ZERO) return uiState.value.maxFee + val yieldTokenStatus = yieldSupplyGetTokenStatusUseCase(cryptoCurrency as CryptoCurrency.Token) + .getOrNull() + return yieldTokenStatus?.maxFeeNative?.parseToBigDecimal(cryptoCurrency.decimals) + } + private suspend fun onLoadFee() { if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading || uiState.value.isTransactionSending) return + val maxFee = if (uiState.value.maxFee == BigDecimal.ZERO) { + getMaxFee() + } else { + uiState.value.maxFee + } ?: return + val transactionListData = yieldSupplyStartEarningUseCase( userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus, - maxNetworkFee = MAX_NETWORK_FEE, + maxNetworkFee = maxFee, ).getOrNull() ?: return uiState.update { @@ -146,7 +163,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( appCurrency = appCurrency, updatedTransactionList = updatedTransactionList, feeValue = feeSum, - maxNetworkFee = MAX_NETWORK_FEE, + maxNetworkFee = maxFee, ), ) yieldSupplyNotificationsUpdateTrigger.triggerUpdate( @@ -274,8 +291,4 @@ internal class YieldSupplyStartEarningModel @Inject constructor( popBack = params.callback::onBackClick, ) } - - private companion object { - val MAX_NETWORK_FEE: BigDecimal = BigDecimal.TEN // TODO replace with value from api - } } \ No newline at end of file From bbd0c7927dfd13bf1bbca236672f1f230460607c Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Sep 2025 18:46:49 +0500 Subject: [PATCH 08/46] Updated on 2026-08-14 --- features/walletconnect/impl/build.gradle.kts | 11 ++-- .../entity/approve/WcSpendAllowanceUM.kt | 1 + .../model/WcSendTransactionModel.kt | 19 +++++- .../ui/approve/WcCustomAllowanceContent.kt | 1 + .../blockaid/TransactionCheckResultsItem.kt | 60 ++++++++++++++++--- .../WcEstimatedWalletChangeUMConverter.kt | 3 +- .../WcSendAndReceiveBlockAidUiConverter.kt | 19 ++++-- .../blockaid/WcSpendAllowanceUMConverter.kt | 20 ++++--- .../send/WcSendTransactionModalBottomSheet.kt | 31 ++++++++++ 9 files changed, 133 insertions(+), 32 deletions(-) diff --git a/features/walletconnect/impl/build.gradle.kts b/features/walletconnect/impl/build.gradle.kts index 0bf16b6415..1d3a97b3a6 100644 --- a/features/walletconnect/impl/build.gradle.kts +++ b/features/walletconnect/impl/build.gradle.kts @@ -16,13 +16,16 @@ dependencies { implementation(projects.features.walletconnect.api) implementation(projects.features.sendV2.api) - /** Core */ - implementation(projects.core.configToggles) - implementation(projects.core.decompose) - implementation(projects.core.ui) + /** Common */ implementation(projects.common.routing) implementation(projects.common.ui) + + /** Core */ implementation(projects.core.analytics) + implementation(projects.core.configToggles) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.ui) /** Domain models */ implementation(projects.domain.appCurrency.models) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt index 0bdad0e725..57aef929c4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/approve/WcSpendAllowanceUM.kt @@ -11,4 +11,5 @@ internal data class WcSpendAllowanceUM( val tokenSymbol: String, val tokenImageUrl: String?, val networkIconRes: Int?, + val onLearnMoreClicked: () -> Unit, ) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index 8d36bbe2c6..8b201362c2 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -16,6 +16,7 @@ import com.tangem.core.decompose.model.Model 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.url.UrlOpener import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2 import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUMV2.Icon.Type @@ -55,6 +56,7 @@ import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransacti import com.tangem.features.walletconnect.transaction.routes.WcTransactionRoutes import com.tangem.features.walletconnect.transaction.ui.blockaid.WcSendAndReceiveBlockAidUiConverter import com.tangem.features.walletconnect.utils.WcNotificationsFactory +import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -74,11 +76,11 @@ internal class WcSendTransactionModel @Inject constructor( private val clipboardManager: ClipboardManager, private val useCaseFactory: WcRequestUseCaseFactory, private val converter: WcSendTransactionUMConverter, - private val blockAidUiConverter: WcSendAndReceiveBlockAidUiConverter, private val getFeeUseCase: GetFeeUseCase, private val getNetworkCoinUseCase: GetNetworkCoinStatusUseCase, private val notificationsFactory: WcNotificationsFactory, private val analytics: AnalyticsEventHandler, + private val urlOpener: UrlOpener, ) : Model(), WcCommonTransactionModel, FeeSelectorModelCallback { private val params = paramsContainer.require() @@ -94,6 +96,7 @@ internal class WcSendTransactionModel @Inject constructor( private var signState: WcSignState<*> by Delegates.notNull() private var wcApproval: WcApproval? = null private var sign: () -> Unit = {} + private val blockAidUiConverter = WcSendAndReceiveBlockAidUiConverter() private val feeReloadState = MutableStateFlow(false) private val signatureReceivedAnalyticsSendState = MutableStateFlow(false) private val securityStatusState = @@ -243,8 +246,9 @@ internal class WcSendTransactionModel @Inject constructor( val blockAidState = when (securityCheck) { is Lce.Content -> blockAidUiConverter.convert( WcSendAndReceiveBlockAidUiConverter.Input( - securityCheck.content.result, - if (isApproval) wcApproval?.getAmount() else null, + result = securityCheck.content.result, + approvedAmount = if (isApproval) wcApproval?.getAmount() else null, + onApproveLearnMoreClick = ::onApproveLearnMoreClick, ), ) is Lce.Error -> WcSendReceiveTransactionCheckResultsUM(isLoading = false) @@ -291,6 +295,15 @@ internal class WcSendTransactionModel @Inject constructor( stackNavigation.pop() } + private fun onApproveLearnMoreClick() { + val code = SupportedLanguages.getCurrentSupportedLanguageCode() + .takeIf { it == SupportedLanguages.RUSSIAN } + ?: SupportedLanguages.ENGLISH + + val url = "https://tangem.com/$code/blog/post/give-revoke-permission/" + urlOpener.openUrl(url) + } + private fun isMultipleSignRequired(useCase: WcSignUseCase<*>): Boolean { return if (useCase is SignRequirements) { useCase.isMultipleSignRequired() diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt index 60c4fc70c2..a4e431c1a1 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/approve/WcCustomAllowanceContent.kt @@ -255,6 +255,7 @@ private class WcCustomAllowanceStateProvider : CollectionPreviewParameterProvide amountValue = BigDecimal("100"), tokenSymbol = "ETH", isUnlimited = false, + onLearnMoreClicked = {}, ), ), ) \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt index 2b7aca4340..92dbcdd661 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/TransactionCheckResultsItem.kt @@ -3,25 +3,29 @@ package com.tangem.features.walletconnect.transaction.ui.blockaid import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.isNullOrEmpty -import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.walletconnect.impl.R +import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM import com.tangem.features.walletconnect.transaction.entity.blockaid.BlockAidNotificationUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangeUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangesUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM import com.tangem.features.walletconnect.transaction.ui.approve.WcSpendAllowanceItem import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal @Composable internal fun TransactionCheckResultsItem( @@ -29,11 +33,7 @@ internal fun TransactionCheckResultsItem( onClickAllowToSpend: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { if (item.isLoading) { WcEstimatedWalletChangesLoadingItem() } else { @@ -44,6 +44,10 @@ internal fun TransactionCheckResultsItem( WcEstimatedWalletChangesItem(item.estimatedWalletChanges) } else if (item.spendAllowance != null) { WcSpendAllowanceItem(item.spendAllowance, onClickAllowToSpend) + ApproveDescription( + modifier = Modifier.padding(bottom = 6.dp, start = 12.dp, end = 12.dp), + onLearnMoreClick = item.spendAllowance.onLearnMoreClicked, + ) } else if (!item.additionalNotification.isNullOrEmpty()) { WcEstimatedWalletChangesNotificationItem(description = item.additionalNotification) } else { @@ -53,6 +57,29 @@ internal fun TransactionCheckResultsItem( } } +@Composable +private fun ApproveDescription(modifier: Modifier = Modifier, onLearnMoreClick: () -> Unit) { + val linkText = stringResourceSafe(R.string.common_learn_more) + val fullString = stringResourceSafe(R.string.wc_approve_description) + val defaultColor = TangemTheme.colors.text.tertiary + val linkColor = TangemTheme.colors.text.accent + Text( + modifier = modifier, + style = TangemTheme.typography.caption2, + text = buildAnnotatedString { + appendColored(fullString, defaultColor) + appendSpace() + withLink( + link = LinkAnnotation.Clickable( + tag = "WC_APPROVE_LEARN_MORE_TAG", + linkInteractionListener = { onLearnMoreClick() }, + ), + block = { appendColored(text = linkText, color = linkColor) }, + ) + }, + ) +} + @Composable @Preview(showBackground = true, device = Devices.PIXEL_7_PRO) @Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) @@ -95,5 +122,22 @@ private class TransactionCheckResultsItemProvider : PreviewParameterProvider { override fun convert(value: Input): WcEstimatedWalletChangeUM { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt index 3ef6afbf4c..e8777d39f3 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSendAndReceiveBlockAidUiConverter.kt @@ -20,15 +20,16 @@ import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal -import javax.inject.Inject private const val DECIMALS_AMOUNT = 2 @Suppress("CyclomaticComplexMethod", "LongMethod") -internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor( - private val estimatedWalletChangeUMConverter: WcEstimatedWalletChangeUMConverter, - private val spendAllowanceUMConverter: WcSpendAllowanceUMConverter, -) : Converter { +internal class WcSendAndReceiveBlockAidUiConverter : + Converter { + + private val estimatedWalletChangeUMConverter = WcEstimatedWalletChangeUMConverter() + private val spendAllowanceUMConverter = WcSpendAllowanceUMConverter() + override fun convert(value: Input): WcSendReceiveTransactionCheckResultsUM { val description = value.result.description?.let { if (it.isNotEmpty()) TextReference.Str(it) else null } val simulation = value.result.simulation @@ -118,7 +119,12 @@ internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor( when (data) { is SimulationData.SendAndReceive, SimulationData.NoWalletChangesDetected -> null is SimulationData.Approve -> value.approvedAmount?.let { - spendAllowanceUMConverter.convert(it) + spendAllowanceUMConverter.convert( + WcSpendAllowanceUMConverter.Input( + approvedAmount = it, + onLearnMoreClick = value.onApproveLearnMoreClick, + ), + ) } } }, @@ -128,6 +134,7 @@ internal class WcSendAndReceiveBlockAidUiConverter @Inject constructor( data class Input( val result: CheckTransactionResult, val approvedAmount: WcApprovedAmount?, + val onApproveLearnMoreClick: () -> Unit, ) } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt index 4f7bf34268..e79abdf12d 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt @@ -6,24 +6,26 @@ import com.tangem.domain.walletconnect.model.WcApprovedAmount import com.tangem.features.walletconnect.impl.R import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM import com.tangem.utils.converter.Converter -import javax.inject.Inject -internal class WcSpendAllowanceUMConverter @Inject constructor() : Converter { +internal class WcSpendAllowanceUMConverter : Converter { - override fun convert(value: WcApprovedAmount): WcSpendAllowanceUM { - val amount = value.amount?.value ?: 0.0.toBigDecimal() - val isUnlimited = value.amount?.value == null + override fun convert(value: Input): WcSpendAllowanceUM { + val amount = value.approvedAmount.amount?.value ?: 0.0.toBigDecimal() + val isUnlimited = value.approvedAmount.amount?.value == null return WcSpendAllowanceUM( amountValue = amount, - amountText = if (value.amount?.value == null) { + amountText = if (value.approvedAmount.amount?.value == null) { TextReference.Res(R.string.wc_common_unlimited) } else { TextReference.Str(amount.amountText()) }, isUnlimited = isUnlimited, - tokenSymbol = value.amount?.currencySymbol ?: "", - tokenImageUrl = value.logoUrl, - networkIconRes = value.chainId?.toString()?.let { getActiveIconRes(it) }, + tokenSymbol = value.approvedAmount.amount?.currencySymbol ?: "", + tokenImageUrl = value.approvedAmount.logoUrl, + networkIconRes = value.approvedAmount.chainId?.toString()?.let { getActiveIconRes(it) }, + onLearnMoreClicked = value.onLearnMoreClick, ) } + + data class Input(val approvedAmount: WcApprovedAmount, val onLearnMoreClick: () -> Unit) } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt index a1d574e005..1f02216d53 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt @@ -37,6 +37,7 @@ import com.tangem.features.walletconnect.connections.entity.VerifiedDAppState import com.tangem.features.walletconnect.connections.ui.WcAppInfoItem import com.tangem.features.walletconnect.impl.R import com.tangem.features.walletconnect.transaction.components.PreviewFeeSelectorBlockComponent +import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM import com.tangem.features.walletconnect.transaction.entity.blockaid.BlockAidNotificationUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangeUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcEstimatedWalletChangesUM @@ -51,6 +52,7 @@ import com.tangem.features.walletconnect.transaction.ui.common.WcSmallTitleItem import com.tangem.features.walletconnect.transaction.ui.common.WcTransactionRequestButtons import com.tangem.features.walletconnect.transaction.ui.common.WcTransactionRequestItem import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal @Suppress("LongParameterList", "LongMethod") @Composable @@ -316,5 +318,34 @@ private class WcSendTransactionStateProvider : CollectionPreviewParameterProvide ), transactionValidationResult = ValidationResult.SAFE, ), + WcSendTransactionItemUM( + onDismiss = {}, + onSend = {}, + appInfo = WcTransactionAppInfoContentUM( + appName = "React App", + appIcon = "", + verifiedState = VerifiedDAppState.Verified {}, + appSubtitle = "react-app.walletconnect.com", + ), + estimatedWalletChanges = WcSendReceiveTransactionCheckResultsUM( + isLoading = false, + spendAllowance = WcSpendAllowanceUM( + amountValue = BigDecimal.ZERO, + isUnlimited = false, + amountText = stringReference("0.00 WPOL"), + tokenSymbol = "", + tokenImageUrl = "", + networkIconRes = 0, + onLearnMoreClicked = {}, + ), + ), + walletName = "Tangem 2.0", + networkInfo = WcNetworkInfoUM(name = "Ethereum", iconRes = R.drawable.img_eth_22), + feeState = WcTransactionFeeState.None, + address = "0xdac17f958d2ee523a2206206994597c13d831ec7", + sendEnabled = true, + feeErrorNotification = null, + transactionValidationResult = ValidationResult.SAFE, + ), ), ) \ No newline at end of file From 4534ceb54b1e53527fcc4addde2fdeb7147432da Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 17:41:56 +0200 Subject: [PATCH 09/46] Updated on 2026-08-14 --- .../moonpay/MoonpayBlockchainMapping.kt | 1 + .../core/ui/extensions/BlockchainIcons.kt | 3 +++ core/ui/src/main/res/drawable/ic_linea_22.xml | 15 +++++++++++ .../ui/src/main/res/drawable/img_linea_22.xml | 26 +++++++++++++++++++ .../data/common/network/NetworkFactory.kt | 1 + .../legacy/MercuryoBlockchainMapping.kt | 1 + .../domain/card/configs/Wallet2CardConfig.kt | 2 ++ .../card/configs/Wallet2CardConfigTest.kt | 2 ++ gradle/tangem_dependencies.toml | 2 +- .../tangem/blockchainsdk/utils/Blockchain.kt | 5 ++++ 10 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 core/ui/src/main/res/drawable/ic_linea_22.xml create mode 100644 core/ui/src/main/res/drawable/img_linea_22.xml diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt index 3a83c00641..06a188ca28 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt @@ -160,4 +160,5 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency? Pepecoin, PepecoinTestnet -> null Hyperliquid, HyperliquidTestnet -> null Quai, QuaiTestnet -> null + Linea, LineaTestnet -> null } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index e106f177ab..d0a89131e0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt @@ -96,6 +96,7 @@ fun getActiveIconRes(blockchainId: String): Int { "pepecoin", "pepecoin/test" -> R.drawable.img_pepecoin_22 "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 "quai", "quai/test" -> R.drawable.img_quai_22 + "linea", "linea/test" -> R.drawable.img_linea_22 else -> R.drawable.ic_alert_24 } } @@ -190,6 +191,7 @@ fun getActiveIconResByCoinId(coinId: String): Int { "pepecoin-network", "pepecoin-network/test" -> R.drawable.img_pepecoin_22 "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 "quai", "quai/test" -> R.drawable.img_quai_22 + "linea", "linea/test" -> R.drawable.img_linea_22 else -> R.drawable.ic_alert_24 } } @@ -287,6 +289,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "pepecoin", "pepecoin/test" -> R.drawable.ic_pepecoin_22 "hyperliquid", "hyperliquid/test" -> R.drawable.ic_hyperliquid_22 "quai", "quai/test" -> R.drawable.ic_quai_22 + "linea", "linea/test" -> R.drawable.ic_linea_22 else -> R.drawable.ic_alert_24 } } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_linea_22.xml b/core/ui/src/main/res/drawable/ic_linea_22.xml new file mode 100644 index 0000000000..32b14b57c5 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_linea_22.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_linea_22.xml b/core/ui/src/main/res/drawable/img_linea_22.xml new file mode 100644 index 0000000000..5c2542faaa --- /dev/null +++ b/core/ui/src/main/res/drawable/img_linea_22.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt index c7aff7add9..07463e771f 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt @@ -326,6 +326,7 @@ class NetworkFactory @Inject constructor( Blockchain.Pepecoin, Blockchain.PepecoinTestnet, Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet, Blockchain.Quai, Blockchain.QuaiTestnet, + Blockchain.Linea, Blockchain.LineaTestnet, -> Network.TransactionExtrasType.NONE // endregion } diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt index fe654114a6..2a67f6acfe 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt @@ -160,5 +160,6 @@ public val Blockchain.mercuryoNetwork: String? Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> null Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> null Blockchain.Quai, Blockchain.QuaiTestnet -> null + Blockchain.Linea, Blockchain.LineaTestnet -> null } } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt index 0d983f457f..1d3fa41ced 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt @@ -212,6 +212,8 @@ data object Wallet2CardConfig : CardConfig { Blockchain.HyperliquidTestnet -> EllipticCurve.Secp256k1 Blockchain.Quai -> EllipticCurve.Secp256k1 Blockchain.QuaiTestnet -> EllipticCurve.Secp256k1 + Blockchain.Linea -> EllipticCurve.Secp256k1 + Blockchain.LineaTestnet -> EllipticCurve.Secp256k1 } } } \ No newline at end of file diff --git a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt index 8f9f3f26ff..2dd73a1cb4 100644 --- a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt +++ b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt @@ -168,6 +168,8 @@ class Wallet2CardConfigTest { Blockchain.HyperliquidTestnet to EllipticCurve.Secp256k1, Blockchain.Quai to EllipticCurve.Secp256k1, Blockchain.QuaiTestnet to EllipticCurve.Secp256k1, + Blockchain.Linea to EllipticCurve.Secp256k1, + Blockchain.LineaTestnet to EllipticCurve.Secp256k1, ) @Test diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 9dabe4209d..0c32661884 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1256" +tangemBlockchainSdk = "develop-1257" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-564" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt index d1d5a501c6..381452eefa 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt @@ -169,6 +169,8 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? { "hyperevm/test" -> Blockchain.HyperliquidTestnet "quai-network" -> Blockchain.Quai "quai-network/test" -> Blockchain.QuaiTestnet + "linea" -> Blockchain.Linea + "linea/test" -> Blockchain.LineaTestnet else -> null } } @@ -335,6 +337,8 @@ fun Blockchain.toNetworkId(): String { Blockchain.HyperliquidTestnet -> "hyperevm/test" Blockchain.Quai -> "quai-network" Blockchain.QuaiTestnet -> "quai-network/test" + Blockchain.Linea -> "linea" + Blockchain.LineaTestnet -> "linea/test" } } @@ -440,6 +444,7 @@ fun Blockchain.toCoinId(): String { Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> "pepecoin-network" Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> "hyperliquid" Blockchain.Quai, Blockchain.QuaiTestnet -> "quai-network" + Blockchain.Linea, Blockchain.LineaTestnet -> "linea" } } From 480bfdd412d95d5a4b190c0c1fc51e0f147c305b Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 19:47:17 +0500 Subject: [PATCH 10/46] Updated on 2026-08-14 --- .../tap/di/domain/YieldSupplyDomainModule.kt | 18 +++++++++++++ .../supply/DefaultYieldSupplyRepository.kt | 25 +++++++++++++++++++ .../yield/supply/YieldSupplyRepository.kt | 19 ++++++++++++++ .../usecase/YieldSupplyActivateUseCase.kt | 14 +++++++++++ .../usecase/YieldSupplyDeactivateUseCase.kt | 14 +++++++++++ .../impl/main/model/YieldSupplyModel.kt | 16 ++++++++++++ .../model/YieldSupplyStartEarningModel.kt | 3 +++ .../model/YieldSupplyStopEarningModel.kt | 3 +++ 8 files changed, 112 insertions(+) create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index ccc23e94e3..839504ffbe 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -114,4 +114,22 @@ internal object YieldSupplyDomainModule { yieldSupplyRepository = yieldSupplyRepository, ) } + + @Provides + @Singleton + fun provideYieldSupplyActivateUseCase(yieldSupplyRepository: YieldSupplyRepository): YieldSupplyActivateUseCase { + return YieldSupplyActivateUseCase( + yieldSupplyRepository = yieldSupplyRepository, + ) + } + + @Provides + @Singleton + fun provideYieldSupplyDeactivateUseCase( + yieldSupplyRepository: YieldSupplyRepository, + ): YieldSupplyDeactivateUseCase { + return YieldSupplyDeactivateUseCase( + yieldSupplyRepository = yieldSupplyRepository, + ) + } } \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt index 304697ca8d..6a7c33ebbb 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -11,6 +11,7 @@ import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter import com.tangem.datasource.api.tangemTech.YieldSupplyApi import com.tangem.data.yield.supply.converters.YieldTokenStatusConverter import com.tangem.data.yield.supply.converters.YieldTokenChartConverter +import com.tangem.datasource.api.tangemTech.models.YieldSupplyChangeTokenStatusBody import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade @@ -73,6 +74,30 @@ internal class DefaultYieldSupplyRepository( (walletManager as? YieldSupplyProvider)?.isSupported() ?: false } + override suspend fun activateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean = + withContext(dispatchers.io) { + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + ?: error("Chain id is required for evm's") + yieldSupplyApi.activateYieldModule( + YieldSupplyChangeTokenStatusBody( + tokenAddress = cryptoCurrencyToken.contractAddress, + chainId = chainId, + ), + ).getOrThrow().isActive + } + + override suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean = + withContext(dispatchers.io) { + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + ?: error("Chain id is required for evm's") + yieldSupplyApi.deactivateYieldModule( + YieldSupplyChangeTokenStatusBody( + tokenAddress = cryptoCurrencyToken.contractAddress, + chainId = chainId, + ), + ).getOrThrow().isActive + } + private fun List.enrichNetworkIds(): List { val chainIdMap = Blockchain.entries.associate { it.getChainId() to it.toNetworkId() } return this.map { token -> diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt index e751e75a1c..eceba08082 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt @@ -38,4 +38,23 @@ interface YieldSupplyRepository { suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData suspend fun isYieldSupplySupported(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean + + /** + * Activate yield protocol for the specified token. + * + * Returns whether the token is active after the operation completes. + * May throw on network/backend errors or if required chain id cannot be resolved. + */ + @Throws + suspend fun activateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean + + /** + * Deactivate yield protocol for the specified token. + * + * Returns whether the token is active after the operation completes + * (expected to be false when deactivation succeeds). May throw on + * network/backend errors or if required chain id cannot be resolved. + */ + @Throws + suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token): Boolean } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt new file mode 100644 index 0000000000..92b5787e7d --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.yield.supply.YieldSupplyRepository + +class YieldSupplyActivateUseCase( + private val yieldSupplyRepository: YieldSupplyRepository, +) { + + suspend operator fun invoke(cryptoCurrencyToken: CryptoCurrency.Token): Either = Either.catch { + yieldSupplyRepository.activateProtocol(cryptoCurrencyToken) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt new file mode 100644 index 0000000000..6449187071 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.yield.supply.YieldSupplyRepository + +class YieldSupplyDeactivateUseCase( + private val yieldSupplyRepository: YieldSupplyRepository, +) { + + suspend operator fun invoke(cryptoCurrencyToken: CryptoCurrency.Token): Either = Either.catch { + yieldSupplyRepository.deactivateProtocol(cryptoCurrencyToken) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index fb7fbdf51f..ad5f40b8cf 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -16,6 +16,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyIsAvailableUseCase import com.tangem.features.yield.supply.api.YieldSupplyComponent @@ -47,6 +49,8 @@ internal class YieldSupplyModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, private val yieldSupplyIsAvailableUseCase: YieldSupplyIsAvailableUseCase, + private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase, + private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, ) : Model(), YieldSupplyClickIntents { private val params = paramsContainer.require() @@ -162,6 +166,7 @@ internal class YieldSupplyModel @Inject constructor( val yieldTransaction = cryptoCurrencyStatus.value.pendingTransactions.firstOrNull { it.type is TxInfo.TransactionType.YieldSupply }?.type as? TxInfo.TransactionType.YieldSupply + sendInfoAboutProtocolStatus(yieldSupplyStatus?.isActive == true) val yieldSupplyUM = when { hasActiveTransaction && yieldTransaction != null -> { @@ -193,6 +198,17 @@ internal class YieldSupplyModel @Inject constructor( } } + private fun sendInfoAboutProtocolStatus(isActivated: Boolean) { + val token = cryptoCurrency as? CryptoCurrency.Token ?: return + modelScope.launch(dispatchers.default) { + if (isActivated) { + yieldSupplyActivateUseCase(token) + } else { + yieldSupplyDeactivateUseCase(token) + } + } + } + private companion object { const val PROCESSING_UPDATE_DELAY = 10_000L } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index e052894b7f..991aa736fa 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -19,6 +19,7 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase @@ -58,6 +59,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( private val yieldSupplyNotificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger, private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, + private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase, private val yieldSupplyGetTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { @@ -206,6 +208,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( }, ifRight = { fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, cryptoCurrency.id) + yieldSupplyActivateUseCase(cryptoCurrency as CryptoCurrency.Token) modelScope.launch { params.callback.onTransactionSent() } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index b1bba83d10..4204a8bc55 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -15,6 +15,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory @@ -49,6 +50,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor( private val urlOpener: UrlOpener, private val yieldSupplyNotificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, + private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require() @@ -134,6 +136,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor( ) }, ifRight = { + yieldSupplyDeactivateUseCase(cryptoCurrency as CryptoCurrency.Token) params.callback.onTransactionSent() }, ) From 971c4196231804a3eb3a2a49ad7b1abc5bf878a6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 21:21:36 +0500 Subject: [PATCH 11/46] Updated on 2026-08-14 --- .../com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt | 2 +- .../tangem/data/yield/supply/DefaultYieldSupplyRepository.kt | 3 ++- gradle/tangem_dependencies.toml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt index 20aae40e90..2158ef7ede 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt @@ -15,7 +15,7 @@ import retrofit2.http.Query interface YieldSupplyApi { @GET("api/v1/yield/markets") - suspend fun getYieldMarkets(@Query("chainId") chainId: Int? = null): ApiResponse + suspend fun getYieldMarkets(@Query("chainId") chainId: String? = null): ApiResponse @GET("api/v1/yield/token/{chainId}/{tokenAddress}") suspend fun getYieldTokenStatus( diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt index 6a7c33ebbb..8d96150927 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -39,7 +39,8 @@ internal class DefaultYieldSupplyRepository( } override suspend fun updateMarkets(): List = withContext(dispatchers.io) { - val response = yieldSupplyApi.getYieldMarkets().getOrThrow() + val chains = Blockchain.yieldSupplySupportedBlockchains().map { it.getChainId() }.joinToString(",") + val response = yieldSupplyApi.getYieldMarkets(chainId = chains).getOrThrow() val domain = response.marketDtos.map(YieldMarketTokenConverter::convert) store.store(response.marketDtos) domain diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 0c32661884..1d38e0c3a2 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1257" +tangemBlockchainSdk = "develop-1259" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-564" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 231c97e371d8baced21d2e86b2ea448e3df5fbe4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Oct 2025 11:49:42 +0500 Subject: [PATCH 12/46] Updated on 2026-08-14 --- .../ui/components/tokenlist/TokenListItem.kt | 18 +- .../tokenlist/state/TokensListItemUM.kt | 1 + features/onramp/impl/build.gradle.kts | 1 + .../LoadingAccountTokenItemConverter.kt | 29 +++ .../SetNoAvailablePairsTransformerV2.kt | 61 ++++++ .../model/AvailableSwapPairsModel.kt | 203 ++++++++++++++++-- .../swap/entity/AccountAvailabilityTokenUM.kt | 14 ++ .../onramp/tokenlist/entity/TokenListUM.kt | 15 +- .../tokenlist/entity/TokenListUMController.kt | 1 + .../SetLoadingAccountTokenListTransformer.kt | 45 ++++ .../SetNothingToFoundStateTransformer.kt | 2 + .../SetNothingToFoundStateTransformerV2.kt | 29 +++ .../UpdateAccountTokenItemConverter.kt | 45 ++++ .../UpdateAccountTokenListTransformer.kt | 64 ++++++ .../tokenlist/model/OnrampTokenListModel.kt | 186 ++++++++++++++-- .../onramp/tokenlist/ui/OnrampTokenList.kt | 17 ++ .../ui/preview/PreviewTokenListUMProvider.kt | 2 + .../WalletCurrencyActionsClickIntents.kt | 37 ++-- .../common/preview/WalletScreenPreviewData.kt | 2 + .../converter/TokenListStateConverter.kt | 1 + 20 files changed, 717 insertions(+), 56 deletions(-) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt index ff3b869c1f..989637b8eb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt @@ -60,7 +60,7 @@ fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: M @Composable fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { if (state.isExpanded) { - ExpandedPortfolioHeader(state.state, modifier) + ExpandedPortfolioHeader(state = state.state, isCollapsable = state.isCollapsable, modifier = modifier) } else { TokenItem( state = state.state, @@ -83,7 +83,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B } @Composable -private fun ExpandedPortfolioHeader(state: TokenItemState, modifier: Modifier = Modifier) { +private fun ExpandedPortfolioHeader(state: TokenItemState, isCollapsable: Boolean, modifier: Modifier = Modifier) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -130,11 +130,13 @@ private fun ExpandedPortfolioHeader(state: TokenItemState, modifier: Modifier = ) } - Icon( - modifier = Modifier.size(TangemTheme.dimens.size16), - painter = painterResource(id = R.drawable.ic_minimize_24), - tint = TangemTheme.colors.icon.inactive, - contentDescription = null, - ) + if (isCollapsable) { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size16), + painter = painterResource(id = R.drawable.ic_minimize_24), + tint = TangemTheme.colors.icon.inactive, + contentDescription = null, + ) + } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt index 24da6e9d28..23b2ef9da6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt @@ -43,6 +43,7 @@ sealed interface TokensListItemUM { data class Portfolio( val state: TokenItemState, val isExpanded: Boolean, + val isCollapsable: Boolean, val tokens: List, ) : TokensListItemUM { override val id: String = state.id diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 8280707bae..e1d7b97469 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.settings) implementation(projects.domain.transaction.models) + implementation(projects.domain.account.status) /** DI */ implementation(deps.hilt.android) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt new file mode 100644 index 0000000000..8d9c2042a9 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.features.onramp.swap.availablepairs.entity.converters + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.AccountStatus +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toPersistentList + +internal class LoadingAccountTokenItemConverter( + private val appCurrency: AppCurrency, +) : Converter { + + override fun convert(value: AccountStatus.CryptoPortfolio): TokensListItemUM.Portfolio { + val (account, currencies) = value + + return TokensListItemUM.Portfolio( + state = AccountCryptoPortfolioItemStateConverter( + appCurrency = appCurrency, + account = account, + onItemClick = null, + ).convert(TotalFiatBalance.Failed), + isExpanded = true, + isCollapsable = false, + tokens = currencies.flattenCurrencies().map(LoadingTokenListItemConverter::convert).toPersistentList(), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt new file mode 100644 index 0000000000..669604724c --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt @@ -0,0 +1,61 @@ +package com.tangem.features.onramp.swap.availablepairs.entity.transformers + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData +import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer +import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +internal class SetNoAvailablePairsTransformerV2( + private val appCurrency: AppCurrency, + private val accountList: Map>, + private val isBalanceHidden: Boolean, + private val isAccountsMode: Boolean, + private val unavailableErrorText: TextReference, +) : TokenListUMTransformer { + private val unavailableConverter = OnrampTokenItemStateConverterFactory + .createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText) + + override fun transform(prevState: TokenListUM): TokenListUM { + return prevState.copy( + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + tokensListData = if (isAccountsMode) { + TokenListUMData.AccountList( + tokensList = accountList.map { (account, cryptoCurrencies) -> + TokensListItemUM.Portfolio( + state = AccountCryptoPortfolioItemStateConverter( + appCurrency = appCurrency, + account = account, + onItemClick = null, + ).convert(TotalFiatBalance.Failed), + isExpanded = true, + isCollapsable = false, + tokens = unavailableConverter.convertList(cryptoCurrencies) + .map(TokensListItemUM::Token) + .toPersistentList(), + ) + }.toPersistentList(), + ) + } else { + TokenListUMData.TokenList( + tokensList = accountList.flatMap { (_, cryptoCurrencies) -> + unavailableConverter.convertList(cryptoCurrencies) + .map(TokensListItemUM::Token) + }.toPersistentList(), + ) + }, + isBalanceHidden = isBalanceHidden, + warning = NotificationUM.Warning.SwapNoAvailablePair, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index d62cce58ae..8e19216bf3 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -1,12 +1,15 @@ package com.tangem.features.onramp.swap.availablepairs.model -import arrow.core.getOrElse import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.extensions.capitalize import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -15,6 +18,8 @@ import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList @@ -28,11 +33,13 @@ import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponen import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetLoadingTokenItemsTransformer import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformer +import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformerV2 +import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM +import com.tangem.features.onramp.swap.entity.AccountCurrencyUM import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.entity.TokenListUMController import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer +import com.tangem.features.onramp.tokenlist.entity.transformer.* import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer @@ -43,7 +50,7 @@ import javax.inject.Inject private typealias AvailablePairsState = Lce> -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class AvailableSwapPairsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, @@ -53,7 +60,10 @@ internal class AvailableSwapPairsModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getAvailablePairsUseCase: GetAvailablePairsUseCase, - private val getWalletsUseCase: GetWalletsUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, + getWalletsUseCase: GetWalletsUseCase, ) : Model() { val state: StateFlow = tokenListUMController.state @@ -62,13 +72,17 @@ internal class AvailableSwapPairsModel @Inject constructor( private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } private val tokenListFlow = getTokenListUseCaseFlow() - + private val accountListFlow = getAccountListUseCaseFlow() private val availablePairsByNetworkFlow = MutableStateFlow>(emptyMap()) init { - initializeSearchBarCallbacks() + if (accountsFeatureToggles.isFeatureEnabled) { + subscribeOnUpdateStateV2() + } else { + subscribeOnUpdateState() + } - subscribeOnUpdateState() + initializeSearchBarCallbacks() subscribeOnAvailablePairsUpdates() } @@ -79,12 +93,20 @@ internal class AvailableSwapPairsModel @Inject constructor( maybeTokenList.getOrElse( ifLoading = { it ?: TokenList.Empty }, ifError = { TokenList.Empty }, - ) - .flattenCurrencies() + ).flattenCurrencies() } .shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1) } + private fun getAccountListUseCaseFlow(): SharedFlow> { + return singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(params.userWalletId)) + .distinctUntilChanged() + .map { accountStatusList -> + accountStatusList.accountStatuses.toList() + }.flowOn(dispatchers.default) + .shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1) + } + private fun initializeSearchBarCallbacks() { tokenListUMController.update( transformer = UpdateSearchBarCallbacksTransformer( @@ -130,6 +152,53 @@ internal class AvailableSwapPairsModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeOnUpdateStateV2() { + combine( + flow = getAccountsAndModeFlow(), + flow2 = getAppCurrencyAndBalanceHidingFlow(), + flow3 = params.selectedStatus, + flow4 = searchManager.query, + flow5 = availablePairsByNetworkFlow + .map { it[params.selectedStatus.value?.toLeastTokenInfo()] } + .distinctUntilChanged(), + ) { accountListAndMode, appCurrencyAndBalanceHiding, selectedStatus, query, availablePairsState -> + val (accountList, isAccountsMode) = accountListAndMode + availablePairsState?.fold( + ifLoading = { + SetLoadingAccountTokenListTransformer( + appCurrency = appCurrencyAndBalanceHiding.first, + accountList = accountList, + isAccountsMode = isAccountsMode, + ) + }, + ifContent = { pairs -> + handleContentStateV2( + appCurrencyAndBalanceHiding = appCurrencyAndBalanceHiding, + accountList = accountList, + selectedStatus = selectedStatus, + query = query, + availablePairs = pairs, + isAccountsMode = isAccountsMode, + ) + }, + ifError = { + handleErrorStateV2( + cause = it, + networkInfo = params.selectedStatus.value?.toLeastTokenInfo(), + accountList = accountList, + ) + }, + ) ?: SetLoadingAccountTokenListTransformer( + appCurrency = appCurrencyAndBalanceHiding.first, + accountList = accountList, + isAccountsMode = isAccountsMode, + ) + } + .onEach(tokenListUMController::update) + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + private fun handleContentState( appCurrencyAndBalanceHiding: Pair, currencies: List, @@ -176,6 +245,53 @@ internal class AvailableSwapPairsModel @Inject constructor( } } + private fun handleContentStateV2( + appCurrencyAndBalanceHiding: Pair, + accountList: List, + selectedStatus: CryptoCurrencyStatus?, + query: String, + availablePairs: List, + isAccountsMode: Boolean, + ): TokenListUMTransformer { + val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding + + val filterByQueryAccountList = accountList.associate { accountStatus -> + when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.account to accountStatus.tokenList.flattenCurrencies() + .filter { it.currency != selectedStatus?.currency } + .filterByQuery(query = query) + } + } + + if (availablePairs.isEmpty()) { + return SetNoAvailablePairsTransformerV2( + appCurrency = appCurrency, + accountList = filterByQueryAccountList, + unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), + isBalanceHidden = isBalanceHidden, + isAccountsMode = isAccountsMode, + ) + } + + return if (query.isNotEmpty() && filterByQueryAccountList.isEmpty()) { + SetNothingToFoundStateTransformerV2( + isBalanceHidden = isBalanceHidden, + emptySearchMessageReference = resourceReference( + id = R.string.action_buttons_swap_empty_search_message, + ), + ) + } else { + UpdateAccountTokenListTransformer( + appCurrency = appCurrency, + onItemClick = params.onTokenClick, + accountList = filterByQueryAccountList.filterByAvailability(availablePairs = availablePairs), + isBalanceHidden = isBalanceHidden, + unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), + isAccountsMode = isAccountsMode, + ) + } + } + private fun handleErrorState( cause: Throwable, networkInfo: LeastTokenInfo?, @@ -193,6 +309,26 @@ internal class AvailableSwapPairsModel @Inject constructor( ) } + private fun handleErrorStateV2( + cause: Throwable, + networkInfo: LeastTokenInfo?, + accountList: List, + ): SetErrorWarningTransformer { + return SetErrorWarningTransformer( + cause = cause, + onRefresh = { + modelScope.launch { + if (networkInfo != null) { + accountList.filterIsInstance() + .forEach { (_, currencies) -> + updateAvailablePairs(networkInfo, currencies.flattenCurrencies()) + } + } + } + }, + ) + } + private fun subscribeOnAvailablePairsUpdates() { modelScope.launch { params.selectedStatus @@ -203,9 +339,19 @@ internal class AvailableSwapPairsModel @Inject constructor( val isAlreadyLoaded = availablePairsByNetworkFlow.value[networkInfo]?.isContent() == true if (isAlreadyLoaded) return@collectLatest - val statuses = tokenListFlow.firstOrNull() ?: return@collectLatest - - updateAvailablePairs(networkInfo = networkInfo, statuses = statuses) + if (accountsFeatureToggles.isFeatureEnabled) { + val accountList = accountListFlow.firstOrNull() ?: return@collectLatest + updateAvailablePairs( + networkInfo = networkInfo, + statuses = accountList.filterIsInstance() + .flatMap { accountStatus -> + accountStatus.flattenCurrencies() + }.toSet().toList(), + ) + } else { + val statuses = tokenListFlow.firstOrNull() ?: return@collectLatest + updateAvailablePairs(networkInfo = networkInfo, statuses = statuses) + } } } } @@ -247,6 +393,14 @@ internal class AvailableSwapPairsModel @Inject constructor( ) } + private fun getAccountsAndModeFlow(): Flow, Boolean>> { + return combine( + flow = accountListFlow.distinctUntilChanged(), + flow2 = isAccountsModeEnabledUseCase().distinctUntilChanged(), + transform = ::Pair, + ) + } + private fun onSearchQueryChange(newQuery: String) { if (state.value.searchBarUM.query == newQuery) return @@ -286,6 +440,29 @@ internal class AvailableSwapPairsModel @Inject constructor( } } + private fun Map>.filterByAvailability( + availablePairs: List, + ): List { + return map { (account, currencies) -> + AccountAvailabilityUM( + account = account, + currencyList = currencies.map { status -> + val isAvailable = availablePairs.map(SwapPairLeast::to).contains(status.toLeastTokenInfo()) + + val isAvailableToSwap = isAvailable && + status.value !is CryptoCurrencyStatus.MissedDerivation && + status.value !is CryptoCurrencyStatus.Unreachable && + !status.currency.isCustom + + AccountCurrencyUM( + cryptoCurrencyStatus = status, + isAvailable = isAvailableToSwap, + ) + }, + ) + } + } + private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo { return LeastTokenInfo( contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt new file mode 100644 index 0000000000..f0856222ba --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.onramp.swap.entity + +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus + +internal data class AccountAvailabilityUM( + val account: Account.CryptoPortfolio, + val currencyList: List, +) + +internal data class AccountCurrencyUM( + val isAvailable: Boolean, + val cryptoCurrencyStatus: CryptoCurrencyStatus, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt index 615823f4e4..3f88743a05 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt @@ -19,6 +19,19 @@ internal data class TokenListUM( val searchBarUM: SearchBarUM, val availableItems: ImmutableList, val unavailableItems: ImmutableList, + val tokensListData: TokenListUMData, val isBalanceHidden: Boolean, val warning: NotificationUM? = null, -) \ No newline at end of file +) + +internal sealed interface TokenListUMData { + data class AccountList( + val tokensList: ImmutableList, + ) : TokenListUMData + + data class TokenList( + val tokensList: ImmutableList, + ) : TokenListUMData + + data object EmptyList : TokenListUMData +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt index 80bb1d02f0..583697cca2 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUMController.kt @@ -31,6 +31,7 @@ internal class TokenListUMController @Inject constructor() { ), availableItems = persistentListOf(), unavailableItems = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, isBalanceHidden = false, ), ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt new file mode 100644 index 0000000000..48d7b7331d --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt @@ -0,0 +1,45 @@ +package com.tangem.features.onramp.tokenlist.entity.transformer + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountStatus +import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingAccountTokenItemConverter +import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingTokenListItemConverter +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData +import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +internal class SetLoadingAccountTokenListTransformer( + appCurrency: AppCurrency, + private val accountList: List, + private val isAccountsMode: Boolean, +) : TokenListUMTransformer { + + private val accountListItemConverter = LoadingAccountTokenItemConverter(appCurrency) + + override fun transform(prevState: TokenListUM): TokenListUM { + return prevState.copy( + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + tokensListData = if (isAccountsMode) { + TokenListUMData.AccountList( + tokensList = accountListItemConverter.convertList( + accountList.filterIsInstance(), + ).toPersistentList(), + ) + } else { + TokenListUMData.TokenList( + tokensList = accountList.flatMap { account -> + when (account) { + is AccountStatus.CryptoPortfolio -> LoadingTokenListItemConverter.convertList( + account.tokenList.flattenCurrencies(), + ) + } + }.toPersistentList(), + ) + }, + warning = null, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt index 2b2c7963eb..fcaa9d6147 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformer.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -28,6 +29,7 @@ internal class SetNothingToFoundStateTransformer( ).let(::add) }.toImmutableList(), unavailableItems = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, isBalanceHidden = isBalanceHidden, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt new file mode 100644 index 0000000000..dafa2ac842 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetNothingToFoundStateTransformerV2.kt @@ -0,0 +1,29 @@ +package com.tangem.features.onramp.tokenlist.entity.transformer + +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData +import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class SetNothingToFoundStateTransformerV2( + private val isBalanceHidden: Boolean, + private val emptySearchMessageReference: TextReference, +) : TokenListUMTransformer { + + override fun transform(prevState: TokenListUM): TokenListUM { + return prevState.copy( + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + tokensListData = TokenListUMData.TokenList(tokensList = buildList { + TokensListItemUM.Text( + id = emptySearchMessageReference.hashCode(), + text = emptySearchMessageReference, + ).let(::add) + }.toImmutableList()), + isBalanceHidden = isBalanceHidden, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt new file mode 100644 index 0000000000..2389d1aec9 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt @@ -0,0 +1,45 @@ +package com.tangem.features.onramp.tokenlist.entity.transformer + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM +import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toPersistentList + +internal class UpdateAccountTokenItemConverter( + private val appCurrency: AppCurrency, + private val unavailableErrorText: TextReference, + onItemClick: (TokenItemState, CryptoCurrencyStatus) -> Unit, +) : Converter { + + private val availableConverter = OnrampTokenItemStateConverterFactory + .createAvailableItemConverter(appCurrency, onItemClick) + + private val unavailableConverter = OnrampTokenItemStateConverterFactory + .createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText) + + override fun convert(value: AccountAvailabilityUM): TokensListItemUM.Portfolio { + return TokensListItemUM.Portfolio( + state = AccountCryptoPortfolioItemStateConverter( + appCurrency = appCurrency, + account = value.account, + onItemClick = null, + ).convert(TotalFiatBalance.Failed), + isExpanded = true, + isCollapsable = false, + tokens = value.currencyList.asSequence().map { (isAvailable, status) -> + if (isAvailable) { + availableConverter.convert(status) + } else { + unavailableConverter.convert(status) + } + }.map(TokensListItemUM::Token).toPersistentList(), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt new file mode 100644 index 0000000000..5b13ba643c --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt @@ -0,0 +1,64 @@ +package com.tangem.features.onramp.tokenlist.entity.transformer + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData +import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer +import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +internal class UpdateAccountTokenListTransformer( + private val appCurrency: AppCurrency, + private val onItemClick: (TokenItemState, CryptoCurrencyStatus) -> Unit, + private val accountList: List, + private val isBalanceHidden: Boolean, + private val unavailableErrorText: TextReference, + private val warning: NotificationUM? = null, + private val isAccountsMode: Boolean, +) : TokenListUMTransformer { + + private val accountListItemConverter = UpdateAccountTokenItemConverter( + appCurrency = appCurrency, + onItemClick = onItemClick, + unavailableErrorText = unavailableErrorText, + ) + + private val availableConverter = OnrampTokenItemStateConverterFactory + .createAvailableItemConverter(appCurrency, onItemClick) + + private val unavailableConverter = OnrampTokenItemStateConverterFactory + .createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText) + + override fun transform(prevState: TokenListUM): TokenListUM { + return prevState.copy( + availableItems = persistentListOf(), + unavailableItems = persistentListOf(), + tokensListData = if (isAccountsMode) { + TokenListUMData.AccountList( + tokensList = accountListItemConverter.convertList(accountList).toPersistentList(), + ) + } else { + TokenListUMData.TokenList( + tokensList = accountList.flatMap { (_, currencyList) -> + currencyList.asSequence().map { (isAvailable, status) -> + if (isAvailable) { + availableConverter.convert(status) + } else { + unavailableConverter.convert(status) + } + }.map(TokensListItemUM::Token).toPersistentList() + }.toPersistentList(), + ) + }, + isBalanceHidden = isBalanceHidden, + warning = warning, + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index ae9b3f2e32..a61e5837a8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -6,6 +6,11 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -13,6 +18,8 @@ import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.settings.usercountry.GetUserCountryUseCase @@ -23,13 +30,11 @@ import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM +import com.tangem.features.onramp.swap.entity.AccountCurrencyUM import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent -import com.tangem.features.onramp.tokenlist.entity.OnrampOperation -import com.tangem.features.onramp.tokenlist.entity.TokenListUM -import com.tangem.features.onramp.tokenlist.entity.TokenListUMController -import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer +import com.tangem.features.onramp.tokenlist.entity.* +import com.tangem.features.onramp.tokenlist.entity.transformer.* import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer @@ -42,7 +47,9 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") +typealias AccountCryptoList = Map> + +@Suppress("LargeClass", "LongParameterList") internal class OnrampTokenListModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, @@ -55,6 +62,9 @@ internal class OnrampTokenListModel @Inject constructor( private val rampStateManager: RampStateManager, private val getUserCountryUseCase: GetUserCountryUseCase, private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, ) : Model() { val state: StateFlow = tokenListUMController.state @@ -71,8 +81,11 @@ internal class OnrampTokenListModel @Inject constructor( onActiveChange = ::onSearchBarActiveChange, ), ) - - subscribeOnUpdateState() + if (accountsFeatureToggles.isFeatureEnabled) { + subscribeOnUpdateStateV2() + } else { + subscribeOnUpdateState() + } } private fun subscribeOnUpdateState() { @@ -95,12 +108,7 @@ internal class OnrampTokenListModel @Inject constructor( if (query.isNotEmpty() && filterByQueryTokenList.isEmpty()) { SetNothingToFoundStateTransformer( isBalanceHidden = isBalanceHidden, - emptySearchMessageReference = when (params.filterOperation) { - OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message - OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message - OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message - } - .let(::resourceReference), + emptySearchMessageReference = getEmptySearchMessageReference(), ) } else { val isInsufficientBalanceForSell = if (params.filterOperation == OnrampOperation.SELL) { @@ -134,6 +142,62 @@ internal class OnrampTokenListModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeOnUpdateStateV2() { + combine( + flow = singleAccountStatusListSupplier( + SingleAccountStatusListProducer.Params(params.userWalletId), + ).distinctUntilChanged(), + flow2 = getAppCurrencyAndBalanceHidingFlow(), + flow3 = isAccountsModeEnabledUseCase(), + flow4 = searchManager.query, + flow5 = hasRestrictionForSellFlow(), + ) { accountList, appCurrencyAndBalanceHiding, isAccountsMode, query, hasRestrictionForSell -> + val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding + val filterByQueryAccountList = accountList.filterAccountsByQuery(query) + + if (query.isNotEmpty() && filterByQueryAccountList.isEmpty()) { + updateTokenListUM( + SetNothingToFoundStateTransformerV2( + isBalanceHidden = isBalanceHidden, + emptySearchMessageReference = getEmptySearchMessageReference(), + ), + ) + } else { + updateTokenListUM( + SetLoadingAccountTokenListTransformer( + appCurrency = appCurrency, + accountList = accountList.accountStatuses.toList(), + isAccountsMode = isAccountsMode, + ), + ) + updateTokenListUM( + UpdateAccountTokenListTransformer( + appCurrency = appCurrency, + onItemClick = params.onTokenClick, + accountList = filterByQueryAccountList.filterByAvailability(), + isBalanceHidden = isBalanceHidden, + unavailableErrorText = getUnavailableTokensHeaderReference(), + warning = getSellWarning( + hasRestrictionForSell = hasRestrictionForSell, + isInsufficientBalanceForSell = accountList.isInsufficientBalanceForSell(), + ), + isAccountsMode = isAccountsMode, + ), + ) + } + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun getAppCurrencyAndBalanceHidingFlow(): Flow> { + return combine( + flow = getSelectedAppCurrencyUseCase().map { it.getOrElse { AppCurrency.Default } }.distinctUntilChanged(), + flow2 = getBalanceHidingSettingsUseCase().map { it.isBalanceHidden }.distinctUntilChanged(), + transform = ::Pair, + ) + } + private fun hasRestrictionForSellFlow(): Flow { return if (params.filterOperation == OnrampOperation.SELL) { getUserCountryUseCase().map { maybe -> @@ -154,25 +218,52 @@ internal class OnrampTokenListModel @Inject constructor( } } + private fun AccountStatusList.isInsufficientBalanceForSell(): Boolean { + return if (params.filterOperation == OnrampOperation.SELL) { + (totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isZero() == true + } else { + false + } + } + private fun getUnavailableTokensHeaderReference() = when (params.filterOperation) { OnrampOperation.BUY -> R.string.tokens_list_unavailable_to_purchase_header OnrampOperation.SELL -> R.string.tokens_list_unavailable_to_sell_header OnrampOperation.SWAP -> R.string.tokens_list_unavailable_to_swap_source_header }.let(::resourceReference) + private fun getEmptySearchMessageReference() = when (params.filterOperation) { + OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message + OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message + OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message + }.let(::resourceReference) + private fun updateTokenListUM(transformer: TokenListUMTransformer) { - tokenListUMController.update { prevState -> - transformer.transform(prevState).apply { - if (isFirstInitialization(prevState = prevState, newState = this)) { - params.onTokenListInitialized() + modelScope.launch { + tokenListUMController.update { prevState -> + transformer.transform(prevState).apply { + if (isFirstInitialization(prevState = prevState, newState = this)) { + params.onTokenListInitialized() + } } } } } + private fun getSellWarning(hasRestrictionForSell: Boolean, isInsufficientBalanceForSell: Boolean) = when { + hasRestrictionForSell -> NotificationUM.Warning.SellingRegionalRestriction + isInsufficientBalanceForSell -> NotificationUM.Warning.InsufficientBalanceForSelling + else -> null + } + private fun isFirstInitialization(prevState: TokenListUM, newState: TokenListUM): Boolean { - return prevState.availableItems.isEmpty() && prevState.unavailableItems.isEmpty() && - (newState.availableItems.isNotEmpty() || newState.unavailableItems.isNotEmpty()) + return if (accountsFeatureToggles.isFeatureEnabled) { + prevState.tokensListData == TokenListUMData.EmptyList && + newState.tokensListData != TokenListUMData.EmptyList + } else { + prevState.availableItems.isEmpty() && prevState.unavailableItems.isEmpty() && + (newState.availableItems.isNotEmpty() || newState.unavailableItems.isNotEmpty()) + } } private fun onSearchQueryChange(newQuery: String) { @@ -195,6 +286,16 @@ internal class OnrampTokenListModel @Inject constructor( ) } + private fun AccountStatusList.filterAccountsByQuery(query: String) = accountStatuses.asSequence() + .associate { accountStatus -> + when (accountStatus) { + is AccountStatus.CryptoPortfolio -> { + val filteredList = accountStatus.tokenList.flattenCurrencies().filterByQuery(query = query) + accountStatus.account to filteredList + } + } + }.filter { (_, value) -> value.isNotEmpty() } + private fun List.filterByQuery(query: String): List { return filter { it.currency.name.contains(other = query, ignoreCase = true) || @@ -237,6 +338,49 @@ internal class OnrampTokenListModel @Inject constructor( } } + private suspend fun AccountCryptoList.filterByAvailability(): List { + return coroutineScope { + map { (account, currencies) -> + async { + AccountAvailabilityUM( + account = account, + currencyList = currencies.map { status -> + val isOperationAvailable = checkAvailabilityByOperation(status = status) + val isNotMissedDerivation = status.value !is CryptoCurrencyStatus.MissedDerivation + val isNotLoading = status.value !is CryptoCurrencyStatus.Loading + + val requirements = getAssetRequirementsUseCase( + userWalletId = userWallet.walletId, + currency = status.currency, + ).getOrNull() + + val isAvailableForBuy = rampStateManager.checkAssetRequirements(requirements) + val isNotUnreachable = status.value !is CryptoCurrencyStatus.Unreachable + + val isAvailable = when (params.filterOperation) { + OnrampOperation.BUY -> { + isAvailableForBuy + } // unreachable state is available for Buy operation + OnrampOperation.SELL -> isNotUnreachable + OnrampOperation.SWAP -> { + isNotUnreachable && isAvailableForBuy + } + } + + val isTotalAvailable = + isOperationAvailable && isNotMissedDerivation && isNotLoading && isAvailable + + AccountCurrencyUM( + cryptoCurrencyStatus = status, + isAvailable = isTotalAvailable, + ) + }, + ) + } + }.awaitAll() + } + } + private suspend fun checkAvailabilityByOperation(status: CryptoCurrencyStatus): Boolean { return when (params.filterOperation) { OnrampOperation.BUY -> { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt index 8aeb293ca5..aefd164597 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt @@ -30,6 +30,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.ui.preview.PreviewTokenListUMProvider import kotlinx.collections.immutable.ImmutableList @@ -70,6 +71,22 @@ internal fun LazyListScope.onrampTokenList(state: TokenListUM) { tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) tokensList(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden) + + when (val list = state.tokensListData) { + is TokenListUMData.AccountList -> list.tokensList.forEach { item -> + portfolioTokensList( + portfolio = item, + isBalanceHidden = state.isBalanceHidden, + ) + } + is TokenListUMData.TokenList -> { + tokensList( + items = list.tokensList, + isBalanceHidden = state.isBalanceHidden, + ) + } + TokenListUMData.EmptyList -> Unit + } } private fun LazyListScope.searchBarItem(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/preview/PreviewTokenListUMProvider.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/preview/PreviewTokenListUMProvider.kt index 684e6d0cc3..2f7e48ad75 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/preview/PreviewTokenListUMProvider.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/preview/PreviewTokenListUMProvider.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import kotlinx.collections.immutable.persistentListOf internal class PreviewTokenListUMProvider : PreviewParameterProvider { @@ -42,6 +43,7 @@ internal class PreviewTokenListUMProvider : PreviewParameterProvider checkSwapCryptoAvailability( + tokenCount = tokenListState.items.count { it is TokensListItemUM.Token }, ) - - return + is WalletTokensListState.ContentState.PortfolioContent -> checkSwapCryptoAvailability( + tokenCount = tokenListState.items.sumOf { it.tokens.count { it is TokensListItemUM.Token } }, + ) + WalletTokensListState.ContentState.Loading, + WalletTokensListState.ContentState.Locked, + WalletTokensListState.Empty, + -> return } modelScope.launch { @@ -808,4 +808,15 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( ) } } + + private fun checkSwapCryptoAvailability(tokenCount: Int) { + if (tokenCount < 2) { + handleError( + alertState = WalletAlertState.InsufficientTokensCountForSwapping, + eventCreator = MainScreenAnalyticsEvent::ButtonSwap, + ) + + return + } + } } \ No newline at end of file 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 cd5f108efd..37319208bd 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 @@ -88,12 +88,14 @@ internal object WalletScreenPreviewData { TokensListItemUM.Portfolio( tokens = textContentTokensState.items.filterIsInstance(), isExpanded = false, + isCollapsable = true, state = AccountItemPreviewData.accountItem .copy(iconState = AccountItemPreviewData.accountLetterIcon), ), TokensListItemUM.Portfolio( tokens = textContentTokensState.items.filterIsInstance(), isExpanded = true, + isCollapsable = true, state = AccountItemPreviewData.accountItem, ), ), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 931921c425..07afba9a94 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -114,6 +114,7 @@ internal class TokenListStateConverter( return TokensListItemUM.Portfolio( state = accountItem, isExpanded = isExtend, + isCollapsable = true, tokens = items.filterIsInstance(), ) } From 373880691ea23060d08992a2517641f929405ce9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 17:12:39 +0400 Subject: [PATCH 13/46] Updated on 2026-08-14 --- .../account/SaveWalletAccountsResponse.kt | 33 +++++++++++- .../com/tangem/datasource/di/MoshiModule.kt | 2 + .../tangem/datasource/utils/SerializeNulls.kt | 5 ++ .../datasource/utils/SerializeNullsFactory.kt | 25 +++++++++ .../utils/SerializeNullsFactoryTest.kt | 51 +++++++++++++++++++ .../SaveWalletAccountsResponseConverter.kt | 5 +- .../fetcher/DefaultWalletAccountsFetcher.kt | 6 +-- ...SaveWalletAccountsResponseConverterTest.kt | 3 +- .../tester/presentation/TesterActivity.kt | 4 +- .../accounts/entity/AccountsUM.kt | 3 +- .../accounts/ui/AccountsScreen.kt | 17 ++----- ...iewModel.kt => TesterAccountsViewModel.kt} | 36 ++----------- 12 files changed, 129 insertions(+), 61 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNulls.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNullsFactory.kt create mode 100644 core/datasource/src/test/kotlin/com/tangem/datasource/utils/SerializeNullsFactoryTest.kt rename features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/{AccountsViewModel.kt => TesterAccountsViewModel.kt} (79%) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/SaveWalletAccountsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/SaveWalletAccountsResponse.kt index 3f36276519..4157c42c41 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/SaveWalletAccountsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/SaveWalletAccountsResponse.kt @@ -2,8 +2,37 @@ package com.tangem.datasource.api.tangemTech.models.account import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import com.tangem.datasource.utils.SerializeNulls @JsonClass(generateAdapter = true) data class SaveWalletAccountsResponse( - @Json(name = "accounts") val accounts: List, -) \ No newline at end of file + @Json(name = "accounts") val accounts: List, +) { + + @SerializeNulls + @JsonClass(generateAdapter = true) + data class AccountDTO( + @Json(name = "id") val id: String, + @Json(name = "name") val name: String?, + @Json(name = "derivation") val derivationIndex: Int, + @Json(name = "icon") val icon: String, + @Json(name = "iconColor") val iconColor: String, + ) + + companion object { + + operator fun invoke(accounts: List): SaveWalletAccountsResponse { + return SaveWalletAccountsResponse( + accounts = accounts.map { accountDto -> + AccountDTO( + id = accountDto.id, + name = accountDto.name, + derivationIndex = accountDto.derivationIndex, + icon = accountDto.icon, + iconColor = accountDto.iconColor, + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index 37ea5507c5..1da93e6b44 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -9,6 +9,7 @@ import com.tangem.common.json.MoshiJsonConverter import com.tangem.datasource.api.common.adapter.* import com.tangem.datasource.local.config.providers.models.ProviderModel import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.datasource.utils.SerializeNullsFactory import com.tangem.domain.models.scan.serialization.* import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.domain.visa.model.VisaCardActivationStatus @@ -28,6 +29,7 @@ class MoshiModule { @NetworkMoshi fun provideNetworkMoshi(): Moshi { return Moshi.Builder() + .add(SerializeNullsFactory) .add( PolymorphicJsonAdapterFactory.of(ProviderModel::class.java, "type") .withSubtype(ProviderModel.Public::class.java, "public") diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNulls.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNulls.kt new file mode 100644 index 0000000000..8361150150 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNulls.kt @@ -0,0 +1,5 @@ +package com.tangem.datasource.utils + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class SerializeNulls \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNullsFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNullsFactory.kt new file mode 100644 index 0000000000..ac77f01f7c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/SerializeNullsFactory.kt @@ -0,0 +1,25 @@ +package com.tangem.datasource.utils + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.lang.reflect.Type + +/** + * Factory to serialize nulls in Moshi if the class is annotated with [SerializeNulls]. + * +[REDACTED_AUTHOR] + */ +internal object SerializeNullsFactory : JsonAdapter.Factory { + + override fun create(type: Type, annotations: MutableSet, moshi: Moshi): JsonAdapter<*>? { + val rawType = Types.getRawType(type) + if (!rawType.isAnnotationPresent(SerializeNulls::class.java)) { + return null + } + + val nextAdapter: JsonAdapter = moshi.nextAdapter(this, type, annotations) + + return nextAdapter.serializeNulls() + } +} \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/utils/SerializeNullsFactoryTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/utils/SerializeNullsFactoryTest.kt new file mode 100644 index 0000000000..9dafd03360 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/utils/SerializeNullsFactoryTest.kt @@ -0,0 +1,51 @@ +package com.tangem.datasource.utils + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +// --- DTO --- +@SerializeNulls +@JsonClass(generateAdapter = true) +data class UserWithNulls(val id: String?, val name: String?) + +@JsonClass(generateAdapter = true) +data class UserWithoutNulls(val id: String?, val name: String?) + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SerializeNullsFactoryTest { + + private val moshi = Moshi.Builder() + .add(SerializeNullsFactory) + .build() + + @Test + fun `should serialize nulls for annotated class`() { + val adapter = moshi.adapter(UserWithNulls::class.java) + + val json = adapter.toJson(UserWithNulls(id = null, name = "John")) + + assertThat(json).isEqualTo("""{"id":null,"name":"John"}""") + } + + @Test + fun `should skip nulls for non-annotated class`() { + val adapter = moshi.adapter(UserWithoutNulls::class.java) + + val json = adapter.toJson(UserWithoutNulls(id = null, name = "John")) + + assertThat(json).isEqualTo("""{"name":"John"}""") + } + + @Test + fun `should deserialize annotated class correctly`() { + val adapter = moshi.adapter(UserWithNulls::class.java) + + val json = """{"id":null,"name":"Jane"}""" + val result = adapter.fromJson(json) + + assertThat(result).isEqualTo(UserWithNulls(id = null, name = "Jane")) + } +} \ 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 index 07c76c7c24..8f26a20de5 100644 --- 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 @@ -1,7 +1,6 @@ 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 @@ -21,8 +20,8 @@ internal object SaveWalletAccountsResponseConverter : Converter().apply { + val viewModel = hiltViewModel().apply { setupNavigation(innerTesterRouter) } val state by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/entity/AccountsUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/entity/AccountsUM.kt index 168afd1186..64cf095f9c 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/entity/AccountsUM.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/entity/AccountsUM.kt @@ -9,9 +9,8 @@ internal data class AccountsUM( val onBackClick: () -> Unit, val walletSelector: WalletSelector, val accountListBottomSheetConfig: AccountListBottomSheetConfig, - val onAccountsClick: () -> Unit, + val onAccountsClick: () -> Boolean, val onFetchAccountsClick: () -> Unit, - val onCreateMainAccountClick: () -> Unit, val onClearETagClick: () -> Unit, ) { diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/ui/AccountsScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/ui/AccountsScreen.kt index 66202d3842..8ee0fd82be 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/ui/AccountsScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/ui/AccountsScreen.kt @@ -62,8 +62,9 @@ internal fun AccountsScreen(state: AccountsUM, modifier: Modifier = Modifier) { ManageAccountsButtons( state = state, onAccountsClick = { context -> - if (state.accountListBottomSheetConfig.accounts.isNotEmpty()) { - state.onAccountsClick() + val isEmpty = state.onAccountsClick() + + if (!isEmpty) { isAccountListShown = true } else { Toast.makeText(context, "No accounts found", Toast.LENGTH_SHORT).show() @@ -242,16 +243,4 @@ private fun LazyListScope.ManageAccountsButtons(state: AccountsUM, onAccountsCli .fillMaxWidth(), ) } - - if (state.accountListBottomSheetConfig.accounts.none { it.isMainAccount }) { - item { - PrimaryButton( - text = "Create Main account", - onClick = state.onCreateMainAccountClick, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .fillMaxWidth(), - ) - } - } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/TesterAccountsViewModel.kt similarity index 79% rename from features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt rename to features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/TesterAccountsViewModel.kt index de73765db2..0824ae8aaa 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/AccountsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/accounts/viewmodel/TesterAccountsViewModel.kt @@ -6,13 +6,9 @@ import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.domain.account.fetcher.SingleAccountListFetcher import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer -import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.TokensGroupType -import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.tester.presentation.accounts.entity.AccountsUM @@ -28,11 +24,10 @@ import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel -internal class AccountsViewModel @Inject constructor( +internal class TesterAccountsViewModel @Inject constructor( private val userWalletsListRepository: UserWalletsListRepository, private val singleAccountListFetcher: SingleAccountListFetcher, private val singleAccountListSupplier: SingleAccountListSupplier, - private val accountsCRUDRepository: AccountsCRUDRepository, private val eTagsStore: ETagsStore, private val dispatchers: CoroutineDispatcherProvider, ) : ViewModel() { @@ -88,7 +83,6 @@ internal class AccountsViewModel @Inject constructor( ), onAccountsClick = ::updateAccountsList, onFetchAccountsClick = ::fetchAccounts, - onCreateMainAccountClick = ::createMainAccount, onClearETagClick = ::clearETag, ) } @@ -107,8 +101,8 @@ internal class AccountsViewModel @Inject constructor( } } - private fun updateAccountsList() { - val userWalletId = uiState.value.walletSelector.selected?.walletId ?: return + private fun updateAccountsList(): Boolean { + val userWalletId = uiState.value.walletSelector.selected?.walletId ?: return false val accounts = walletAccounts.value[userWalletId]?.accounts ?.filterIsInstance() @@ -122,6 +116,8 @@ internal class AccountsViewModel @Inject constructor( ), ) } + + return accounts.isEmpty() } private fun fetchAccounts() { @@ -134,28 +130,6 @@ internal class AccountsViewModel @Inject constructor( } } - private fun createMainAccount() { - viewModelScope.launch { - val userWallet = uiState.value.walletSelector.selected ?: return@launch - - // It's temporary solution to create main account for testing purposes - val accountList = AccountList( - userWalletId = userWallet.walletId, - accounts = setOf( - Account.CryptoPortfolio.createMainAccount(userWallet.walletId).copy( - accountName = AccountName.invoke(value = "Main Account").getOrNull()!!, - ), - ), - totalAccounts = 1, - sortType = TokensSortType.NONE, - groupType = TokensGroupType.NONE, - ) - .getOrNull()!! - - accountsCRUDRepository.saveAccounts(accountList) - } - } - private fun clearETag() { viewModelScope.launch { val userWallet = uiState.value.walletSelector.selected ?: return@launch From cafd4d074bbe460f3ab3b48c4e398928f040f77f Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 22:37:04 +0400 Subject: [PATCH 14/46] Updated on 2026-08-14 --- data/account/build.gradle.kts | 1 + .../fetcher/DefaultWalletAccountsFetcher.kt | 26 +- .../FetchWalletAccountsErrorHandler.kt | 82 ++---- .../DefaultWalletAccountsResponseFactory.kt | 68 +++++ .../DefaultWalletAccountsFetcherTest.kt | 13 + .../FetchWalletAccountsErrorHandlerTest.kt | 74 ++---- ...efaultWalletAccountsResponseFactoryTest.kt | 240 ++++++++++++++++++ 7 files changed, 378 insertions(+), 126 deletions(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactory.kt create mode 100644 data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index d5c297866a..58b5628a0d 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { // region Project - Domain api(projects.domain.account) api(projects.domain.card) + api(projects.domain.common) api(projects.domain.models) // endregion diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt index a4fe4cd17d..5740e30ccb 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt @@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.assignTokens import com.tangem.data.account.utils.toUserTokensResponse import com.tangem.data.common.account.WalletAccountsFetcher @@ -14,6 +15,7 @@ import com.tangem.datasource.api.common.response.ApiResponseError.HttpException. import com.tangem.datasource.api.common.response.ETAG_HEADER import com.tangem.datasource.api.common.response.isNetworkError import com.tangem.datasource.api.tangemTech.TangemTechApi +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.SaveWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO @@ -31,17 +33,20 @@ import javax.inject.Singleton * @property accountsResponseStoreFactory factory to create [AccountsResponseStore] * @property userTokensSaver saves user tokens to the database * @property fetchWalletAccountsErrorHandler handles errors during fetching wallet accounts + * @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse] * @property eTagsStore store for ETags to manage caching * @property dispatchers dispatchers * [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") @Singleton internal class DefaultWalletAccountsFetcher @Inject constructor( private val tangemTechApi: TangemTechApi, private val accountsResponseStoreFactory: AccountsResponseStoreFactory, private val userTokensSaver: UserTokensSaver, private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler, + private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, private val eTagsStore: ETagsStore, private val dispatchers: CoroutineDispatcherProvider, ) : WalletAccountsFetcher, WalletAccountsSaver { @@ -49,9 +54,11 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( override suspend fun fetch(userWalletId: UserWalletId) { val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull() val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse) - val unassignedTokens = accountsResponse?.unassignedTokens + ?: return - if (!unassignedTokens.isNullOrEmpty()) { + if (accountsResponse.accounts.isEmpty()) { + initializeAccounts(userWalletId, accountsResponse) + } else if (accountsResponse.unassignedTokens.isNotEmpty()) { assignTokens(userWalletId, accountsResponse) } } @@ -131,12 +138,23 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( pushWalletAccounts = ::push, storeWalletAccounts = ::store, ) - - null }, ) } + private suspend fun initializeAccounts(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { + val response = defaultWalletAccountsResponseFactory.create( + userWalletId = userWalletId, + userTokensResponse = UserTokensResponse( + group = accountsResponse.wallet.group, + sort = accountsResponse.wallet.sort, + tokens = accountsResponse.unassignedTokens, + ), + ) + + pushAndStore(userWalletId, response) + } + private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 3c7d1af215..6f284edf5b 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -1,10 +1,8 @@ package com.tangem.data.account.fetcher -import com.tangem.data.account.converter.CryptoPortfolioConverter -import com.tangem.data.account.utils.assignTokens +import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.toUserTokensResponse -import com.tangem.data.common.currency.CardCryptoCurrencyFactory -import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code @@ -13,10 +11,6 @@ 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.datasource.local.token.UserTokensResponseStore -import com.tangem.datasource.local.userwallet.UserWalletsStore -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 timber.log.Timber import javax.inject.Inject @@ -24,12 +18,9 @@ import javax.inject.Inject /** * Handles errors that occur during the fetching of wallet accounts * - * @property userTokensSaver saves user tokens to the storage - * @property userWalletsStore provides access to user wallet data - * @property userTokensResponseStore provides access to user token responses. - * @property cryptoPortfolioCF factory for converting crypto portfolios - * @property userTokensResponseFactory factory for creating user token responses - * @property cardCryptoCurrencyFactory factory for creating default cryptocurrencies for multi-currency wallets + * @property userTokensSaver saves user tokens to the storage + * @property userTokensResponseStore provides access to user token responses. + * @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse] * * @see DefaultWalletAccountsFetcher * @@ -37,11 +28,8 @@ import javax.inject.Inject */ internal class FetchWalletAccountsErrorHandler @Inject constructor( private val userTokensSaver: UserTokensSaver, - private val userWalletsStore: UserWalletsStore, private val userTokensResponseStore: UserTokensResponseStore, - private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, - private val userTokensResponseFactory: UserTokensResponseFactory, - private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, + private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, ) { /** @@ -61,20 +49,19 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( savedAccountsResponse: GetWalletAccountsResponse?, pushWalletAccounts: suspend (userWalletId: UserWalletId, accounts: List) -> Unit, storeWalletAccounts: suspend (userWalletId: UserWalletId, response: GetWalletAccountsResponse) -> Unit, - ) { + ): GetWalletAccountsResponse? { val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED) if (isResponseUpToDate) { Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId") - return + return savedAccountsResponse } - val (accountDTOs, userTokensResponse) = if (savedAccountsResponse == null) { - val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) + val response = savedAccountsResponse ?: defaultWalletAccountsResponseFactory.create( + userWalletId = userWalletId, + userTokensResponse = getFromLegacyStore(userWalletId), + ) - createDefaultAccountDTOs(userWallet) to getFromLegacyStore(userWalletId).orDefault(userWallet) - } else { - savedAccountsResponse.accounts to savedAccountsResponse.toUserTokensResponse() - } + val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse() val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) if (isNotFoundError) { @@ -82,49 +69,18 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse) } - val response = savedAccountsResponse.orDefault(userWalletId, accountDTOs, userTokensResponse) storeWalletAccounts(userWalletId, response) - } - private fun createDefaultAccountDTOs(userWallet: UserWallet): List { - val accounts = AccountList.empty(userWallet.walletId).accounts - .filterIsInstance() - - val converter = cryptoPortfolioCF.create(userWallet = userWallet) - - return converter.convertListBack(input = accounts) + return response } private suspend fun getFromLegacyStore(userWalletId: UserWalletId): UserTokensResponse? { return userTokensResponseStore.getSyncOrNull(userWalletId) + ?.let { + it.copy( + tokens = UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, tokens = it.tokens), + ) + } .also { userTokensResponseStore.clear(userWalletId) } } - - private fun UserTokensResponse?.orDefault(userWallet: UserWallet): UserTokensResponse { - if (this != null) return this - - return userTokensResponseFactory.createUserTokensResponse( - currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet = userWallet), - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - } - - private fun GetWalletAccountsResponse?.orDefault( - userWalletId: UserWalletId, - accountDTOs: List, - userTokensResponse: UserTokensResponse, - ): GetWalletAccountsResponse { - if (this != null) return this - - return GetWalletAccountsResponse( - wallet = GetWalletAccountsResponse.Wallet( - group = userTokensResponse.group, - sort = userTokensResponse.sort, - totalAccounts = accountDTOs.size, - ), - accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = userTokensResponse.tokens), - unassignedTokens = emptyList(), - ) - } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactory.kt new file mode 100644 index 0000000000..8dad68c1de --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactory.kt @@ -0,0 +1,68 @@ +package com.tangem.data.account.utils + +import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +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.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import javax.inject.Inject + +/** + * Factory to create default [GetWalletAccountsResponse]. + * + * @property userWalletsListRepository repository to get user wallet information + * @property cryptoPortfolioCF converter factory to convert crypto portfolio accounts + * @property userTokensResponseFactory factory to create [UserTokensResponse] + * @property cardCryptoCurrencyFactory factory to get default coins for multi-currency wallet + * +[REDACTED_AUTHOR] + */ +internal class DefaultWalletAccountsResponseFactory @Inject constructor( + private val userWalletsListRepository: UserWalletsListRepository, + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, + private val userTokensResponseFactory: UserTokensResponseFactory, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, +) { + + suspend fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse { + val userWallet = userWalletsListRepository.userWalletsSync().firstOrNull { it.walletId == userWalletId } + + val accountDTOs = userWallet?.let(::createDefaultAccountDTOs).orEmpty() + val response = userTokensResponse.orDefault(userWallet = userWallet) + + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = response.group, + sort = response.sort, + totalAccounts = accountDTOs.size, + ), + accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = response.tokens), + unassignedTokens = emptyList(), + ) + } + + private fun createDefaultAccountDTOs(userWallet: UserWallet): List { + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + + val converter = cryptoPortfolioCF.create(userWallet = userWallet) + + return converter.convertListBack(input = accounts) + } + + private fun UserTokensResponse?.orDefault(userWallet: UserWallet?): UserTokensResponse { + if (this != null) return this + + return userTokensResponseFactory.createUserTokensResponse( + currencies = userWallet?.let(cardCryptoCurrencyFactory::createDefaultCoinsForMultiCurrencyWallet).orEmpty(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt index ac012e76fb..4494f5af61 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt @@ -5,6 +5,7 @@ import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.store.AccountsResponseStore import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.ApiResponse @@ -36,6 +37,7 @@ class DefaultWalletAccountsFetcherTest { private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true) + private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true) private val fetcher: DefaultWalletAccountsFetcher = DefaultWalletAccountsFetcher( @@ -43,6 +45,7 @@ class DefaultWalletAccountsFetcherTest { accountsResponseStoreFactory = accountsResponseStoreFactory, userTokensSaver = userTokensSaver, fetchWalletAccountsErrorHandler = fetchWalletAccountsErrorHandler, + defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, eTagsStore = eTagsStore, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -205,6 +208,16 @@ class DefaultWalletAccountsFetcherTest { tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag) } returns apiError as ApiResponse + coEvery { + fetchWalletAccountsErrorHandler.handle( + error = apiError.cause, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = any(), + storeWalletAccounts = any(), + ) + } returns savedAccountsResponse + // Act fetcher.fetch(userWalletId) diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index c174b4d463..e8a3341536 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -1,9 +1,7 @@ package com.tangem.data.account.fetcher -import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.toUserTokensResponse -import com.tangem.data.common.currency.CardCryptoCurrencyFactory -import com.tangem.data.common.currency.UserTokensResponseFactory import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code @@ -11,12 +9,11 @@ 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.datasource.local.token.UserTokensResponseStore -import com.tangem.datasource.local.userwallet.UserWalletsStore -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.* +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -29,35 +26,21 @@ import org.junit.jupiter.api.TestInstance class FetchWalletAccountsErrorHandlerTest { private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) - private val userWalletsStore: UserWalletsStore = mockk() private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) - private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory = mockk() - private val cryptoPortfolioConverter = mockk() - private val userTokensResponseFactory: UserTokensResponseFactory = mockk() - private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() private val handler = FetchWalletAccountsErrorHandler( userTokensSaver = userTokensSaver, - userWalletsStore = userWalletsStore, userTokensResponseStore = userTokensResponseStore, - cryptoPortfolioCF = cryptoPortfolioCF, - userTokensResponseFactory = userTokensResponseFactory, - cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, ) - private val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId - } - @BeforeEach fun setupEach() { clearMocks( userTokensSaver, - userWalletsStore, userTokensResponseStore, - cryptoPortfolioCF, - cryptoPortfolioConverter, - cardCryptoCurrencyFactory, + defaultWalletAccountsResponseFactory, ) } @@ -84,12 +67,8 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify(inverse = true) { - userWalletsStore.getSyncStrict(key = any()) userTokensResponseStore.getSyncOrNull(userWalletId = any()) - userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) - cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) - cryptoPortfolioCF.create(any()) - cryptoPortfolioConverter.convertListBack(any()) + defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any()) pushWalletAccounts(any(), any()) userTokensSaver.push(userWalletId = any(), response = any()) storeWalletAccounts(any(), any()) @@ -146,12 +125,8 @@ class FetchWalletAccountsErrorHandlerTest { } coVerify(inverse = true) { - userWalletsStore.getSyncStrict(key = any()) userTokensResponseStore.getSyncOrNull(userWalletId = any()) - userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) - cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) - cryptoPortfolioCF.create(any()) - cryptoPortfolioConverter.convertListBack(any()) + defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any()) } } @@ -160,9 +135,6 @@ class FetchWalletAccountsErrorHandlerTest { // Arrange val error = ApiResponseError.TimeoutException() - val accounts = AccountList.empty(userWalletId).accounts - .filterIsInstance() - val accountDTO = WalletAccountDTO( id = "nibh", name = "Michael Dotson", @@ -186,18 +158,10 @@ class FetchWalletAccountsErrorHandlerTest { val userTokensResponse = savedAccountsResponse.toUserTokensResponse() - every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet - every { cryptoPortfolioCF.create(userWallet) } returns cryptoPortfolioConverter - every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountDTO) - coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns null - every { - userTokensResponseFactory.createUserTokensResponse( - currencies = emptyList(), - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - } returns userTokensResponse - every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList() + coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns userTokensResponse + coEvery { + defaultWalletAccountsResponseFactory.create(userWalletId, userTokensResponse) + } returns savedAccountsResponse val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) @@ -213,16 +177,8 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify { - userWalletsStore.getSyncStrict(userWalletId) - cryptoPortfolioCF.create(userWallet) - cryptoPortfolioConverter.convertListBack(accounts) userTokensResponseStore.getSyncOrNull(userWalletId) - userTokensResponseFactory.createUserTokensResponse( - currencies = emptyList(), - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) + defaultWalletAccountsResponseFactory.create(userWalletId, userTokensResponse) storeWalletAccounts(userWalletId, any()) } diff --git a/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt new file mode 100644 index 0000000000..e270dacc98 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/utils/DefaultWalletAccountsResponseFactoryTest.kt @@ -0,0 +1,240 @@ +package com.tangem.data.account.utils + +import com.google.common.truth.Truth +import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.converter.createWalletAccountDTO +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +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.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultWalletAccountsResponseFactoryTest { + + private val userWalletsListRepository = mockk() + private val cryptoPortfolioCF = mockk() + private val cryptoPortfolioConverter = mockk() + private val userTokensResponseFactory = mockk() + private val cardCryptoCurrencyFactory = mockk() + + private val factory = DefaultWalletAccountsResponseFactory( + userWalletsListRepository = userWalletsListRepository, + cryptoPortfolioCF = cryptoPortfolioCF, + userTokensResponseFactory = userTokensResponseFactory, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) + + private val userWalletId = UserWalletId("011") + + @BeforeEach + fun setUpEach() { + every { cryptoPortfolioCF.create(any()) } returns cryptoPortfolioConverter + } + + @AfterEach + fun tearDownEach() { + clearMocks( + userWalletsListRepository, + cryptoPortfolioCF, + cryptoPortfolioConverter, + userTokensResponseFactory, + cardCryptoCurrencyFactory, + ) + } + + @Test + fun `create returns empty accounts when user wallet not found`() = runTest { + // Arrange + val userTokensResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = emptyList(), + ) + + coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns userTokensResponse + + // Act + val actual = factory.create(userWalletId = userWalletId, userTokensResponse = null) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + userWalletsListRepository.userWalletsSync() + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } + } + + @Test + fun `create returns response with default tokens when userTokensResponse is null`() = runTest { + // Arrange + val userWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + val defaultCoins = listOf(mockk()) + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns defaultCoins + + val defaultResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = listOf(mockk(relaxed = true)), + ) + + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = defaultCoins, + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns defaultResponse + + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + + val accountsDTO = createWalletAccountDTO(userWalletId) + every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO) + + // Act + val actual = factory.create(userWalletId, null) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = defaultResponse.group, + sort = defaultResponse.sort, + totalAccounts = 1, + ), + accounts = listOf(accountsDTO), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + userWalletsListRepository.userWalletsSync() + cryptoPortfolioConverter.convertListBack(accounts) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) + userTokensResponseFactory.createUserTokensResponse( + currencies = defaultCoins, + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } + } + + @Test + fun `create returns response with default tokens when userTokensResponse is null and no default coins`() = runTest { + // Arrange + val userWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList() + val defaultResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = emptyList(), + ) + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns defaultResponse + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList() + + // Act + val actual = factory.create(userWalletId, null) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = defaultResponse.group, + sort = defaultResponse.sort, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `create returns response with assigned tokens`() = runTest { + // Arrange + val userWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + val assignedTokens = listOf(mockk(), mockk()) + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + val userTokensResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = listOf(mockk(relaxed = true)), + ) + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = assignedTokens, + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns userTokensResponse + + val accounts = AccountList.empty(userWallet.walletId).accounts + .filterIsInstance() + val accountsDTO = createWalletAccountDTO(userWalletId) + every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO) + + // Act + val actual = factory.create(userWalletId, userTokensResponse) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = userTokensResponse.group, + sort = userTokensResponse.sort, + totalAccounts = 1, + ), + accounts = listOf(accountsDTO), + unassignedTokens = emptyList(), + ) + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file From f1f9dff5ce92507ed60616c7c3afadd9fa3dd15d Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Oct 2025 12:11:36 +0300 Subject: [PATCH 15/46] Updated on 2026-08-14 --- .../domain/wallets/hot/HotWalletPasswordRequester.kt | 3 ++- .../accesscoderequest/HotAccessCodeRequestModel.kt | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt index c3f3df8d95..ce09a009a3 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt @@ -36,7 +36,8 @@ interface HotWalletPasswordRequester { * @param hotWalletId The ID of the hot wallet to authenticate with. * @param authMode Indicates whether the request is for authentication mode. * In auth mode user can be deleted after failed attempts. - * @param hasBiometry Indicates whether to show biometric authentication option. + * @param hasBiometry Indicates whether to show biometric authentication option to the user. + * Will be ignored if the device does not support biometry at the moment of the request. */ data class AttemptRequest( val hotWalletId: HotWalletId, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt index 5c9a7148de..ad3e784dd7 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt @@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS @@ -31,6 +32,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, private val userWalletsListRepository: UserWalletsListRepository, + private val canUseBiometryUseCase: CanUseBiometryUseCase, ) : Model() { private val result = MutableStateFlow(null) @@ -60,7 +62,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( it.copy( isShown = true, accessCode = "", - useBiometricVisible = attemptRequest.hasBiometry, + useBiometricVisible = attemptRequest.isBiometryButtonVisible(), onAccessCodeChange = ::onAccessCodeChange, ) } @@ -83,7 +85,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( it.copy( accessCodeColor = PinTextColor.WrongCode, onAccessCodeChange = {}, - useBiometricVisible = currentRequest.hasBiometry, + useBiometricVisible = currentRequest.isBiometryButtonVisible(), ) } delay(timeMillis = 500) // Delay to show the wrong access code state @@ -212,6 +214,9 @@ internal class HotAccessCodeRequestModel @Inject constructor( dismiss() } + private suspend fun HotWalletPasswordRequester.AttemptRequest.isBiometryButtonVisible(): Boolean = + hasBiometry && canUseBiometryUseCase() + private fun dismissState() { uiState.update { it.copy(isShown = false) From aefaf636e87dc8bd715748896af970db0095663e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Oct 2025 19:37:13 +0500 Subject: [PATCH 16/46] Updated on 2026-08-14 --- .../DefaultTangemPayTxHistoryRepository.kt | 2 +- .../PreviewTangemPayTxHistoryComponent.kt | 1 + .../TangemPayEmptyTransactionHistoryState.kt | 29 ++++++++ .../model/TangemPayTxHistoryModel.kt | 17 ++++- .../tangempay/ui/TangemPayDetailsScreen.kt | 50 +++++++------- .../tangempay/ui/TangemPayTxHistoryUi.kt | 66 ++++++++++++++++++- .../utils/TangemPayTxHistoryListManager.kt | 2 + .../utils/TangemPayTxHistoryState.kt | 1 + 8 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEmptyTransactionHistoryState.kt diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt index be43b2bd72..7e197fea52 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt @@ -86,6 +86,6 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( }.result val items = TangemPayTxHistoryItemConverter.convertList(result.transactions).filterNotNull() txHistoryItemsStore.store(key = customerWalletAddress, cursor = cursor ?: INITIAL_CURSOR, value = items) - } + }.onLeft { error(it.toString()) } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt index b430960c14..ba9bbc588f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt @@ -21,6 +21,7 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor companion object { val loadingUM = TangemPayTxHistoryUM.Loading(isBalanceHidden = true) val emptyUM = TangemPayTxHistoryUM.Empty(isBalanceHidden = true) + val errorUM = TangemPayTxHistoryUM.Error(isBalanceHidden = true, onReload = {}) val contentUM = TangemPayTxHistoryUM.Content( isBalanceHidden = false, loadMore = { false }, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEmptyTransactionHistoryState.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEmptyTransactionHistoryState.kt new file mode 100644 index 0000000000..50816ac2a7 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEmptyTransactionHistoryState.kt @@ -0,0 +1,29 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.details.impl.R + +internal sealed class TangemPayEmptyTransactionHistoryState { + + abstract val iconRes: Int + abstract val text: TextReference + + data class FailedToLoad( + private val onReload: () -> Unit, + ) : TangemPayEmptyTransactionHistoryState() { + override val iconRes: Int = R.drawable.ic_alert_history_64 + override val text: TextReference = resourceReference(R.string.transaction_history_error_failed_to_load) + val actionButtonConfig = ActionButtonConfig( + text = resourceReference(R.string.common_reload), + iconResId = R.drawable.ic_refresh_24, + onClick = onReload, + ) + } + + data object Empty : TangemPayEmptyTransactionHistoryState() { + override val iconRes: Int = R.drawable.ic_empty_token_64 + override val text: TextReference = resourceReference(R.string.transaction_history_empty_transactions) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt index 1b3778feb0..ac67e1d5b4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryModel.kt @@ -54,11 +54,16 @@ internal class TangemPayTxHistoryModel @Inject constructor( .onEach(::updateState) .launchIn(modelScope) listManager.paginationStatus - .onEach { paginationStatus -> handlePaginationStatus(paginationStatus) } + .onEach(::handlePaginationStatus) + .launchIn(modelScope) + listManager.emptyStatus + .onEach(::handleEmptyState) .launchIn(modelScope) } private fun updateState(items: ImmutableList) { + if (items.isEmpty()) return // fast exit. If items is empty, no need to update ui items + uiState.update { state -> if (state is TangemPayTxHistoryUM.Content) { state.copy(items = items) @@ -72,6 +77,12 @@ internal class TangemPayTxHistoryModel @Inject constructor( } } + private fun handleEmptyState(isEmpty: Boolean) { + if (isEmpty) { + uiState.update { getEmptyState(it.isBalanceHidden) } + } + } + private fun handlePaginationStatus(status: PaginationStatus<*>) { uiState.update { state -> when (status) { @@ -108,6 +119,10 @@ internal class TangemPayTxHistoryModel @Inject constructor( Timber.d("onTransactionClick: $item") } + private fun getEmptyState(isBalanceHidden: Boolean): TangemPayTxHistoryUM.Empty { + return TangemPayTxHistoryUM.Empty(isBalanceHidden = isBalanceHidden) + } + private fun getErrorState(isBalanceHidden: Boolean): TangemPayTxHistoryUM.Error { return TangemPayTxHistoryUM.Error(isBalanceHidden = isBalanceHidden, onReload = ::reload) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index c2e6cc25f7..bd9cc78a83 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -2,12 +2,7 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -15,12 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,21 +35,14 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.components.snackbar.TangemSnackbarHost import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.orMaskWithStars -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsTopBarTestTags import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM -import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig -import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.* import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf @@ -415,8 +399,8 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi ) } -@Preview(device = Devices.PIXEL_7_PRO, group = "day") -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO, group = "night") +@Preview(device = Devices.PIXEL_7_PRO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) @Composable private fun TangemPayDetailsScreenPreview( @PreviewParameter(TangemPayDetailsUMProvider::class) state: TangemPayDetailsUM, @@ -473,4 +457,26 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + PreviewTangemPayTxHistoryComponent.loadingUM, + PreviewTangemPayTxHistoryComponent.contentUM, + PreviewTangemPayTxHistoryComponent.emptyUM, + PreviewTangemPayTxHistoryComponent.errorUM, + ), ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt index b7c07cbc03..466c8de420 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ChainStyle import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension @@ -27,6 +28,7 @@ import coil.compose.rememberAsyncImagePainter import com.tangem.core.ui.R import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.buttons.actions.ActionButton import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.components.transactions.TxHistoryGroupTitle import com.tangem.core.ui.decorations.roundedShapeItemDecoration @@ -34,7 +36,9 @@ import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.EmptyTransactionBlockTestTags import com.tangem.core.ui.test.TransactionHistoryBlockTestTags +import com.tangem.features.tangempay.entity.TangemPayEmptyTransactionHistoryState import com.tangem.features.tangempay.entity.TangemPayTransactionState import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM @@ -43,12 +47,25 @@ private const val LOAD_ITEMS_BUFFER = 20 internal fun LazyListScope.tangemPayTxHistoryItems(listState: LazyListState, state: TangemPayTxHistoryUM) { when (state) { is TangemPayTxHistoryUM.Content -> contentItems(listState = listState, state = state) - is TangemPayTxHistoryUM.Empty -> TODO("[REDACTED_JIRA]") - is TangemPayTxHistoryUM.Error -> TODO("[REDACTED_JIRA]") + is TangemPayTxHistoryUM.Empty -> nonContentItem(state = TangemPayEmptyTransactionHistoryState.Empty) + is TangemPayTxHistoryUM.Error -> nonContentItem( + state = TangemPayEmptyTransactionHistoryState.FailedToLoad(onReload = state.onReload), + ) is TangemPayTxHistoryUM.Loading -> loadingItems(state = state) } } +private fun LazyListScope.nonContentItem(state: TangemPayEmptyTransactionHistoryState, modifier: Modifier = Modifier) { + item(key = state::class.java, contentType = state::class.java) { + TangemPayEmptyTransactionBlock( + state = state, + modifier = modifier + .padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing12) + .fillMaxWidth(), + ) + } +} + private fun LazyListScope.contentItems(listState: LazyListState, state: TangemPayTxHistoryUM.Content) { itemsIndexed( items = state.items, @@ -370,4 +387,49 @@ private fun Timestamp(state: TangemPayTransactionState, modifier: Modifier = Mod ) } } +} + +@Composable +private fun TangemPayEmptyTransactionBlock( + state: TangemPayEmptyTransactionHistoryState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(color = TangemTheme.colors.background.primary) + .padding(vertical = TangemTheme.dimens.spacing24) + .testTag(EmptyTransactionBlockTestTags.BLOCK), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size64) + .testTag(EmptyTransactionBlockTestTags.ICON), + painter = painterResource(id = state.iconRes), + tint = TangemTheme.colors.icon.inactive, + contentDescription = null, + ) + + Text( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing32) + .testTag(EmptyTransactionBlockTestTags.TEXT), + textAlign = TextAlign.Center, + text = state.text.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + + when (state) { + is TangemPayEmptyTransactionHistoryState.Empty -> Unit + is TangemPayEmptyTransactionHistoryState.FailedToLoad -> ActionButton( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + config = state.actionButtonConfig, + ) + } + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt index 3ccf9cf202..40cd31c7da 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt @@ -33,6 +33,7 @@ internal class TangemPayTxHistoryListManager( private val uiManager = TangemPayTxHistoryUiManager(state = state, txHistoryUiActions = txHistoryUiActions) val uiItems: Flow> = uiManager.items + val emptyStatus: Flow = state.map { it.isEmpty }.distinctUntilChanged() val paginationStatus: Flow> = state.map { it.status }.distinctUntilChanged() suspend fun launchPagination() = coroutineScope { @@ -80,6 +81,7 @@ internal class TangemPayTxHistoryListManager( newCurrencyBatches = batchListState.data, clearUiBatches = clearUiBatches, ), + isEmpty = batchListState.status is PaginationStatus.EndOfPagination && batchListState.data.isEmpty(), ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt index 5717238dda..5df7a11936 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryState.kt @@ -7,4 +7,5 @@ import com.tangem.pagination.PaginationStatus internal data class TangemPayTxHistoryState( val status: PaginationStatus<*> = PaginationStatus.None, val uiBatches: List>> = listOf(), + val isEmpty: Boolean = false, ) \ No newline at end of file From 9ff657252a12b81fc9c0d55af66c9fe655dd4f82 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Oct 2025 17:35:41 +0400 Subject: [PATCH 17/46] Updated on 2026-08-14 --- domain/account/status/build.gradle.kts | 3 + .../status/di/AccountStatusUseCaseModule.kt | 7 +- .../GetAccountCurrencyStatusUseCase.kt | 102 +++++++++++- ...faultMultiAccountStatusListProducerTest.kt | 0 ...aultSingleAccountStatusListProducerTest.kt | 0 .../GetAccountCurrencyStatusUseCaseTest.kt | 157 ++++++++++++++++++ .../CryptoCurrencyStatusesFlowFactoryTest.kt | 0 .../derivation/AccountNodeRecognizer.kt | 16 +- 8 files changed, 272 insertions(+), 13 deletions(-) rename domain/account/status/src/test/{java => kotlin}/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt (100%) rename domain/account/status/src/test/{java => kotlin}/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt (100%) create mode 100644 domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt rename domain/account/status/src/test/{java => kotlin}/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt (100%) diff --git a/domain/account/status/build.gradle.kts b/domain/account/status/build.gradle.kts index 1bc12d67f3..564baa96b8 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -25,12 +25,15 @@ dependencies { api(projects.domain.staking) api(projects.domain.tokens) + implementation(projects.libs.blockchainSdk) implementation(projects.libs.crypto) implementation(deps.kotlin.datetime) implementation(deps.kotlin.serialization) implementation(deps.timber) + implementation(tangemDeps.blockchain) + // region DI implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt index 2e1d0ce365..f7cab3c413 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt @@ -1,5 +1,6 @@ package com.tangem.domain.account.status.di +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.supplier.SingleAccountListSupplier @@ -31,7 +32,9 @@ internal object AccountStatusUseCaseModule { @Provides @Singleton - fun provideGetAccountCurrencyStatusUseCase(): GetAccountCurrencyStatusUseCase { - return GetAccountCurrencyStatusUseCase() + fun provideGetAccountCurrencyStatusUseCase( + singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + ): GetAccountCurrencyStatusUseCase { + return GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = singleAccountStatusListSupplier) } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt index be08772f57..1846999f3d 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt @@ -2,24 +2,110 @@ package com.tangem.domain.account.status.usecase import arrow.core.Option import arrow.core.none +import arrow.core.toOption +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer /** * Use case to retrieve the status of a specific cryptocurrency associated with an account. * + * @property singleAccountStatusListSupplier supplier to get the list of account statuses. + * [REDACTED_AUTHOR] */ -// TODO: Implement [REDACTED_JIRA] -class GetAccountCurrencyStatusUseCase { +class GetAccountCurrencyStatusUseCase( + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, +) { /** - * Invokes the use case to get the [AccountCryptoCurrencyStatus] for the given [currencyId]. + * Invokes the use case to get the status of a specific cryptocurrency for a given user wallet. * - * @param currencyId The ID of the cryptocurrency to look up. - * - * @return An [Option] containing the [AccountCryptoCurrencyStatus] if found, - * or [arrow.core.None] if not found or if any validation fails. + * @param userWalletId the ID of the user wallet. + * @param currency the cryptocurrency for which the status is to be retrieved. + * @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None. */ - suspend operator fun invoke(currencyId: CryptoCurrency.ID): Option = none() + suspend operator fun invoke( + userWalletId: UserWalletId, + currency: CryptoCurrency, + ): Option { + return invoke(userWalletId = userWalletId, currencyId = currency.id, network = currency.network) + } + + /** + * Invokes the use case to get the status of a specific cryptocurrency by its ID for a given user wallet and network. + * If the [network] is null, it searches across all accounts for the cryptocurrency. + * + * @param userWalletId the ID of the user wallet. + * @param currencyId the ID of the cryptocurrency. + * @param network the network associated with the cryptocurrency, can be null. + * @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None. + */ + suspend operator fun invoke( + userWalletId: UserWalletId, + currencyId: CryptoCurrency.ID, + network: Network?, + ): Option { + val accountStatusList = singleAccountStatusListSupplier.getSyncOrNull( + params = SingleAccountStatusListProducer.Params(userWalletId), + ) ?: return none() + + return accountStatusList.getExpectedAccountStatuses(network) + .asSequence() + .filterIsInstance() + .mapNotNull { accountStatus -> + val status = accountStatus.flattenCurrencies().firstOrNull { it.currency.id == currencyId } + ?: return@mapNotNull null + + AccountCryptoCurrencyStatus(account = accountStatus.account, status = status) + } + .firstOrNull() + .toOption() + } + + /** + * Retrieves the expected account statuses based on the provided [network]. + * If the [network] is null, all account statuses are returned. + * If the network has a specific derivation index, it filters the accounts accordingly. + * + * @param network the network to filter accounts by, can be null. + * @return a set of [AccountStatus] that match the expected criteria. + */ + private fun AccountStatusList.getExpectedAccountStatuses(network: Network?): Set { + val possibleAccountIndex = network?.getAccountIndexOrNull() + + return when (possibleAccountIndex) { + // currency can be in any account + null -> accountStatuses + // currency only in the main account + DerivationIndex.Main.value -> setOf(mainAccount) + // currency only in the account with specific derivation index or in the main account + else -> { + val accountStatus = accountStatuses.firstOrNull { + val cryptoPortfolio = it.account as? Account.CryptoPortfolio ?: return@firstOrNull false + + cryptoPortfolio.derivationIndex.value == possibleAccountIndex + } + + setOfNotNull(accountStatus, mainAccount) + } + } + } + + private fun Network.getAccountIndexOrNull(): Int? { + val blockchain = Blockchain.fromNetworkId(networkId = rawId) ?: return null + val recognizer = AccountNodeRecognizer(blockchain) + + return recognizer.recognize(derivationPath)?.toInt() + } } \ No newline at end of file diff --git a/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt similarity index 100% rename from domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt rename to domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt diff --git a/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt similarity index 100% rename from domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt rename to domain/account/status/src/test/kotlin/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt new file mode 100644 index 0000000000..b8614cc704 --- /dev/null +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt @@ -0,0 +1,157 @@ +package com.tangem.domain.account.status.usecase + +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.utils.assertNone +import com.tangem.common.test.utils.assertSome +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetAccountCurrencyStatusUseCaseTest { + + private val supplier = mockk() + private val useCase = GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = supplier) + + private val userWalletId = UserWalletId("011") + private val supplierParams = SingleAccountStatusListProducer.Params(userWalletId) + private val currency = MockCryptoCurrencyFactory().ethereum.let { + val derivationPath = Network.DerivationPath.Card("m/44'/60'/0'/0/1") + + it.copy( + network = it.network.copy( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + derivationPath = derivationPath, + ), + ) + } + + @BeforeEach + fun setUp() { + clearMocks(supplier) + } + + @Test + fun `invoke returns None when supplier returns null`() = runTest { + // Arrange + coEvery { supplier.getSyncOrNull(supplierParams) } returns null + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + + // Assert + assertNone(actual) + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + + @Test + fun `invoke returns None when AccountList does not contain required currency id`() = runTest { + // Arrange + val accountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } + + coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + + // Assert + assertNone(actual) + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + + @Test + fun `invoke returns Some if network is not null`() = runTest { + // Arrange + val mainAccountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) + + val account = mockk(relaxed = true) { + every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!! + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk()) + } + + coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = currency.network) + + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + assertSome(actual, expected) + + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + + @Test + fun `invoke returns Some if network is null`() = runTest { + // Arrange + val account = mockk(relaxed = true) { + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } + + coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + assertSome(actual, expected) + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt similarity index 100% rename from domain/account/status/src/test/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt rename to domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt index 6008e6ea58..1312cb0581 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt @@ -3,6 +3,7 @@ package com.tangem.lib.crypto.derivation import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.isUTXO import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.models.network.Network /** * Utility class to recognize the account node in a derivation path based on the blockchain type. @@ -21,12 +22,21 @@ class AccountNodeRecognizer(blockchain: Blockchain) { NON_UTXO_BLOCKCHAIN_NODE_INDEX } + /** Recognizes the account node value from the given [derivationPath] */ + fun recognize(derivationPath: Network.DerivationPath): Long? { + val derivationPathValue = derivationPath.value ?: return null + + return recognize(derivationPathValue = derivationPathValue) + } + /** Recognizes the account node value from the given derivation path string [derivationPathValue] */ fun recognize(derivationPathValue: String): Long? { + if (derivationPathValue.isBlank()) return null + return runCatching { - recognize(derivationPath = DerivationPath(rawPath = derivationPathValue)) - } - .getOrNull() + val cardSdkDerivationPath = DerivationPath(rawPath = derivationPathValue) + recognize(derivationPath = cardSdkDerivationPath) + }.getOrNull() } /** Recognizes the account node value from the given [derivationPath] */ From f9ae0d5188752e6b7ba13cdfa069e2a386630393 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Oct 2025 20:03:08 +0500 Subject: [PATCH 18/46] Updated on 2026-08-14 --- .../ui/amountScreen/AmountScreenContent.kt | 40 ++--- .../converters/AmountCurrencyTransformer.kt | 1 - .../converters/AmountReduceByTransformer.kt | 7 +- .../converters/AmountReduceToTransformer.kt | 7 +- .../converters/AmountStateConverter.kt | 110 +----------- .../field/AmountBoundaryUpdateTransformer.kt | 12 +- .../converters/field/AmountFieldConverter.kt | 67 +------- .../AmountFieldSetMaxAmountTransformer.kt | 2 +- .../models/AmountSegmentedButtonsConfig.kt | 21 --- .../ui/amountScreen/models/AmountState.kt | 19 +-- .../preview/AmountStatePreviewData.kt | 27 +-- .../common/ui/amountScreen/ui/AmountBlock.kt | 14 +- .../ui/amountScreen/ui/AmountButtons.kt | 126 -------------- .../common/ui/amountScreen/ui/AmountField.kt | 160 ------------------ .../amountScreen/ui/AmountFieldContainer.kt | 135 +++++---------- .../NavigationButtonsBlock.kt | 1 - .../NavigationButtonsState.kt | 1 - .../preview/NavigationButtonsPreview.kt | 12 -- .../features/send/v2/send/model/SendModel.kt | 2 +- .../amount/SendAmountComponent.kt | 2 - .../amount/model/SendAmountModel.kt | 2 +- .../amount/ui/SendAmountContent.kt | 3 - ...firmationNotificationsTransformerV2Test.kt | 5 - .../analytics/utils/StakingAnalyticSender.kt | 4 +- .../state/StakingStateController.kt | 2 +- .../presentation/state/StakingStateRouter.kt | 2 + .../impl/presentation/state/StakingUiState.kt | 1 + .../transformers/SetAmountDataTransformer.kt | 5 +- .../SetButtonsStateTransformer.kt | 50 ++---- ...etConfirmationStateCompletedTransformer.kt | 2 + .../SetInitialDataStateTransformer.kt | 5 +- .../state/transformers/SetTitleTransformer.kt | 3 +- .../ui/StakingConfirmationContent.kt | 6 +- .../impl/presentation/ui/StakingScreen.kt | 17 +- .../presentation/ui/StakingSuccessContent.kt | 86 ++++++++++ .../v2/impl/amount/entity/SwapAmountUM.kt | 10 +- .../converter/SwapAmountFieldConverter.kt | 4 +- .../SwapAmountBalanceHiddenTransformer.kt | 2 - .../impl/amount/ui/SwapAmountBlockContent.kt | 1 - .../ui/preview/SwapAmountContentPreview.kt | 5 +- 40 files changed, 225 insertions(+), 756 deletions(-) delete mode 100644 common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountSegmentedButtonsConfig.kt delete mode 100644 common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt delete mode 100644 common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt create mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt index fdd122631d..f0fa604881 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt @@ -13,21 +13,17 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountScreenClickIntentsStub import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData -import com.tangem.common.ui.amountScreen.ui.amountField import com.tangem.common.ui.amountScreen.ui.amountFieldV2 -import com.tangem.common.ui.amountScreen.ui.buttons import com.tangem.core.ui.res.TangemThemePreview /** * Amount screen with field * @param amountState amount state - * @param isBalanceHidden flag hidden balances * @param clickIntents amount screen clicks */ @Composable fun AmountScreenContent( amountState: AmountState, - isBalanceHidden: Boolean, clickIntents: AmountScreenClickIntents, modifier: Modifier = Modifier, extraContent: (@Composable () -> Unit)? = null, @@ -38,32 +34,17 @@ fun AmountScreenContent( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - if (amountState.isRedesignEnabled) { - amountFieldV2( - amountState = amountState, - onValueChange = clickIntents::onAmountValueChange, - onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, - onCurrencyChange = clickIntents::onCurrencyChangeClick, - onMaxAmountClick = clickIntents::onMaxValueClick, - ) - if (extraContent != null) { - item("EXTRA_CONTENT_KEY") { - extraContent() - } + amountFieldV2( + amountState = amountState, + onValueChange = clickIntents::onAmountValueChange, + onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, + onCurrencyChange = clickIntents::onCurrencyChangeClick, + onMaxAmountClick = clickIntents::onMaxValueClick, + ) + if (extraContent != null) { + item("EXTRA_CONTENT_KEY") { + extraContent() } - } else if (amountState is AmountState.Data) { - amountField( - amountState = amountState, - isBalanceHidden = isBalanceHidden, - onValueChange = clickIntents::onAmountValueChange, - onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, - ) - buttons( - segmentedButtonConfig = amountState.segmentedButtonConfig, - clickIntents = clickIntents, - isSegmentedButtonsEnabled = amountState.isSegmentedButtonsEnabled, - selectedButton = amountState.selectedButton, - ) } } } @@ -78,7 +59,6 @@ private fun SendAmountContentPreview( TangemThemePreview { AmountScreenContent( amountState = amountState, - isBalanceHidden = false, clickIntents = AmountScreenClickIntentsStub, ) } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountCurrencyTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountCurrencyTransformer.kt index 7406013c29..0faf2844b8 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountCurrencyTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountCurrencyTransformer.kt @@ -38,7 +38,6 @@ class AmountCurrencyTransformer( keyboardType = KeyboardType.Number, ), ), - selectedButton = prevState.segmentedButtonConfig.indexOfFirst { it.isFiat == value }, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt index f96fa69bd0..783682376d 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt @@ -70,10 +70,9 @@ class AmountReduceByTransformer( error = when { isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance) isLessThanMinimumIfProvided -> { - val minimumAmount = minimumTransactionAmount - ?.amount - ?.format { crypto(cryptoCurrencyStatus.currency) } - .orEmpty() + val minimumAmount = minimumTransactionAmount.amount.format { + crypto(cryptoCurrencyStatus.currency) + } resourceReference( R.string.transfer_notification_invalid_minimum_transaction_amount_text, wrappedList(minimumAmount, minimumAmount), diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt index 0afd5d8bc5..864db79015 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt @@ -64,10 +64,9 @@ class AmountReduceToTransformer( error = when { isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance) isLessThanMinimumIfProvided -> { - val minimumAmount = minimumTransactionAmount - ?.amount - ?.format { crypto(cryptoCurrencyStatus.currency) } - .orEmpty() + val minimumAmount = minimumTransactionAmount.amount.format { + crypto(cryptoCurrencyStatus.currency) + } resourceReference( R.string.transfer_notification_invalid_minimum_transaction_amount_text, wrappedList(minimumAmount, minimumAmount), diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index 36f37a67ab..cdcd0771cc 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -1,104 +1,35 @@ package com.tangem.common.ui.amountScreen.converters -import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter -import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverterV2 import com.tangem.common.ui.amountScreen.models.AmountParameters -import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.converter.Converter -import com.tangem.utils.isNullOrZero -import kotlinx.collections.immutable.persistentListOf - -/** - * Converts initial [String] to [AmountState] - * - * @property clickIntents amount screen clicks - * @property appCurrencyProvider selected app currency provider - * @property maxEnterAmount max enter amount data - * @property cryptoCurrencyStatusProvider current cryptocurrency status provider - * @property iconStateConverter currency icon converter - */ -@Deprecated("Use AmountStateConverterV2") -class AmountStateConverter( - private val clickIntents: AmountScreenClickIntents, - private val appCurrencyProvider: Provider, - private val cryptoCurrencyStatusProvider: Provider, - private val maxEnterAmount: EnterAmountBoundary, - private val iconStateConverter: CryptoCurrencyToIconStateConverter, -) : Converter { - - private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) { - AmountFieldConverter( - clickIntents = clickIntents, - cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider, - appCurrencyProvider = appCurrencyProvider, - ) - } - - override fun convert(value: AmountParameters): AmountState { - val appCurrency = appCurrencyProvider() - val status = cryptoCurrencyStatusProvider() - val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) } - val crypto = maxEnterAmount.amount.format { crypto(status.currency) } - val hasNoFeeRate = status.value.fiatRate.isNullOrZero() - - return AmountState.Data( - title = value.title, - availableBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)), - availableBalanceCrypto = stringReference(crypto), - availableBalanceFiat = stringReference(fiat), - tokenName = stringReference(status.currency.name), - tokenIconState = iconStateConverter.convert(status), - amountTextField = amountFieldConverter.convert(value.value), - isPrimaryButtonEnabled = false, - appCurrency = appCurrency, - segmentedButtonConfig = persistentListOf( - AmountSegmentedButtonsConfig( - title = stringReference(status.currency.symbol), - iconState = iconStateConverter.convertCustom( - value = status, - forceGrayscale = hasNoFeeRate, - showCustomTokenBadge = false, - ), - isFiat = false, - ), - AmountSegmentedButtonsConfig( - title = stringReference(appCurrency.code), - iconUrl = appCurrency.iconSmallUrl, - isFiat = true, - ), - ), - isSegmentedButtonsEnabled = !hasNoFeeRate, - selectedButton = 0, - isRedesignEnabled = false, - ) - } -} /** * Converts initial [String] to [AmountState] * * @property clickIntents amount screen clicks * @property appCurrency selected app currency - * @property maxEnterAmount max enter amount data * @property cryptoCurrencyStatus current cryptocurrency status + * @property maxEnterAmount max enter amount data * @property iconStateConverter currency icon converter * @property isBalanceHidden is balance hidden status */ @Suppress("LongParameterList") -class AmountStateConverterV2( +class AmountStateConverter( private val clickIntents: AmountScreenClickIntents, private val appCurrency: AppCurrency, private val cryptoCurrencyStatus: CryptoCurrencyStatus, @@ -108,7 +39,7 @@ class AmountStateConverterV2( ) : Converter { private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) { - AmountFieldConverterV2( + AmountFieldConverter( clickIntents = clickIntents, cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrency = appCurrency, @@ -118,19 +49,13 @@ class AmountStateConverterV2( override fun convert(value: AmountParameters): AmountState { val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) } val crypto = maxEnterAmount.amount.format { crypto(cryptoCurrencyStatus.currency) } - val noFeeRate = cryptoCurrencyStatus.value.fiatRate.isNullOrZero() if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading) { - return AmountState.Empty(isRedesignEnabled = true) + return AmountState.Empty } return AmountState.Data( title = value.title, - availableBalance = combinedReference( - stringReference(crypto), - stringReference(" $DOT "), - stringReference(fiat), - ).orMaskWithStars(isBalanceHidden), availableBalanceCrypto = stringReference(crypto).orMaskWithStars(isBalanceHidden), availableBalanceFiat = if (isBalanceHidden) { TextReference.EMPTY @@ -145,25 +70,6 @@ class AmountStateConverterV2( amountTextField = amountFieldConverter.convert(value.value), isPrimaryButtonEnabled = false, appCurrency = appCurrency, - segmentedButtonConfig = persistentListOf( - AmountSegmentedButtonsConfig( - title = stringReference(cryptoCurrencyStatus.currency.symbol), - iconState = iconStateConverter.convertCustom( - value = cryptoCurrencyStatus, - forceGrayscale = noFeeRate, - showCustomTokenBadge = false, - ), - isFiat = false, - ), - AmountSegmentedButtonsConfig( - title = stringReference(appCurrency.code), - iconUrl = appCurrency.iconSmallUrl, - isFiat = true, - ), - ), - isSegmentedButtonsEnabled = !noFeeRate, - selectedButton = 0, - isRedesignEnabled = true, ) } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountBoundaryUpdateTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountBoundaryUpdateTransformer.kt index 53bdef9c57..21a6f4e7f5 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountBoundaryUpdateTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountBoundaryUpdateTransformer.kt @@ -2,7 +2,10 @@ package com.tangem.common.ui.amountScreen.converters.field import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -32,14 +35,7 @@ class AmountBoundaryUpdateTransformer( val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) } val crypto = maxEnterAmount.amount.format { crypto(cryptoCurrencyStatus.currency) } - val availableBalance = combinedReference( - stringReference(crypto), - stringReference(" $DOT "), - stringReference(fiat), - ) - return prevState.copy( - availableBalance = availableBalance.orMaskWithStars(isBalanceHidden), availableBalanceCrypto = stringReference(crypto).orMaskWithStars(isBalanceHidden), availableBalanceFiat = if (isBalanceHidden) { TextReference.EMPTY diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt index f0baf3a1e9..411de67feb 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt @@ -13,75 +13,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType import com.tangem.domain.tokens.model.convertToAmount -import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import com.tangem.utils.isNullOrZero import java.math.BigDecimal -/** - * Converts initial [String] to [AmountFieldModel] - * - * @property clickIntents amount screen clicks - * @property appCurrencyProvider selected app currency provider - * @property cryptoCurrencyStatusProvider current cryptocurrency status provider - */ -@Deprecated("Use AmountFieldConverterV2") -class AmountFieldConverter( - private val clickIntents: AmountScreenClickIntents, - private val cryptoCurrencyStatusProvider: Provider, - private val appCurrencyProvider: Provider, -) : Converter { - - override fun convert(value: String): AmountFieldModel { - val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() - val cryptoDecimal = value.toBigDecimalOrNull() ?: BigDecimal.ZERO - val cryptoAmount = cryptoDecimal.convertToAmount(cryptoCurrencyStatus.currency) - val fiatRate = cryptoCurrencyStatus.value.fiatRate - val (fiatValue, fiatDecimal) = when { - fiatRate.isNullOrZero() -> "" to null - value.isEmpty() -> "" to BigDecimal.ZERO - else -> { - val fiatDecimal = fiatRate?.multiply(cryptoDecimal) - val fiatValue = fiatDecimal?.parseBigDecimal(FIAT_DECIMALS).orEmpty() - fiatValue to fiatDecimal - } - } - val isDoneActionEnabled = !cryptoDecimal.isNullOrZero() - return AmountFieldModel( - value = value, - fiatValue = fiatValue, - onValueChange = clickIntents::onAmountValueChange, - keyboardOptions = KeyboardOptions( - imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None, - keyboardType = KeyboardType.Number, - ), - keyboardActions = KeyboardActions( - onDone = { clickIntents.onAmountNext() }, - ), - isFiatValue = false, - cryptoAmount = cryptoAmount, - fiatAmount = getAppCurrencyAmount(fiatDecimal, appCurrencyProvider()), - isError = false, - isWarning = false, - error = TextReference.EMPTY, - isFiatUnavailable = fiatRate == null, - isValuePasted = false, - onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, - ) - } - - private fun getAppCurrencyAmount(fiatValue: BigDecimal?, appCurrency: AppCurrency) = Amount( - currencySymbol = appCurrency.symbol, - value = fiatValue, - decimals = FIAT_DECIMALS, - type = AmountType.FiatType(appCurrency.code), - ) - - private companion object { - private const val FIAT_DECIMALS = 2 - } -} - /** * Converts initial [String] to [AmountFieldModel] * @@ -89,7 +24,7 @@ class AmountFieldConverter( * @property appCurrency selected app currency * @property cryptoCurrencyStatus current cryptocurrency status */ -class AmountFieldConverterV2( +class AmountFieldConverter( private val clickIntents: AmountScreenClickIntents, private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val appCurrency: AppCurrency, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt index a8c20c0745..db245b6b30 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt @@ -54,7 +54,7 @@ class AmountFieldSetMaxAmountTransformer( isError = isLessThanMinimumIfProvided, error = when { isLessThanMinimumIfProvided -> { - val minimumAmount = minAmount?.amount.format { crypto(cryptoCurrencyStatus.currency) } + val minimumAmount = minAmount.amount.format { crypto(cryptoCurrencyStatus.currency) } resourceReference( R.string.transfer_notification_invalid_minimum_transaction_amount_text, wrappedList(minimumAmount, minimumAmount), diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountSegmentedButtonsConfig.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountSegmentedButtonsConfig.kt deleted file mode 100644 index 23469c4c03..0000000000 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountSegmentedButtonsConfig.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.common.ui.amountScreen.models - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.extensions.TextReference - -/** - * Segmented buttons config - * - * @param title button title - * @param iconState currency icon state - * @param iconUrl currency icon url - * @param isFiat is fiat currency - */ -@Immutable -data class AmountSegmentedButtonsConfig( - val title: TextReference, - val iconState: CurrencyIconState? = null, - val iconUrl: String? = null, - val isFiat: Boolean, -) \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt index a063b984f5..d207f14e5a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Stable import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.appcurrency.model.AppCurrency -import kotlinx.collections.immutable.PersistentList import java.math.BigDecimal /** Model for amount state */ @@ -12,18 +11,13 @@ import java.math.BigDecimal sealed class AmountState { abstract val isPrimaryButtonEnabled: Boolean - abstract val isRedesignEnabled: Boolean /** * @param isPrimaryButtonEnabled indicates if next state button enabled * @param title title - * @param availableBalance user crypto currency balance with fiat balance * @param availableBalanceCrypto user crypto currency balance in crypto * @param availableBalanceFiat user crypto currency balance in fiat * @param tokenIconState crypto currency icon state - * @param segmentedButtonConfig currency switcher config - * @param selectedButton selected currency index - * @param isSegmentedButtonsEnabled indicates if currency switches is enabled * @param amountTextField amount field state * @param appCurrency app currency * @param isEditingDisabled indicated whether amount is editable @@ -32,17 +26,11 @@ sealed class AmountState { */ data class Data( override val isPrimaryButtonEnabled: Boolean, - override val isRedesignEnabled: Boolean, val title: TextReference, - @Deprecated("Remove with SEND_REDESIGNED toggle") - val availableBalance: TextReference, val availableBalanceCrypto: TextReference, val availableBalanceFiat: TextReference, val tokenName: TextReference, val tokenIconState: CurrencyIconState, - val segmentedButtonConfig: PersistentList, - val selectedButton: Int, - val isSegmentedButtonsEnabled: Boolean, val amountTextField: AmountFieldModel, val appCurrency: AppCurrency, val isEditingDisabled: Boolean = false, @@ -50,8 +38,7 @@ sealed class AmountState { val isIgnoreReduce: Boolean = false, ) : AmountState() - data class Empty( - override val isPrimaryButtonEnabled: Boolean = false, - override val isRedesignEnabled: Boolean, - ) : AmountState() + data object Empty : AmountState() { + override val isPrimaryButtonEnabled: Boolean = false + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt index 98f7c1e21d..0624a2c89d 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/preview/AmountStatePreviewData.kt @@ -3,7 +3,6 @@ package com.tangem.common.ui.amountScreen.preview import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import com.tangem.common.ui.amountScreen.models.AmountFieldModel -import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference @@ -12,31 +11,18 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType import com.tangem.utils.StringsSigns -import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal object AmountStatePreviewData { - val emptyState = AmountState.Empty(isRedesignEnabled = true) + val emptyState = AmountState.Empty val amountState = AmountState.Data( isPrimaryButtonEnabled = false, title = stringReference("Family Wallet"), - availableBalance = stringReference("2 130,81231238 USDT • 2 129,12 \$)"), availableBalanceCrypto = stringReference("2 130,81231238 USDT"), availableBalanceFiat = stringReference("1 232 129,12 \$"), tokenIconState = CurrencyIconState.Loading, - segmentedButtonConfig = persistentListOf( - AmountSegmentedButtonsConfig( - title = stringReference("USDT"), - iconState = CurrencyIconState.Locked, - isFiat = false, - ), - AmountSegmentedButtonsConfig( - title = stringReference("USD"), - isFiat = true, - ), - ), appCurrency = AppCurrency.Default, tokenName = stringReference("Tether"), amountTextField = AmountFieldModel( @@ -65,12 +51,9 @@ object AmountStatePreviewData { isValuePasted = false, onValuePastedTriggerDismiss = {}, ), - isSegmentedButtonsEnabled = true, - selectedButton = 0, - isRedesignEnabled = false, ) - val amountWithValueState = amountState.copy( + private val amountWithValueState = amountState.copy( amountTextField = amountState.amountTextField.copy( value = "100.00", cryptoAmount = amountState.amountTextField.cryptoAmount.copy( @@ -84,16 +67,10 @@ object AmountStatePreviewData { ) val amountStateV2 = amountState.copy( - isRedesignEnabled = true, - availableBalance = stringReference("2 130,81231238 USDT • 2 129,12 \$)"), availableBalanceCrypto = stringReference("2 130,81231238 USDT"), availableBalanceFiat = stringReference(" ${StringsSigns.DOT} 1 232 129,12 $"), ) - val amountWithValueFiatState = amountWithValueState.copy( - amountTextField = amountWithValueState.amountTextField.copy(isFiatValue = false), - ) - val amountStateV2WithoutRates = amountState.copy( amountTextField = amountState.amountTextField.copy( fiatAmount = amountState.amountTextField.fiatAmount.copy( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt index 8e21c12dd9..e659c88013 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt @@ -16,10 +16,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData import com.tangem.core.ui.components.ResizableText +import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.format.bigdecimal.anyDecimals import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat @@ -56,7 +59,16 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick) .padding(TangemTheme.dimens.spacing16), ) { - CurrencyIcon(state = amountState.tokenIconState) + Text( + text = amountState.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerH(20.dp) + CurrencyIcon( + state = amountState.tokenIconState, + iconSize = 40.dp, + ) ResizableText( text = firstAmount, style = TangemTheme.typography.h2, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt deleted file mode 100644 index 64d1c8f25c..0000000000 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountButtons.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.tangem.common.ui.amountScreen.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.testTag -import com.tangem.common.ui.R -import com.tangem.common.ui.amountScreen.AmountScreenClickIntents -import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig -import com.tangem.core.ui.components.SpacerWMax -import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons -import com.tangem.core.ui.components.currency.fiaticon.FiatIcon -import com.tangem.core.ui.components.currency.icon.CurrencyIcon -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.SendScreenTestTags -import kotlinx.collections.immutable.PersistentList - -private const val AMOUNT_BUTTONS_KEY = "amountButtonsKey" - -internal fun LazyListScope.buttons( - segmentedButtonConfig: PersistentList, - clickIntents: AmountScreenClickIntents, - isSegmentedButtonsEnabled: Boolean, - selectedButton: Int, -) { - item( - key = AMOUNT_BUTTONS_KEY, - ) { - val hapticFeedback = LocalHapticFeedback.current - Row { - if (segmentedButtonConfig.isNotEmpty()) { - SegmentedButtons( - modifier = Modifier - .weight(1f) - .height(TangemTheme.dimens.size40), - config = segmentedButtonConfig, - showIndication = false, - onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - clickIntents.onCurrencyChangeClick(it.isFiat) - }, - initialSelectedItem = segmentedButtonConfig.getOrNull(selectedButton), - isEnabled = isSegmentedButtonsEnabled, - ) { - AmountCurrencyButton( - button = it, - isSegmentedButtonsEnabled = isSegmentedButtonsEnabled, - ) - } - } else { - SpacerWMax() - } - Text( - text = stringResourceSafe(R.string.send_max_amount), - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .padding(start = TangemTheme.dimens.spacing8) - .height(TangemTheme.dimens.size40) - .clip(shape = RoundedCornerShape(TangemTheme.dimens.radius26)) - .background(TangemTheme.colors.button.secondary) - .clickable { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - clickIntents.onMaxValueClick() - } - .padding( - vertical = TangemTheme.dimens.spacing10, - horizontal = TangemTheme.dimens.spacing34, - ) - .testTag(SendScreenTestTags.MAX_BUTTON), - ) - } - } -} - -@Composable -private fun AmountCurrencyButton(button: AmountSegmentedButtonsConfig, isSegmentedButtonsEnabled: Boolean) { - Row( - modifier = Modifier - .fillMaxSize() - .padding( - horizontal = TangemTheme.dimens.spacing10, - ) - .testTag(SendScreenTestTags.CURRENCY_BUTTON), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - val iconModifier = Modifier - .size(TangemTheme.dimens.size18) - .padding(horizontal = TangemTheme.dimens.spacing1) - if (button.isFiat) { - FiatIcon( - url = button.iconUrl, - size = TangemTheme.dimens.size18, - isGrayscale = !isSegmentedButtonsEnabled, - modifier = iconModifier.testTag(SendScreenTestTags.FIAT_ICON), - ) - } else if (button.iconState != null) { - CurrencyIcon( - state = button.iconState, - shouldDisplayNetwork = false, - modifier = iconModifier.testTag(SendScreenTestTags.CURRENCY_ICON), - ) - } - Text( - text = button.title.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.button, - modifier = Modifier - .padding( - start = TangemTheme.dimens.spacing8, - ), - ) - } -} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt deleted file mode 100644 index 848bd0ab36..0000000000 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.tangem.common.ui.amountScreen.ui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeightIn -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment.Companion.BottomCenter -import androidx.compose.ui.Alignment.Companion.TopCenter -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDirection -import com.tangem.common.ui.amountScreen.models.AmountFieldModel -import com.tangem.core.ui.components.fields.AmountTextField -import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.SendScreenTestTags -import com.tangem.core.ui.utils.rememberDecimalFormat -import kotlinx.coroutines.delay - -@Composable -internal fun AmountField( - amountField: AmountFieldModel, - appCurrencyCode: String, - onValueChange: (String) -> Unit, - onValuePastedTriggerDismiss: () -> Unit, -) { - val decimalFormat = rememberDecimalFormat() - val isFiatValue = amountField.isFiatValue - val currencyCode = if (isFiatValue) appCurrencyCode else null - val (primaryAmount, primaryValue) = if (isFiatValue) { - amountField.fiatAmount to amountField.fiatValue - } else { - amountField.cryptoAmount to amountField.value - } - val requester = remember { FocusRequester() } - val symbolColor = if (primaryValue.isBlank()) TangemTheme.colors.text.disabled else TangemTheme.colors.text.primary1 - AmountTextField( - value = primaryValue, - decimals = primaryAmount.decimals, - visualTransformation = AmountVisualTransformation( - decimals = primaryAmount.decimals, - symbol = primaryAmount.currencySymbol, - currencyCode = currencyCode, - decimalFormat = decimalFormat, - symbolColor = symbolColor, - ), - onValueChange = onValueChange, - keyboardOptions = amountField.keyboardOptions, - keyboardActions = amountField.keyboardActions, - textStyle = TangemTheme.typography.h2.copy( - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - ), - isAutoResize = true, - isValuePasted = amountField.isValuePasted, - onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, - modifier = Modifier - .focusRequester(requester) - .padding( - top = TangemTheme.dimens.spacing24, - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - ) - .requiredHeightIn(min = TangemTheme.dimens.size32), - ) - - LaunchedEffect(key1 = Unit) { - delay(timeMillis = 200) - requester.requestFocus() - } - - AmountSecondary(amountField, appCurrencyCode) -} - -@Composable -private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: String) { - val secondaryAmount = if (amountField.isFiatValue) amountField.cryptoAmount else amountField.fiatAmount - Box( - modifier = Modifier - .fillMaxWidth() - .animateContentSize() - .padding( - top = TangemTheme.dimens.spacing8, - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - ), - ) { - val text = if (amountField.isFiatValue) { - secondaryAmount.value.format { crypto(secondaryAmount.currencySymbol, secondaryAmount.decimals) } - } else { - secondaryAmount.value.format { - fiat( - fiatCurrencySymbol = secondaryAmount.currencySymbol, - fiatCurrencyCode = appCurrencyCode, - ) - } - } - Text( - text = text, - style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr), - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - modifier = Modifier - .align(TopCenter) - .padding(bottom = TangemTheme.dimens.spacing32) - .testTag(SendScreenTestTags.SECONDARY_AMOUNT), - ) - AmountFieldError( - isError = amountField.isError, - isWarning = amountField.isWarning, - error = amountField.error, - modifier = Modifier - .align(BottomCenter) - .padding( - top = TangemTheme.dimens.spacing20, - bottom = TangemTheme.dimens.spacing12, - ), - ) - } -} - -@Composable -private fun AmountFieldError( - isError: Boolean, - isWarning: Boolean, - error: TextReference, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - visible = isError || isWarning, - enter = fadeIn(), - exit = fadeOut(), - modifier = modifier, - ) { - val errorText = remember(this, error) { error } - val color = if (isError) TangemTheme.colors.text.warning else TangemTheme.colors.text.attention - Text( - text = errorText.resolveReference(), - style = TangemTheme.typography.caption2, - color = color, - textAlign = TextAlign.Center, - ) - } -} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt index 6fd563f173..d5973e72b8 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt @@ -15,8 +15,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState @@ -25,68 +23,12 @@ import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.test.SendScreenTestTags private const val AMOUNT_FIELD_KEY = "amountFieldKey" -internal fun LazyListScope.amountField( - amountState: AmountState.Data, - isBalanceHidden: Boolean, - modifier: Modifier = Modifier, - onValueChange: (String) -> Unit, - onValuePastedTriggerDismiss: () -> Unit, -) { - item(key = AMOUNT_FIELD_KEY) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) - .background(TangemTheme.colors.background.action), - ) { - Text( - text = amountState.title.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing14) - .testTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE), - ) - - val balance = amountState.availableBalance.orMaskWithStars(isBalanceHidden).resolveReference() - AnimatedContent( - targetState = balance, - label = "Hide Balance Animation", - ) { - Text( - text = it, - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing2) - .testTag(SendScreenTestTags.AMOUNT_CONTAINER_TEXT), - ) - } - CurrencyIcon( - state = amountState.tokenIconState, - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing32), - ) - AmountField( - amountField = amountState.amountTextField, - appCurrencyCode = amountState.appCurrency.code, - onValueChange = onValueChange, - onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, - ) - } - } -} - internal fun LazyListScope.amountFieldV2( amountState: AmountState, modifier: Modifier = Modifier, @@ -183,46 +125,49 @@ private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modi @Composable private fun AmountInfoMain(amountUM: AmountState, modifier: Modifier = Modifier) { AnimatedContent( - targetState = amountUM !is AmountState.Data, + targetState = amountUM, modifier = modifier, - ) { isContent -> - if (isContent) { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - TextShimmer( - style = TangemTheme.typography.subtitle2, - modifier = Modifier.width(56.dp), - ) - TextShimmer( - style = TangemTheme.typography.caption2, - modifier = Modifier.width(72.dp), - ) - } - } else { - val amountUM = amountUM as AmountState.Data - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = amountUM.tokenName.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.primary1, - maxLines = 1, - ) - Row { - EllipsisText( - text = amountUM.availableBalanceCrypto.resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ellipsis = TextEllipsis.OffsetEnd(amountUM.amountTextField.cryptoAmount.currencySymbol.length), - modifier = Modifier.weight(1f, fill = false), + ) { currentAmount -> + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + when (currentAmount) { + is AmountState.Data -> { + val amountUM = amountUM as AmountState.Data + Text( + text = amountUM.tokenName.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 1, ) - EllipsisText( - text = amountUM.availableBalanceFiat.resolveReference(), + Row { + EllipsisText( + text = amountUM.availableBalanceCrypto.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = TextEllipsis.OffsetEnd( + amountUM.amountTextField.cryptoAmount.currencySymbol.length, + ), + modifier = Modifier.weight(1f, fill = false), + ) + EllipsisText( + text = amountUM.availableBalanceFiat.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = TextEllipsis.OffsetEnd( + amountUM.amountTextField.fiatAmount.currencySymbol.length, + ), + ) + } + } + AmountState.Empty -> { + TextShimmer( + style = TangemTheme.typography.subtitle2, + modifier = Modifier.width(56.dp), + ) + TextShimmer( style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ellipsis = TextEllipsis.OffsetEnd(amountUM.amountTextField.fiatAmount.currencySymbol.length), + modifier = Modifier.width(72.dp), ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt index a52403de79..47233d7291 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt @@ -55,7 +55,6 @@ fun NavigationButtonsBlock( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - PreviousButton(state?.prevButton) NavigationPrimaryButton(state?.primaryButton, modifier = Modifier.weight(1f)) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt index 59bab529ed..6191b4de9c 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt @@ -8,7 +8,6 @@ sealed class NavigationButtonsState { data class Data( val primaryButton: NavigationButton?, - val prevButton: NavigationButton?, val extraButtons: Pair?, val txUrl: String? = null, val onTextClick: (String) -> Unit, diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt index 76ebce30a8..6c5a8d5991 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt @@ -3,7 +3,6 @@ package com.tangem.common.ui.navigationButtons.preview import com.tangem.common.ui.R import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationButtonsState -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference internal object NavigationButtonsPreview { @@ -26,16 +25,6 @@ internal object NavigationButtonsPreview { onClick = {}, ) - private val prev = NavigationButton( - textReference = TextReference.EMPTY, - iconRes = R.drawable.ic_back_24, - isSecondary = true, - isIconVisible = true, - shouldShowProgress = false, - isEnabled = true, - onClick = {}, - ) - private val finished = NavigationButton( textReference = resourceReference(R.string.common_close), isSecondary = false, @@ -47,7 +36,6 @@ internal object NavigationButtonsPreview { val allButtons = NavigationButtonsState.Data( primaryButton = finished, - prevButton = prev, extraButtons = extraButtons, txUrl = "https://tangem.com", onTextClick = {}, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 141773d3c7..4b8551fd88 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -454,7 +454,7 @@ internal class SendModel @Inject constructor( } private fun initialState(): SendUM = SendUM( - amountUM = AmountState.Empty(isRedesignEnabled = true), + amountUM = AmountState.Empty, destinationUM = SendDestinationInitialStateTransformer( cryptoCurrency = cryptoCurrency, ).transform(DestinationUM.Empty()), diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt index 0dcbee1083..acae2c05d8 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt @@ -26,12 +26,10 @@ internal class SendAmountComponent( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() - val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle() val isSendWithSwapAvailable by model.isSendWithSwapAvailable.collectAsStateWithLifecycle() SendAmountContent( amountState = state, - isBalanceHidden = isBalanceHidden, clickIntents = model, isSendWithSwapAvailable = isSendWithSwapAvailable, modifier = modifier, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index 4d3ac6848c..aa40a2693e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -172,7 +172,7 @@ internal class SendAmountModel @Inject constructor( if (uiState.value is AmountState.Empty && userWallet != null) { val isOnlyOneWallet = getWalletsUseCase.invokeSync().size == 1 _uiState.update { - AmountStateConverterV2( + AmountStateConverter( clickIntents = this, appCurrency = appCurrency, cryptoCurrencyStatus = cryptoCurrencyStatus, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt index b72232c3d4..ae0b8597ec 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/ui/SendAmountContent.kt @@ -30,7 +30,6 @@ import com.tangem.features.send.v2.subcomponents.amount.ui.preview.SendAmountCli @Composable fun SendAmountContent( amountState: AmountState, - isBalanceHidden: Boolean, clickIntents: SendAmountClickIntents, isSendWithSwapAvailable: Boolean, modifier: Modifier = Modifier, @@ -38,7 +37,6 @@ fun SendAmountContent( Column(modifier = modifier.background(TangemTheme.colors.background.tertiary)) { AmountScreenContent( amountState = amountState, - isBalanceHidden = isBalanceHidden, clickIntents = clickIntents, extraContent = { SendConvertTokenButton( @@ -95,7 +93,6 @@ private fun SendAmountContent_Preview(@PreviewParameter(SendAmountContentPreview TangemThemePreview { SendAmountContent( amountState = params, - isBalanceHidden = true, clickIntents = SendAmountClickIntentsStub, isSendWithSwapAvailable = true, ) diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index 15b4768aaa..ea7e72a8c2 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -201,16 +201,11 @@ class SendConfirmationNotificationsTransformerV2Test { return AmountState.Data( isPrimaryButtonEnabled = true, - isRedesignEnabled = false, title = mockk(relaxed = true), - availableBalance = mockk(relaxed = true), availableBalanceCrypto = mockk(relaxed = true), availableBalanceFiat = mockk(relaxed = true), tokenName = mockk(relaxed = true), tokenIconState = mockk(relaxed = true), - segmentedButtonConfig = persistentListOf(), - selectedButton = 0, - isSegmentedButtonsEnabled = false, amountTextField = AmountFieldModel( value = "1.5", onValueChange = {}, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt index 131d64fb11..cdd95ace06 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt @@ -52,7 +52,9 @@ internal class StakingAnalyticSender( source = when (value.currentStep) { StakingStep.InitialInfo -> StakeScreenSource.Info StakingStep.Amount -> StakeScreenSource.Amount - StakingStep.Confirmation -> StakeScreenSource.Confirmation + StakingStep.Success, + StakingStep.Confirmation, + -> StakeScreenSource.Confirmation StakingStep.Validators, StakingStep.RestakeValidator, StakingStep.RewardsValidators, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index e1e93296cf..92676e87dd 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -87,7 +87,7 @@ internal class StakingStateController @Inject constructor( cryptoCurrencyBlockchainId = "", currentStep = StakingStep.InitialInfo, initialInfoState = StakingStates.InitialInfoState.Empty(), - amountState = AmountState.Empty(isRedesignEnabled = false), + amountState = AmountState.Empty, validatorState = StakingStates.ValidatorState.Empty(), rewardsValidatorsState = StakingStates.RewardsValidatorsState.Empty(), confirmationState = StakingStates.ConfirmationState.Empty(), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt index 5ba32182c8..a4d0229f85 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt @@ -42,6 +42,7 @@ internal class StakingStateRouter( StakingStep.Amount, -> showConfirmation() StakingStep.Confirmation -> showInitial() + StakingStep.Success -> appRouter.pop() } } @@ -65,6 +66,7 @@ internal class StakingStateRouter( } } StakingStep.Validators -> showConfirmation() + StakingStep.Success -> appRouter.pop() } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index ae13e50f62..56f8f60341 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -138,4 +138,5 @@ enum class StakingStep { RestakeValidator, Confirmation, Validators, + Success, } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt index 936fea5c1b..9bb2b5eac3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt @@ -46,10 +46,11 @@ internal class SetAmountDataTransformer( return prevState.copy( amountState = AmountStateConverter( clickIntents = clickIntents, - cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider, - appCurrencyProvider = appCurrencyProvider, iconStateConverter = iconStateConverter, maxEnterAmount = maxEnterAmount, + appCurrency = appCurrencyProvider(), + cryptoCurrencyStatus = cryptoCurrencyStatusProvider(), + isBalanceHidden = false, ).convert( AmountParameters( title = title, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt index a5d35a5215..e51cdf8d61 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -25,7 +25,6 @@ internal class SetButtonsStateTransformer( val buttonsState = if (prevState.isButtonsVisible()) { NavigationButtonsState.Data( primaryButton = getPrimaryButton(prevState), - prevButton = getPrevButton(prevState), extraButtons = getExtraButtons(prevState).takeIf { txUrl != null }, txUrl = txUrl, onTextClick = urlOpener::openUrl, @@ -64,18 +63,6 @@ internal class SetButtonsStateTransformer( ) } - private fun getPrevButton(prevState: StakingUiState): NavigationButton? { - return NavigationButton( - textReference = TextReference.EMPTY, - iconRes = R.drawable.ic_back_24, - isSecondary = true, - isIconVisible = true, - shouldShowProgress = false, - isEnabled = true, - onClick = prevState.clickIntents::onPrevClick, - ).takeIf { prevState.currentStep.isPrevButtonVisible() } - } - private fun getExtraButtons(prevState: StakingUiState): Pair { return NavigationButton( textReference = resourceReference(R.string.common_explore), @@ -111,7 +98,7 @@ internal class SetButtonsStateTransformer( resourceReference(R.string.common_stake) } } - + StakingStep.Success -> resourceReference(R.string.common_close) StakingStep.Confirmation -> getConfirmationButtonText() StakingStep.Validators -> resourceReference(R.string.common_continue) StakingStep.Amount, @@ -125,21 +112,17 @@ internal class SetButtonsStateTransformer( val confirmationState = confirmationState as? StakingStates.ConfirmationState.Data val amountState = amountState as? AmountState.Data return if (confirmationState != null && amountState != null) { - if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { - resourceReference(R.string.common_close) - } else { - when (actionType) { - is StakingActionCommonType.Enter -> { - val amount = amountState.amountTextField.cryptoAmount.value.orZero() - if (confirmationState.isApprovalNeeded && confirmationState.allowance < amount) { - resourceReference(R.string.give_permission_title) - } else { - resourceReference(R.string.common_stake) - } + when (actionType) { + is StakingActionCommonType.Enter -> { + val amount = amountState.amountTextField.cryptoAmount.value.orZero() + if (confirmationState.isApprovalNeeded && confirmationState.allowance < amount) { + resourceReference(R.string.give_permission_title) + } else { + resourceReference(R.string.common_stake) } - is StakingActionCommonType.Exit -> resourceReference(R.string.common_unstake) - is StakingActionCommonType.Pending -> confirmationState.pendingAction?.type.getPendingActionTitle() } + is StakingActionCommonType.Exit -> resourceReference(R.string.common_unstake) + is StakingActionCommonType.Pending -> confirmationState.pendingAction?.type.getPendingActionTitle() } } else { resourceReference(R.string.common_close) @@ -155,6 +138,7 @@ internal class SetButtonsStateTransformer( StakingStep.Amount -> clickIntents.onAmountEnterClick() StakingStep.Confirmation -> onConfirmationClick() StakingStep.RewardsValidators -> Unit + StakingStep.Success -> clickIntents.onBackClick() } } @@ -178,17 +162,6 @@ internal class SetButtonsStateTransformer( } } - private fun StakingStep.isPrevButtonVisible(): Boolean = when (this) { - StakingStep.InitialInfo, - StakingStep.RewardsValidators, - StakingStep.RestakeValidator, - StakingStep.Confirmation, - StakingStep.Validators, - -> false - StakingStep.Amount, - -> true - } - private fun StakingUiState.isPrimaryButtonDisabled(): Boolean { val initialState = initialInfoState as? StakingStates.InitialInfoState.Data val hasNotStaking = initialState?.yieldBalance == InnerYieldBalanceState.Empty @@ -205,6 +178,7 @@ internal class SetButtonsStateTransformer( StakingStep.RewardsValidators -> rewardsValidatorsState.isPrimaryButtonEnabled StakingStep.RestakeValidator, StakingStep.Validators, + StakingStep.Success, -> true } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt index 7eb0589c34..6f745765ff 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt @@ -4,6 +4,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingStep import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.features.staking.impl.presentation.state.TransactionDoneState import com.tangem.utils.transformer.Transformer @@ -17,6 +18,7 @@ internal class SetConfirmationStateCompletedTransformer( override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( confirmationState = prevState.confirmationState.copyWrapped(), + currentStep = StakingStep.Success, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index d26cfd7e24..f36b37e184 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -229,10 +229,11 @@ internal class SetInitialDataStateTransformer( ) return AmountStateConverter( clickIntents = clickIntents, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - appCurrencyProvider = appCurrencyProvider, + cryptoCurrencyStatus = cryptoCurrencyStatus, + appCurrency = appCurrencyProvider(), iconStateConverter = iconStateConverter, maxEnterAmount = maxEnterAmount, + isBalanceHidden = false, ).convert( AmountParameters( title = stringReference(userWalletProvider().name), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt index d16bb669e4..12b26f1cfc 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt @@ -1,5 +1,6 @@ package com.tangem.features.staking.impl.presentation.state.transformers +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.isNullOrEmpty import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -28,7 +29,7 @@ internal object SetTitleTransformer : Transformer { R.string.staking_title_stake, wrappedList(prevState.cryptoCurrencyName), ) - + StakingStep.Success -> TextReference.EMPTY StakingStep.Confirmation -> { when (actionType) { is StakingActionCommonType.Enter -> resourceReference( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt index 42f7777f9f..7f3853dbbd 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt @@ -14,12 +14,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData -import com.tangem.common.ui.amountScreen.ui.AmountBlock +import com.tangem.common.ui.amountScreen.ui.AmountBlockV2 import com.tangem.core.ui.components.transactions.TransactionDoneTitle import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState import com.tangem.features.staking.impl.presentation.state.StakingNotification import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -30,7 +31,6 @@ import com.tangem.features.staking.impl.presentation.state.stub.StakingClickInte import com.tangem.features.staking.impl.presentation.ui.block.NotificationsBlock import com.tangem.features.staking.impl.presentation.ui.block.StakingFeeBlock import com.tangem.features.staking.impl.presentation.ui.block.ValidatorBlock -import com.tangem.features.staking.impl.presentation.model.StakingClickIntents @Suppress("LongParameterList") @Composable @@ -60,7 +60,7 @@ internal fun StakingConfirmationContent( subtitle = resourceReference(R.string.staking_transaction_in_progress_text), ) } - AmountBlock( + AmountBlockV2( amountState = amountState, isClickDisabled = !state.isAmountEditable || isTransactionSent || isTransactionInProgress, isEditingDisabled = !state.isAmountEditable && state.innerState != InnerConfirmationStakingState.COMPLETED, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt index 5170756b32..3e5d834e16 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt @@ -91,6 +91,7 @@ private fun StakingAppBar(uiState: StakingUiState) { val (backIcon, click) = when (uiState.currentStep) { StakingStep.Amount, StakingStep.Confirmation, + StakingStep.Success, -> R.drawable.ic_close_24 to uiState.clickIntents::onBackClick StakingStep.Validators, StakingStep.RewardsValidators, @@ -108,6 +109,7 @@ private fun StakingAppBar(uiState: StakingUiState) { ) } +@Suppress("LongMethod") @Composable private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = Modifier) { val currentScreen = uiState.currentStep @@ -136,10 +138,10 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M contentAlignment = Alignment.TopCenter, label = "Staking Screen Navigation", transitionSpec = { - val direction = if (initialState.ordinal < targetState.ordinal) { - AnimatedContentTransitionScope.SlideDirection.Start - } else { - AnimatedContentTransitionScope.SlideDirection.End + val direction = when { + targetState == StakingStep.Success -> AnimatedContentTransitionScope.SlideDirection.Up + initialState.ordinal < targetState.ordinal -> AnimatedContentTransitionScope.SlideDirection.Start + else -> AnimatedContentTransitionScope.SlideDirection.End } slideIntoContainer(towards = direction, animationSpec = tween()) @@ -163,7 +165,6 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M } StakingStep.Amount -> AmountScreenContent( amountState = uiState.amountState, - isBalanceHidden = uiState.isBalanceHidden, clickIntents = uiState.clickIntents, modifier = Modifier.background(TangemTheme.colors.background.secondary), ) @@ -173,6 +174,12 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M validatorState = uiState.validatorState, clickIntents = uiState.clickIntents, ) + StakingStep.Success -> StakingSuccessContent( + amountState = uiState.amountState, + state = uiState.confirmationState, + validatorState = uiState.validatorState, + clickIntents = uiState.clickIntents, + ) StakingStep.RestakeValidator, StakingStep.Validators, -> StakingValidatorListContent( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt new file mode 100644 index 0000000000..d9297f768d --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingSuccessContent.kt @@ -0,0 +1,86 @@ +package com.tangem.features.staking.impl.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData +import com.tangem.common.ui.amountScreen.ui.AmountBlock +import com.tangem.core.ui.components.transactions.TransactionDoneTitle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.model.StakingClickIntents +import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState +import com.tangem.features.staking.impl.presentation.state.StakingNotification +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData +import com.tangem.features.staking.impl.presentation.state.previewdata.ValidatorStatePreviewData +import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub +import com.tangem.features.staking.impl.presentation.ui.block.NotificationsBlock +import com.tangem.features.staking.impl.presentation.ui.block.StakingFeeBlock +import com.tangem.features.staking.impl.presentation.ui.block.ValidatorBlock + +@Suppress("LongParameterList") +@Composable +internal fun StakingSuccessContent( + amountState: AmountState, + state: StakingStates.ConfirmationState, + validatorState: StakingStates.ValidatorState, + clickIntents: StakingClickIntents, +) { + if (state !is StakingStates.ConfirmationState.Data) return + val isTransactionSent = state.innerState == InnerConfirmationStakingState.COMPLETED + val isTransactionInProgress = state.notifications.any { it is StakingNotification.Warning.TransactionInProgress } + Column( + modifier = Modifier + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + TransactionDoneTitle( + title = resourceReference(R.string.common_in_progress), + subtitle = resourceReference(R.string.staking_transaction_in_progress_text), + ) + AmountBlock( + amountState = amountState, + isClickDisabled = !state.isAmountEditable || isTransactionSent || isTransactionInProgress, + isEditingDisabled = !state.isAmountEditable && state.innerState != InnerConfirmationStakingState.COMPLETED, + onClick = clickIntents::onPrevClick, + ) + ValidatorBlock( + validatorState = validatorState, + isClickable = !isTransactionInProgress, + onClick = clickIntents::openValidators, + ) + StakingFeeBlock(feeState = state.feeState) + NotificationsBlock(notifications = state.notifications) + Spacer(Modifier) + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_StakingConfirmationContent() { + TangemThemePreview { + Column(Modifier.background(TangemTheme.colors.background.primary)) { + StakingConfirmationContent( + amountState = AmountStatePreviewData.amountState, + state = ConfirmationStatePreviewData.assentStakingState, + validatorState = ValidatorStatePreviewData.validatorState, + clickIntents = StakingClickIntentsStub, + ) + } + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt index b2d95fed03..62eb8a0d3b 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt @@ -64,19 +64,13 @@ sealed class SwapAmountFieldUM { data class Empty( override val amountType: SwapAmountType, ) : SwapAmountFieldUM() { - override val amountField: AmountState = AmountState.Empty( - isPrimaryButtonEnabled = false, - isRedesignEnabled = true, - ) + override val amountField: AmountState = AmountState.Empty } data class Loading( override val amountType: SwapAmountType, ) : SwapAmountFieldUM() { - override val amountField: AmountState = AmountState.Empty( - isPrimaryButtonEnabled = false, - isRedesignEnabled = true, - ) + override val amountField: AmountState = AmountState.Empty } data class Content( diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt index c8eb091285..a490422d2f 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt @@ -1,7 +1,7 @@ package com.tangem.features.swap.v2.impl.amount.model.converter import com.tangem.common.ui.amountScreen.AmountScreenClickIntents -import com.tangem.common.ui.amountScreen.converters.AmountStateConverterV2 +import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.core.ui.components.atoms.text.TextEllipsis @@ -44,7 +44,7 @@ internal class SwapAmountFieldConverter( subtitleEllipsisRight = TextEllipsis.OffsetEnd(appCurrency.symbol.length), priceImpact = null, isClickEnabled = selectedType.isViewingField(), - amountField = AmountStateConverterV2( + amountField = AmountStateConverter( clickIntents = clickIntents, appCurrency = appCurrency, cryptoCurrencyStatus = cryptoCurrencyStatus, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt index 52fc0217cd..b6b0ccec59 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountBalanceHiddenTransformer.kt @@ -46,9 +46,7 @@ internal class SwapAmountBalanceHiddenTransformer( val newData = recalculatedPrimary.amountField newData.copy( amountTextField = oldData.amountTextField, - selectedButton = oldData.selectedButton, isPrimaryButtonEnabled = oldData.isPrimaryButtonEnabled, - isSegmentedButtonsEnabled = oldData.isSegmentedButtonsEnabled, isEditingDisabled = oldData.isEditingDisabled, reduceAmountBy = oldData.reduceAmountBy, isIgnoreReduce = oldData.isIgnoreReduce, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt index bc921fd483..54177a3435 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt @@ -127,7 +127,6 @@ private fun ConstraintLayoutScope.SwapAmountBlock( AmountBlockV2( amountState = (amountUM.secondaryAmount.amountField as? AmountState.Data)?.copy( title = resourceReference(R.string.send_with_swap_recipient_amount_title), - availableBalance = TextReference.EMPTY, availableBalanceCrypto = TextReference.EMPTY, ) ?: amountUM.secondaryAmount.amountField, isClickDisabled = true, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt index 3e6da5d36b..352cd998fa 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt @@ -97,9 +97,7 @@ internal data object SwapAmountContentPreview { val defaultState = SwapAmountUM.Content( primaryAmount = SwapAmountFieldUM.Content( amountType = SwapAmountType.From, - amountField = AmountStatePreviewData.amountState.copy( - availableBalance = stringReference("Balance: 100 BTC"), - ), + amountField = AmountStatePreviewData.amountState, title = stringReference("Tether"), subtitleLeft = stringReference("11 101,123123456 BTC"), subtitleRight = stringReference(" ${StringsSigns.DOT} 1 212,12 $"), @@ -112,7 +110,6 @@ internal data object SwapAmountContentPreview { amountType = SwapAmountType.To, amountField = AmountStatePreviewData.amountState.copy( title = stringReference("Amount to receive"), - availableBalance = TextReference.EMPTY, ), title = stringReference("Shiba Inu"), priceImpact = stringReference("(-10%)"), From 269a6d1d57aceb5b9c4fb34f8425638eeaf6915c Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 12:50:16 +0500 Subject: [PATCH 19/46] Updated on 2026-08-14 --- .../tangem/common/ui/account/AccountLabel.kt | 53 +++++++++++++++++ core/res/src/main/res/values-de/strings.xml | 5 ++ core/res/src/main/res/values-es/strings.xml | 5 ++ core/res/src/main/res/values-fr/strings.xml | 7 ++- core/res/src/main/res/values-ja/strings.xml | 16 ++++++ core/res/src/main/res/values-ru/strings.xml | 1 + .../src/main/res/values-uk-rUA/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 8 +++ .../ui/components/tokenlist/TokenListItem.kt | 4 +- .../tokenlist/state/TokensListItemUM.kt | 7 ++- .../LoadingAccountTokenItemConverter.kt | 2 +- .../SetNoAvailablePairsTransformerV2.kt | 2 +- .../onramp/swap/entity/ExchangeCardUM.kt | 26 +++++++-- .../transformer/SelectFromTokenTransformer.kt | 6 ++ .../transformer/SelectToTokenTransformer.kt | 10 +++- .../swap/entity/utils/ExchangeCardUMExt.kt | 23 +++++++- .../swap/model/SwapSelectTokensModel.kt | 32 ++++++++++- .../features/onramp/swap/ui/ExchangeCard.kt | 57 ++++++++++++++----- .../UpdateAccountTokenItemConverter.kt | 2 +- .../common/preview/WalletScreenPreviewData.kt | 9 +-- .../converter/TokenListStateConverter.kt | 4 +- 21 files changed, 240 insertions(+), 40 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/account/AccountLabel.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountLabel.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountLabel.kt new file mode 100644 index 0000000000..51f13b848f --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountLabel.kt @@ -0,0 +1,53 @@ +package com.tangem.common.ui.account + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +/** + * Displays account name with icon + * + * @param name account name + * @param icon portfolio account icon model + * @param iconSize portfolio account icon size + * @param nameStyle account name style + * @param nameColor account name color + * @see AccountIcon + */ +@Composable +fun AccountLabel( + name: TextReference, + icon: CryptoPortfolioIconUM, + iconSize: AccountIconSize, + modifier: Modifier = Modifier, + nameStyle: TextStyle = TangemTheme.typography.subtitle2, + nameColor: Color = TangemTheme.colors.text.tertiary, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + AccountIcon( + name = name, + icon = icon, + size = iconSize, + ) + Text( + text = name.resolveReference(), + style = nameStyle, + color = nameColor, + maxLines = 1, + ) + } +} \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 36849c64d9..2817667d7e 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -320,6 +320,7 @@ Tangem Wallet Allgemeine Geschäftsbedingungen Nutzungsbedingungen + An Heute %d Token @@ -1304,8 +1305,12 @@ Verwende %s oder scanne eine Karte oder Ring, um Zugriff auf deine Wallet zu erhalten. Verbindung fehlgeschlagen: Diese dApp verwendet Wallet Connect Version 1.0, die nicht unterstützt wird. Bitte stelle sicher, dass die dApp Wallet Connect Version 2.0 unterstützt, um eine erfolgreiche Verbindung herzustellen. Bleib auf dem Laufenden mit den neuesten Funktionen und Neuigkeiten + Echtzeit-Warnungen für Transaktionen, Umtausch und wichtige Aktualisierungen. + Transaktionswarnungen Genehmigung für Transaktionen anfordern Sei der Erste, der von neuen Aktionen erfährt + Frühzeitiger Zugriff auf neue Funktionen und exklusive Angebote. + Updates zu Funktionen und Neuigkeiten Möchtest du Push-Benachrichtigungen verwenden? Neues Wallet hinzufügen Möchtest Du diese Wallet wirklich entfernen? diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 36b3355615..af4f17bfca 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -258,6 +258,7 @@ Intercambiar términos y condiciones Condiciones de uso + A Hoy %d token @@ -1204,8 +1205,12 @@ Use %s o escanee una tarjeta/anillo para tener acceso a su billetera Error de conexión: Esta dApp utiliza la versión 1.0 de Wallet Connect, que no es compatible. Asegúrese de que la dApp sea compatible con la versión 2.0 de Wallet Connect para conectarse correctamente. Manténgase actualizado con las últimas funciones y noticias + Alertas en tiempo real de transacciones, intercambios y actualizaciones críticas. + Alertas de transacciones Recibir notificaciones de las transacciones entrantes Sea el primero en enterarte de nuevas promociones + Acceso anticipado a nuevas funciones y ofertas exclusivas. + Actualizaciones de características y noticias ¿Quiere utilizar\nnotificaciones push? Agregar una nueva billetera ¿Estás seguro de que deseas olvidar esta billetera? diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 47bac750bc..503724daf0 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -235,6 +235,7 @@ Échanger termes et conditions Conditions d\'utilisation + À Aujourd\'hui %d jeton @@ -1106,7 +1107,7 @@ Échanger ce montant de jetons sélectionnés aura un impact significatif sur les prix et réduira votre résultat. Fonds insuffisants Donner l\'autorisation - Échangez + Échanger Vous recevez Choisir le jeton non disponible @@ -1177,8 +1178,12 @@ Utilisez %s ou scannez une carte/bague pour avoir accès à votre portefeuille Échec de la connexion : Cette dApp utilise Wallet Connect version1.0, qui n\'est pas prise en charge. Veuillez vous assurer que la dApp prend en charge Wallet Connect version2.0 pour réussir la connexion. Restez à jour avec les dernières fonctionnalités et actualités + Alertes en temps réel pour les transactions, les échanges et les mises à jour importantes. + Alertes de transaction Recevez des notifications des transactions entrantes Soyez le premier informé des nouvelles promotions + Accès anticipé à de nouvelles fonctionnalités et à des offres exclusives. + Actualités et mises à jour Souhaitez-vous utiliser les\nnotifications push? Ajouter un nouveau portefeuille Êtes-vous sûr de vouloir supprimer ce portefeuille ? diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 48e34783ae..d76ede5115 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -33,6 +33,7 @@ アカウントを追加 保存 アカウント名 + アカウント名はすでに存在しています アカウント 新しいアカウント アカウントを追加 @@ -323,6 +324,7 @@ Tangem 利用規約 利用規約 + 宛先 今日 %d トークン @@ -885,6 +887,7 @@ %s分 以下が手に入ります。 + サービスは外部プロバイダーによって提供されます。 \nTangemは責任を負いません。 この画面を閉じて、トークンの詳細画面で取引状況を確認できます。 最大 経由 @@ -1094,6 +1097,7 @@ ステーキングアカウントとは、ステーキングされたSOLが保管される特別なアカウントです。トークンをバリデーターに委任し、取引の検証に参加して報酬を受け取る際に作成されます。ステーキングアカウントの作成には少額の手数料がかかりますが、ステーキング完了後に返金されます。 年率 ステーキングに参加することで得られる年間収益率。 + APYは、バリデータのパフォーマンスに基づいた年間利回りを示します。 APR APY 報酬は毎日自動的にステーキング残高に蓄積されます。 @@ -1254,6 +1258,10 @@ 受け取る トークンを選択 利用不可 + データの読み込みに失敗しました。しばらくしてからもう一度お試しください。 + 非表示 + 表示 + カードの詳細 アカウントを作成してカードを発行 Tangem Pay ステータスを表示 @@ -1266,6 +1274,7 @@ カードの詳細は保護されており、アプリ内で完全に制御できます。 内蔵セキュリティ 無料のCryptoカード\nを数分で入手しましょう + Tangem Pay これは私のウォレットです 残高非表示 残高表示 @@ -1293,6 +1302,7 @@ %%image%% %1$sネットワーク上のトークン %1$s ( %2$s ) トークンは%3$sネットワークの主要通貨であり、このネットワーク上の他のトークンがリストにある限り、非表示にすることはできません。 %sを非表示にできません + QRコードを表示 このトークンは、2月%2$s-%3$s の間、%1$s のサービス手数料で別のトークンと交換できます。 Changellyでスワップ、手数料%s 今すぐスワップ @@ -1661,6 +1671,7 @@ 承認を確定する 資産残高に関するテキスト [プレースホルダー] あなたの%sはAaveに預けられています + チャートを読み込めません・・ 受け取った金額%1$s %2$sはAaveに入金されませんでした。 %1$s%% を獲得 利用可能 @@ -1678,6 +1689,7 @@ 最大手数料 手数料ポリシー ネットワーク手数料が現在高すぎます。設定した上限を下回るまで待機しています。 + 過去のリターン ここに説明を入力してください。1〜3行が理想的です。[プレースホルダー] トークン承認が必要 ネットワーク接続を確認してください @@ -1708,6 +1720,7 @@ 収益を停止する オフにすると、Aaveから資金が引き出され、ウォレットの%sに戻され、報酬の獲得が停止されます。 出金金額からネットワーク手数料が差し引かれます。 + 供給APR APY あなたの資産を眠らせない — 残高を運用して利息を得ましょう。 保有資産から報酬を得る @@ -1716,4 +1729,7 @@ 自動 取引のネットワーク手数料をカバーするために、 %1$s %2$sを入金してください %s手数料を支払えません + 利息サービスは現在利用できません。しばらくしてから再度お試しください。 + 収益は利用できません + チャートを読み込めません・・ diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 88c15e79d0..d3729cd4c9 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -250,6 +250,7 @@ Обменять условия участия Условиями использования + На Сегодня %d токен 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 6946a0d572..e832ab378e 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -233,6 +233,7 @@ Обмін умови участі Умовами використання + До Сьогодні %d токен diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index db57bd8a71..9987d7f342 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -179,6 +179,7 @@ Accept Access denied Accounts + Activate Add Add to portfolio Add token @@ -253,6 +254,7 @@ Speed and fee Finish Free + From Synchronize addresses Get started Go to provider @@ -331,6 +333,7 @@ Tangem Wallet terms and conditions Terms of Use + To Today %d token @@ -1112,6 +1115,9 @@ This will remove the wallet from the application. The wallet itself can be added again. Name Put your token to work + A network fee is a small payment to process and confirm your transaction on the blockchain. + To start staking, your TON account must be activated with a self-transaction of 1 TON. The funds stay in your wallet — this step only enables your account for staking. + Account activation The amount to stake must be at least %s Staking amount will be rounded to %1$s TRX due to network rules. Unstaking amount will be rounded to %1$s TRX due to network rules. @@ -1178,6 +1184,8 @@ Reinvests your earned rewards in your staked amount, increasing potential earnings. Restake lets you move your funds from one validator to another without the need to unstake You’re about to stake your entire balance. We recommend leaving a small amount to cover network fees for unstaking or claiming rewards. + To start staking, your TON account must be activated with a self-transaction of 1 TON. The funds stay in your wallet — this step only enables your account for staking. + Account activation To start staking in TON, first send a small transaction to your own address — this will activate your wallet. Up to 0.2 TON may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. 0.2 TON is required to proceed with this operation, in addition to the network fee. Please top up your balance. diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt index 989637b8eb..fa775ca36c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt @@ -60,10 +60,10 @@ fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: M @Composable fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { if (state.isExpanded) { - ExpandedPortfolioHeader(state = state.state, isCollapsable = state.isCollapsable, modifier = modifier) + ExpandedPortfolioHeader(state = state.tokenItemUM, isCollapsable = state.isCollapsable, modifier = modifier) } else { TokenItem( - state = state.state, + state = state.tokenItemUM, isBalanceHidden = isBalanceHidden, modifier = modifier, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt index 23b2ef9da6..7797159dc1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList /** Tokens list item state */ @Immutable @@ -41,12 +42,12 @@ sealed interface TokensListItemUM { } data class Portfolio( - val state: TokenItemState, + val tokenItemUM: TokenItemState, val isExpanded: Boolean, val isCollapsable: Boolean, - val tokens: List, + val tokens: ImmutableList, ) : TokensListItemUM { - override val id: String = state.id + override val id: String = tokenItemUM.id } data class Text(override val id: Any, val text: TextReference) : TokensListItemUM diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt index 8d9c2042a9..9489858928 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt @@ -16,7 +16,7 @@ internal class LoadingAccountTokenItemConverter( val (account, currencies) = value return TokensListItemUM.Portfolio( - state = AccountCryptoPortfolioItemStateConverter( + tokenItemUM = AccountCryptoPortfolioItemStateConverter( appCurrency = appCurrency, account = account, onItemClick = null, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt index 669604724c..9ee61d4ba9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformerV2.kt @@ -33,7 +33,7 @@ internal class SetNoAvailablePairsTransformerV2( TokenListUMData.AccountList( tokensList = accountList.map { (account, cryptoCurrencies) -> TokensListItemUM.Portfolio( - state = AccountCryptoPortfolioItemStateConverter( + tokenItemUM = AccountCryptoPortfolioItemStateConverter( appCurrency = appCurrency, account = account, onItemClick = null, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt index 817e7218ba..0b159c1423 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt @@ -1,5 +1,7 @@ package com.tangem.features.onramp.swap.entity +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference @@ -11,7 +13,7 @@ import com.tangem.core.ui.extensions.TextReference internal sealed interface ExchangeCardUM { /** Title reference */ - val titleReference: TextReference + val titleUM: TitleUM /** Remove button UI model */ val removeButtonUM: RemoveButtonUM? @@ -19,11 +21,11 @@ internal sealed interface ExchangeCardUM { /** * Empty state * - * @property titleReference title reference + * @property titleUM title reference * @property subtitleReference empty token subtitle reference */ data class Empty( - override val titleReference: TextReference, + override val titleUM: TitleUM, val subtitleReference: TextReference, ) : ExchangeCardUM { @@ -33,15 +35,29 @@ internal sealed interface ExchangeCardUM { /** * Filled * - * @property titleReference title reference + * @property titleUM title reference * @property removeButtonUM remove button UI model * @property tokenItemState token item state */ data class Filled( - override val titleReference: TextReference, + override val titleUM: TitleUM, override val removeButtonUM: RemoveButtonUM?, val tokenItemState: TokenItemState, ) : ExchangeCardUM data class RemoveButtonUM(val onClick: () -> Unit) + + @Immutable + sealed interface TitleUM { + + data class Text( + val title: TextReference, + ) : TitleUM + + data class Account( + val prefixText: TextReference, + val name: TextReference, + val icon: CryptoPortfolioIconUM, + ) : TitleUM + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt index 08c088f59e..036c81e855 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.onramp.swap.entity.transformer import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.models.account.Account import com.tangem.features.onramp.swap.entity.ExchangeCardUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer @@ -17,6 +18,8 @@ import com.tangem.features.onramp.swap.entity.utils.toFilled internal class SelectFromTokenTransformer( private val selectedTokenItemState: TokenItemState, private val onRemoveClick: () -> Unit, + private val account: Account.CryptoPortfolio, + private val isAccountsMode: Boolean, ) : SwapSelectTokensUMTransformer { override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM { @@ -24,6 +27,9 @@ internal class SelectFromTokenTransformer( exchangeFrom = prevState.exchangeFrom.toFilled( selectedTokenItemState = selectedTokenItemState, removeButtonUM = ExchangeCardUM.RemoveButtonUM(onClick = onRemoveClick), + account = account, + isAccountsMode = isAccountsMode, + isFromCurrency = true, ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt index 433b28f5ff..f8798e8f75 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.onramp.swap.entity.transformer import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.models.account.Account import com.tangem.features.onramp.swap.entity.ExchangeCardUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer @@ -15,12 +16,19 @@ import com.tangem.features.onramp.swap.entity.utils.toFilled */ internal class SelectToTokenTransformer( private val selectedTokenItemState: TokenItemState, + private val isAccountsMode: Boolean, + private val account: Account.CryptoPortfolio, ) : SwapSelectTokensUMTransformer { override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM { return prevState.copy( exchangeFrom = prevState.exchangeFrom.hideRemoveButton(), - exchangeTo = prevState.exchangeTo.toFilled(selectedTokenItemState = selectedTokenItemState), + exchangeTo = prevState.exchangeTo.toFilled( + selectedTokenItemState = selectedTokenItemState, + isAccountsMode = isAccountsMode, + account = account, + isFromCurrency = false, + ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt index 6daf403437..04615a8b92 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt @@ -1,14 +1,16 @@ package com.tangem.features.onramp.swap.entity.utils +import com.tangem.common.ui.account.toUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.Account import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.swap.entity.ExchangeCardUM /** Create empty exchange "from" card */ internal fun createEmptyExchangeFrom(): ExchangeCardUM.Empty { return ExchangeCardUM.Empty( - titleReference = resourceReference(id = R.string.swapping_from_title), + titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)), subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_swap), ) } @@ -16,7 +18,7 @@ internal fun createEmptyExchangeFrom(): ExchangeCardUM.Empty { /** Create empty exchange "to" card */ internal fun createEmptyExchangeTo(): ExchangeCardUM.Empty { return ExchangeCardUM.Empty( - titleReference = resourceReference(id = R.string.swapping_to_title), + titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_to_title)), subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_receive), ) } @@ -29,10 +31,25 @@ internal fun createEmptyExchangeTo(): ExchangeCardUM.Empty { */ internal fun ExchangeCardUM.toFilled( selectedTokenItemState: TokenItemState, + account: Account.CryptoPortfolio, + isAccountsMode: Boolean, + isFromCurrency: Boolean, removeButtonUM: ExchangeCardUM.RemoveButtonUM? = null, ): ExchangeCardUM.Filled { return ExchangeCardUM.Filled( - titleReference = titleReference, + titleUM = if (isAccountsMode) { + ExchangeCardUM.TitleUM.Account( + prefixText = if (isFromCurrency) { + resourceReference(R.string.common_from) + } else { + resourceReference(R.string.common_to) + }, + name = account.accountName.toUM().value, + icon = account.icon.toUM(), + ) + } else { + titleUM + }, tokenItemState = selectedTokenItemState, removeButtonUM = removeButtonUM, ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt index 5472e0e61c..e0e2d8e657 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt @@ -8,7 +8,9 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.onramp.component.SwapSelectTokensComponent import com.tangem.features.onramp.swap.entity.SwapSelectTokensController @@ -32,6 +34,7 @@ internal class SwapSelectTokensModel @Inject constructor( private val router: Router, private val analyticsEventHandler: AnalyticsEventHandler, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, ) : Model() { val state: StateFlow = controller.state @@ -43,9 +46,12 @@ internal class SwapSelectTokensModel @Inject constructor( private val params = paramsContainer.require() + private var isAccountsMode: Boolean = false + init { controller.update { it.copy(onBackClick = ::onBackClick) } + subscribeOnAccountsMode() subscribeOnBalanceHidingSettings() } @@ -66,6 +72,11 @@ internal class SwapSelectTokensModel @Inject constructor( transformer = SelectFromTokenTransformer( selectedTokenItemState = selectedTokenItemState, onRemoveClick = ::onRemoveFromTokenClick, + isAccountsMode = isAccountsMode, + account = Account.CryptoPortfolio.createMainAccount( + userWalletId = params.userWalletId, + cryptoCurrencies = setOf(status.currency), + ), // todo account from from cryptocurrency ), ) } @@ -84,7 +95,16 @@ internal class SwapSelectTokensModel @Inject constructor( modelScope.launch { _toCurrencyStatus.value = status - controller.update(transformer = SelectToTokenTransformer(selectedTokenItemState)) + controller.update( + transformer = SelectToTokenTransformer( + selectedTokenItemState = selectedTokenItemState, + isAccountsMode = isAccountsMode, + account = Account.CryptoPortfolio.createMainAccount( + userWalletId = params.userWalletId, + cryptoCurrencies = setOf(status.currency), + ), // todo account from from cryptocurrency + ), + ) // require some delay to show state with selected "from" and "to" tokens delay(timeMillis = 500) @@ -119,6 +139,16 @@ internal class SwapSelectTokensModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeOnAccountsMode() { + isAccountsModeEnabledUseCase() + .distinctUntilChanged() + .onEach { + isAccountsMode = it + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + private fun onBackClick() { analyticsEventHandler.send( event = MainScreenAnalyticsEvent.ButtonClose(source = AnalyticsParam.ScreensSources.Swap), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt index da3b5ead84..4ee9b3eb0e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt @@ -14,11 +14,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountLabel +import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.rows.NetworkTitle @@ -46,13 +49,14 @@ internal fun ExchangeCard(state: ExchangeCardUM, isBalanceHidden: Boolean, modif modifier = modifier .fillMaxWidth() .heightIn(min = 116.dp) - .background( - color = TangemTheme.colors.background.primary, - shape = TangemTheme.shapes.roundedCornersXMedium, - ), + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary), verticalArrangement = Arrangement.SpaceBetween, ) { - Title(titleReference = state.titleReference, removeButtonUM = state.removeButtonUM) + Title( + titleUM = state.titleUM, + removeButtonUM = state.removeButtonUM, + ) AnimatedContent( targetState = state, @@ -73,16 +77,39 @@ internal fun ExchangeCard(state: ExchangeCardUM, isBalanceHidden: Boolean, modif } @Composable -private fun Title(titleReference: TextReference, removeButtonUM: ExchangeCardUM.RemoveButtonUM?) { +private fun Title(titleUM: ExchangeCardUM.TitleUM, removeButtonUM: ExchangeCardUM.RemoveButtonUM?) { NetworkTitle( title = { - Text( - text = titleReference.resolveReference(), - color = TangemTheme.colors.text.tertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = TangemTheme.typography.subtitle2, - ) + AnimatedContent( + titleUM, + ) { currentState -> + when (currentState) { + is ExchangeCardUM.TitleUM.Account -> Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = currentState.prefixText.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + AccountLabel( + name = currentState.name, + icon = currentState.icon, + iconSize = AccountIconSize.ExtraSmall, + nameStyle = TangemTheme.typography.subtitle2, + nameColor = TangemTheme.colors.text.tertiary, + ) + } + is ExchangeCardUM.TitleUM.Text -> Text( + text = currentState.title.resolveReference(), + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography.subtitle2, + ) + } + } }, action = { RemoveButton(state = removeButtonUM) }, ) @@ -153,7 +180,7 @@ private class ExchangeCardUMProvider : PreviewParameterProvider override val values: Sequence = sequenceOf( ExchangeCardUM.Empty( - titleReference = resourceReference(id = R.string.swapping_from_title), + titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)), subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_swap), ), createFilled(removeButtonUM = null), @@ -162,7 +189,7 @@ private class ExchangeCardUMProvider : PreviewParameterProvider private fun createFilled(removeButtonUM: ExchangeCardUM.RemoveButtonUM?): ExchangeCardUM.Filled { return ExchangeCardUM.Filled( - titleReference = resourceReference(id = R.string.swapping_from_title), + titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)), removeButtonUM = removeButtonUM, tokenItemState = TokenItemState.Content( id = "1", diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt index 2389d1aec9..f90f1e9794 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt @@ -26,7 +26,7 @@ internal class UpdateAccountTokenItemConverter( override fun convert(value: AccountAvailabilityUM): TokensListItemUM.Portfolio { return TokensListItemUM.Portfolio( - state = AccountCryptoPortfolioItemStateConverter( + tokenItemUM = AccountCryptoPortfolioItemStateConverter( appCurrency = appCurrency, account = value.account, onItemClick = null, 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 37319208bd..50569e441a 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 @@ -19,6 +19,7 @@ import com.tangem.feature.wallet.presentation.common.WalletPreviewData.topBarCon import com.tangem.feature.wallet.presentation.wallet.state.model.* import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList internal object WalletScreenPreviewData { private val tokenItemState = TokenItemState.Content( @@ -86,17 +87,17 @@ internal object WalletScreenPreviewData { private val portfolioContentState = WalletTokensListState.ContentState.PortfolioContent( items = persistentListOf( TokensListItemUM.Portfolio( - tokens = textContentTokensState.items.filterIsInstance(), + tokens = textContentTokensState.items.filterIsInstance().toPersistentList(), isExpanded = false, isCollapsable = true, - state = AccountItemPreviewData.accountItem + tokenItemUM = AccountItemPreviewData.accountItem .copy(iconState = AccountItemPreviewData.accountLetterIcon), ), TokensListItemUM.Portfolio( - tokens = textContentTokensState.items.filterIsInstance(), + tokens = textContentTokensState.items.filterIsInstance().toPersistentList(), isExpanded = true, isCollapsable = true, - state = AccountItemPreviewData.accountItem, + tokenItemUM = AccountItemPreviewData.accountItem, ), ), organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 07afba9a94..8cbaeee5da 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -112,10 +112,10 @@ internal class TokenListStateConverter( is WalletTokensListState.Empty -> listOf() } return TokensListItemUM.Portfolio( - state = accountItem, + tokenItemUM = accountItem, isExpanded = isExtend, isCollapsable = true, - tokens = items.filterIsInstance(), + tokens = items.filterIsInstance().toPersistentList(), ) } From 161477c16a489db2aa5d483366a831f67bf909c9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 12:41:59 +0400 Subject: [PATCH 20/46] Updated on 2026-08-14 --- .../tap/data/DefaultTangemPayStorage.kt | 6 +- .../datasource/local/visa/TangemPayStorage.kt | 4 +- .../repository/DefaultOnboardingRepository.kt | 67 +++++++++++++------ .../tangem/domain/pay/model/CustomerInfo.kt | 6 +- .../tangem/domain/pay/model/OrderStatus.kt | 2 +- .../model/TangemPayOnboardingModel.kt | 2 +- .../wallet/child/wallet/model/WalletModel.kt | 10 +-- ...kt => TangemPayInitialStateTransformer.kt} | 47 +++---------- ...TangemPayIssueAvailableStateTransformer.kt | 12 ++++ .../TangemPayIssueProgressStateTransformer.kt | 10 +++ .../state/util/TangemPayStateCreator.kt | 31 +++++++++ 11 files changed, 125 insertions(+), 72 deletions(-) rename features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/{TangemPayStateTransformer.kt => TangemPayInitialStateTransformer.kt} (50%) create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueAvailableStateTransformer.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueProgressStateTransformer.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/util/TangemPayStateCreator.kt diff --git a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt index 58e979c7dc..ce88071554 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -67,7 +67,11 @@ internal class DefaultTangemPayStorage @Inject constructor( secureStorage.get(createOrderIdKey(customerWalletAddress))?.decodeToString(throwOnInvalidSequence = true) } - override suspend fun clear(customerWalletAddress: String) = withContext(dispatcherProvider.io) { + override suspend fun clearOrderId(customerWalletAddress: String) = withContext(dispatcherProvider.io) { + secureStorage.delete(createOrderIdKey(customerWalletAddress)) + } + + override suspend fun clearAll(customerWalletAddress: String) = withContext(dispatcherProvider.io) { secureStorage.delete(createKey(customerWalletAddress)) secureStorage.delete(createOrderIdKey(customerWalletAddress)) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt index 1ed58f2220..bf8bf7fa78 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt @@ -12,5 +12,7 @@ interface TangemPayStorage { suspend fun getOrderId(customerWalletAddress: String): String? - suspend fun clear(customerWalletAddress: String) + suspend fun clearOrderId(customerWalletAddress: String) + + suspend fun clearAll(customerWalletAddress: String) } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index b005177a40..d0d1af52ab 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext import javax.inject.Inject private const val VALID_STATUS = "valid" +private const val APPROVED_KYC_STATUS = "APPROVED" private const val TAG = "TangemPay: OnboardingRepository" internal class DefaultOnboardingRepository @Inject constructor( @@ -47,13 +48,36 @@ internal class DefaultOnboardingRepository @Inject constructor( override suspend fun getMainScreenCustomerInfo(): Either { return requestHelper.runWithErrorLogs(TAG) { - val result = requestHelper.requestWithPersistedToken { authHeader -> - tangemPayApi.getCustomerMe(authHeader) - }.result + val customerWalletAddress = requestHelper.getCustomerWalletAddress() - val orderStatus = getOrderStatus().getOrNull() ?: error("Order status is null") + when (val orderId = tangemPayStorage.getOrderId(customerWalletAddress)) { + // If order id wasn't saved -> get customer info + null -> { + MainScreenCustomerInfo( + info = getCustomerInfoWithPersistedToken(), + orderStatus = OrderStatus.UNKNOWN, + ) + } + // If order id was saved -> check its status + else -> { + val orderStatus = getOrderStatus(orderId) + val customerInfo = when (orderStatus) { + // Kyc is passed and user waits for order creation -> no need to get customer info + OrderStatus.NEW, + OrderStatus.PROCESSING, + -> CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null) - MainScreenCustomerInfo(info = getCustomerInfo(result), orderStatus = orderStatus) + // Order was created/cancelled -> clear order id and get customer info + OrderStatus.UNKNOWN, + OrderStatus.COMPLETED, + OrderStatus.CANCELED, + -> getCustomerInfoWithPersistedToken().also { + tangemPayStorage.clearOrderId(customerWalletAddress) + } + } + MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus) + } + } } } @@ -84,27 +108,28 @@ internal class DefaultOnboardingRepository @Inject constructor( } return CustomerInfo( productInstance = response?.productInstance?.let { ProductInstance(id = it.id, status = it.status) }, - kycStatus = response?.kyc?.status, + isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS, cardInfo = cardInfo, ) } - private suspend fun getOrderStatus(): Either { - return requestHelper.runWithErrorLogs(TAG) { - val walletAddress = requestHelper.getCustomerWalletAddress() - val orderId: String = tangemPayStorage.getOrderId(walletAddress) - ?: return@runWithErrorLogs OrderStatus.NOT_ISSUED + private suspend fun getOrderStatus(orderId: String): OrderStatus { + val result = requestHelper.request { authHeader -> + tangemPayApi.getOrder(authHeader, orderId) + }.result ?: error("Order result is null") - val result = requestHelper.request { authHeader -> - tangemPayApi.getOrder(authHeader, orderId) - }.result ?: error("Order result is null") - - when (result.status) { - OrderStatus.NEW.apiName -> OrderStatus.NEW - OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING - OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED - else -> OrderStatus.CANCELED - } + return when (result.status) { + OrderStatus.NEW.apiName -> OrderStatus.NEW + OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING + OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED + else -> OrderStatus.CANCELED } } + + private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo { + val result = requestHelper.requestWithPersistedToken { authHeader -> + tangemPayApi.getCustomerMe(authHeader) + }.result + return getCustomerInfo(result) + } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 9a5438f259..cb32317055 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -2,8 +2,6 @@ package com.tangem.domain.pay.model import java.math.BigDecimal -private const val APPROVED_KYC_STATUS = "APPROVED" - data class MainScreenCustomerInfo( val info: CustomerInfo, val orderStatus: OrderStatus, @@ -11,7 +9,7 @@ data class MainScreenCustomerInfo( data class CustomerInfo( val productInstance: ProductInstance?, - val kycStatus: String?, + val isKycApproved: Boolean, val cardInfo: CardInfo?, ) { @@ -26,6 +24,4 @@ data class CustomerInfo( val currencyCode: String, val customerWalletAddress: String, ) - - fun isKycApproved() = kycStatus == APPROVED_KYC_STATUS } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index 6cab68a6fd..6ae706a0e1 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -1,7 +1,7 @@ package com.tangem.domain.pay.model enum class OrderStatus(val apiName: String) { - NOT_ISSUED(""), + UNKNOWN(""), NEW("NEW"), PROCESSING("PROCESSING"), COMPLETED("COMPLETED"), diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt index 65caca46f0..4b41785218 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -56,7 +56,7 @@ internal class TangemPayOnboardingModel @Inject constructor( repository.getCustomerInfo() .onRight { customerInfo -> when { - !customerInfo.isKycApproved() -> { + !customerInfo.isKycApproved -> { when (params) { is TangemPayOnboardingComponent.Params.Deeplink -> screenState.value = screenState.value.copy(fullScreenLoading = false) 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 c843cb1153..c2d9b76a0c 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 @@ -359,10 +359,10 @@ internal class WalletModel @Inject constructor( val info = tangemPayMainScreenCustomerInfoUseCase() if (info != null) { stateHolder.update( - transformer = TangemPayStateTransformer( + transformer = TangemPayInitialStateTransformer( value = info, - onIssueOrderClick = ::issueOrder, - onContinueKycClick = innerWalletRouter::openTangemPayOnboarding, + onClickIssue = ::issueOrder, + onClickKyc = innerWalletRouter::openTangemPayOnboarding, openDetails = innerWalletRouter::openTangemPayDetails, ), ) @@ -371,9 +371,9 @@ internal class WalletModel @Inject constructor( private fun issueOrder() { modelScope.launch { - stateHolder.update(TangemPayStateTransformer(issueProgressState = true)) + stateHolder.update(TangemPayIssueProgressStateTransformer()) tangemPayIssueOrderUseCase().onLeft { - stateHolder.update(TangemPayStateTransformer(issueState = true, onIssueOrderClick = ::issueOrder)) + stateHolder.update(TangemPayIssueAvailableStateTransformer(onClickIssue = ::issueOrder)) } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayInitialStateTransformer.kt similarity index 50% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayStateTransformer.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayInitialStateTransformer.kt index 9e367377ba..4af0c5bdc7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayInitialStateTransformer.kt @@ -1,33 +1,28 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers -import com.tangem.common.ui.R import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.pay.model.CustomerInfo.CardInfo import com.tangem.domain.pay.model.MainScreenCustomerInfo import com.tangem.domain.pay.model.OrderStatus.CANCELED -import com.tangem.domain.pay.model.OrderStatus.NOT_ISSUED +import com.tangem.domain.pay.model.OrderStatus.UNKNOWN import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createIssueAvailableState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createIssueProgressState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createKycInProgressState import java.util.Currency -internal class TangemPayStateTransformer( +internal class TangemPayInitialStateTransformer( private val value: MainScreenCustomerInfo? = null, - private val onIssueOrderClick: () -> Unit = {}, - private val onContinueKycClick: () -> Unit = {}, + private val onClickIssue: () -> Unit = {}, + private val onClickKyc: () -> Unit = {}, private val openDetails: (customerWalletAddress: String, cardNumberEnd: String) -> Unit = { _, _ -> }, - private val issueProgressState: Boolean = false, - private val issueState: Boolean = false, ) : WalletScreenStateTransformer { override fun transform(prevState: WalletScreenState): WalletScreenState { - val tangemPayState = when { - issueProgressState -> createIssueProgressState() - issueState -> createIssueState() - else -> createInitialState() - } + val tangemPayState = createInitialState() return prevState.copy(tangemPayState = tangemPayState) } @@ -35,35 +30,13 @@ internal class TangemPayStateTransformer( val cardInfo = value?.info?.cardInfo return when { value == null -> TangemPayState.Empty - !value.info.isKycApproved() -> createKycInProgressState(onContinueKycClick) + !value.info.isKycApproved -> createKycInProgressState(onClickKyc) cardInfo != null -> getCardInfoState(cardInfo) - value.orderStatus == NOT_ISSUED || value.orderStatus == CANCELED -> createIssueState() + value.orderStatus == UNKNOWN || value.orderStatus == CANCELED -> createIssueAvailableState(onClickIssue) else -> createIssueProgressState() } } - private fun createIssueProgressState(): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_issue_card_notification_title), - buttonText = TextReference.EMPTY, - iconRes = R.drawable.ic_tangem_pay_promo_card_36, - onButtonClick = {}, - showProgress = true, - ) - - private fun createIssueState() = Progress( - title = TextReference.Res(R.string.tangempay_issue_card_notification_title), - buttonText = TextReference.Res(R.string.common_continue), - iconRes = R.drawable.ic_tangem_pay_promo_card_36, - onButtonClick = onIssueOrderClick, - ) - - private fun createKycInProgressState(onContinueKycClick: () -> Unit): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_title), - buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), - iconRes = R.drawable.ic_promo_kyc_36, - onButtonClick = onContinueKycClick, - ) - private fun getCardInfoState(cardInfo: CardInfo): TangemPayState = TangemPayState.Card( lastFourDigits = TextReference.Str("*${cardInfo.lastFourDigits}"), balanceText = TextReference.Str(getBalanceText(cardInfo)), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueAvailableStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueAvailableStateTransformer.kt new file mode 100644 index 0000000000..3f66ab33ad --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueAvailableStateTransformer.kt @@ -0,0 +1,12 @@ +package com.tangem.feature.wallet.presentation.wallet.state.transformers + +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createIssueAvailableState + +internal class TangemPayIssueAvailableStateTransformer( + private val onClickIssue: () -> Unit = {}, +) : WalletScreenStateTransformer { + + override fun transform(prevState: WalletScreenState): WalletScreenState = + prevState.copy(tangemPayState = createIssueAvailableState(onClickIssue)) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueProgressStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueProgressStateTransformer.kt new file mode 100644 index 0000000000..b7bc76806e --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayIssueProgressStateTransformer.kt @@ -0,0 +1,10 @@ +package com.tangem.feature.wallet.presentation.wallet.state.transformers + +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createIssueProgressState + +internal class TangemPayIssueProgressStateTransformer : WalletScreenStateTransformer { + + override fun transform(prevState: WalletScreenState): WalletScreenState = + prevState.copy(tangemPayState = createIssueProgressState()) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/util/TangemPayStateCreator.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/util/TangemPayStateCreator.kt new file mode 100644 index 0000000000..f8b3293d44 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/util/TangemPayStateCreator.kt @@ -0,0 +1,31 @@ +package com.tangem.feature.wallet.presentation.wallet.state.util + +import com.tangem.common.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState +import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress + +internal object TangemPayStateCreator { + + fun createKycInProgressState(onClickKyc: () -> Unit): TangemPayState = Progress( + title = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_title), + buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), + iconRes = R.drawable.ic_promo_kyc_36, + onButtonClick = onClickKyc, + ) + + fun createIssueAvailableState(onClickIssue: () -> Unit) = Progress( + title = TextReference.Res(R.string.tangempay_issue_card_notification_title), + buttonText = TextReference.Res(R.string.common_continue), + iconRes = R.drawable.ic_tangem_pay_promo_card_36, + onButtonClick = onClickIssue, + ) + + fun createIssueProgressState(): TangemPayState = Progress( + title = TextReference.Res(R.string.tangempay_issue_card_notification_title), + buttonText = TextReference.EMPTY, + iconRes = R.drawable.ic_tangem_pay_promo_card_36, + onButtonClick = {}, + showProgress = true, + ) +} \ No newline at end of file From 4dc994e8cf137ea1fdc282fee855fa39ca6a259c Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 18:17:18 +0700 Subject: [PATCH 21/46] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 10 +- .../com/tangem/common/routing/AppRoute.kt | 89 ++----------- .../intents/WalletContentClickIntents.kt | 22 ++-- .../WalletCurrencyActionsClickIntents.kt | 84 +++++------- .../intents/WalletWarningsClickIntents.kt | 2 +- .../router/DefaultWalletRouter.kt | 5 +- .../presentation/router/InnerWalletRouter.kt | 3 +- .../utils/TokenListAnalyticsSender.kt | 29 +++-- .../domain/GetMultiWalletWarningsFactory.kt | 123 ++++++++++-------- .../SetCryptoCurrencyActionsTransformer.kt | 9 +- .../transformers/SetVisaInfoTransformer.kt | 3 +- .../MultiWalletCurrencyActionsConverter.kt | 19 +-- .../converter/TokenListStateConverter.kt | 6 +- .../subscribers/BasicTokenListSubscriber.kt | 25 +++- .../MultiCurrencyAccountContent.kt | 12 +- .../utils/WalletFeatureUseCasesFacade.kt | 24 ---- 16 files changed, 196 insertions(+), 269 deletions(-) delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/utils/WalletFeatureUseCasesFacade.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 900514a667..deb8b82709 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 @@ -200,7 +200,7 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = OnrampComponent.Params( - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param, + userWalletId = route.userWalletId, cryptoCurrency = route.currency, source = route.source, shouldLaunchSepa = route.shouldLaunchSepa, @@ -274,7 +274,7 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = TokenDetailsComponent.Params( - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param + userWalletId = route.userWalletId, currency = route.currency, ), componentFactory = tokenDetailsComponentFactory, @@ -284,7 +284,7 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = StakingComponent.Params( - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param, + userWalletId = route.userWalletId, cryptoCurrencyId = route.cryptoCurrencyId, yieldId = route.yieldId, ), @@ -297,7 +297,7 @@ internal class ChildFactory @Inject constructor( params = SwapComponent.Params( currencyFrom = route.currencyFrom, currencyTo = route.currencyTo, - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param, + userWalletId = route.userWalletId, isInitialReverseOrder = route.isInitialReverseOrder, screenSource = route.screenSource, ), @@ -308,7 +308,7 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = SendComponent.Params( - userWalletId = route.portfolioId.userWalletId, // todo account portfolioId param, + userWalletId = route.userWalletId, currency = route.currency, transactionId = route.transactionId, amount = route.amount, 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 d4566f6a58..d051bdb21e 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 @@ -11,7 +11,6 @@ import com.tangem.core.decompose.navigation.Route import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.scan.ScanResponse @@ -51,50 +50,25 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class CurrencyDetails( - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val currency: CryptoCurrency, - ) : AppRoute(path = "/currency_details/${portfolioId.stringValue}/${currency.id.value}") { - companion object { - operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency) = CurrencyDetails( - portfolioId = PortfolioId(userWalletId), - currency = currency, - ) - } - } + ) : AppRoute(path = "/currency_details/${userWalletId.stringValue}/${currency.id.value}") @Serializable data class Send( - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val currency: CryptoCurrency, val transactionId: String? = null, val amount: String? = null, val tag: String? = null, val destinationAddress: String? = null, ) : AppRoute( - path = "/send/${portfolioId.stringValue}/${currency.id.value}?" + + path = "/send/${userWalletId.stringValue}/${currency.id.value}?" + "&$transactionId" + "&$amount" + "&$tag" + "&$destinationAddress", - ) { - companion object { - operator fun invoke( - userWalletId: UserWalletId, - currency: CryptoCurrency, - transactionId: String? = null, - amount: String? = null, - tag: String? = null, - destinationAddress: String? = null, - ) = Send( - portfolioId = PortfolioId(userWalletId), - currency = currency, - transactionId = transactionId, - amount = amount, - tag = tag, - destinationAddress = destinationAddress, - ) - } - } + ) @Serializable data class Details( @@ -199,51 +173,26 @@ sealed class AppRoute(val path: String) : Route { data class Swap( val currencyFrom: CryptoCurrency, val currencyTo: CryptoCurrency? = null, - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val isInitialReverseOrder: Boolean = false, val screenSource: String, ) : AppRoute( path = "/swap" + "/${currencyFrom.id.value}" + "/${currencyTo?.id?.value}" + - "/${portfolioId.stringValue}" + + "/${userWalletId.stringValue}" + "/$isInitialReverseOrder", - ) { - companion object { - operator fun invoke( - userWalletId: UserWalletId, - currencyFrom: CryptoCurrency, - currencyTo: CryptoCurrency? = null, - isInitialReverseOrder: Boolean = false, - screenSource: String, - ) = Swap( - portfolioId = PortfolioId(userWalletId), - currencyFrom = currencyFrom, - currencyTo = currencyTo, - isInitialReverseOrder = isInitialReverseOrder, - screenSource = screenSource, - ) - } - } + ) @Serializable data object AppCurrencySelector : AppRoute(path = "/app_currency_selector") @Serializable data class Staking( - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val cryptoCurrencyId: CryptoCurrency.ID, val yieldId: String, - ) : AppRoute(path = "/staking/${portfolioId.stringValue}/${cryptoCurrencyId.value}/$yieldId") { - companion object { - operator fun invoke(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, yieldId: String) = - Staking( - portfolioId = PortfolioId(userWalletId), - cryptoCurrencyId = cryptoCurrencyId, - yieldId = yieldId, - ) - } - } + ) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrencyId.value}/$yieldId") @Serializable data class PushNotification( @@ -287,25 +236,11 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Onramp( val source: OnrampSource, - val portfolioId: PortfolioId, + val userWalletId: UserWalletId, val currency: CryptoCurrency, val shouldLaunchSepa: Boolean = false, - ) : AppRoute(path = "/onramp/${portfolioId.stringValue}/${currency.symbol}"), RouteBundleParams { + ) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams { override fun getBundle(): Bundle = bundle(serializer()) - - companion object { - operator fun invoke( - source: OnrampSource, - userWalletId: UserWalletId, - currency: CryptoCurrency, - launchSepa: Boolean = false, - ) = Onramp( - source = source, - portfolioId = PortfolioId(userWalletId), - currency = currency, - shouldLaunchSepa = launchSepa, - ) - } } @Serializable diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index eb07bc02f7..a9e2270a44 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -4,10 +4,10 @@ import arrow.core.getOrElse import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.nft.analytics.NFTAnalyticsEvent import com.tangem.domain.redux.ReduxStateHolder @@ -45,9 +45,9 @@ internal interface WalletContentClickIntents { fun onDismissMarketsOnboarding() - fun onTokenItemClick(portfolioId: PortfolioId, currencyStatus: CryptoCurrencyStatus) + fun onTokenItemClick(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) - fun onTokenItemLongClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onTokenItemLongClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) fun onAccountExpandClick(account: Account) @@ -138,13 +138,12 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } } - override fun onTokenItemClick(portfolioId: PortfolioId, currencyStatus: CryptoCurrencyStatus) { - router.openTokenDetails(portfolioId, currencyStatus) + override fun onTokenItemClick(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) { + router.openTokenDetails(userWalletId, currencyStatus) } - override fun onTokenItemLongClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onTokenItemLongClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) { modelScope.launch(dispatchers.main) { - val userWalletId = portfolioId.userWalletId val userWallet = getUserWalletUseCase(userWalletId).getOrElse { Timber.e( """ @@ -160,7 +159,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( getCryptoCurrencyActionsUseCase(userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus) .take(count = 1) .collectLatest { - showActionsBottomSheet(it, userWallet, portfolioId) + showActionsBottomSheet(it, userWallet) } } } @@ -175,17 +174,12 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( accountDependencies.expandedAccountsHolder.collapseAccount(userWalletId, account.accountId) } - private fun showActionsBottomSheet( - tokenActionsState: TokenActionsState, - userWallet: UserWallet, - portfolioId: PortfolioId, - ) { + private fun showActionsBottomSheet(tokenActionsState: TokenActionsState, userWallet: UserWallet) { stateHolder.showBottomSheet( ActionsBottomSheetConfig( actions = MultiWalletCurrencyActionsConverter( userWallet = userWallet, clickIntents = currencyActionsClickIntents, - portfolioId = portfolioId, ).convert(tokenActionsState), ), userWallet.walletId, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index 79f970b483..c2c173c0e9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -29,7 +29,6 @@ import com.tangem.domain.core.utils.lceError import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency @@ -66,7 +65,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender -import com.tangem.feature.wallet.presentation.wallet.utils.WalletFeatureUseCasesFacade import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -80,7 +78,7 @@ import javax.inject.Inject interface WalletCurrencyActionsClickIntents { fun onSendClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) @@ -88,32 +86,32 @@ interface WalletCurrencyActionsClickIntents { fun onSellClick(cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason) fun onBuyClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) fun onSwapClick( cryptoCurrencyStatus: CryptoCurrencyStatus, - portfolioId: PortfolioId, + userWalletId: UserWalletId, unavailabilityReason: ScenarioUnavailabilityReason, ) fun onReceiveClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent? = null, ) - fun onStakeClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) + fun onStakeClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) fun onCopyAddressLongClick(cryptoCurrencyStatus: CryptoCurrencyStatus): TextReference? - fun onCopyAddressClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onCopyAddressClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) - fun onHideTokensClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onHideTokensClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) - fun onPerformHideToken(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onPerformHideToken(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) fun onExploreClick() @@ -135,7 +133,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val walletManagersFacade: WalletManagersFacade, private val isDemoCardUseCase: IsDemoCardUseCase, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, - private val useCasesFacade: WalletFeatureUseCasesFacade, private val getExploreUrlUseCase: GetExploreUrlUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getStoryContentUseCase: GetStoryContentUseCase, @@ -154,10 +151,12 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, private val needShowYieldSupplyDepositedWarningUseCase: NeedShowYieldSupplyDepositedWarningUseCase, private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase, + private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase, + private val removeCurrencyUseCase: RemoveCurrencyUseCase, ) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { override fun onSendClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) { @@ -181,27 +180,21 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( modelScope.launch { saveViewedYieldSupplyWarningUseCase(cryptoCurrencyStatus.currency.name) stateHolder.hideBottomSheet() - navigateToSend(cryptoCurrencyStatus, portfolioId) + navigateToSend(cryptoCurrencyStatus, userWalletId) } }, ) } else { - navigateToSend(cryptoCurrencyStatus, portfolioId) + navigateToSend(cryptoCurrencyStatus, userWalletId) } } } override fun onReceiveClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent?, ) { - val userWalletId = portfolioId.userWalletId - if (portfolioId is PortfolioId.Account) { - // todo account find address - TODO("account") - } - analyticsEventHandler.send( event = TokenScreenAnalyticsEvent.ButtonWithParams.ButtonReceive( token = cryptoCurrencyStatus.currency.symbol, @@ -274,12 +267,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( ) } - override fun onCopyAddressClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) { - val userWalletId = portfolioId.userWalletId - if (portfolioId is PortfolioId.Account) { - // todo account find address - TODO("account") - } + override fun onCopyAddressClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) { analyticsEventHandler.send( event = TokenReceiveNewAnalyticsEvent.ButtonCopyAddress( token = cryptoCurrencyStatus.currency.symbol, @@ -301,7 +289,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } - override fun onHideTokensClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onHideTokensClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) { analyticsEventHandler.send( event = TokenScreenAnalyticsEvent.ButtonRemoveToken(cryptoCurrencyStatus.currency.symbol), ) @@ -309,19 +297,19 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( modelScope.launch(dispatchers.main) { walletEventSender.send( event = WalletEvent.ShowAlert( - state = getHideTokeAlertConfig(portfolioId, cryptoCurrencyStatus), + state = getHideTokeAlertConfig(userWalletId, cryptoCurrencyStatus), ), ) } } private suspend fun getHideTokeAlertConfig( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, ): WalletAlertState.DefaultAlert { val currency = cryptoCurrencyStatus.currency val isCryptoCurrencyCoinCouldHide = currency is CryptoCurrency.Coin && - !useCasesFacade.isCryptoCurrencyCoinCouldHide(portfolioId = portfolioId, cryptoCurrencyCoin = currency) + !isCryptoCurrencyCoinCouldHide(userWalletId = userWalletId, cryptoCurrencyCoin = currency) return if (isCryptoCurrencyCoinCouldHide) { WalletAlertState.DefaultAlert( title = resourceReference( @@ -347,14 +335,14 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( formatArgs = WrappedList(listOf(cryptoCurrencyStatus.currency.name)), ), message = resourceReference(R.string.token_details_hide_alert_message), - onConfirmClick = { onPerformHideToken(portfolioId, cryptoCurrencyStatus) }, + onConfirmClick = { onPerformHideToken(userWalletId, cryptoCurrencyStatus) }, ) } } - override fun onPerformHideToken(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onPerformHideToken(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus) { modelScope.launch(dispatchers.io) { - useCasesFacade.removeCurrencyUseCase(portfolioId, cryptoCurrencyStatus.currency) + removeCurrencyUseCase(userWalletId, cryptoCurrencyStatus.currency) .fold( ifLeft = { walletEventSender.send( @@ -362,7 +350,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( ) }, ifRight = { - stateHolder.update(CloseBottomSheetTransformer(userWalletId = portfolioId.userWalletId)) + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) }, ) } @@ -395,7 +383,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } override fun onBuyClick( - portfolioId: PortfolioId, + userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, unavailabilityReason: ScenarioUnavailabilityReason, ) { @@ -411,7 +399,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( appRouter.push( AppRoute.Onramp( - portfolioId = portfolioId, + userWalletId = userWalletId, currency = cryptoCurrencyStatus.currency, source = OnrampSource.TOKEN_LONG_TAP, ), @@ -420,7 +408,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( override fun onSwapClick( cryptoCurrencyStatus: CryptoCurrencyStatus, - portfolioId: PortfolioId, + userWalletId: UserWalletId, unavailabilityReason: ScenarioUnavailabilityReason, ) { analyticsEventHandler.send( @@ -443,12 +431,12 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( modelScope.launch { saveViewedYieldSupplyWarningUseCase(cryptoCurrencyStatus.currency.name) stateHolder.hideBottomSheet() - navigateToSwap(cryptoCurrencyStatus, portfolioId) + navigateToSwap(cryptoCurrencyStatus, userWalletId) } }, ) } else { - navigateToSwap(cryptoCurrencyStatus, portfolioId) + navigateToSwap(cryptoCurrencyStatus, userWalletId) } } } @@ -489,15 +477,15 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } - override fun onStakeClick(portfolioId: PortfolioId, cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) { - stateHolder.update(CloseBottomSheetTransformer(userWalletId = portfolioId.userWalletId)) + override fun onStakeClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) { + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) modelScope.launch { val cryptoCurrency = cryptoCurrencyStatus.currency appRouter.push( AppRoute.Staking( - portfolioId = portfolioId, + userWalletId = userWalletId, cryptoCurrencyId = cryptoCurrency.id, yieldId = yield?.id ?: return@launch, ), @@ -766,21 +754,21 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus) } - private fun navigateToSend(cryptoCurrencyStatus: CryptoCurrencyStatus, portfolioId: PortfolioId) { - stateHolder.update(CloseBottomSheetTransformer(userWalletId = portfolioId.userWalletId)) + private fun navigateToSend(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) { + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) val route = AppRoute.Send( currency = cryptoCurrencyStatus.currency, - portfolioId = portfolioId, + userWalletId = userWalletId, ) appRouter.push(route) } - private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, portfolioId: PortfolioId) { + private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) { appRouter.push( AppRoute.Swap( currencyFrom = cryptoCurrencyStatus.currency, - portfolioId = portfolioId, + userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.LongTap.value, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index d1e7c8cba5..937ae92eea 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -365,7 +365,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( userWalletId = userWallet.walletId, currency = cryptoCurrency, source = OnrampSource.SEPA_BANNER, - launchSepa = true, + shouldLaunchSepa = true, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 95c0458d24..1bab1f8a28 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -7,7 +7,6 @@ import com.tangem.common.routing.AppRoute.ManageTokens.Source import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.navigation.url.UrlOpener -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -67,12 +66,12 @@ internal class DefaultWalletRouter @Inject constructor( urlOpener.openUrl(url) } - override fun openTokenDetails(portfolioId: PortfolioId, currencyStatus: CryptoCurrencyStatus) { + override fun openTokenDetails(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) { val networkAddress = currencyStatus.value.networkAddress if (networkAddress != null && networkAddress.defaultAddress.value.isNotEmpty()) { router.push( AppRoute.CurrencyDetails( - portfolioId = portfolioId, + userWalletId = userWalletId, currency = currencyStatus.currency, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 2ff741c7b5..106cafa945 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -2,7 +2,6 @@ package com.tangem.feature.wallet.presentation.router import androidx.compose.runtime.Stable import com.arkivanov.decompose.router.slot.SlotNavigation -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -42,7 +41,7 @@ internal interface InnerWalletRouter { fun openUrl(url: String) /** Open token details screen */ - fun openTokenDetails(portfolioId: PortfolioId, currencyStatus: CryptoCurrencyStatus) + fun openTokenDetails(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) /** Open stories screen */ fun openStoriesScreen() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt index bf5b27d5ea..fe583f7183 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt @@ -14,7 +14,6 @@ import com.tangem.domain.analytics.model.WalletBalanceState import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency @@ -38,33 +37,41 @@ internal class TokenListAnalyticsSender @Inject constructor( private val mutex = Mutex() private val loadingTraces = mutableMapOf() - suspend fun send(displayedUiState: WalletState?, userWallet: UserWallet, tokenList: TokenList) { + suspend fun send( + displayedUiState: WalletState?, + userWallet: UserWallet, + totalFiatBalance: TotalFiatBalance, + flattenCurrencies: List, + ) { if (screenLifecycleProvider.isBackgroundState.value) return if (displayedUiState == null || displayedUiState.pullToRefreshConfig.isRefreshing) return - if (tokenList.totalFiatBalance is TotalFiatBalance.Loading) { - startLoadingTraceIfNeeded(userWallet.walletId, tokenList) + if (totalFiatBalance is TotalFiatBalance.Loading) { + startLoadingTraceIfNeeded(userWallet.walletId, flattenCurrencies) return } - if (isTerminalState(tokenList.totalFiatBalance)) { - stopLoadingTraceIfNeeded(userWallet.walletId, tokenList.totalFiatBalance) + if (isTerminalState(totalFiatBalance)) { + stopLoadingTraceIfNeeded(userWallet.walletId, totalFiatBalance) } - val currenciesStatuses = tokenList.flattenCurrencies() + val currenciesStatuses = flattenCurrencies - sendBalanceLoadedEventIfNeeded(tokenList.totalFiatBalance, currenciesStatuses) - sendToppedUpEventIfNeeded(userWallet, tokenList.totalFiatBalance, currenciesStatuses) + sendBalanceLoadedEventIfNeeded(totalFiatBalance, currenciesStatuses) + sendToppedUpEventIfNeeded(userWallet, totalFiatBalance, currenciesStatuses) sendUnreachableNetworksEventIfNeeded(currenciesStatuses) sendTokenBalancesIfNeeded(currenciesStatuses) } - private suspend fun startLoadingTraceIfNeeded(userWalletId: UserWalletId, tokenList: TokenList) { + private suspend fun startLoadingTraceIfNeeded( + userWalletId: UserWalletId, + flattenCurrencies: List, + ) { mutex.withLock { if (!loadingTraces.containsKey(userWalletId)) { val trace = FirebasePerformance.getInstance().newTrace(BALANCE_LOADED_TRACE_NAME) trace.start() - trace.putAttribute(TOKENS_COUNT, tokenList.flattenCurrencies().size.toString()) + trace.putAttribute(TOKENS_COUNT, flattenCurrencies.size.toString()) loadingTraces[userWalletId] = trace } } 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 2d5684a5c0..be35057603 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 @@ -9,17 +9,16 @@ 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.account.models.AccountStatusList import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.onramp.GetOnrampCountryUseCase @@ -45,6 +44,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import timber.log.Timber import javax.inject.Inject @@ -70,72 +70,81 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow> { val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver - val tokenListFlow = if (accountDependencies.accountsFeatureToggles.isFeatureEnabled) { + val accountStatusList by lazy { val params = SingleAccountStatusListProducer.Params(userWallet.walletId) accountDependencies.singleAccountStatusListSupplier(params) - } else { - tokenListStore.getOrThrow(userWallet.walletId) + .map { it.totalFiatBalance to it.flattenCurrencies() } + .map { Lce.Content(it) } } + + fun tokenListFlow(): LceFlow>> { + return if (accountDependencies.accountsFeatureToggles.isFeatureEnabled) { + accountStatusList + } else { + runCatching { tokenListStore.getOrThrow(userWallet.walletId) } + .map { result -> result.map { lce -> lce.map { it.totalFiatBalance to it.flattenCurrencies() } } } + .getOrNull() + // in case of runtime change ft in tester menu + ?: accountStatusList + } + } + + // val params = SingleAccountStatusListProducer.Params(userWallet.walletId) + // val accountStatusListFlow = accountDependencies.singleAccountStatusListSupplier(params) return combine( - tokenListFlow, + // todo account just use it, after delete accountsFeatureToggles + // accountStatusListFlow, isReadyToShowRateAppUseCase(), isNeedToBackupUseCase(userWallet.walletId), seedPhraseNotificationUseCase(userWalletId = userWallet.walletId), shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.Referral), shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.Sepa), notificationsRepository.getShouldShowNotification(NotificationId.EnablePushesReminderNotification.key), - ) { array -> - val totalFiatBalance: Lce - val flattenCurrencies: Lce> - if (accountDependencies.accountsFeatureToggles.isFeatureEnabled) { - val accountStatusList = array[0] as AccountStatusList - totalFiatBalance = Lce.Content(accountStatusList.totalFiatBalance) - flattenCurrencies = Lce.Content(accountStatusList.flattenCurrencies()) - } else { - val maybeTokenList = array[0] as Lce - totalFiatBalance = maybeTokenList.map { it.totalFiatBalance } - flattenCurrencies = maybeTokenList.map { it.flattenCurrencies() } + ) { array -> array } + .combine(tokenListFlow()) { array, any: Any -> arrayOf(any).plus(elements = array) } + .map { array -> + val lceTokens = array[0] as Lce>> + val totalFiatBalance = lceTokens.map { it.first } + val flattenCurrencies = lceTokens.map { it.second } + val isReadyToShowRating = array[1] as Boolean + val isNeedToBackup = array[2] as Boolean + val seedPhraseIssueStatus = array[3] as SeedPhraseNotificationsStatus + val shouldShowReferralPromo = array[4] as Boolean + val shouldShowSepaBanner = array[5] as Boolean + val shouldShowEnablePushesReminderNotification = array[6] as Boolean + + buildList { + addUsedOutdatedDataNotification(totalFiatBalance) + + addCriticalNotifications(userWallet, seedPhraseIssueStatus, clickIntents) + + addFinishWalletActivationNotification(userWallet, totalFiatBalance, clickIntents) + + addReferralPromoNotification(cardTypesResolver, clickIntents, shouldShowReferralPromo) + + addSepaPromoNotification(userWallet, clickIntents, shouldShowSepaBanner) + + addInformationalNotifications(userWallet, cardTypesResolver, flattenCurrencies, clickIntents) + + addWarningNotifications(cardTypesResolver, flattenCurrencies, isNeedToBackup, clickIntents) + + addPushReminderNotification( + clickIntents = clickIntents, + shouldShowPushReminderBanner = shouldShowEnablePushesReminderNotification && + !notificationsRepository.isUserAllowToSubscribeOnPushNotifications(), + ) + + addYieldSupplyNotifications(flattenCurrencies) + + val hasCriticalOrWarning = any { notification -> + notification is WalletNotification.Critical || notification is WalletNotification.Warning + } + + if (!hasCriticalOrWarning) { + addRateTheAppNotification(isReadyToShowRating, clickIntents) + } + }.toImmutableList() } - - val isReadyToShowRating = array[1] as Boolean - val isNeedToBackup = array[2] as Boolean - val seedPhraseIssueStatus = array[3] as SeedPhraseNotificationsStatus - val shouldShowReferralPromo = array[4] as Boolean - val shouldShowSepaBanner = array[5] as Boolean - val shouldShowEnablePushesReminderNotification = array[6] as Boolean - - buildList { - addUsedOutdatedDataNotification(totalFiatBalance) - - addCriticalNotifications(userWallet, seedPhraseIssueStatus, clickIntents) - - addFinishWalletActivationNotification(userWallet, totalFiatBalance, clickIntents) - - addReferralPromoNotification(cardTypesResolver, clickIntents, shouldShowReferralPromo) - - addSepaPromoNotification(userWallet, clickIntents, shouldShowSepaBanner) - - addInformationalNotifications(userWallet, cardTypesResolver, flattenCurrencies, clickIntents) - - addWarningNotifications(cardTypesResolver, flattenCurrencies, isNeedToBackup, clickIntents) - - addPushReminderNotification( - clickIntents = clickIntents, - shouldShowPushReminderBanner = shouldShowEnablePushesReminderNotification && - !notificationsRepository.isUserAllowToSubscribeOnPushNotifications(), - ) - - addYieldSupplyNotifications(flattenCurrencies) - - val hasCriticalOrWarning = any { notification -> - notification is WalletNotification.Critical || notification is WalletNotification.Warning - } - - if (!hasCriticalOrWarning) { - addRateTheAppNotification(isReadyToShowRating, clickIntents) - } - }.toImmutableList() - } } private fun MutableList.addUsedOutdatedDataNotification( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetCryptoCurrencyActionsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetCryptoCurrencyActionsTransformer.kt index 9565609246..230eac3a61 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetCryptoCurrencyActionsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetCryptoCurrencyActionsTransformer.kt @@ -50,7 +50,7 @@ internal class SetCryptoCurrencyActionsTransformer( dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, onClick = { clickIntents.onBuyClick( - portfolioId = portfolioId, + userWalletId = portfolioId.userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, unavailabilityReason = action.unavailabilityReason, ) @@ -62,7 +62,10 @@ internal class SetCryptoCurrencyActionsTransformer( enabled = true, dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, onClick = { - clickIntents.onReceiveClick(portfolioId, cryptoCurrencyStatus = cryptoCurrencyStatus) + clickIntents.onReceiveClick( + portfolioId.userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ) }, onLongClick = { clickIntents.onCopyAddressLongClick(cryptoCurrencyStatus = cryptoCurrencyStatus) @@ -87,7 +90,7 @@ internal class SetCryptoCurrencyActionsTransformer( dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, onClick = { clickIntents.onSendClick( - portfolioId = portfolioId, + userWalletId = portfolioId.userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, unavailabilityReason = action.unavailabilityReason, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt index 18974cedeb..0c3ac24ecd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt @@ -9,7 +9,6 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.card.common.util.getCardsCount -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.visa.exception.RefreshTokenExpiredException @@ -153,7 +152,7 @@ internal class SetVisaInfoTransformer( dimContent = false, onClick = { clickIntents.onReceiveClick( - portfolioId = PortfolioId(userWalletId), // todo account Visa use Main account? + userWalletId = userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, event = MainScreenAnalyticsEvent.ButtonReceive, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCurrencyActionsConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCurrencyActionsConverter.kt index 6a88f70616..72e55cfb23 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCurrencyActionsConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletCurrencyActionsConverter.kt @@ -3,9 +3,9 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.feature.wallet.child.wallet.model.intents.WalletCurrencyActionsClickIntents @@ -18,10 +18,11 @@ import kotlinx.collections.immutable.toImmutableList internal class MultiWalletCurrencyActionsConverter( private val userWallet: UserWallet, - private val portfolioId: PortfolioId, private val clickIntents: WalletCurrencyActionsClickIntents, ) : Converter> { + private val userWalletId: UserWalletId = userWallet.walletId + override fun convert(value: TokenActionsState): ImmutableList { return value.states .filterIfSingleWithToken() @@ -56,17 +57,17 @@ internal class MultiWalletCurrencyActionsConverter( is TokenActionsState.ActionState.Buy -> { title = resourceReference(R.string.common_buy) icon = R.drawable.ic_plus_24 - action = { clickIntents.onBuyClick(portfolioId, cryptoCurrencyStatus, noneReason) } + action = { clickIntents.onBuyClick(userWalletId, cryptoCurrencyStatus, noneReason) } } is TokenActionsState.ActionState.Receive -> { title = resourceReference(R.string.common_receive) icon = R.drawable.ic_arrow_down_24 - action = { clickIntents.onReceiveClick(portfolioId, cryptoCurrencyStatus) } + action = { clickIntents.onReceiveClick(userWalletId, cryptoCurrencyStatus) } } is TokenActionsState.ActionState.Stake -> { title = resourceReference(R.string.common_stake) icon = R.drawable.ic_staking_24 - action = { clickIntents.onStakeClick(portfolioId, cryptoCurrencyStatus, actionsState.yield) } + action = { clickIntents.onStakeClick(userWalletId, cryptoCurrencyStatus, actionsState.yield) } } is TokenActionsState.ActionState.Sell -> { title = resourceReference(R.string.common_sell) @@ -76,7 +77,7 @@ internal class MultiWalletCurrencyActionsConverter( is TokenActionsState.ActionState.Send -> { title = resourceReference(R.string.common_send) icon = R.drawable.ic_arrow_up_24 - action = { clickIntents.onSendClick(portfolioId, cryptoCurrencyStatus, noneReason) } + action = { clickIntents.onSendClick(userWalletId, cryptoCurrencyStatus, noneReason) } } is TokenActionsState.ActionState.Swap -> { title = resourceReference(R.string.swapping_swap_action) @@ -84,7 +85,7 @@ internal class MultiWalletCurrencyActionsConverter( action = { clickIntents.onSwapClick( cryptoCurrencyStatus = cryptoCurrencyStatus, - portfolioId = portfolioId, + userWalletId = userWalletId, unavailabilityReason = noneReason, ) } @@ -92,12 +93,12 @@ internal class MultiWalletCurrencyActionsConverter( is TokenActionsState.ActionState.CopyAddress -> { title = resourceReference(R.string.common_copy_address) icon = R.drawable.ic_copy_24 - action = { clickIntents.onCopyAddressClick(portfolioId, cryptoCurrencyStatus) } + action = { clickIntents.onCopyAddressClick(userWalletId, cryptoCurrencyStatus) } } is TokenActionsState.ActionState.HideToken -> { title = resourceReference(R.string.token_details_hide_token) icon = R.drawable.ic_hide_24 - action = { clickIntents.onHideTokensClick(portfolioId, cryptoCurrencyStatus) } + action = { clickIntents.onHideTokensClick(userWalletId, cryptoCurrencyStatus) } } is TokenActionsState.ActionState.Analytics -> { title = resourceReference(R.string.common_analytics) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 8cbaeee5da..909953e420 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -41,14 +41,12 @@ internal class TokenListStateConverter( private val onTokenClick: (accountId: AccountId?, currencyStatus: CryptoCurrencyStatus) -> Unit = { accountId, currencyStatus -> - val id = accountId?.let { PortfolioId(accountId) } ?: PortfolioId(selectedWallet.walletId) - clickIntents.onTokenItemClick(id, currencyStatus) + clickIntents.onTokenItemClick(selectedWallet.walletId, currencyStatus) } private val onTokenLongClick: (accountId: AccountId?, currencyStatus: CryptoCurrencyStatus) -> Unit = { accountId, currencyStatus -> - val id = accountId?.let { PortfolioId(accountId) } ?: PortfolioId(selectedWallet.walletId) - clickIntents.onTokenItemLongClick(id, currencyStatus) + clickIntents.onTokenItemLongClick(selectedWallet.walletId, currencyStatus) } private fun tokenStatusConverter(accountId: AccountId? = null) = TokenItemStateConverter( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt index b9b464642d..80068f621c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt @@ -8,7 +8,9 @@ import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.models.PortfolioId +import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase @@ -63,7 +65,10 @@ internal abstract class BasicTokenListSubscriber : WalletSubscriber() { flow = tokenListFlow(coroutineScope) .onEach { maybeTokenList -> coroutineScope.launch { - sendTokenListAnalytics(maybeTokenList) + sendTokenListAnalytics( + flattenCurrencies = maybeTokenList.getOrNull()?.flattenCurrencies(), + totalFiatBalance = maybeTokenList.getOrNull()?.totalFiatBalance, + ) }.saveIn(sendAnalyticsJobHolder) } .distinctUntilChanged() @@ -140,12 +145,14 @@ internal abstract class BasicTokenListSubscriber : WalletSubscriber() { private fun createAccountListFlow(coroutineScope: CoroutineScope): Flow<*> = combine( flow = accountListFlow(coroutineScope) - // todo account analytics for account total balance - /*.onEach { maybeTokenList -> + .onEach { accountStatusList -> coroutineScope.launch { - sendTokenListAnalytics(maybeTokenList) + sendTokenListAnalytics( + flattenCurrencies = accountStatusList.flattenCurrencies(), + totalFiatBalance = accountStatusList.totalFiatBalance, + ) }.saveIn(sendAnalyticsJobHolder) - }*/ + } .distinctUntilChanged() .onEach { accountList -> // todo account see[onAccountListReceived] @@ -207,13 +214,17 @@ internal abstract class BasicTokenListSubscriber : WalletSubscriber() { } } - private suspend fun sendTokenListAnalytics(maybeTokenList: Lce) { + private suspend fun sendTokenListAnalytics( + flattenCurrencies: List?, + totalFiatBalance: TotalFiatBalance?, + ) { val displayedState = stateHolder.getWalletStateIfSelected(userWallet.walletId) tokenListAnalyticsSender.send( displayedUiState = displayedState, userWallet = userWallet, - tokenList = maybeTokenList.getOrNull() ?: return, + flattenCurrencies = flattenCurrencies ?: return, + totalFiatBalance = totalFiatBalance ?: return, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt index 140a458bfc..95126a8582 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyAccountContent.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.tokenlist.PortfolioListItem import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM @@ -59,6 +60,7 @@ internal fun LazyListScope.portfolioTokensList( contentType = { _, item -> item::class.java }, itemContent = { tokenIndex, token -> val indexWithHeader = tokenIndex.inc() + val lastIndex = tokens.lastIndex.inc() val isPreview = LocalInspectionMode.current val appear = remember { MutableTransitionState(isPreview).apply { targetState = true } @@ -69,11 +71,12 @@ internal fun LazyListScope.portfolioTokensList( .animateItem() .roundedShapeItemDecoration( currentIndex = indexWithHeader, - lastIndex = tokens.lastIndex.inc(), + lastIndex = lastIndex, backgroundColor = TangemTheme.colors.background.primary, ), visibleState = appear, ) { + val modifier = if (indexWithHeader == lastIndex) Modifier.padding(bottom = 8.dp) else Modifier PortfolioTokensListItem( state = token, isBalanceHidden = isBalanceHidden, @@ -114,10 +117,15 @@ private fun LazyListScope.portfolioItem( modifier = anchorModifier, visibleState = appear, ) { + val modifier = if (portfolio.tokens.isEmpty()) { + Modifier.padding(vertical = 8.dp) + } else { + Modifier.padding(top = 8.dp) + } PortfolioListItem( state = portfolio, isBalanceHidden = isBalanceHidden, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + modifier = modifier, ) } } else { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/utils/WalletFeatureUseCasesFacade.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/utils/WalletFeatureUseCasesFacade.kt deleted file mode 100644 index 8ab07acad0..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/utils/WalletFeatureUseCasesFacade.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.utils - -import com.tangem.domain.models.PortfolioId -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.tokens.IsCryptoCurrencyCoinCouldHideUseCase -import com.tangem.domain.tokens.RemoveCurrencyUseCase -import javax.inject.Inject - -class WalletFeatureUseCasesFacade @Inject constructor( - private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase, - private val removeCurrencyUseCase: RemoveCurrencyUseCase, -) { - - suspend fun isCryptoCurrencyCoinCouldHide(portfolioId: PortfolioId, cryptoCurrencyCoin: CryptoCurrency.Coin) = - when (portfolioId) { - is PortfolioId.Account -> TODO("account") - is PortfolioId.Wallet -> isCryptoCurrencyCoinCouldHide(portfolioId.userWalletId, cryptoCurrencyCoin) - } - - suspend fun removeCurrencyUseCase(portfolioId: PortfolioId, currency: CryptoCurrency) = when (portfolioId) { - is PortfolioId.Account -> TODO("account") - is PortfolioId.Wallet -> removeCurrencyUseCase(portfolioId.userWalletId, currency) - } -} \ No newline at end of file From 03da1731ea29f89041ca1624fba73019c1fc2922 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 18:25:02 +0700 Subject: [PATCH 22/46] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 10 ++- .../com/tangem/common/routing/AppRoute.kt | 4 +- core/res/src/main/res/values-de/strings.xml | 88 ++++++++++++++++++- core/res/src/main/res/values-ja/strings.xml | 19 +++- core/res/src/main/res/values-ru/strings.xml | 24 +++++ .../src/main/res/values-uk-rUA/strings.xml | 4 + core/res/src/main/res/values/strings.xml | 13 ++- .../SingleAccountStatusListSupplier.kt | 10 ++- .../com/tangem/domain/tokens/TokensAction.kt | 16 ---- .../account/details/AccountDetailsModel.kt | 14 ++- .../details/entity/AccountDetailsUM.kt | 1 + .../details/ui/AccountDetailsContent.kt | 29 +++--- .../component/ManageTokensComponent.kt | 10 +-- .../component/ManageTokensSource.kt | 9 +- features/manage-tokens/impl/build.gradle.kts | 10 +++ .../entity/managetokens/ManageTokensUM.kt | 3 + .../model/CustomTokenSelectorModel.kt | 73 ++++++++++++++- .../managetokens/model/ManageTokensModel.kt | 54 +++++++++--- .../managetokens/ui/ManageTokensScreen.kt | 10 ++- .../model/WalletSettingsModel.kt | 5 +- .../walletsettings/utils/ItemsBuilder.kt | 3 +- .../intents/WalletContentClickIntents.kt | 10 --- .../router/DefaultWalletRouter.kt | 5 -- .../presentation/router/InnerWalletRouter.kt | 3 - 24 files changed, 335 insertions(+), 92 deletions(-) delete mode 100644 domain/legacy/src/main/java/com/tangem/domain/tokens/TokensAction.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 deb8b82709..e2ce3a2b4a 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 @@ -2,6 +2,7 @@ package com.tangem.tap.routing.utils import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.models.PortfolioId import com.tangem.domain.qrscanning.models.SourceType import com.tangem.feature.qrscanning.QrScanningComponent import com.tangem.feature.referral.api.ReferralComponent @@ -19,6 +20,7 @@ import com.tangem.features.hotwallet.* import com.tangem.features.kyc.KycComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.markets.tokenlist.MarketsTokenListComponent @@ -140,9 +142,15 @@ internal class ChildFactory @Inject constructor( AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES } + val mode = when (val portfolio = route.portfolioId) { + is PortfolioId.Account -> ManageTokensMode.Account(portfolio.accountId) + is PortfolioId.Wallet -> ManageTokensMode.Wallet(portfolio.userWalletId) + null -> ManageTokensMode.None + } + createComponentChild( context = context, - params = ManageTokensComponent.Params(route.userWalletId, source), + params = ManageTokensComponent.Params(mode, source), componentFactory = manageTokensComponentFactory, ) } 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 d051bdb21e..ce692050bc 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 @@ -121,8 +121,8 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class ManageTokens( val source: Source, - val userWalletId: UserWalletId? = null, - ) : AppRoute(path = "${source.name.lowercase()}/manage_tokens/$userWalletId") { + val portfolioId: PortfolioId? = null, + ) : AppRoute(path = "${source.name.lowercase()}/manage_tokens/${portfolioId?.stringValue}") { enum class Source { STORIES, diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 2817667d7e..645d4943f0 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -4,8 +4,8 @@ Trotzdem überspringen Zugangscode nicht festgelegt Zugangscode eingeben - Falscher Zugangscode. Deine Hot-Wallet wird nach %s weiteren Fehlversuchen gelöscht. - Falscher Zugangscode. App wird bei %s weiteren Eingabefehlern gesperrt + Falscher Zugangscode. Deine mobile Wallet wird nach %s weiteren Fehlversuchen gelöscht. + Falscher Zugangscode. Die App wird nach %s weiteren Fehlversuchen gesperrt. Falscher Zugangscode.\nBitte warte %s Sekunden und versuche es erneut. Bestätige Deinen zuvor eingegebenen Code, um fortzufahren Zugangscode erneut eingeben @@ -19,6 +19,7 @@ Wiederherstellen Du bist dabei, dich zu erholen \"%1$s”. Konto wiederherstellen + Du hast das Limit von 20 aktiven Konten bereits überschritten. Archiviere eines, um es wiederherzustellen. Archiviert Konto erstellt Archivkonto @@ -30,6 +31,7 @@ Konto hinzufügen Speichern Kontoname + Der Kontoname ist bereits vorhanden Konto Neues Konto Konto hinzufügen @@ -104,7 +106,7 @@ %s Karte %s Karten - Handy-Wallet + Mobile-Wallet Du hast Deine Wallet erfolgreich gesichert. Diese Wörter können bei Verlust nicht wiederhergestellt werden. Bewahre diese an einem sicheren Ort auf. Sicherung abgeschlossen @@ -174,6 +176,7 @@ Akzeptieren Zugang verweigert Konten + Aktivieren Hinzufügen Zum Portfolio hinzufügen Token hinzufügen @@ -182,6 +185,7 @@ Erlauben Betrag Analysen + und Anwenden Genehmigung Genehmigen @@ -198,6 +202,7 @@ D hast keinen Zugang zur Kamera erteilt, bitte passe deine Datenschutzeinstellungen an Abbrechen Ändern + Konto auswählen Aktion wählen Netzwerk auswählen Token auswählen @@ -246,7 +251,9 @@ Gebühren Beenden Kostenlos + Vom Adressen synchronisieren + Erste Schritte Zum Anbieter gehen Zum Token Verstanden @@ -255,6 +262,7 @@ Importieren In Arbeit Später + Mehr erfahren %1$s übrig Legacy Bitcoin Gesperrt @@ -505,6 +513,7 @@ Neues Wallet erstellen Karte oder Ring bestellen Karte oder Ring scannen + Möchtest Du „Tangem“ die biometrische Authentifizierung erlauben? Um Deine Identität zu bestätigen und die App zu öffnen An %s Im %s Netzwerk Willst Du den Vorgang zur Erstellung des Zugangscodes wirklich beenden? @@ -516,7 +525,7 @@ Wenn dies der Fall ist, musst Du von vorne beginnen. Bist Du sicher, dass Du den Aktivierungsprozess beenden willst? Wenn dies der Fall ist, musst Du von vorne beginnen. - Wiederherstellung einer bestehenden Wallet, die in Ihrem Google Drive-Backup gespeichert ist + Vorhandene Wallet über Google Drive-Backup wiederherstellen Google Drive-Backup Verbesser Deine Sicherheit sofort mit einer erstklassigen Hardware-Wallet von Tangem. Hardware-Wallet @@ -528,6 +537,7 @@ Schlüssel werden in der App gespeichert Sicherung der Seed-Phrase Mobile Wallet erstellen + Vorhandene Wallet importieren Diese Wiederherstellungsphrase wurde bereits importiert Mobile Wallet Dein Geld bleibt während des gesamten Prozesses sicher und vollständig zugänglich @@ -848,6 +858,7 @@ Zugangscode wiederherstellen Identische Karten oder Ring Zugangscode + Alle Angebote Verfügbar mit %s Anbieter erleichtern Transaktionen Suche nach Land @@ -855,13 +866,24 @@ Andere Währungen Beliebte Fiats Suche nach Währung + Sofort Durch die Nutzung der Onramp-Funktionalität stimmst Du den %1$s und %2$s des Anbieters zu. + Der Service wird von einem externen Anbieter bereitgestellt. \n Tangem übernimmt keine Verantwortung. Der Kaufbetrag sollte nicht höher sein als %s Der zu kaufende Betrag muss mindestens %s betragen Keine verfügbaren Anbieter für diese Währung + Schnellste Bezahlen mit + Zahlungsmethode Verfügbar bis zu %s Erhältlich bei %s + + Anbieter + Anbieter + + Anbieter + Kürzlich verwendet + Empfohlen Du kannst Deine Transaktion beim Drittanbieter %s abschließen. Umleitung auf %s… Unsere Dienstleistungen sind in diesem Land nicht verfügbar @@ -870,13 +892,18 @@ Bitte wähle das richtige Land aus, um korrekte Zahlungsoptionen und Dienstleistungen zu gewährleisten. Einstellungen Du kannst diesen Bildschirm schließen und den Transaktionsstatus auf dem Bildschirm mit den Token-Details überprüfen. + %s min + Du erhältst + Der Service wird von einem externen Anbieter bereitgestellt. \n Tangem übernimmt keine Verantwortung. Du kannst diesen Bildschirm schließen und den Transaktionsstatus auf dem Bildschirm mit den Token-Details überprüfen. + Bis zu Über Du zahlst Gruppe erstellen Nach Guthaben Token organisieren Gruppe löschen + %s Unterstützung Mehr Infos Du kannst Benachrichtigungen für Tangem in den Einstellungen aktivieren. Später aktivieren @@ -895,6 +922,7 @@ Sende nur %s an diese Adresse. Der Versand einer anderen Währung führt zu ihrem unwiderruflichen Verlust. Senden nur %1$s im Netzwerk %2$s Überweise Geld von einem anderen Wallet oder einer anderen Börse + Adresse für Prämien Teilnehmen Die Informationen zum Empfehlungsprogramm konnten nicht geladen werden. Bitte versuche es später noch einmal. Die Informationen über das Empfehlungsprogramm konnten nicht geladen werden. Fehlercode: %s. Bitte versuche es später noch einmal. @@ -1071,6 +1099,9 @@ Hiermit wird die Wallet aus der Anwendung entfernt. Die Wallet selbst kann wieder hinzugefügt werden. Name Lass deinen Token für dich arbeiten + Eine Netzwerkgebühr ist eine kleine Zahlung, die erforderlich ist, um Deine Transaktion auf der Blockchain zu verarbeiten und zu bestätigen. + Um mit dem Staking zu beginnen, muss Dein TON-Konto mit einer Selbsttransaktion von 1 TON aktiviert werden. Das Guthaben verbleibt in Deiner Wallet – dieser Schritt aktiviert Dein Konto lediglich für das Staking. + Kontoaktivierung Die Anzahl der zu stakenden Krypros muss mindesten %s betragen Der Stakingbetrag wird aufgrund der Netzwerkregeln auf %1$s TRX aufgerundet. Der Betrag der unstaked wird, wird aufgrund der Netzwerkregeln auf %1$s TRX gerundet. @@ -1079,6 +1110,7 @@ Ein Staking-Konto ist ein spezielles Konto, auf dem eingesetzte SOL-Token gespeichert werden. Es wird erstellt, indem Sie Ihre Token an einen Validator delegieren, um an der Transaktionsvalidierung teilzunehmen und Belohnungen zu erhalten. Für die Einrichtung des Staking-Kontos wird eine geringe Gebühr erhoben, die nach Abschluss des Stakings zurückerstattet wird. Jährliche prozentuale Rendite Die jährliche prozentuale Rendite, die du durch die Teilnahme am Staking erzielen kannst. + APY zeigt den jährlichen prozentualen Ertrag des Validators basierend auf seiner Leistung Effektiver Jahreszins Belohnungen sammeln sich täglich automatisch in deinem Staking-Konto an. Verfügbar @@ -1134,6 +1166,8 @@ Reinvestiert Deine verdienten Prämien in Deinen Einsatzbetrag und erhöht so den potenziellen Gewinn. Mit Restake kannst Du Dein Guthaben von einem Validator zu einem anderen verschieben, ohne dass Du den Stake aufheben musst. Du bist dabei, Dein gesamtes Guthaben zu staken. Wir empfehlen, einen kleinen Betrag übrig zu lassen, um die Netzwerkgebühren für die Aufhebung des Stakes oder das Einfordern von Prämien abzudecken. + Um mit dem Staking zu beginnen, muss Dein TON-Konto mit einer Selbsttransaktion von 1 TON aktiviert werden. Das Guthaben verbleibt in Deiner Wallet – dieser Schritt aktiviert Dein Konto lediglich für das Staking. + Kontoaktivierung Um mit dem Staking bei TON zu beginnen, führe zunächst eine ausgehende Transaktion in beliebiger Höhe durch – dadurch wird Deine Wallet aktiviert. Für den Abschluss der Transaktion können zusätzlich zur Netzwerkgebühr bis zu 0,2 TON erforderlich sein. Nicht genutzte Beträge werden zurückerstattet. Für diesen Vorgang sind zusätzlich zur Netzwerkgebühr 0,2 TON erforderlich. Bitte lade Dein Guthaben auf. @@ -1166,6 +1200,7 @@ Monatlich Wöchentlich Belohnungen + Belohnungen auf Solana werden automatisch Deinem Staking-Guthaben hinzugefügt und können nicht separat angezeigt werden. Stake gesperrt Mehr staken Beim Staking %1$s wird Dein gesamtes %2$s -Guthaben eingesetzt. Alle weiteren %2$s due Du in Deine Tangem-Wallet einzahlst, werden ebenfalls automatisch eingesetzt. @@ -1205,6 +1240,7 @@ Web 3.0-kompatibel Zum Fortfahren ist eine eingehende Transaktion von mindestens %1$s erforderlich Unzureichende Mittel + Durch die Genehmigung erlaubst Du dem Smart Contract, Deine Token in zukünftigen Transaktionen zu verwenden. Fester Zinssatz Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Sie die Verwendung Ihres Tokens für den Swap genehmigen. Tausche mehr Token zu besseren Kursen direkt in deiner Brieftasche. @@ -1237,6 +1273,19 @@ Du erhältst Token auswählen Nicht verfügbar + Daten konnten nicht geladen werden. Versuche es später noch einmal. + Verstecken + Aufdecken + Kartendetails + Karte erhalten + Füge es Deiner Wallet hinzu und bezahle überall mit Deinem Smartphone. + Apple Pay & Google Pay + Verwende Dein USDC-Guthaben, um alltägliche Einkäufe problemlos zu bezahlen. + Einkäufe im Alltag + Deine Kartendaten sind geschützt – volle Kontrolle in der App. + Integrierte Sicherheit + Hol Dir Deine kostenlose Crypto Card \n in wenigen Minuten + Tangem Pay Das ist meine Wallet Guthaben versteckt Angezeigte Salden @@ -1264,6 +1313,7 @@ Token in %%image%% %1$s Netzwerk Der %1$s (%2$s) Token ist die Hauptwährung im %3$s Netzwerk und kann nicht versteckt werden, solange du andere Token dieses Netzwerks in der Liste aktiv hast. %s kann nicht ausgeblendet werden + QR-Code anzeigen Tausche diesen Token gegen einen anderen zu %1$s Servicegebühren von Februar %2$s-%3$s. Tausche mit Changelly, %s Gebühren Jetzt tauschen @@ -1312,6 +1362,8 @@ Frühzeitiger Zugriff auf neue Funktionen und exklusive Angebote. Updates zu Funktionen und Neuigkeiten Möchtest du Push-Benachrichtigungen verwenden? + Aktiviere Push-Benachrichtigungen und wir benachrichtigen Dich sofort, wenn Gelder eintreffen \n + Verpasse keine Transaktion Neues Wallet hinzufügen Möchtest Du diese Wallet wirklich entfernen? Es ist ein Fehler aufgetreten, bitte scanne deine Karte oder Ring, um sich anzumelden @@ -1548,6 +1600,7 @@ Wir haben eine Art Problem Alle dApps getrennt Erlaubnis auszugeben + Durch die Genehmigung erlaubst Du dApps oder Smart Contracts, Token in zukünftigen Transaktionen zu verwenden. Vertragsadresse Verbinden Laden @@ -1622,4 +1675,31 @@ Nein, alles senden Um %s XTZ reduziert Damit Sie beim nächsten Aufladen Ihrer Brieftasche keine erhöhte Provision zahlen, soll der Betrag um %s XTZ reduziert werden + Chart konnte nicht geladen werden... + Der erhaltene Betrag %1$s %2$s wurde nicht auf Aave eingezahlt. + Aktuelle Gebühr + Maximale Gebühr + Gebührenpolitik + Die Netzwerkgebühr ist derzeit zu hoch. Warte, bis sie unter Dein Limit fällt. + Historische Renditen + Sofortige Auszahlung + Wie funktioniert das? + Verdiene %s%% jährlich + Aave • Variabler Zinssatz + Aave + Durchschnitt %s + Renditen des letzten Jahres + Unterstützt durch + Der Zinssatz ist variabel + Beginn des Verdienstes + Siehe Gebührenrichtlinie + Aktiv + Pausiert + Effektiver Jahreszins für Versorgung + Lass Dein Geld arbeiten – verdiene Zinsen auf Dein Guthaben. + Verdienst auf Dein Guthaben + Verdienen %1$s%% pro Jahr + Der Stakingservice ist derzeit nicht verfügbar. Bitte versuche es später erneut. + Einnahmen nicht verfügbar + Chart konnte nicht geladen werden... diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index d76ede5115..f252579b6e 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -19,7 +19,7 @@ 回復する 「 %1$s 」を回復しようとしています。 アカウントを回復する - すでにアクティブアカウント数の上限(20件)を超えています。復元するには1件アーカイブしてください。 + すでにアクティブアカウントの上限(20件)に達しています。復元するには、1つをアーカイブしてください。 アカウントを復元できません アーカイブ済み アカウントを作成できませんでした。しばらくしてからもう一度お試しください。 @@ -176,6 +176,7 @@ 受け入れる アクセスが拒否されました アカウント + 有効化 追加 ポートフォリオに追加 トークンを追加 @@ -248,6 +249,7 @@ 速度と料金 終了 無料 + 送信元 アドレスを同期する はじめる プロバイダーへ移動 @@ -532,6 +534,7 @@ 鍵はアプリに保存されます シードフレーズのバックアップ モバイルウォレットを作成する + 既存のウォレットをインポートする このリカバリーフレーズはすでにインポートされています。 モバイルウォレット アップグレードできません。このデバイスにはすでにウォレットが存在します。 @@ -896,6 +899,7 @@ 残高順 トークンを整理する グループ解除 + %sサポート 詳細はこちら Tangemの通知は設定で有効にできます。 後で有効にする @@ -1089,6 +1093,9 @@ これにより、ウォレットがアプリから削除されます。ウォレットは再度追加できます。 名前 トークンを活用しよう + ネットワーク手数料とは、ブロックチェーン上で取引を処理し、承認するために支払う少額の料金です。 + ステーキングを始めるには、1 TONの自己取引によってTONアカウントを有効化する必要があります。資金はウォレット内にそのまま残ります。これは、ステーキング有効化のためのステップにすぎません。 + アカウントの有効化 ステーキング金額は %s 以上である必要があります ネットワークルールにより、ステーキング金額は%1$s TRX に切り上げられます。 ネットワークルールにより、ステーキング解除の量は%1$s TRX に切り上げられます。 @@ -1154,6 +1161,8 @@ 獲得した報酬をステーキングに再投資し、潜在的な収益を増やします。 再ステーキングを使うと、ステーキングを解除することなく、あるバリデータから別のバリデータに資金を移動できます。 残高のすべてをステーキングしようとしています。ステーキング解除や報酬請求にかかるネットワーク手数料をカバーするために、少額を残しておくことをお勧めします。 + ステーキングを始めるには、TONアカウントを1 TONの自己取引で有効化する必要があります。資金はウォレット内にそのまま残り、この手順でステーキング用にアカウントを有効化できます。 + アカウントの有効化 TONのステーキングを開始するには、まず自分のアドレスに少額の取引を送信します。これによりウォレットが有効になります。 取引を完了するには、ネットワーク手数料に加えて最大0.2TONが必要になる場合があります。未使用分は返金されます。 この操作を続行するには、ネットワーク手数料に加えて0.2 TONが必要です。残高を補充してください。 @@ -1186,6 +1195,7 @@ 毎月 毎週 報酬 + Solanaの報酬は自動的にステーキング残高に追加され、個別に表示することはできません。 ステーキングはロックされています もっとステーキングする %1$sをステーキングすると、 %2$s残高全体がステーキングされます。Tangemウォレットに入金した追加の%2$sも、自動的にステーキングされます。 @@ -1647,6 +1657,13 @@ URIはすでに使用されています WalletConnect 不審な取引 + 業界最高水準のハードウェアウォレット + 迅速な配送 + シンプルな操作 + Tangemでハードウェアウォレットを作成しましょう。銀行のカードのようにスリムで、金庫のように安全です。 + ソフトウェアウォレットを作成またはインポート + モバイルウォレットから始める + その他の方法 破棄 バックアップが中断されました。再開しますか? はい、再開します diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index d3729cd4c9..cf16e56da7 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1,8 +1,11 @@ Ваш кошелёк не защищён без кода доступа. + Лимит в 20 активных аккаунтов достигнут. Пожалуйста, зархивируйте один аккаунт, чтобы продолжить. + Невозможно восстановить аккаунт Архив Вы архивируете свой аккаунт, но в любое время можете вернуть его обратно + Имя аккаунта уже существует Аккаунт Новый аккаунт Основной аккаунт @@ -118,6 +121,7 @@ Недостаточно ADA Принять Доступ запрещен + Активировать Добавить Добавить в портфель Добавить токен @@ -188,6 +192,7 @@ Медленно Скорость и комиссия Завершить + Из Синхронизировать адреса К провайдеру Перейти в токен @@ -859,6 +864,7 @@ Отправляйте только %s на этот адрес. Использование другой сети может привести к утрате средств. Отправляйте только %1$s в сети %2$s Переводите средства с любого кошелька или биржи + Адрес для начислений Участвовать Не удалось загрузить информацию по реферальной программе. Пожалуйста, попробуйте позже. Не удалось загрузить информацию по реферальной программе. Код ошибки: %s. Пожалуйста, попробуйте позже. @@ -1035,6 +1041,9 @@ Это приведет к удалению кошелька из приложения. Сам кошелек можно добавить снова. Имя Заставьте ваш токен работать + Сетевая комиссия — это небольшая плата за обработку и подтверждение вашей транзакции в блокчейне. + Чтобы начать стейкинг, ваш TON-аккаунт должен быть активирован транзакцией самому себе на 1 TON. Средства остаются в вашем кошельке — этот шаг лишь активирует ваш аккаунт для участия в стейкинге. + Активация аккаунта Сумма для стейкинга должна быть не менее %s Согласно правилам сети, сумма стейкинга будет округлена до %1$sTRX. Сумма для вывода из стейкинга будет округлена до %1$s TRX ввиду особенностей сети. @@ -1098,6 +1107,8 @@ Реинвестируйте свои заработанные награды в вашу застейканную сумму, увеличивая потенциальный доход Рестейк позволяет вам переместить средства из одного валидатора в другого без необходимости выхода из стейкинга. Вы собираетесь застейкать весь баланс, рекомендуем оставить небольшую сумму для оплаты комиссии сети при выходе из стейкинга или получении награды. + Чтобы начать стейкинг, ваш TON-аккаунт должен быть активирован транзакцией самому себе на 1 TON. Средства остаются в вашем кошельке — этот шаг лишь активирует ваш аккаунт для участия в стейкинге. + Активация аккаунта Чтобы начать стейкинг в TON, сначала отправьте небольшую транзакцию на свой же адрес — это активирует ваш кошелёк. До 0.2 TON может потребоваться сверх сетевой комиссии для завершения транзакции. Неиспользованная часть будет возвращена. Для выполнения операции требуется дополнительно 0.2 TON, помимо сетевой комиссии. Пожалуйста, пополните баланс. @@ -1130,6 +1141,7 @@ Месяц Еженедельно Вознаграждения + Вознаграждения в сети Solana автоматически добавляются к вашему стейкинг-балансу и не могут отображаться отдельно. Стейкинг закрыт Застейкать еще При стейкинге %1$s используется весь ваш баланс в %2$s. Любой дополнительный %2$s депозит будет автоматически застейкан. @@ -1169,6 +1181,7 @@ Поддержка Web 3.0 Для отправки требуется входящая транзакция на сумму не менее %1$s Недостаточно средств + Подтверждая, вы разрешаете смарт-контракту использовать ваши токены в будущих транзакциях. Комиссия сети за одобрение токена будет взиматься за подтверждение того, что именно вы разрешаете использовать ваш токен для обмена. Обменивайте больше токенов по лучшим курсам прямо в вашем кошельке. Новый провайдер обмена! @@ -1227,6 +1240,7 @@ Токен в сети %%image%% %1$s Токен %1$s (%2$s) является основной валютой в сети %3$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети Невозможно скрыть %s + Показать QR код Обменивайте этот токен на другие с %1$s комиссии за обслуживание с %2$s по %3$s февраля. Обмен с Changelly, %s комиссии Обменять @@ -1516,6 +1530,7 @@ Изменения в кошельке не обнаружены. Обнаружены потенциальные риски или вредоносное поведение. Подключение или подписание транзакций может привести к потере средств. Известный риск безопасности + Откройте Web3-приложение и выберите опцию WalletConnect. Запрос от Подписать всё равно Тип подписи @@ -1544,4 +1559,13 @@ Нет, отправить все Уменьшить на %s XTZ Чтобы не платить повышенную комиссию при следующем пополнении кошелька, уменьшите сумму на %s XTZ + Невозможно загрузить график + Полученная сумма, %1$s %2$s, не была зачислена на Aave. + Сетевая комиссия сейчас слишком высокая. Ожидаем, пока она упадёт ниже вашего лимита. + Историческая доходность + Проверьте ваше интернет соединение + Годовая доходность + Сервис начисления процентов в данный момент недоступен. Пожалуйста, попробуйте позже. + Данные о доходе недоступны + Невозможно загрузить график 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 e832ab378e..ddc9e58212 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1183,8 +1183,12 @@ Використовуйте %s або відскануйте картку/кільце, щоб отримати доступ до свого гаманця Не вдалося встановити з\'єднання: Цей dApp використовує Wallet Connect версії 1.0, яка не підтримується. Будь ласка, переконайтеся, що dApp підтримує Wallet Connect версії 2.0 для успішного підключення. Будьте в курсі останніх функцій та новин + Миттєві сповіщення про транзакції, обміни та важливі оновлення. + Сповіщення про транзакції Отримуйте сповіщення про вхідні транзакції Дізнавайтеся першими про нові акції + Ранній доступ до нових функцій та ексклюзивних пропозицій. + Нові функції та важливі новини Бажаєте використовувати Push-повідомлення? Додати новий гаманець Ви впевнені, що хочете видалити цей гаманець? diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 9987d7f342..6479a28a3f 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -19,7 +19,7 @@ Recover You’re about to recover “%1$s”. Recover account - You have already exceeded the limit of 20 active accounts. Archive one to recover + You have already exceeded the limit of 20 active accounts. Archive one to recover. Can\'t recover account Archived We couldn’t create account. Please try again later. @@ -355,6 +355,8 @@ Yes Contract address copied! Available networks + Derivation of your token matches the derivation of %1$s. Your token will be added to this account. + Derivation belongs to another account. Contract address Contract address is invalid Please select the network @@ -1713,7 +1715,7 @@ No wallet changes detected Potential risks or malicious behavior have been detected. Connecting or signing transactions may lead to loss of funds. Known security risk - Open Web3 app and chose WalletConnect option + Open the Web3 app and choose the WalletConnect option. Request from Sign anyway Signature Type @@ -1729,6 +1731,13 @@ URI already used WalletConnect Suspicious transaction + Best in class hardware wallet + Fast delivery + Simple to use + Create a hardware wallet with Tangem. Slim as a bank card, secure as a bank vault. + Create or import a software wallet + Start with Mobile Wallet + Other method Discard You have an interrupted backup. Do you want to resume? Yes, resume diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt index 5e5ef8f506..6177e2cde5 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/supplier/SingleAccountStatusListSupplier.kt @@ -3,6 +3,8 @@ package com.tangem.domain.account.status.supplier import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow /** * Supplier that provides a single [AccountStatusList] for a specific user wallet. @@ -12,4 +14,10 @@ import com.tangem.domain.core.flow.FlowCachingSupplier abstract class SingleAccountStatusListSupplier( override val factory: SingleAccountStatusListProducer.Factory, override val keyCreator: (SingleAccountStatusListProducer.Params) -> String, -) : FlowCachingSupplier() \ No newline at end of file +) : FlowCachingSupplier() { + + operator fun invoke(userWalletId: UserWalletId): Flow { + val params = SingleAccountStatusListProducer.Params(userWalletId) + return this.invoke(params) + } +} \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/tokens/TokensAction.kt b/domain/legacy/src/main/java/com/tangem/domain/tokens/TokensAction.kt deleted file mode 100644 index 90378e19c2..0000000000 --- a/domain/legacy/src/main/java/com/tangem/domain/tokens/TokensAction.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.domain.tokens - -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.Token -import org.rekotlin.Action - -sealed interface TokensAction : Action { - - /** Single way to pass data to the screen */ - sealed interface SetArgs : TokensAction { - object ManageAccess : SetArgs - object ReadAccess : SetArgs - } -} - -data class TokenWithBlockchain(val token: Token, val blockchain: Blockchain) \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt index 2c0a24413a..2cf339e309 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/AccountDetailsModel.kt @@ -13,7 +13,10 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase +import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.portfolioIcon import com.tangem.features.account.details.entity.AccountDetailsUM @@ -30,6 +33,7 @@ internal class AccountDetailsModel @Inject constructor( private val router: Router, override val dispatchers: CoroutineDispatcherProvider, private val archiveCryptoPortfolioUseCase: ArchiveCryptoPortfolioUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, ) : Model() { private val params = paramsContainer.require() @@ -42,8 +46,11 @@ internal class AccountDetailsModel @Inject constructor( } private fun onManageTokensClick() { - // todo account add account param - router.push(AppRoute.ManageTokens(source = AppRoute.ManageTokens.Source.SETTINGS)) + val route = AppRoute.ManageTokens( + source = AppRoute.ManageTokens.Source.SETTINGS, + portfolioId = PortfolioId(params.account.accountId), + ) + router.push(route) } private fun onArchiveAccountClick() { @@ -89,6 +96,8 @@ internal class AccountDetailsModel @Inject constructor( ) } } + val isMultiCurrency = getUserWalletUseCase(params.account.accountId.userWalletId) + .getOrNull()?.isMultiCurrency ?: false return AccountDetailsUM( accountName = params.account.accountName.toUM().value, accountIcon = params.account.portfolioIcon.toUM(), @@ -96,6 +105,7 @@ internal class AccountDetailsModel @Inject constructor( onAccountEditClick = ::onEditAccountClick, onManageTokensClick = ::onManageTokensClick, archiveMode = archiveMode, + isManageTokensAvailable = isMultiCurrency, ) } } \ No newline at end of file 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 3938e757fb..27ad494553 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 @@ -7,6 +7,7 @@ internal data class AccountDetailsUM( val accountName: TextReference, val accountIcon: CryptoPortfolioIconUM, val archiveMode: ArchiveMode, + val isManageTokensAvailable: Boolean, val onCloseClick: () -> Unit, val onAccountEditClick: () -> Unit, val onManageTokensClick: () -> Unit, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt index bd4a0e319f..e3708f495f 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt @@ -22,7 +22,6 @@ import com.tangem.common.ui.R import com.tangem.common.ui.account.AccountIconPreviewData import com.tangem.common.ui.account.AccountRow import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig @@ -49,6 +48,7 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier = ) Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxSize() .padding(horizontal = TangemTheme.dimens.spacing16) @@ -61,21 +61,22 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier = style = TangemTheme.typography.h1, color = TangemTheme.colors.text.primary1, ) - SpacerH16() AccountRow(state) - SpacerH16() - ManageTokensRow(state) + if (state.isManageTokensAvailable) { + ManageTokensRow(state) + } when (state.archiveMode) { is AccountDetailsUM.ArchiveMode.Available -> { - SpacerH16() - ArchiveAccountRow(state.archiveMode) - SpacerH(8.dp) - Text( - modifier = Modifier.padding(horizontal = 12.dp), - text = stringResourceSafe(R.string.account_details_archive_description), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) + Column { + ArchiveAccountRow(state.archiveMode) + SpacerH(8.dp) + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResourceSafe(R.string.account_details_archive_description), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } } AccountDetailsUM.ArchiveMode.None -> Unit } @@ -186,10 +187,12 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider } \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt index 3c34dd0f34..bdd3cdbc10 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt @@ -17,6 +17,13 @@ sealed interface ManageTokensMode { } sealed interface AddCustomTokenMode { - data class Wallet(val userWalletId: UserWalletId) : AddCustomTokenMode + + val userWalletId: UserWalletId + get() = when (this) { + is Account -> accountId.userWalletId + is Wallet -> userWalletId + } + + data class Wallet(override val userWalletId: UserWalletId) : AddCustomTokenMode data class Account(val accountId: AccountId) : AddCustomTokenMode } \ No newline at end of file diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 0506848d40..58cffdfd57 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { implementation(projects.common.ui) /* Project - Domain */ + implementation(projects.domain.account.status) + implementation(projects.domain.account) implementation(projects.domain.card) implementation(projects.domain.legacy) implementation(projects.domain.manageTokens) @@ -35,6 +37,14 @@ dependencies { implementation(projects.domain.swap.models) implementation(projects.domain.notifications) + // region Project - Libs + implementation(projects.libs.crypto) + // endregion + + // region Tangem SDKs + implementation(tangemDeps.blockchain) + // endregion + /* AndroidX */ implementation(deps.androidx.activity.compose) implementation(deps.lifecycle.compose) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt index 56323dcd9a..13fca6567a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/managetokens/ManageTokensUM.kt @@ -54,6 +54,7 @@ internal sealed class ManageTokensUM { isSavingInProgress: Boolean = this is ManageContent && this.isSavingInProgress, scrollToTop: StateEvent = this.scrollToTop, needToInteractWithColdWallet: Boolean = this is ManageContent && this.needToInteractWithColdWallet, + topBar: ManageTokensTopBarUM? = this.topBar, ): ManageTokensUM { return when (this) { is ManageContent -> copy( @@ -65,6 +66,7 @@ internal sealed class ManageTokensUM { isSavingInProgress = isSavingInProgress, scrollToTop = scrollToTop, needToInteractWithColdWallet = needToInteractWithColdWallet, + topBar = topBar, ) is ReadContent -> copy( search = search, @@ -72,6 +74,7 @@ internal sealed class ManageTokensUM { isInitialBatchLoading = isInitialBatchLoading, isNextBatchLoading = isNextBatchLoading, scrollToTop = scrollToTop, + topBar = topBar, ) } } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt index 3bb119e580..cadcf9f356 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -3,13 +3,22 @@ package com.tangem.features.managetokens.model import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.ui.account.toUM 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.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.managetokens.GetSupportedNetworksUseCase +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.component.AddCustomTokenMode import com.tangem.features.managetokens.component.CustomTokenSelectorComponent @@ -25,10 +34,12 @@ import com.tangem.features.managetokens.entity.item.SelectableItemUM import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.utils.mapper.toCurrencyNetworkModel import com.tangem.features.managetokens.utils.mapper.toDerivationPathModel +import com.tangem.lib.crypto.derivation.AccountNodeRecognizer import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -38,6 +49,8 @@ internal class CustomTokenSelectorModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val getSupportedNetworksUseCase: GetSupportedNetworksUseCase, private val messageSender: UiMessageSender, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val accountsFeatureToggles: AccountsFeatureToggles, paramsContainer: ParamsContainer, ) : Model() { @@ -146,9 +159,8 @@ internal class CustomTokenSelectorModel @Inject constructor( return derivationPaths } - private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List = when (mode) { - is AddCustomTokenMode.Account -> TODO("Account") - is AddCustomTokenMode.Wallet -> getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e -> + private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List { + return getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e -> val message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)) messageSender.send(message) @@ -168,7 +180,60 @@ internal class CustomTokenSelectorModel @Inject constructor( fun selectCustomDerivationPath(value: SelectedDerivationPath) { when (params) { is NetworkSelector -> return - is DerivationPathSelector -> params.onDerivationPathSelected(value) + is DerivationPathSelector -> if (accountsFeatureToggles.isFeatureEnabled) { + params.checkAccountDerivation(value) + } else { + params.onDerivationPathSelected(value) + } } } + + private fun DerivationPathSelector.checkAccountDerivation(derivationPath: SelectedDerivationPath) = + modelScope.launch { + val accountName = derivationPath.id + ?.let { Blockchain.fromId(it.rawId.value) }?.let(::AccountNodeRecognizer) + ?.let { recognizer -> derivationPath.value.value?.let { recognizer.recognize(it) } } + ?.let { accountNode -> + fun AccountStatus.CryptoPortfolio.sameNodeAndNotMain() = !this.account.isMainAccount && + this.account.derivationIndex.value.toLong() == accountNode + + val accounts = singleAccountStatusListSupplier(mode.userWalletId) + .first().accountStatuses + val account = accounts.find { + when (it) { + is AccountStatus.CryptoPortfolio -> it.sameNodeAndNotMain() + } + } + val accountName = when (account) { + is AccountStatus.CryptoPortfolio -> account.account.accountName.toUM() + null -> null + } + accountName + } + + if (accountName == null) { + onDerivationPathSelected(derivationPath) + } else { + showAccountNameExist( + accountName = accountName.value, + onClick = { onDerivationPathSelected(derivationPath) }, + ) + } + } + + private fun showAccountNameExist(accountName: TextReference, onClick: () -> Unit) { + val firstAction = EventMessageAction( + title = resourceReference(R.string.common_got_it), + onClick = onClick, + ) + val dialogMessage = DialogMessage( + title = resourceReference(R.string.custom_token_another_account_dialog_title), + message = resourceReference( + R.string.custom_token_another_account_dialog_description, + wrappedList(accountName), + ), + firstActionBuilder = { firstAction }, + ) + messageSender.send(dialogMessage) + } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt index c34a6ba32d..74ee2da6c5 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent import com.tangem.features.managetokens.component.ManageTokensComponent @@ -40,13 +41,14 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @ModelScoped internal class ManageTokensModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val messageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, manageTokensListManagerFactory: ManageTokensListManager.Factory, manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory, paramsContainer: ParamsContainer, @@ -86,6 +88,7 @@ internal class ManageTokensModel @Inject constructor( modelScope.launch { manageTokensListManager.launchPagination(isCollapsed = true) } + checkIsSupportAddCustomTokens() } fun reloadList() { @@ -105,16 +108,34 @@ internal class ManageTokensModel @Inject constructor( } } + private fun getTopBarInitialState(): ManageTokensTopBarUM = when (params.mode) { + is ManageTokensMode.Wallet -> manageContentTopBar() + is ManageTokensMode.Account -> ManageTokensTopBarUM.ReadContent( + title = resourceReference(id = R.string.main_manage_tokens), + onBackButtonClick = router::pop, + ) + ManageTokensMode.None -> ManageTokensTopBarUM.ReadContent( + title = resourceReference(R.string.common_search_tokens), + onBackButtonClick = router::pop, + ) + } + + private fun manageContentTopBar() = ManageTokensTopBarUM.ManageContent( + title = resourceReference(id = R.string.main_manage_tokens), + onBackButtonClick = router::pop, + endButton = TopAppBarButtonUM.Icon( + iconRes = R.drawable.ic_plus_24, + onClicked = ::navigateToAddCustomToken, + ), + ) + private fun createReadContentModel(): ManageTokensUM.ReadContent { return ManageTokensUM.ReadContent( popBack = router::pop, isInitialBatchLoading = true, isNextBatchLoading = false, items = getLoadingItems(), - topBar = ManageTokensTopBarUM.ReadContent( - title = resourceReference(R.string.common_search_tokens), - onBackButtonClick = router::pop, - ), + topBar = getTopBarInitialState(), search = SearchBarUM( placeholderText = resourceReference(R.string.common_search), query = "", @@ -132,14 +153,7 @@ internal class ManageTokensModel @Inject constructor( isInitialBatchLoading = true, isNextBatchLoading = false, items = getLoadingItems(), - topBar = ManageTokensTopBarUM.ManageContent( - title = resourceReference(id = R.string.main_manage_tokens), - onBackButtonClick = router::pop, - endButton = TopAppBarButtonUM.Icon( - iconRes = R.drawable.ic_plus_24, - onClicked = ::navigateToAddCustomToken, - ), - ), + topBar = getTopBarInitialState(), search = SearchBarUM( placeholderText = resourceReference(R.string.common_search), query = "", @@ -174,6 +188,20 @@ internal class ManageTokensModel @Inject constructor( .launchIn(modelScope) } + private fun checkIsSupportAddCustomTokens() { + when (val mode = params.mode) { + is ManageTokensMode.Account -> modelScope.launch { + val mainAccount = singleAccountStatusListSupplier(mode.accountId.userWalletId).first().mainAccount + if (mode.accountId == mainAccount.account.accountId) { + state.update { it.copySealed(topBar = manageContentTopBar()) } + } + } + ManageTokensMode.None, + is ManageTokensMode.Wallet, + -> Unit // use init state + } + } + private fun updateItems(items: ImmutableList) { val updatedState = state.updateAndGet { state -> state.copySealed( diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt index e7426ae87c..2b20edf084 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt @@ -57,6 +57,7 @@ import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent import com.tangem.features.managetokens.entity.item.CurrencyItemUM @@ -442,20 +443,23 @@ private class PreviewManageTokensComponentProvider : PreviewParameterProvider, ): PersistentList { + val accountsFeatureEnabled = accountsFeatureToggles.isFeatureEnabled val isMultiCurrency = when (userWallet) { is UserWallet.Cold -> userWallet.isMultiCurrency is UserWallet.Hot -> true @@ -188,7 +191,7 @@ internal class WalletSettingsModel @Inject constructor( is UserWallet.Cold -> userWallet.scanResponse.card.backupStatus == CardDTO.BackupStatus.NoBackup is UserWallet.Hot -> false }, - isManageTokensAvailable = isMultiCurrency, + isManageTokensAvailable = !accountsFeatureEnabled && isMultiCurrency, isNFTFeatureEnabled = isMultiCurrency, isNFTEnabled = isNFTEnabled, onCheckedNFTChange = ::onCheckedNFTChange, 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 6d2bf36754..e3caaf89d5 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,6 +9,7 @@ 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.domain.models.PortfolioId import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.walletsettings.analytics.Settings import com.tangem.feature.walletsettings.entity.WalletSettingsAccountsUM @@ -207,7 +208,7 @@ internal class ItemsBuilder @Inject constructor( iconRes = R.drawable.ic_tether_24, onClick = { analyticsEventHandler.send(Settings.ButtonManageTokens) - router.push(AppRoute.ManageTokens(Source.SETTINGS, userWalletId)) + router.push(AppRoute.ManageTokens(Source.SETTINGS, PortfolioId(userWalletId))) }, ).let(::add) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index a9e2270a44..39e5b37251 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -10,11 +10,9 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.nft.analytics.NFTAnalyticsEvent -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.settings.ShouldShowMarketsTooltipUseCase import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase -import com.tangem.domain.tokens.TokensAction import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -39,8 +37,6 @@ internal interface WalletContentClickIntents { fun onDetailsClick() - fun onManageTokensClick() - fun onOrganizeTokensClick() fun onDismissMarketsOnboarding() @@ -81,7 +77,6 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val shouldShowMarketsTooltipUseCase: ShouldShowMarketsTooltipUseCase, private val dispatchers: CoroutineDispatcherProvider, - private val reduxStateHolder: ReduxStateHolder, private val walletEventSender: WalletEventSender, private val analyticsEventHandler: AnalyticsEventHandler, private val hotWalletFeatureToggles: HotWalletFeatureToggles, @@ -122,11 +117,6 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } } - override fun onManageTokensClick() { - reduxStateHolder.dispatch(action = TokensAction.SetArgs.ManageAccess) - router.openManageTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) - } - override fun onOrganizeTokensClick() { router.openOrganizeTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 1bab1f8a28..f2f41882cc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -3,7 +3,6 @@ package com.tangem.feature.wallet.presentation.router import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRoute.ManageTokens.Source import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.navigation.url.UrlOpener @@ -86,10 +85,6 @@ internal class DefaultWalletRouter @Inject constructor( return router.stack.lastOrNull() is AppRoute.Wallet } - override fun openManageTokensScreen(userWalletId: UserWalletId) { - router.push(AppRoute.ManageTokens(Source.SETTINGS, userWalletId)) - } - override fun openScanFailedDialog(onTryAgain: () -> Unit) { reduxStateHolder.dispatchDialogShow(StateDialog.ScanFailsDialog(StateDialog.ScanFailsSource.MAIN, onTryAgain)) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 106cafa945..4c6c7ba002 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -49,9 +49,6 @@ internal interface InnerWalletRouter { /** Is wallet last screen */ fun isWalletLastScreen(): Boolean - /** Open manage tokens screen */ - fun openManageTokensScreen(userWalletId: UserWalletId) - /** Open scan failed dialog */ fun openScanFailedDialog(onTryAgain: () -> Unit) From f8cbb6d0b91bfd679f5f67ed820dcf12e7094df5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 11:32:19 +0000 Subject: [PATCH 23/46] 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 6c17617aca..1d38e0c3a2 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.30-1262" +tangemBlockchainSdk = "develop-1259" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.30-567" +tangemCardSdk = "develop-564" #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 cc39f0f4b81a8d2350bb6a8028d1a5667c836a5e Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 14:33:53 +0300 Subject: [PATCH 24/46] Updated on 2026-08-14 --- .../com/tangem/data/settings/DefaultSettingsRepository.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt index 5412605960..9c1b8171f0 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt @@ -7,6 +7,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.get import com.tangem.datasource.local.preferences.utils.getSyncOrDefault +import com.tangem.datasource.local.preferences.utils.getSyncOrNull import com.tangem.datasource.local.preferences.utils.store import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.usercountry.models.GB_COUNTRY @@ -91,11 +92,16 @@ internal class DefaultSettingsRepository( } override suspend fun shouldSaveAccessCodes(): Boolean { - return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, default = false) + return appPreferencesStore.getSyncOrNull(key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY)?.not() + ?: appPreferencesStore.getSyncOrDefault( + key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, + default = false, + ) } override suspend fun setShouldSaveAccessCodes(value: Boolean) { appPreferencesStore.store(key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, value = value) + appPreferencesStore.store(key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY, value = value.not()) } override suspend fun incrementAppLaunchCounter() { From e6f2453dc4794bdf916059f48a06985790d6a8ab Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 14:34:09 +0300 Subject: [PATCH 25/46] Updated on 2026-08-14 --- .../src/main/kotlin/com/tangem/common/routing/AppRoute.kt | 1 + gradle/tangem_dependencies.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 ce692050bc..d5506408c3 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 @@ -11,6 +11,7 @@ import com.tangem.core.decompose.navigation.Route import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.PortfolioId import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.scan.ScanResponse diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 1d38e0c3a2..7a730369ed 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1259" +tangemBlockchainSdk = "develop-1263" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-564" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 3bde7640606e328eed0f2d44da3b9337f0fe6bbd Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 17:48:02 +0500 Subject: [PATCH 26/46] Updated on 2026-08-14 --- .../api/tangemTech/YieldSupplyApi.kt | 4 +-- .../tangemTech/models/YieldMarketsResponse.kt | 16 ++------- ...sponse.kt => YieldSupplyMarketTokenDto.kt} | 2 +- .../tangem/datasource/di/YieldSupplyModule.kt | 4 +-- .../yieldsupply/DefaultYieldMarketsStore.kt | 10 +++--- .../local/yieldsupply/YieldMarketsStore.kt | 8 ++--- .../supply/DefaultYieldSupplyRepository.kt | 6 ++-- .../converters/YieldMarketTokenConverter.kt | 13 ++++--- .../converters/YieldTokenStatusConverter.kt | 21 ------------ .../currency/CryptoCurrencyExtensions.kt | 5 +++ .../yield/supply/models/YieldMarketToken.kt | 2 ++ .../supply/models/YieldMarketTokenStatus.kt | 19 ----------- .../yield/supply/YieldSupplyRepository.kt | 5 ++- .../usecase/YieldSupplyActivateUseCase.kt | 5 +-- .../usecase/YieldSupplyDeactivateUseCase.kt | 5 +-- .../YieldSupplyGetTokenStatusUseCase.kt | 9 +++-- .../converter/TokenListStateConverter.kt | 8 ++--- .../impl/main/entity/LoadingStatusMode.kt | 6 ---- .../impl/main/model/YieldSupplyModel.kt | 12 +++---- ...ieldSupplyTokenStatusFailureTransformer.kt | 17 ---------- ...ieldSupplyTokenStatusSuccessTransformer.kt | 34 +++++-------------- .../model/YieldSupplyStartEarningModel.kt | 2 +- .../model/YieldSupplyStopEarningModel.kt | 2 +- 23 files changed, 65 insertions(+), 150 deletions(-) rename core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/{YieldTokenStatusResponse.kt => YieldSupplyMarketTokenDto.kt} (94%) delete mode 100644 data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldTokenStatusConverter.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt delete mode 100644 domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketTokenStatus.kt delete mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/LoadingStatusMode.kt delete mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusFailureTransformer.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt index 2158ef7ede..6ed56e4ce7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/YieldSupplyApi.kt @@ -4,7 +4,7 @@ import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse import com.tangem.datasource.api.tangemTech.models.YieldModuleStatusResponse import com.tangem.datasource.api.tangemTech.models.YieldSupplyChangeTokenStatusBody -import com.tangem.datasource.api.tangemTech.models.YieldTokenStatusResponse +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse import retrofit2.http.Body import retrofit2.http.GET @@ -21,7 +21,7 @@ interface YieldSupplyApi { suspend fun getYieldTokenStatus( @Path("chainId") chainId: Int, @Path("tokenAddress") tokenAddress: String, - ): ApiResponse + ): ApiResponse @GET("api/v1/yield/token/{chainId}/{tokenAddress}/chart") suspend fun getYieldTokenChart( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldMarketsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldMarketsResponse.kt index ac62a8c655..d52ede3a6d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldMarketsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldMarketsResponse.kt @@ -2,21 +2,9 @@ package com.tangem.datasource.api.tangemTech.models import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import java.math.BigDecimal @JsonClass(generateAdapter = true) data class YieldMarketsResponse( - @Json(name = "tokens") val marketDtos: List, + @Json(name = "tokens") val marketDtos: List, @Json(name = "lastUpdatedAt") val lastUpdated: String, -) { - - @JsonClass(generateAdapter = true) - data class MarketDto( - @Json(name = "tokenAddress") val tokenAddress: String? = null, - @Json(name = "tokenSymbol") val tokenSymbol: String? = null, - @Json(name = "tokenName") val tokenName: String? = null, - @Json(name = "apy") val apy: BigDecimal, - @Json(name = "isActive") val isActive: Boolean, - @Json(name = "chainId") val chainId: Int? = null, - ) -} \ No newline at end of file +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldTokenStatusResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldSupplyMarketTokenDto.kt similarity index 94% rename from core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldTokenStatusResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldSupplyMarketTokenDto.kt index 6788fc5292..a2d32540f9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldTokenStatusResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/YieldSupplyMarketTokenDto.kt @@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass import java.math.BigDecimal @JsonClass(generateAdapter = true) -data class YieldTokenStatusResponse( +data class YieldSupplyMarketTokenDto( @Json(name = "tokenAddress") val tokenAddress: String? = null, @Json(name = "tokenSymbol") val tokenSymbol: String? = null, @Json(name = "tokenName") val tokenName: String? = null, diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt index 2b7cfecd0d..179051f898 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi -import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import com.tangem.datasource.local.yieldsupply.DefaultYieldMarketsStore import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.utils.MoshiDataStoreSerializer @@ -34,7 +34,7 @@ object YieldSupplyModule { persistenceStore = DataStoreFactory.create( serializer = MoshiDataStoreSerializer( moshi = moshi, - types = listTypes(), + types = listTypes(), defaultValue = emptyList(), ), produceFile = { context.dataStoreFile(fileName = "yield_markets_cache") }, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt index 7f4dbc5eb7..c1d86f9243 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/DefaultYieldMarketsStore.kt @@ -1,21 +1,21 @@ package com.tangem.datasource.local.yieldsupply import androidx.datastore.core.DataStore -import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull internal class DefaultYieldMarketsStore( - private val persistenceStore: DataStore>, + private val persistenceStore: DataStore>, ) : YieldMarketsStore { - override fun get(): Flow> = persistenceStore.data + override fun get(): Flow> = persistenceStore.data - override suspend fun getSyncOrNull(): List? { + override suspend fun getSyncOrNull(): List? { return persistenceStore.data.firstOrNull() } - override suspend fun store(items: List) { + override suspend fun store(items: List) { persistenceStore.updateData { _ -> items } } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt index 8857e6f40a..d20f7aad04 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/YieldMarketsStore.kt @@ -1,13 +1,13 @@ package com.tangem.datasource.local.yieldsupply -import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import kotlinx.coroutines.flow.Flow interface YieldMarketsStore { - fun get(): Flow> + fun get(): Flow> - suspend fun getSyncOrNull(): List? + suspend fun getSyncOrNull(): List? - suspend fun store(items: List) + suspend fun store(items: List) } \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt index 8d96150927..8d8613e976 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -9,7 +9,6 @@ import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter import com.tangem.datasource.api.tangemTech.YieldSupplyApi -import com.tangem.data.yield.supply.converters.YieldTokenStatusConverter import com.tangem.data.yield.supply.converters.YieldTokenChartConverter import com.tangem.datasource.api.tangemTech.models.YieldSupplyChangeTokenStatusBody import com.tangem.domain.models.currency.CryptoCurrency @@ -17,7 +16,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldMarketToken -import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow @@ -50,11 +48,11 @@ internal class DefaultYieldSupplyRepository( it.map(YieldMarketTokenConverter::convert).enrichNetworkIds() } - override suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketTokenStatus { + override suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketToken { val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() ?: error("Chain id is required for evm's") val response = yieldSupplyApi.getYieldTokenStatus(chainId, cryptoCurrencyToken.contractAddress).getOrThrow() - return YieldTokenStatusConverter.convert(response) + return YieldMarketTokenConverter.convert(response) } override suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData { diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverter.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverter.kt index ca069668ff..c66fb73edd 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverter.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverter.kt @@ -1,16 +1,19 @@ package com.tangem.data.yield.supply.converters -import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero -internal object YieldMarketTokenConverter : Converter { - override fun convert(value: YieldMarketsResponse.MarketDto): YieldMarketToken { +internal object YieldMarketTokenConverter : Converter { + override fun convert(value: YieldSupplyMarketTokenDto): YieldMarketToken { return YieldMarketToken( tokenAddress = value.tokenAddress.orEmpty(), - apy = value.apy, - isActive = value.isActive, + apy = value.apy.orZero(), + isActive = value.isActive ?: false, chainId = value.chainId ?: -1, + maxFeeUSD = value.maxFeeUSD.orEmpty(), + maxFeeNative = value.maxFeeNative.orEmpty(), ) } } \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldTokenStatusConverter.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldTokenStatusConverter.kt deleted file mode 100644 index 8035634fc9..0000000000 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/converters/YieldTokenStatusConverter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.data.yield.supply.converters - -import com.tangem.datasource.api.tangemTech.models.YieldTokenStatusResponse -import com.tangem.domain.models.serialization.SerializedBigDecimal -import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus -import com.tangem.utils.converter.Converter - -internal object YieldTokenStatusConverter : Converter { - override fun convert(value: YieldTokenStatusResponse): YieldMarketTokenStatus { - return YieldMarketTokenStatus( - tokenAddress = value.tokenAddress.orEmpty(), - tokenSymbol = value.tokenSymbol.orEmpty(), - tokenName = value.tokenName.orEmpty(), - apy = value.apy ?: SerializedBigDecimal.ZERO, - isActive = value.isActive ?: false, - chainId = value.chainId ?: -1, - maxFeeUSD = value.maxFeeUSD.orEmpty(), - maxFeeNative = value.maxFeeNative.orEmpty(), - ) - } -} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt new file mode 100644 index 0000000000..b3fb460bdb --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.models.currency + +fun CryptoCurrency.Token.yieldSupplyKey(): String { + return "${network.backendId}_$contractAddress" +} \ No newline at end of file diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketToken.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketToken.kt index 900bb790b2..7d379d4af6 100644 --- a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketToken.kt +++ b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketToken.kt @@ -12,6 +12,8 @@ data class YieldMarketToken( val chainId: Int, val apy: SerializedBigDecimal, val isActive: Boolean, + val maxFeeNative: String, + val maxFeeUSD: String, val backendId: String? = null, ) { diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketTokenStatus.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketTokenStatus.kt deleted file mode 100644 index d7a6eedcf3..0000000000 --- a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldMarketTokenStatus.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.domain.yield.supply.models - -import com.tangem.domain.models.serialization.SerializedBigDecimal -import kotlinx.serialization.Serializable - -/** - * Domain model representing a token entry in the Yield Markets list. - */ -@Serializable -data class YieldMarketTokenStatus( - val tokenAddress: String, - val tokenSymbol: String, - val tokenName: String, - val chainId: Int, - val apy: SerializedBigDecimal, - val isActive: Boolean, - val maxFeeNative: String, - val maxFeeUSD: String, -) \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt index eceba08082..c721f9e942 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyRepository.kt @@ -3,7 +3,6 @@ package com.tangem.domain.yield.supply import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.yield.supply.models.YieldMarketToken -import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData import kotlinx.coroutines.flow.Flow @@ -26,10 +25,10 @@ interface YieldSupplyRepository { fun getMarketsFlow(): Flow> /** - * Get yield token status by contract address. + * Get yield token status by contract address from cache */ @Throws - suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketTokenStatus + suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketToken /** * Get yield token APY chart by contract address. diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt index 92b5787e7d..3ca5a7b0e8 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyActivateUseCase.kt @@ -8,7 +8,8 @@ class YieldSupplyActivateUseCase( private val yieldSupplyRepository: YieldSupplyRepository, ) { - suspend operator fun invoke(cryptoCurrencyToken: CryptoCurrency.Token): Either = Either.catch { - yieldSupplyRepository.activateProtocol(cryptoCurrencyToken) + suspend operator fun invoke(cryptoCurrency: CryptoCurrency): Either = Either.catch { + val token = cryptoCurrency as? CryptoCurrency.Token ?: error("Token expected") + yieldSupplyRepository.activateProtocol(token) } } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt index 6449187071..fc666ada1a 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyDeactivateUseCase.kt @@ -8,7 +8,8 @@ class YieldSupplyDeactivateUseCase( private val yieldSupplyRepository: YieldSupplyRepository, ) { - suspend operator fun invoke(cryptoCurrencyToken: CryptoCurrency.Token): Either = Either.catch { - yieldSupplyRepository.deactivateProtocol(cryptoCurrencyToken) + suspend operator fun invoke(cryptoCurrency: CryptoCurrency): Either = Either.catch { + val token = cryptoCurrency as? CryptoCurrency.Token ?: error("Token expected") + yieldSupplyRepository.deactivateProtocol(token) } } \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt index c76f4c8e71..36737e8d43 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetTokenStatusUseCase.kt @@ -2,14 +2,17 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.yield.supply.YieldSupplyRepository -import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus +import com.tangem.domain.yield.supply.models.YieldMarketToken class YieldSupplyGetTokenStatusUseCase( private val yieldSupplyRepository: YieldSupplyRepository, ) { - suspend operator fun invoke(token: CryptoCurrency.Token): Either = Either.catch { - yieldSupplyRepository.getTokenStatus(token) + suspend operator fun invoke(token: CryptoCurrency.Token): Either = Either.catch { + val tokens = yieldSupplyRepository.getCachedMarkets().orEmpty() + val cachedStatus = tokens.firstOrNull { it.yieldSupplyKey == token.yieldSupplyKey() } + cachedStatus ?: error("YieldMarketToken not found") } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 909953e420..eb2c314a09 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -16,6 +16,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.tokenlist.TokenList.GroupedByNetwork.NetworkGroup import com.tangem.domain.models.wallet.UserWallet @@ -208,12 +209,9 @@ internal class TokenListStateConverter( ?.yieldSupplyStatus?.isActive == true if (isYieldSupplyActive) return null - val contract = (cryptoCurrencyStatus.currency as? CryptoCurrency.Token) - ?.contractAddress - ?.lowercase() ?: return null + val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return null - val yieldSupplyKey = "${cryptoCurrencyStatus.currency.network.backendId}_$contract" - return apyMap[yieldSupplyKey] + return apyMap[token.yieldSupplyKey()] } private fun getOrganizeTokensButtonState(tokenList: TokenList): WalletOrganizeTokensButtonConfig? { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/LoadingStatusMode.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/LoadingStatusMode.kt deleted file mode 100644 index 9480ba16ad..0000000000 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/LoadingStatusMode.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.features.yield.supply.impl.main.entity - -internal enum class LoadingStatusMode { - Initial, - LoadApy, -} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index ad5f40b8cf..9b267af4e3 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -22,8 +22,6 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyIsAvailableUseCase import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM -import com.tangem.features.yield.supply.impl.main.entity.LoadingStatusMode -import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusFailureTransformer import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.DelayedWork @@ -118,7 +116,7 @@ internal class YieldSupplyModel @Inject constructor( } } - private fun loadTokenStatus(mode: LoadingStatusMode) { + private fun loadTokenStatus() { val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return modelScope.launch(dispatchers.default) { yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) @@ -127,11 +125,10 @@ internal class YieldSupplyModel @Inject constructor( YieldSupplyTokenStatusSuccessTransformer( tokenStatus = tokenStatus, onStartEarningClick = ::onStartEarningClick, - mode = mode, ), ) }.onLeft { - uiState.update(YieldSupplyTokenStatusFailureTransformer(mode)) + uiState.update { YieldSupplyUM.Unavailable } } } } @@ -186,14 +183,13 @@ internal class YieldSupplyModel @Inject constructor( onClick = ::onActiveClick, isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, ) - else -> YieldSupplyUM.Loading + else -> YieldSupplyUM.Initial } uiState.update { yieldSupplyUM } when (yieldSupplyUM) { - is YieldSupplyUM.Loading -> loadTokenStatus(LoadingStatusMode.Initial) - is YieldSupplyUM.Content -> loadTokenStatus(LoadingStatusMode.LoadApy) + is YieldSupplyUM.Initial -> loadTokenStatus() else -> Unit } } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusFailureTransformer.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusFailureTransformer.kt deleted file mode 100644 index 0154fa52ef..0000000000 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusFailureTransformer.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.features.yield.supply.impl.main.model.transformers - -import com.tangem.features.yield.supply.impl.main.entity.LoadingStatusMode -import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM -import com.tangem.utils.transformer.Transformer - -internal class YieldSupplyTokenStatusFailureTransformer( - private val mode: LoadingStatusMode, -) : Transformer { - - override fun transform(prevState: YieldSupplyUM): YieldSupplyUM { - return when (mode) { - LoadingStatusMode.Initial -> YieldSupplyUM.Unavailable - LoadingStatusMode.LoadApy -> prevState // TODO apply correct UI - } - } -} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt index f45986dc3c..4fed5db894 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt @@ -1,42 +1,26 @@ package com.tangem.features.yield.supply.impl.main.model.transformers -import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus import com.tangem.features.yield.supply.impl.R -import com.tangem.features.yield.supply.impl.main.entity.LoadingStatusMode import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.utils.transformer.Transformer internal class YieldSupplyTokenStatusSuccessTransformer( - private val tokenStatus: YieldMarketTokenStatus, + private val tokenStatus: YieldMarketToken, private val onStartEarningClick: () -> Unit, - private val mode: LoadingStatusMode, ) : Transformer { override fun transform(prevState: YieldSupplyUM): YieldSupplyUM { if (!tokenStatus.isActive) return YieldSupplyUM.Unavailable - return when (mode) { - LoadingStatusMode.Initial -> { - YieldSupplyUM.Available( - title = resourceReference( - id = R.string.yield_module_token_details_earn_notification_title, - formatArgs = wrappedList(tokenStatus.apy), - ), - onClick = onStartEarningClick, - ) - } - LoadingStatusMode.LoadApy -> { - if (prevState is YieldSupplyUM.Content) { - prevState.copy( - rewardsApy = stringReference("${tokenStatus.apy}%"), - ) - } else { - prevState - } - } - } + return YieldSupplyUM.Available( + title = resourceReference( + id = R.string.yield_module_token_details_earn_notification_title, + formatArgs = wrappedList(tokenStatus.apy), + ), + onClick = onStartEarningClick, + ) } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index 991aa736fa..4a5568cfe5 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -208,7 +208,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( }, ifRight = { fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, cryptoCurrency.id) - yieldSupplyActivateUseCase(cryptoCurrency as CryptoCurrency.Token) + yieldSupplyActivateUseCase(cryptoCurrency) modelScope.launch { params.callback.onTransactionSent() } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index 4204a8bc55..ff7bb1fa17 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -136,7 +136,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor( ) }, ifRight = { - yieldSupplyDeactivateUseCase(cryptoCurrency as CryptoCurrency.Token) + yieldSupplyDeactivateUseCase(cryptoCurrency) params.callback.onTransactionSent() }, ) From 4e1b562e82bb11b3f967557406e1f5d527082681 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 16:31:02 +0300 Subject: [PATCH 27/46] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 12 +- .../tangem/common/constants/TestConstants.kt | 1 + .../com/tangem/common/utils/ClipboardUtils.kt | 18 + .../tangem/screens/ChromeBrowserPageObject.kt | 55 +++ .../tangem/screens/MainScreenPageObject.kt | 5 + ... => ReceiveAssetsBottomSheetPageObject.kt} | 13 +- .../com/tangem/screens/SwapTokenPageObject.kt | 4 + .../TokenActionsBottomSheetPageObject.kt | 75 ++++ ...TokenReceiveQrCodeBottomSheetPageObject.kt | 56 +++ ...okenReceiveWarningBottomSheetPageObject.kt | 28 ++ .../MainScreenActionButtonsTest.kt | 405 ++++++++++++++++++ .../tests/balance/TotalBalanceUpdateTest.kt | 2 +- .../tangem/core/ui/components/SettingsRow.kt | 5 +- .../core/ui/test/BaseBottomSheetTestTags.kt | 3 +- .../core/ui/test/SwapTokenScreenTestTags.kt | 2 +- .../TokenReceiveQrCodeBottomSheetTestTags.kt | 7 + .../TokenReceiveWarningBottomSheetTestTags.kt | 5 + .../tangem/feature/swap/ui/TransactionCard.kt | 2 +- .../ui/TokenReceiveQrCodeContent.kt | 7 +- .../ui/TokenReceiveWarningContent.kt | 4 +- 20 files changed, 690 insertions(+), 19 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/ChromeBrowserPageObject.kt rename app/src/androidTest/kotlin/com/tangem/screens/{BaseBottomSheetPageObject.kt => ReceiveAssetsBottomSheetPageObject.kt} (50%) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/TokenActionsBottomSheetPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveQrCodeBottomSheetTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveWarningBottomSheetTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 798d610177..5322b95a0c 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -1,8 +1,8 @@ package com.tangem.common import android.Manifest +import androidx.compose.ui.test.isRoot import androidx.compose.ui.test.junit4.createEmptyComposeRule -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.printToLog import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents @@ -121,6 +121,8 @@ abstract class BaseTestCase : TestCase( /** * Prints the Compose semantics tree to logcat for debugging UI tests. * + * @param rootIndex Use rootIndex > 0, if you need to print semantics tree for bottom sheet. + * Default: 0. * @param useUnmergedTree When true, shows unmerged tree with all individual nodes. * Use for accessing inner elements of compound components. * Default: false (merged tree - accessibility view). @@ -129,11 +131,13 @@ abstract class BaseTestCase : TestCase( * Default: Int.MAX_VALUE (unlimited depth). */ fun printSemanticTree( + rootIndex: Int = 0, useUnmergedTree: Boolean = false, tag: String = "SEMANTIC_TREE", - maxDepth: Int = Int.MAX_VALUE) - { - composeTestRule.onRoot(useUnmergedTree = useUnmergedTree).printToLog(tag, maxDepth) + maxDepth: Int = Int.MAX_VALUE + ) { + composeTestRule.onAllNodes(isRoot(), useUnmergedTree = useUnmergedTree)[rootIndex] + .printToLog(tag, maxDepth) } fun waitForIdle() = composeTestRule.waitForIdle() diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index 82b612164d..62e83ab9c4 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -4,6 +4,7 @@ object TestConstants { const val TOTAL_BALANCE = "$3,299.18" const val RECIPIENT_ADDRESS = "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0" + const val BITCOIN_ADDRESS = "bc1qtg9aa6jcpqtvun0pe0uct7sxm8nq2nsxfmfxm3" const val CARDANO_ADDRESS = "addr1q8f9499e58k4hhfd9vhawprxt3xd94x7rmlyp33ee4xkatakcl2zgkrg0p6ceqkndtkw4cumfe9enhdph8yhuswn785srksm9p" const val WAIT_UNTIL_TIMEOUT = 20_000L diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt index f4648d1cc9..c2e3981abc 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/ClipboardUtils.kt @@ -3,6 +3,7 @@ package com.tangem.common.utils import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import androidx.test.core.app.ApplicationProvider fun getClipboardText(context: Context): String? { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager @@ -17,4 +18,21 @@ fun setClipboardText(context: Context, text: String?) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("label", text) clipboard.setPrimaryClip(clip) +} + +fun clearClipboard( + context: Context = ApplicationProvider.getApplicationContext() +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.clearPrimaryClip() +} + +fun assertClipboardTextEquals( + expected: String, + context: Context = ApplicationProvider.getApplicationContext() +) { + val actual = getClipboardText(context) + assert(actual == expected) { + "Clipboard text mismatch.\nExpected: '$expected'\nActual: '$actual'" + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ChromeBrowserPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ChromeBrowserPageObject.kt new file mode 100644 index 0000000000..78c2fa41dd --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ChromeBrowserPageObject.kt @@ -0,0 +1,55 @@ +package com.tangem.screens + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import com.kaspersky.kaspresso.screens.KScreen + +object ChromeBrowserPageObject : KScreen() { + + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + private val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + private const val TIMEOUT = 10000L + + fun assertChromeIsOpened() { + val chromePackage = "com.android.chrome" + device.wait(Until.hasObject(By.pkg(chromePackage).depth(0)), TIMEOUT) + assert(device.hasObject(By.pkg(chromePackage))) { + "Chrome browser is not opened" + } + } + + fun assertUrlContains(expectedUrl: String) { + val urlBar = device.findObject( + By.res("com.android.chrome:id/url_bar") + ) + urlBar?.let { + assert(it.text.contains(expectedUrl)) { + "URL doesn't contain expected: $expectedUrl, actual: ${it.text}" + } + } + } + + private fun findElementByText(text: String): UiObject2? { + device.wait(Until.hasObject(By.text(text)), TIMEOUT) + return device.findObject(By.text(text)) + } + + fun assertElementWithTextExists(text: String) { + val element = findElementByText(text) + assert(element != null) { "Element with text '$text' not found" } + } + + fun isElementWithTextExists(text: String): Boolean { + return device.wait(Until.hasObject(By.text(text)), TIMEOUT) + } + + fun clickOnElementWithText(text: String) { + findElementByText(text)?.click() + ?: throw AssertionError("Cannot click - element with text '$text' not found") + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index da077e296f..d1ce4ef047 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -17,6 +17,7 @@ import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText +import com.tangem.core.ui.R as CoreUiR class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -200,6 +201,10 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + val snackbarCopiedAddressMessage: KNode = child { + hasText(getResourceString(CoreUiR.string.wallet_notification_address_copied)) + } + /** * Find token list item with title and address */ diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt similarity index 50% rename from app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt index d40a96c38a..acea682a27 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/BaseBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt @@ -3,20 +3,19 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.R -import com.tangem.core.ui.test.BaseBottomSheetTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString -class BaseBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { +class ReceiveAssetsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { - val hideButton: KNode = child { - hasText(getResourceString(R.string.token_details_hide_token)) - hasTestTag(BaseBottomSheetTestTags.ACTION_TITLE) + val showQrCodeButton: KNode = child { + hasText(getResourceString(R.string.token_receive_show_qr_code_title)) + useUnmergedTree = true } } -internal fun BaseTestCase.onBottomSheet(function: BaseBottomSheetPageObject.() -> Unit) = +internal fun BaseTestCase.onReceiveAssetsBottomSheet(function: ReceiveAssetsBottomSheetPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index d069f7abe1..d2f4fa7b1b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -71,6 +71,10 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_swap)) } + fun tokenSymbol(symbol: String): KNode = child { + hasTestTag(SwapTokenScreenTestTags.TOKEN_SYMBOL) + } + } internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenActionsBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenActionsBottomSheetPageObject.kt new file mode 100644 index 0000000000..af3647e158 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenActionsBottomSheetPageObject.kt @@ -0,0 +1,75 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText + +class TokenActionsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val analyticsButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_analytics))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val copyAddressButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_copy_address))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val receiveButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_receive))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val sendButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_send))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val swapButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_swap))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val buyButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_buy))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val sellButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_sell))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } + + val hideTokenButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.token_details_hide_token))) + hasTestTag(BaseBottomSheetTestTags.ACTION_BUTTON) + hasAnyChild(withTestTag(BaseBottomSheetTestTags.ACTION_ICON)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTokenActionsBottomSheet(function: TokenActionsBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt new file mode 100644 index 0000000000..e6bb3c228b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt @@ -0,0 +1,56 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.TokenReceiveQrCodeBottomSheetTestTags +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class TokenReceiveQrCodeBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val closeButton: KNode = child { + hasTestTag(BaseBottomSheetTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + val title: KNode = child { + hasTestTag(TokenReceiveQrCodeBottomSheetTestTags.TITLE) + useUnmergedTree = true + } + + val qrCode: KNode = child { + hasTestTag(TokenReceiveQrCodeBottomSheetTestTags.QR_CODE) + useUnmergedTree = true + } + + val addressTitle: KNode = child { + hasText(getResourceString(R.string.wc_common_address)) + useUnmergedTree = true + } + + val address: KNode = child { + hasTestTag(TokenReceiveQrCodeBottomSheetTestTags.ADDRESS) + useUnmergedTree = true + } + + val copyButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_copy)) + useUnmergedTree = true + } + + val shareButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_share)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTokenReceiveQrCodeBottomSheet(function: TokenReceiveQrCodeBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt new file mode 100644 index 0000000000..2a0148863b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt @@ -0,0 +1,28 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.TokenReceiveWarningBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class TokenReceiveWarningBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val bottomSheet: KNode = child { + hasTestTag(TokenReceiveWarningBottomSheetTestTags.BOTTOM_SHEET) + } + + val gotItButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_got_it)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTokenReceiveWarningBottomSheet(function: TokenReceiveWarningBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt new file mode 100644 index 0000000000..76debeb60a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -0,0 +1,405 @@ +package com.tangem.tests.actionButtons + +import androidx.compose.ui.test.longClick +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.BITCOIN_ADDRESS +import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.assertClipboardTextEquals +import com.tangem.common.utils.clearClipboard +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.* +import com.tangem.screens.onTokenActionsBottomSheet +import com.tangem.screens.onBuyTokenDetailsScreen +import com.tangem.screens.onDialog +import com.tangem.screens.onMainScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class MainScreenActionButtonsTest : BaseTestCase() { + + @AllureId("79") + @DisplayName("Action buttons (long tap): validate UI") + @Test + fun actionButtonsValidateLongTapUiTest() { + val tokenTitle = "Ethereum" + val balance = TOTAL_BALANCE + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Analytics' button is displayed") { + onTokenActionsBottomSheet { analyticsButton.assertIsDisplayed() } + } + step("Assert 'Copy address' button is displayed") { + onTokenActionsBottomSheet { copyAddressButton.assertIsDisplayed() } + } + step("Assert 'Receive' button is displayed") { + onTokenActionsBottomSheet { receiveButton.assertIsDisplayed() } + } + step("Assert 'Send' button is displayed") { + onTokenActionsBottomSheet { sendButton.assertIsDisplayed() } + } + step("Assert 'Swap' button is displayed") { + onTokenActionsBottomSheet { swapButton.assertIsDisplayed() } + } + step("Assert 'Buy' button is displayed") { + onTokenActionsBottomSheet { buyButton.assertIsDisplayed() } + } + step("Assert 'Sell' button is displayed") { + onTokenActionsBottomSheet { sellButton.assertIsDisplayed() } + } + step("Assert 'Hide token' button is displayed") { + onTokenActionsBottomSheet { hideTokenButton.assertIsDisplayed() } + } + } + } + + @AllureId("84") + @DisplayName("Action buttons (long tap): check 'Copy address' button") + @Test + fun clickOnCopyAddressButtonTest() { + val tokenTitle = "Bitcoin" + val balance = TOTAL_BALANCE + val bitcoinAddress = BITCOIN_ADDRESS + + setupHooks( + additionalBeforeSection = { + clearClipboard() + }, + additionalAfterSection = { + clearClipboard() + } + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Copy address' button is displayed") { + onTokenActionsBottomSheet { copyAddressButton.assertIsDisplayed() } + } + step("Click on 'Copy address' button") { + onTokenActionsBottomSheet { copyAddressButton.performClick() } + } + step("Assert snack bar message is displayed") { + onMainScreen { snackbarCopiedAddressMessage.assertIsDisplayed() } + } + step("Check clipboard has '$tokenTitle' address '$bitcoinAddress'") { + waitForIdle() + assertClipboardTextEquals(expected = bitcoinAddress) + } + } + } + + @AllureId("82") + @DisplayName("Action buttons (long tap): check 'Buy' button") + @Test + fun clickOnBuyButtonTest() { + val tokenTitle = "Bitcoin" + val tokenSymbol = "BTC" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Buy' button is displayed") { + onTokenActionsBottomSheet { buyButton.assertIsDisplayed() } + } + step("Click on 'Buy' button") { + onTokenActionsBottomSheet { buyButton.performClick() } + } + step("Click on 'Confirm' button in 'Dialog'") { + waitForIdle() + onDialog { confirmButton.clickWithAssertion() } + } + step("Assert top app bar title contains '$tokenTitle'") { + onBuyTokenDetailsScreen { topBarTitle.assertTextContains("Buy $tokenTitle") } + } + step("Assert fiat currency text field is displayed") { + onBuyTokenDetailsScreen { fiatAmountTextField.assertIsDisplayed() } + } + step("Assert fiat currency icon is displayed") { + onBuyTokenDetailsScreen { fiatCurrencyIcon.assertIsDisplayed() } + } + step("Assert token amount field is displayed") { + onBuyTokenDetailsScreen { tokenAmountField.assertTextContains(tokenSymbol, substring = true) } + } + step("Assert 'Continue' button") { + onBuyTokenDetailsScreen { continueButton.assertIsDisplayed() } + } + } + } + + @AllureId("87") + @DisplayName("Action buttons (long tap): check 'Swap' button") + @Test + fun clickOnSwapButtonTest() { + val tokenTitle = "Ethereum" + val tokenSymbol = "ETH" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Swap' button is displayed") { + onTokenActionsBottomSheet { swapButton.assertIsDisplayed() } + } + step("Click on 'Swap' button") { + onTokenActionsBottomSheet { swapButton.performClick() } + } + step("Close 'Stories' screen") { + onSwapStoriesScreen { closeButton.clickWithAssertion() } + } + step("Assert 'Swap' screen title is displayed") { + onSwapTokenScreen { title.assertIsDisplayed() } + } + step("Assert token symbol: '$tokenSymbol' is displayed") { + onSwapTokenScreen { tokenSymbol(tokenSymbol).assertIsDisplayed() } + } + } + } + + @AllureId("83") + @DisplayName("Action buttons (long tap): check 'Send' button") + @Test + fun clickOnSendButtonTest() { + val tokenTitle = "Ethereum" + val tokenSymbol = "ETH" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Send' button is displayed") { + onTokenActionsBottomSheet { sendButton.assertIsDisplayed() } + } + step("Click on 'Send' button") { + onTokenActionsBottomSheet { sendButton.performClick() } + } + step("Assert amount input text field contains token symbol: '$tokenSymbol'") { + onSendScreen { + amountInputTextField.assertTextContains(value = tokenSymbol, substring = true) + } + } + } + } + + @AllureId("86") + @DisplayName("Action buttons (long tap): check 'Receive' button") + @Test + fun clickOnReceiveButtonTest() { + val tokenTitle = "Bitcoin" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Receive' button is displayed") { + onTokenActionsBottomSheet { receiveButton.assertIsDisplayed() } + } + step("Click on 'Receive' button") { + onTokenActionsBottomSheet { receiveButton.performClick() } + } + step("Assert 'Token receive warning' bottom sheet is displayed") { + onTokenReceiveWarningBottomSheet { bottomSheet.assertIsDisplayed() } + } + step("Click on 'Got it' button") { + onTokenReceiveWarningBottomSheet { gotItButton.performClick() } + } + step("Click on 'Show QR code' button") { + onReceiveAssetsBottomSheet { showQrCodeButton.clickWithAssertion() } + } + step("Assert bottom sheet with QR code title is displayed") { + onTokenReceiveQrCodeBottomSheet { title.assertIsDisplayed() } + } + step("Assert QR code is displayed") { + onTokenReceiveQrCodeBottomSheet { qrCode.assertIsDisplayed() } + } + step("Assert address title is displayed") { + onTokenReceiveQrCodeBottomSheet { addressTitle.assertIsDisplayed() } + } + step("Assert address is displayed") { + onTokenReceiveQrCodeBottomSheet { address.assertIsDisplayed() } + } + step("Assert 'Copy' button is displayed") { + onTokenReceiveQrCodeBottomSheet { copyButton.assertIsDisplayed() } + } + step("Assert 'Share' button is displayed") { + onTokenReceiveQrCodeBottomSheet { shareButton.assertIsDisplayed() } + } + } + } + + @AllureId("85") + @DisplayName("Action buttons (long tap): check 'Sell' button") + @Test + fun clickOnSellButtonTest() { + val tokenTitle = "Ethereum" + val tokenSymbol = "ETH" + val balance = TOTAL_BALANCE + val url = "sell.moonpay.com" + val useWithoutAccount = "Use without an account" + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Receive' button is displayed") { + onTokenActionsBottomSheet { sellButton.assertIsDisplayed() } + } + step("Click on 'Receive' button") { + onTokenActionsBottomSheet { sellButton.performClick() } + } + step("Assert Chrome Browser is opened") { + ChromeBrowserPageObject { assertChromeIsOpened() } + } + if (ChromeBrowserPageObject.isElementWithTextExists(useWithoutAccount)) { + step("Click on '$useWithoutAccount' button on Chrome browser") { + ChromeBrowserPageObject { clickOnElementWithText(useWithoutAccount) } + } + } + step("Assert url contains: '$url'") { + ChromeBrowserPageObject { assertUrlContains(url) } + } + step("Assert token symbol '$tokenSymbol' is displayed") { + ChromeBrowserPageObject { assertElementWithTextExists(tokenSymbol) } + } + } + } + + @AllureId("77") + @DisplayName("Action buttons (long tap): assert 'Sell' button is not displayed if token doesn't support it") + @Test + fun assertSellButtonIsNotDisplayedTest() { + val tokenTitle = "Bitcoin" + val balance = TOTAL_BALANCE + + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses(balance) + } + step("Long click on token with name: '$tokenTitle'") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenTitle).performTouchInput { + longClick( + position = center, + durationMillis = 1000L + ) + } + } + } + step("Assert 'Sell' button is not displayed") { + onTokenActionsBottomSheet { sellButton.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt index 6fbc4b4812..756f9ea213 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -162,7 +162,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { } } step("Click 'Hide token' button") { - onBottomSheet { hideButton.clickWithAssertion() } + onTokenActionsBottomSheet { hideTokenButton.clickWithAssertion() } } step("Click 'Hide' button in dialog") { onDialog { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt index f3bfb8028b..ebdceec152 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/SettingsRow.kt @@ -40,7 +40,7 @@ fun SimpleSettingsRow( onItemsClick() } }, - ).testTag(BaseBottomSheetTestTags.ACTION_TITLE), + ).testTag(BaseBottomSheetTestTags.ACTION_BUTTON), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { @@ -48,7 +48,8 @@ fun SimpleSettingsRow( painter = painterResource(id = icon), contentDescription = null, modifier = Modifier - .padding(horizontal = if (redesign) TangemTheme.dimens.spacing12 else TangemTheme.dimens.spacing20), + .padding(horizontal = if (redesign) TangemTheme.dimens.spacing12 else TangemTheme.dimens.spacing20) + .testTag(BaseBottomSheetTestTags.ACTION_ICON), tint = rowColors.iconColor(enabled = enabled).value, ) Column( diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt index d2d4a850de..ccf4a1033e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BaseBottomSheetTestTags.kt @@ -1,7 +1,8 @@ package com.tangem.core.ui.test object BaseBottomSheetTestTags { - const val ACTION_TITLE = "BASE_BOTTOM_SHEET_ACTION_TITLE" + const val ACTION_BUTTON = "BASE_BOTTOM_SHEET_ACTION_BUTTON" + const val ACTION_ICON = "BASE_BOTTOM_SHEET_ACTION_ICON" const val TITLE = "BASE_BOTTOM_SHEET_TITLE" const val SUBTITLE = "BASE_BOTTOM_SHEET_SUBTITLE" const val CLOSE_BUTTON = "BASE_BOTTOM_SHEET_CLOSE_BUTTON" diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt index cd6cf947d1..c64a02bb85 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SwapTokenScreenTestTags.kt @@ -9,6 +9,6 @@ object SwapTokenScreenTestTags { const val PROVIDERS_BLOCK = "SWAP_TOKEN_SCREEN_PROVIDERS_BLOCK" const val SWAP_BUTTON = "SWAP_TOKEN_SCREEN_SWAP_BUTTON" const val TOKEN = "SWAP_TOKEN_SCREEN_TOKEN" - const val TOKEN_NAME = "SWAP_TOKEN_SCREEN_TOKEN_NAME" + const val TOKEN_SYMBOL = "SWAP_TOKEN_SCREEN_TOKEN_SYMBOL" const val TOKEN_ICON = "SWAP_TOKEN_SCREEN_TOKEN_ICON" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveQrCodeBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveQrCodeBottomSheetTestTags.kt new file mode 100644 index 0000000000..0d9ae631fd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveQrCodeBottomSheetTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test + +object TokenReceiveQrCodeBottomSheetTestTags { + const val TITLE = "TOKEN_RECEIVE_QR_CODE_BOTTOM_SHEET_TITLE" + const val QR_CODE = "TOKEN_RECEIVE_QR_CODE_BOTTOM_SHEET_QR_CODE" + const val ADDRESS = "TOKEN_RECEIVE_QR_CODE_BOTTOM_SHEET_ADDRESS" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveWarningBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveWarningBottomSheetTestTags.kt new file mode 100644 index 0000000000..e644401122 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveWarningBottomSheetTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object TokenReceiveWarningBottomSheetTestTags { + const val BOTTOM_SHEET = "TOKEN_RECEIVE_WARNING_BOTTOM_SHEET" +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 8b8331fff7..6443a3dd5f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -390,7 +390,7 @@ fun Token( textAlign = TextAlign.Center, modifier = Modifier .defaultMinSize(minWidth = TangemTheme.dimens.size80) - .testTag(SwapTokenScreenTestTags.TOKEN_NAME), + .testTag(SwapTokenScreenTestTags.TOKEN_SYMBOL), ) } } diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt index 959045d098..39b2363e21 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveQrCodeContent.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -32,6 +33,7 @@ import com.tangem.core.ui.components.* import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TokenReceiveQrCodeBottomSheetTestTags import com.tangem.features.tokenreceive.impl.R import com.tangem.features.tokenreceive.ui.state.QrCodeUM import kotlinx.coroutines.launch @@ -88,6 +90,7 @@ private fun QrCodePage(addressFullName: TextReference, addressValue: String, net color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, style = TangemTheme.typography.h3, + modifier = Modifier.testTag(TokenReceiveQrCodeBottomSheetTestTags.TITLE), ) SpacerH(20.dp) @@ -99,7 +102,8 @@ private fun QrCodePage(addressFullName: TextReference, addressValue: String, net color = TangemTheme.colors.icon.constant, shape = RoundedCornerShape(8.dp), ) - .padding(8.dp), + .padding(8.dp) + .testTag(TokenReceiveQrCodeBottomSheetTestTags.QR_CODE), ) { Image( @@ -126,6 +130,7 @@ private fun QrCodePage(addressFullName: TextReference, addressValue: String, net color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, style = TangemTheme.typography.subtitle1, + modifier = Modifier.testTag(TokenReceiveQrCodeBottomSheetTestTags.ADDRESS), ) } } diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt index ed2a67a9be..6f4c57d692 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveWarningContent.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -25,6 +26,7 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TokenReceiveWarningBottomSheetTestTags import com.tangem.features.tokenreceive.impl.R import com.tangem.features.tokenreceive.ui.state.WarningUM @@ -40,7 +42,7 @@ internal fun TokenReceiveWarningContent(warningUM: WarningUM) { start = 16.dp, end = 16.dp, bottom = 16.dp, - ), + ).testTag(TokenReceiveWarningBottomSheetTestTags.BOTTOM_SHEET), horizontalAlignment = Alignment.CenterHorizontally, ) { CurrencyIcon( From 55f731f1c3aa6c0d829f17142d33d1171e0d8c0b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 15:32:23 +0200 Subject: [PATCH 28/46] Updated on 2026-08-14 --- .../configs/excluded_blockchains_config.json | 4 --- .../core/ui/extensions/BlockchainIcons.kt | 6 ++-- .../ui/src/main/res/drawable/ic_scroll_22.xml | 21 +++++++++++ .../src/main/res/drawable/img_scroll_22.xml | 36 +++++++++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_scroll_22.xml create mode 100644 core/ui/src/main/res/drawable/img_scroll_22.xml diff --git a/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json b/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json index 11acf26497..8b0cb7f15f 100644 --- a/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json +++ b/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json @@ -27,10 +27,6 @@ "name": "alephium", "version": "5.21.0" }, - { - "name": "scroll", - "version": "undefined" - }, { "name": "zklink", "version": "undefined" diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index d0a89131e0..d08bd2f20b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt @@ -90,7 +90,7 @@ fun getActiveIconRes(blockchainId: String): Int { "bitrock", "bitrock/test" -> R.drawable.img_bitrock_22 "sonic", "sonic/test" -> R.drawable.img_sonic_22 "apechain", "apechain/test" -> R.drawable.img_apecoin_22 - "scroll", "scroll/test" -> R.drawable.ic_alert_24 // FIXME: add icon during full integration + "scroll", "scroll/test" -> R.drawable.img_scroll_22 "zklink", "zklink/test" -> R.drawable.img_zklink_22 "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 "pepecoin", "pepecoin/test" -> R.drawable.img_pepecoin_22 @@ -185,7 +185,7 @@ fun getActiveIconResByCoinId(coinId: String): Int { "bitrock", "bitrock/test" -> R.drawable.img_bitrock_22 "sonic", "sonic/test" -> R.drawable.img_sonic_22 "apechain", "apechain/test" -> R.drawable.img_apecoin_22 - "scroll", "scroll/test" -> R.drawable.ic_alert_24 // FIXME: add icon during full integration + "scroll", "scroll/test" -> R.drawable.img_scroll_22 "zklink", "zklink/test" -> R.drawable.img_zklink_22 "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 "pepecoin-network", "pepecoin-network/test" -> R.drawable.img_pepecoin_22 @@ -283,7 +283,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "bitrock", "bitrock/test" -> R.drawable.ic_bitrock_22 "sonic", "sonic/test" -> R.drawable.ic_sonic_22 "apechain", "apechain/test" -> R.drawable.ic_apecoin_22 - "scroll", "scroll/test" -> R.drawable.ic_alert_24 // FIXME: add icon during full integration + "scroll", "scroll/test" -> R.drawable.ic_scroll_22 "zklink", "zklink/test" -> R.drawable.ic_zklink_22 "vanar-chain", "vanar-chain/test" -> R.drawable.ic_vanar_22 "pepecoin", "pepecoin/test" -> R.drawable.ic_pepecoin_22 diff --git a/core/ui/src/main/res/drawable/ic_scroll_22.xml b/core/ui/src/main/res/drawable/ic_scroll_22.xml new file mode 100644 index 0000000000..802b10c5d4 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_scroll_22.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_scroll_22.xml b/core/ui/src/main/res/drawable/img_scroll_22.xml new file mode 100644 index 0000000000..1772fd5f5e --- /dev/null +++ b/core/ui/src/main/res/drawable/img_scroll_22.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + From b89d98aa35394cbd7d7531be6552e03d568f03d8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 13:47:13 +0400 Subject: [PATCH 29/46] Updated on 2026-08-14 --- .../data/networks/di/NetworkDataModule.kt | 16 +++ .../store/DefaultNetworksStatusesStore.kt | 14 +++ .../networks/store/NetworksStatusesStore.kt | 3 + .../networks/utils/DefaultNetworksCleaner.kt | 73 +++++++++++++ .../utils/DefaultNetworksCleanerTest.kt | 101 ++++++++++++++++++ .../data/staking/di/StakingDataModule.kt | 14 +++ .../store/DefaultYieldsBalancesStore.kt | 10 ++ .../data/staking/store/YieldsBalancesStore.kt | 3 + .../staking/utils/DefaultStakingCleaner.kt | 29 +++++ .../utils/DefaultStakingCleanerTest.kt | 55 ++++++++++ .../domain/networks/utils/NetworksCleaner.kt | 20 ++++ .../domain/staking/utils/StakingCleaner.kt | 20 ++++ 12 files changed, 358 insertions(+) create mode 100644 data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt create mode 100644 data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt create mode 100644 data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt create mode 100644 domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt create mode 100644 domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt diff --git a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt index 6bf4e36673..e2f4c82b9c 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkDataModule.kt @@ -8,6 +8,7 @@ import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.networks.repository.DefaultNetworksRepository import com.tangem.data.networks.store.DefaultNetworksStatusesStore import com.tangem.data.networks.store.NetworksStatusesStore +import com.tangem.data.networks.utils.DefaultNetworksCleaner import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.network.entity.NetworkStatusDM @@ -15,6 +16,7 @@ import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.datasource.utils.setTypes import com.tangem.domain.networks.repository.NetworksRepository +import com.tangem.domain.networks.utils.NetworksCleaner import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -67,4 +69,18 @@ internal object NetworkDataModule { dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideNetworksCleaner( + networksStatusesStore: NetworksStatusesStore, + walletManagersFacade: WalletManagersFacade, + dispatchers: CoroutineDispatcherProvider, + ): NetworksCleaner { + return DefaultNetworksCleaner( + networksStatusesStore = networksStatusesStore, + walletManagersFacade = walletManagersFacade, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt index ff25a5ad51..213a73be50 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt @@ -96,6 +96,20 @@ internal class DefaultNetworksStatusesStore( } } + override suspend fun clear(userWalletId: UserWalletId, networks: Set) { + persistenceDataStore.updateData { storedStatuses -> + storedStatuses.toMutableMap().apply { + val updatedValues = this[userWalletId.stringValue].orEmpty().filterNot { + networks.any { network -> + it.networkId.value == network.rawId && it.derivationPath.value == network.derivationPath.value + } + } + + this[userWalletId.stringValue] = updatedValues.toSet() + } + } + } + private suspend fun updateInRuntime( userWalletId: UserWalletId, networks: Set, diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt index 30246ff85c..680ee077ab 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt @@ -41,4 +41,7 @@ internal interface NetworksStatusesStore { * See complex methods in `NetworksStatusesStoreExt`. */ suspend fun store(userWalletId: UserWalletId, status: NetworkStatus) + + /** Clear statuses of [networks] by [userWalletId] */ + suspend fun clear(userWalletId: UserWalletId, networks: Set) } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt b/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt new file mode 100644 index 0000000000..c4ef782bc9 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt @@ -0,0 +1,73 @@ +package com.tangem.data.networks.utils + +import com.tangem.data.networks.store.NetworksStatusesStore +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.utils.NetworksCleaner +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Default implementation of [NetworksCleaner]. + * + * @property networksStatusesStore Store to manage network statuses. + * @property walletManagersFacade Facade to manage wallet managers. + * @property dispatchers Coroutine dispatchers provider. + * +[REDACTED_AUTHOR] + */ +internal class DefaultNetworksCleaner( + private val networksStatusesStore: NetworksStatusesStore, + private val walletManagersFacade: WalletManagersFacade, + private val dispatchers: CoroutineDispatcherProvider, +) : NetworksCleaner { + + override suspend fun invoke(userWalletId: UserWalletId, currencies: List) { + withContext(dispatchers.default) { + val (networks, tokens) = currencies.partitionByType() + + coroutineScope { + launch { cleanStore(userWalletId = userWalletId, networks = networks) } + launch { cleanWalletManager(userWalletId = userWalletId, networks = networks, tokens = tokens) } + } + } + } + + private suspend fun cleanStore(userWalletId: UserWalletId, networks: Set) { + if (networks.isNotEmpty()) { + networksStatusesStore.clear(userWalletId = userWalletId, networks = networks) + } + } + + private suspend fun cleanWalletManager( + userWalletId: UserWalletId, + networks: Set, + tokens: Set, + ) { + if (networks.isNotEmpty()) { + walletManagersFacade.remove(userWalletId = userWalletId, networks = networks) + } + + if (tokens.isNotEmpty()) { + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = tokens) + } + } + + private fun List.partitionByType(): Pair, Set> { + val networks = mutableSetOf() + val tokens = mutableSetOf() + + for (currency in this) { + when (currency) { + is CryptoCurrency.Coin -> networks.add(currency.network) + is CryptoCurrency.Token -> tokens.add(currency) + } + } + + return Pair(networks, tokens) + } +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt new file mode 100644 index 0000000000..c2913c5f5a --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt @@ -0,0 +1,101 @@ +package com.tangem.data.networks.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.networks.store.NetworksStatusesStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultNetworksCleanerTest { + + private val networksStatusesStore = mockk(relaxed = true) + private val walletManagersFacade = mockk(relaxed = true) + private val cleaner = DefaultNetworksCleaner( + networksStatusesStore = networksStatusesStore, + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + private val userWalletId = UserWalletId("011") + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val network = cryptoCurrencyFactory.ethereum.network + private val coin = cryptoCurrencyFactory.ethereum + private val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum) + + @BeforeEach + fun setUp() { + clearMocks(networksStatusesStore, walletManagersFacade) + } + + @Test + fun `should clear networks and remove managers and tokens when called`() = runTest { + // Arrange + val currencies = listOf(coin, token) + + // Act + cleaner(userWalletId = userWalletId, currencies = currencies) + + // Assert + coVerifyOrder { + networksStatusesStore.clear(userWalletId, setOf(network)) + walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) + } + } + + @Test + fun `should handle empty currencies`() = runTest { + // Act + cleaner(userWalletId = userWalletId, currencies = emptyList()) + + // Assert + coVerifyOrder(inverse = true) { + networksStatusesStore.clear(userWalletId = any(), networks = any()) + walletManagersFacade.remove(userWalletId = any(), networks = any()) + walletManagersFacade.removeTokens(userWalletId = any(), tokens = any()) + } + } + + @Test + fun `should clear only networks when there are no tokens`() = runTest { + val currencies = listOf(coin) + + cleaner(userWalletId = userWalletId, currencies = currencies) + + coVerifyOrder { + networksStatusesStore.clear(userWalletId, setOf(network)) + walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) + } + + coVerifyOrder(inverse = true) { + walletManagersFacade.removeTokens(userWalletId = any(), tokens = any()) + } + } + + @Test + fun `should clear only tokens when there are no networks`() = runTest { + // Arrange + val currencies = listOf(token) + + // Act + cleaner(userWalletId = userWalletId, currencies = currencies) + + // Assert + coVerifyOrder { + walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token)) + } + + coVerifyOrder(inverse = true) { + networksStatusesStore.clear(userWalletId = any(), networks = any()) + walletManagersFacade.remove(userWalletId = any(), networks = any()) + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index a5c5a41e2b..d96e876a73 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -10,6 +10,7 @@ import com.tangem.data.staking.DefaultStakingTransactionHashRepository import com.tangem.data.staking.converters.error.StakeKitErrorConverter import com.tangem.data.staking.store.YieldsBalancesStore import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles +import com.tangem.data.staking.utils.DefaultStakingCleaner import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse import com.tangem.datasource.di.NetworkMoshi @@ -21,6 +22,7 @@ import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.staking.repositories.StakingTransactionHashRepository import com.tangem.domain.staking.toggles.StakingFeatureToggles +import com.tangem.domain.staking.utils.StakingCleaner import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -102,4 +104,16 @@ internal object StakingDataModule { fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): StakingFeatureToggles { return DefaultStakingFeatureToggles(featureTogglesManager) } + + @Provides + @Singleton + fun provideStakingCleaner( + yieldsBalancesStore: YieldsBalancesStore, + dispatchers: CoroutineDispatcherProvider, + ): StakingCleaner { + return DefaultStakingCleaner( + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt index 40fbee1534..fa753503be 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt @@ -96,6 +96,16 @@ internal class DefaultYieldsBalancesStore( ) } + override suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) { + persistenceStore.updateData { current -> + current.toMutableMap().apply { + this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty() + .filterNot { it.getStakingId() in stakingIds } + .toSet() + } + } + } + private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set) { val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values) .filterNotNull() diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt index 3ec260a07f..10bfa99242 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt @@ -33,4 +33,7 @@ interface YieldsBalancesStore { /** Store error by [userWalletId] and [stakingIds] */ suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set) + + /** Clear balances of [stakingIds] by [userWalletId] */ + suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt new file mode 100644 index 0000000000..1aa6753150 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt @@ -0,0 +1,29 @@ +package com.tangem.data.staking.utils + +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.utils.StakingCleaner +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +/** + * Default implementation of [StakingCleaner]. + * + * @property yieldsBalancesStore Store to manage yields balances. + * @property dispatchers Coroutine dispatchers provider. + * +[REDACTED_AUTHOR] + */ +internal class DefaultStakingCleaner( + private val yieldsBalancesStore: YieldsBalancesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : StakingCleaner { + + override suspend fun invoke(userWalletId: UserWalletId, stakingIds: Set) { + if (stakingIds.isEmpty()) return + + with(dispatchers.default) { + yieldsBalancesStore.clear(userWalletId, stakingIds) + } + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt new file mode 100644 index 0000000000..08dc91f9d6 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt @@ -0,0 +1,55 @@ +package com.tangem.data.staking.utils + +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultStakingCleanerTest { + + private val yieldsBalancesStore = mockk(relaxed = true) + private val cleaner = DefaultStakingCleaner( + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + private val userWalletId = UserWalletId("011") + private val stakingIds = setOf( + StakingID(integrationId = StakingIntegrationID.Coin.Cardano.value, address = "0x1"), + ) + + @BeforeEach + fun setUp() { + clearMocks(yieldsBalancesStore) + } + + @Test + fun `should clear yields balances when called`() = runTest { + // Act + cleaner(userWalletId = userWalletId, stakingIds = stakingIds) + + // Assert + coVerifyOrder { + yieldsBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds) + } + } + + @Test + fun `should handle empty stakingIds`() = runTest { + // Act + cleaner(userWalletId = userWalletId, stakingIds = emptySet()) + + // Assert + coVerifyOrder(inverse = true) { + yieldsBalancesStore.clear(userWalletId = any(), stakingIds = any()) + } + } +} \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt new file mode 100644 index 0000000000..5db343c0c4 --- /dev/null +++ b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.networks.utils + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Cleans up network-related data for a specific user wallet and a list of cryptocurrencies. + * +[REDACTED_AUTHOR] + */ +interface NetworksCleaner { + + /** + * Cleans up network-related data for the given [userWalletId] and list of [currencies]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param currencies The list of cryptocurrencies whose associated network data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, currencies: List) +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt new file mode 100644 index 0000000000..84925b509b --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.staking.utils + +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Cleans up staking-related data for a specific user wallet and a set of staking IDs. + * +[REDACTED_AUTHOR] + */ +interface StakingCleaner { + + /** + * Cleans up staking-related data for the given [userWalletId] and set of [stakingIds]. + * + * @param userWalletId The ID of the user wallet for which to clean up data. + * @param stakingIds The set of staking IDs whose associated data should be cleaned. + */ + suspend operator fun invoke(userWalletId: UserWalletId, stakingIds: Set) +} \ No newline at end of file From d30713a914b073bca89d4e0020f4bb8e9b92b662 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Oct 2025 10:25:27 +0300 Subject: [PATCH 30/46] Updated on 2026-08-14 --- .../androidTest/kotlin/com/tangem/tests/ScanCardTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt index f1639df077..856a3963b3 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt @@ -1,6 +1,10 @@ package com.tangem.tests import com.tangem.common.BaseTestCase +import com.tangem.common.annotations.ApiEnv +import com.tangem.common.annotations.ApiEnvConfig +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.domain.models.scan.ProductType import com.tangem.scenarios.checkMultiCurrencyMainScreen import com.tangem.scenarios.checkSingleCurrencyMainScreen @@ -15,6 +19,10 @@ import org.junit.Test @HiltAndroidTest class ScanCardTest : BaseTestCase() { + @ApiEnv( + ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD), + ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD) + ) @AllureId("868") @DisplayName("Scan: Scanning single-currency cards") @Test From cfd8ae295761e58eb59b5be77d729b681bcaf63a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Oct 2025 12:28:38 +0500 Subject: [PATCH 31/46] Updated on 2026-08-14 --- .../ui/tokens/TokenItemStateConverter.kt | 33 ++++++- core/res/src/main/res/values-de/strings.xml | 2 +- core/res/src/main/res/values-es/strings.xml | 2 + core/res/src/main/res/values-fr/strings.xml | 2 + core/res/src/main/res/values-ja/strings.xml | 21 ++++- core/res/src/main/res/values-ru/strings.xml | 9 +- core/res/src/main/res/values/strings.xml | 31 +++++-- .../converter/TokenListStateConverter.kt | 47 +--------- .../impl/main/DefaultYieldSupplyComponent.kt | 3 +- .../supply/impl/main/entity/YieldSupplyUM.kt | 3 +- .../impl/main/model/YieldSupplyModel.kt | 85 ++++++++++++------ .../impl/main/ui/YieldSupplyBlockContent.kt | 86 +++++++++++-------- .../active/ui/YieldSupplyActiveContent.kt | 25 ++---- 13 files changed, 209 insertions(+), 140 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index 0d23288ee3..de0d5e70fe 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -7,7 +7,9 @@ import com.tangem.core.ui.components.icons.IconTint import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter import com.tangem.core.ui.components.token.state.TokenItemState +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.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -15,6 +17,8 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.staking.YieldBalance import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.utils.StringsSigns.DASH_SIGN @@ -35,10 +39,13 @@ import java.math.BigDecimal */ class TokenItemStateConverter( private val appCurrency: AppCurrency, + private val apyMap: Map = emptyMap(), private val iconStateProvider: (CryptoCurrencyStatus) -> CurrencyIconState = { CryptoCurrencyToIconStateConverter().convert(it) }, - private val titleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.TitleState = Companion::createTitleState, + private val titleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.TitleState = { + createTitleState(it, apyMap) + }, private val subtitleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.SubtitleState? = { createSubtitleState(it, appCurrency) }, @@ -144,7 +151,10 @@ class TokenItemStateConverter( private fun CryptoCurrencyStatus.getStakedBalance() = (value.yieldBalance as? YieldBalance.Data) ?.getTotalWithRewardsStakingBalance(blockchainId = currency.network.rawId).orZero() - private fun createTitleState(currencyStatus: CryptoCurrencyStatus): TokenItemState.TitleState { + private fun createTitleState( + currencyStatus: CryptoCurrencyStatus, + apyMap: Map, + ): TokenItemState.TitleState { return when (val value = currencyStatus.value) { is CryptoCurrencyStatus.Loading, is CryptoCurrencyStatus.MissedDerivation, @@ -158,14 +168,33 @@ class TokenItemStateConverter( is CryptoCurrencyStatus.NoQuote, is CryptoCurrencyStatus.NoAccount, -> { + val earnApyText = resolveEarnApy(currencyStatus, apyMap)?.let { apy -> + resourceReference( + R.string.yield_module_earn_badge, + wrappedList(apy), + ) + } TokenItemState.TitleState.Content( text = stringReference(currencyStatus.currency.name), hasPending = value.hasCurrentNetworkTransactions, + earnApy = earnApyText, ) } } } + private fun resolveEarnApy(cryptoCurrencyStatus: CryptoCurrencyStatus, apyMap: Map): String? { + if (apyMap.isEmpty()) return null + + val isYieldSupplyActive = (cryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded) + ?.yieldSupplyStatus?.isActive == true + if (isYieldSupplyActive) return null + + val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return null + + return apyMap[token.yieldSupplyKey()] + } + private fun createSubtitleState( currencyStatus: CryptoCurrencyStatus, appCurrency: AppCurrency, diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 522132b11b..56f11d7388 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1362,7 +1362,7 @@ Frühzeitiger Zugriff auf neue Funktionen und exklusive Angebote. Updates zu Funktionen und Neuigkeiten Möchtest du Push-Benachrichtigungen verwenden? - Aktiviere Push-Benachrichtigungen und wir benachrichtigen Dich sofort, wenn Gelder eintreffen \n + Aktiviere Push-Benachrichtigungen und wir benachrichtigen Dich sofort, wenn Gelder eintreffen Verpasse keine Transaktion Neues Wallet hinzufügen Möchtest Du diese Wallet wirklich entfernen? diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index dcce73dd3a..6a2f5f1caf 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1212,6 +1212,8 @@ Acceso anticipado a nuevas funciones y ofertas exclusivas. Actualizaciones de características y noticias ¿Quiere utilizar\nnotificaciones push? + Active las notificaciones push y le avisaremos al instante cuando le lleguen fondos. + No se pierda ninguna transacción Agregar una nueva billetera ¿Estás seguro de que deseas olvidar esta billetera? Ha ocurrido un error, por favor escanee su tarjeta o anillo para iniciar sesión diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 004c7dd622..868ad2f695 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1185,6 +1185,8 @@ Accès anticipé à de nouvelles fonctionnalités et à des offres exclusives. Actualités et mises à jour Souhaitez-vous utiliser les\nnotifications push? + Activez les notifications pour recevoir des alertes lorsque des fonds arrivent dans votre portefeuille. + Ne manquez aucune transaction Ajouter un nouveau portefeuille Êtes-vous sûr de vouloir supprimer ce portefeuille ? Une erreur s\'est produite, veuillez scanner votre carte ou bague pour vous connecter diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 97d1117305..c357e3c31f 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -291,6 +291,7 @@ %1$s — %2$s 続きを読む 受け取る + おすすめ 拒否 リロード 名前を変更 @@ -347,6 +348,8 @@ はい コントラクトアドレスをコピーしました! 利用可能なネットワーク + このトークンの導出パスが%1$sの導出パスと一致しています。トークンはこのアカウントに追加されます。 + 導出パスは別のアカウントに属します。 コントラクトアドレス コントラクトアドレスが無効です ネットワークを選択してください @@ -464,6 +467,7 @@ プロバイダー ベストレート FCA警告リスト + お得なレート FCA警告リストに掲載されたプロバイダー 最大 %s まで使用可能 %s 以上で利用可能 @@ -1361,9 +1365,10 @@ 新しい機能や限定オファーへの早期アクセス。 機能およびニュースのアップデート プッシュ通知を使用しますか? - プッシュ通知を有効にすると、資金が到着するとすぐに通知されます + プッシュ通知を有効にすると、資金が到着するとすぐに通知されます。 取引を見逃さない 新しいウォレットを追加 + バックアップせずにこのウォレットを削除すると、資金に永久にアクセスできなくなります。 このウォレットを忘れてもよろしいですか? エラーが発生しました。カードまたはリングをスキャンしてログインしてください。 このウォレットはすでに保存されています。別のウォレットを追加できます。 @@ -1393,6 +1398,13 @@ ロック解除 カードをスキャンしてアクセスロックを解除する ロック解除が必要 + ウォレットの追加方法を選択してください + Tangemカードまたはリングをスキャンして復元するか、別のウォレットからインポートしてください。 + ハードウェアウォレットを作成 + Tangemウォレットを購入しますか? + シードフレーズをインポート + スマートフォン上でウォレットを復元するか、他のアプリからインポートできますが、Tangemカードと比べるとセキュリティは劣ります。 + お選びください ブロックチェーンにアクセスできません。後でもう一度お試しください。 カードまたはリングをスキャン このウォレットはすでに有効化されています。\nあなたが行ったのでない場合は、サポートに連絡してください。\nTangemは、事前に生成されたアクセスコードと一緒にウォレットを販売することはありません。 @@ -1657,13 +1669,20 @@ URIはすでに使用されています WalletConnect 不審な取引 + すでにTangemをお持ちですか? + 数千種類の資産 業界最高水準のハードウェアウォレット 迅速な配送 + ワンタップで開始 + シームレスで安全 シンプルな操作 Tangemでハードウェアウォレットを作成しましょう。銀行のカードのようにスリムで、金庫のように安全です。 ソフトウェアウォレットを作成またはインポート + スマートフォン上にソフトウェアウォレットを作成するか、インポートしてください。 モバイルウォレットから始める その他の方法 + Tangemハードウェアウォレットを使用する + 詳細を見る・購入する 破棄 バックアップが中断されました。再開しますか? はい、再開します diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index cf16e56da7..acc3f54800 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -80,7 +80,7 @@ Вы отключили биометрическую аутентификацию на вашем телефоне и не сможете сохранять кошельки в приложении. Для сохранения кошельков, пожалуйста, включите функцию биометрической аутентификации в настройках телефона. При обработке промокода произошла ошибка. Пожалуйста, попробуйте позже. Ошибка активации - Ваш промокод успешно активирован. Бонус 10 USDT в Bitcoin будет зачислен через 14 дней. + Ваш промокод был успешно активирован. Награда будет зачислена на ваш счёт в течение 14 дней. Промокод активирован Этот промокод уже был использован и не может быть активирован повторно. Код недоступен @@ -132,7 +132,7 @@ Аналитика Применить Одобрение - Подтвердить + Разрешить Внимание Доступные сети Баланс: %s @@ -1202,7 +1202,7 @@ В сумму включена комиссия провайдера сервиса. \n\nПроскальзывание провайдера составляет до %s Информация Подтверждения считаются отраслевым стандартом для всех децентрализованных бирж и защищают ваш кошелек от доступа со стороны смарт-контракта без вашего разрешения. По замыслу смарт-контракты не могут получить доступ к вашим токенам, если вы не одобрите доступ со своей стороны. «Разблокируя» свои токены, вы даете смарт-контракту 1inch разрешение тратить ваши активы. Майнеры сети получают компенсацию за газ (оплачиваемый вами) за запись этого действия в блокчейне. Как только разрешение будет предоставлено, вы сможете обменять свой токен. - Подтвердить + Разрешение Ошибка расчета комиссии. Пожалуйста, отправьте информацию в поддержку. Вы отправляете Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму. @@ -1288,6 +1288,8 @@ Ранний доступ к новым функциям и эксклюзивным предложениям. Новые функции и важные новости Хотите использовать Push-уведомления? + Включите push-уведомления, и мы мгновенно сообщим вам, когда поступят средства. + Не пропустите транзакцию Добавить новый кошелек Вы уверены, что хотите забыть этот кошелек? Произошла ошибка, пожалуйста, отсканируйте свою карту или кольцо для входа @@ -1559,6 +1561,7 @@ Нет, отправить все Уменьшить на %s XTZ Чтобы не платить повышенную комиссию при следующем пополнении кошелька, уменьшите сумму на %s XTZ + Выдать разрешение Невозможно загрузить график Полученная сумма, %1$s %2$s, не была зачислена на Aave. Сетевая комиссия сейчас слишком высокая. Ожидаем, пока она упадёт ниже вашего лимита. diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 6479a28a3f..368cbca8bf 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -135,7 +135,7 @@ 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. Activation error - Your promo code was successfully activated. A bonus of 10 USDT in Bitcoin will be credited to your account within 14 days. + Your promo code was successfully activated. A reward will be credited to your account within 14 days. Promo Code Activated This promo code has already been used and cannot be activated again. Code unavailable @@ -297,6 +297,7 @@ %1$s — %2$s Read more Receive + Recommended Reject Reload Rename @@ -474,6 +475,7 @@ Provider Best rate FCA Warning List + Great rate Provider in FCA warning list Available up to %s Available from %s @@ -1117,8 +1119,8 @@ This will remove the wallet from the application. The wallet itself can be added again. Name Put your token to work - A network fee is a small payment to process and confirm your transaction on the blockchain. - To start staking, your TON account must be activated with a self-transaction of 1 TON. The funds stay in your wallet — this step only enables your account for staking. + A network fee is a small payment required to process and confirm your transaction on the blockchain. + To start staking, your TON account must be activated with a self-transaction of 1 TON. The funds stay in your wallet — this step only enables your account for staking. Account activation The amount to stake must be at least %s Staking amount will be rounded to %1$s TRX due to network rules. @@ -1386,9 +1388,10 @@ Early access to fresh features and exclusive offers. Feature and News Updates Would you like to use\nPush-notifications? - Enable push notifications and we’ll notify you instantly when funds arrive\n - Don’t Miss a Transaction + Enable push notifications to receive alerts when funds arrive in your wallet. + Don\'t Miss a Transaction Add new wallet + If you delete this wallet without a backup, you will permanently lose access to your funds Are you sure you want to forget this wallet? An error has occurred, please scan your card or ring to log in This wallet has already been saved, you can add another one @@ -1465,6 +1468,13 @@ Unlock Scan your card to unlock access Needed unlock + Choose how to add your wallet + Scan your Tangem card or ring to restore it or import from another wallet. + Create Hardware Wallet + Want to purchase a Tangem Wallet? + Import seed phrase + Restore your wallet on your phone or import from another app — convenient, but less secure than a Tangem card. + What to choose? Blockchain is unreachable. Try later Scan card or ring This wallet has already been activated earlier.\nIf it was not done by you, please contact support.\nTangem never sells wallets along with pre-generated access codes. @@ -1504,6 +1514,7 @@ Connect to dApps WalletConnect Connecting may take a few seconds + Create Tangem Wallet From %s Buy Tangem Wallet—a physical device that securely stores your private key offline. Hardware Wallet @@ -1731,13 +1742,20 @@ URI already used WalletConnect Suspicious transaction + Already have Tangem? + Thousands of assets Best in class hardware wallet Fast delivery + Start in one tap + Seamless and secure Simple to use Create a hardware wallet with Tangem. Slim as a bank card, secure as a bank vault. Create or import a software wallet + Create or import a software wallet on your phone Start with Mobile Wallet Other method + Use Tangem Hardware Wallet + Learn more & buy Discard You have an interrupted backup. Do you want to resume? Yes, resume @@ -1814,7 +1832,8 @@ Supply APR APY Make your money work — earn interest on your balance. - Earning on your balance + Interest accrues automatically + Aave lending Processing your deposit Earn %1$s%% per year Automatic diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index eb2c314a09..eee1c4776a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -2,7 +2,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter import com.tangem.common.ui.tokens.TokenItemStateConverter -import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.resourceReference @@ -14,9 +13,7 @@ import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.tokenlist.TokenList.GroupedByNetwork.NetworkGroup import com.tangem.domain.models.wallet.UserWallet @@ -37,7 +34,7 @@ internal class TokenListStateConverter( private val params: TokenConverterParams, private val selectedWallet: UserWallet, private val clickIntents: WalletClickIntents, - private val apyMap: Map = emptyMap(), + private val apyMap: Map, ) : Converter { private val onTokenClick: (accountId: AccountId?, currencyStatus: CryptoCurrencyStatus) -> Unit = @@ -52,6 +49,7 @@ internal class TokenListStateConverter( private fun tokenStatusConverter(accountId: AccountId? = null) = TokenItemStateConverter( appCurrency = appCurrency, + apyMap = apyMap, onItemClick = { _, status -> onTokenClick(accountId, status) }, onItemLongClick = { _, status -> onTokenLongClick(accountId, status) }, ) @@ -168,52 +166,13 @@ internal class TokenListStateConverter( tokenConverter: TokenItemStateConverter, token: CryptoCurrencyStatus, ): List { - val tokenItemState = tokenConverter.convert(token).withEarnApyBadge(token) + val tokenItemState = tokenConverter.convert(token) add(TokensListItemUM.Token(tokenItemState)) return this } - private fun TokenItemState.withEarnApyBadge(cryptoCurrencyStatus: CryptoCurrencyStatus): TokenItemState { - val apy: String? = resolveEarnApy(cryptoCurrencyStatus) - - val shouldApply = apy != null && this.titleState is TokenItemState.TitleState.Content - - return if (!shouldApply) { - this - } else { - val contentTitle = this.titleState as TokenItemState.TitleState.Content - val newTitle = contentTitle.copy( - earnApy = resourceReference( - R.string.yield_module_earn_badge, - wrappedList(apy), - ), - ) - - when (this) { - is TokenItemState.Content -> this.copy(titleState = newTitle) - is TokenItemState.Unreachable -> this.copy(titleState = newTitle) - is TokenItemState.NoAddress -> this.copy(titleState = newTitle) - is TokenItemState.Loading -> this.copy(titleState = newTitle) - is TokenItemState.Draggable -> this.copy(titleState = newTitle) - is TokenItemState.Locked -> this - } - } - } - - private fun resolveEarnApy(cryptoCurrencyStatus: CryptoCurrencyStatus): String? { - if (apyMap.isEmpty()) return null - - val isYieldSupplyActive = (cryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded) - ?.yieldSupplyStatus?.isActive == true - if (isYieldSupplyActive) return null - - val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return null - - return apyMap[token.yieldSupplyKey()] - } - private fun getOrganizeTokensButtonState(tokenList: TokenList): WalletOrganizeTokensButtonConfig? { val currenciesSize = when (tokenList) { TokenList.Empty -> return null diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt index bfc0e7857d..abb21ee496 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt @@ -38,9 +38,8 @@ internal class DefaultYieldSupplyComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val yieldSupplyUM by model.uiState.collectAsStateWithLifecycle() val bottomSheet by bottomSheetSlot.subscribeAsState() - val isBalanceHidden by model.isBalanceHiddenFlow.collectAsStateWithLifecycle() - YieldSupplyBlockContent(yieldSupplyUM = yieldSupplyUM, isBalanceHidden = isBalanceHidden, modifier = modifier) + YieldSupplyBlockContent(yieldSupplyUM = yieldSupplyUM, modifier = modifier) bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt index 192ea9551a..30008334c4 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt @@ -18,7 +18,8 @@ internal sealed class YieldSupplyUM { data object Unavailable : YieldSupplyUM() data class Content( - val rewardsBalance: TextReference, + val title: TextReference, + val subtitle: TextReference, val rewardsApy: TextReference, val onClick: () -> Unit, val isAllowedToSpend: Boolean, diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 9b267af4e3..5b5f8676da 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -7,12 +7,15 @@ import com.tangem.common.routing.AppRouter 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.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -20,6 +23,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyIsAvailableUseCase +import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer @@ -72,6 +76,8 @@ internal class YieldSupplyModel @Inject constructor( val isBalanceHiddenFlow: StateFlow field = MutableStateFlow(false) + private var lastYieldSupplyStatus: YieldSupplyStatus? = null + init { checkIfYieldSupplyIsAvailable() } @@ -100,7 +106,7 @@ internal class YieldSupplyModel @Inject constructor( maybeCryptoCurrency.fold( ifRight = { cryptoCurrencyStatus -> cryptoCurrencyStatusFlow.update { cryptoCurrencyStatus } - onDataLoaded(cryptoCurrencyStatus) + onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus) }, ifLeft = { Timber.w(it.toString()) @@ -128,6 +134,7 @@ internal class YieldSupplyModel @Inject constructor( ), ) }.onLeft { + Timber.e(it) uiState.update { YieldSupplyUM.Unavailable } } } @@ -157,50 +164,76 @@ internal class YieldSupplyModel @Inject constructor( .launchIn(modelScope) } - private fun onDataLoaded(cryptoCurrencyStatus: CryptoCurrencyStatus) { + @Suppress("MaximumLineLength") + private fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) { val yieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus val hasActiveTransaction = cryptoCurrencyStatus.value.hasCurrentNetworkTransactions val yieldTransaction = cryptoCurrencyStatus.value.pendingTransactions.firstOrNull { it.type is TxInfo.TransactionType.YieldSupply }?.type as? TxInfo.TransactionType.YieldSupply - sendInfoAboutProtocolStatus(yieldSupplyStatus?.isActive == true) + sendInfoAboutProtocolStatus(cryptoCurrencyStatus) - val yieldSupplyUM = when { + when { hasActiveTransaction && yieldTransaction != null -> { coroutineScope.launch(dispatchers.io) { delay(PROCESSING_UPDATE_DELAY) fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, cryptoCurrency.id) } - when (yieldTransaction) { - TxInfo.TransactionType.YieldSupply.Enter -> YieldSupplyUM.Processing.Enter - TxInfo.TransactionType.YieldSupply.Exit -> YieldSupplyUM.Processing.Exit + uiState.update { + when (yieldTransaction) { + TxInfo.TransactionType.YieldSupply.Enter -> YieldSupplyUM.Processing.Enter + TxInfo.TransactionType.YieldSupply.Exit -> YieldSupplyUM.Processing.Exit + } + } + } + yieldSupplyStatus?.isActive == true -> { + val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return + modelScope.launch(dispatchers.default) { + yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) + .onRight { tokenStatus -> + uiState.update { + YieldSupplyUM.Content( + title = resourceReference( + R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, + ), + subtitle = resourceReference( + R.string.yield_module_token_details_earn_notification_earning_on_your_balance_subtitle, + ), + rewardsApy = combinedReference( + resourceReference( + R.string.yield_module_token_details_earn_notification_apy, + ), + stringReference(tokenStatus.apy.toString() + "%"), + ), + onClick = ::onActiveClick, + isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, + ) + } + }.onLeft { + Timber.e(it) + uiState.update { YieldSupplyUM.Loading } + } } } - yieldSupplyStatus?.isActive == true -> - YieldSupplyUM.Content( - rewardsBalance = TextReference.EMPTY, - rewardsApy = TextReference.EMPTY, - onClick = ::onActiveClick, - isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, - ) - else -> YieldSupplyUM.Initial - } - uiState.update { yieldSupplyUM } - - when (yieldSupplyUM) { - is YieldSupplyUM.Initial -> loadTokenStatus() - else -> Unit + else -> { + loadTokenStatus() + } } } - private fun sendInfoAboutProtocolStatus(isActivated: Boolean) { + private fun sendInfoAboutProtocolStatus(cryptoCurrencyStatus: CryptoCurrencyStatus) { + if (lastYieldSupplyStatus == cryptoCurrencyStatus.value.yieldSupplyStatus) return val token = cryptoCurrency as? CryptoCurrency.Token ?: return modelScope.launch(dispatchers.default) { - if (isActivated) { - yieldSupplyActivateUseCase(token) + if (cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true) { + yieldSupplyActivateUseCase(token).onRight { + lastYieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus + } } else { - yieldSupplyDeactivateUseCase(token) + yieldSupplyDeactivateUseCase(token).onRight { + lastYieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus + } } } } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt index e044b1c598..b596d93729 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt @@ -3,6 +3,7 @@ package com.tangem.features.yield.supply.impl.main.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -17,12 +18,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.SpacerW8 import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.buttons.common.TangemButtonSize @@ -34,11 +37,7 @@ import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.utils.StringsSigns @Composable -internal fun YieldSupplyBlockContent( - yieldSupplyUM: YieldSupplyUM, - isBalanceHidden: Boolean, - modifier: Modifier = Modifier, -) { +internal fun YieldSupplyBlockContent(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) { AnimatedContent( targetState = yieldSupplyUM, modifier = modifier, @@ -46,7 +45,7 @@ internal fun YieldSupplyBlockContent( when (supplyUM) { is YieldSupplyUM.Available -> SupplyAvailable(supplyUM) YieldSupplyUM.Loading -> SupplyLoading() - is YieldSupplyUM.Content -> SupplyContent(supplyUM, isBalanceHidden) + is YieldSupplyUM.Content -> SupplyContent(supplyUM) YieldSupplyUM.Processing.Enter -> SupplyProcessing( resourceReference(R.string.yield_module_token_details_earn_notification_processing), ) @@ -87,7 +86,7 @@ private fun SupplyUnavailable() { } @Composable -private fun SupplyContent(supplyUM: YieldSupplyUM.Content, isBalanceHidden: Boolean) { +private fun SupplyContent(supplyUM: YieldSupplyUM.Content) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -96,22 +95,21 @@ private fun SupplyContent(supplyUM: YieldSupplyUM.Content, isBalanceHidden: Bool .clickable(onClick = supplyUM.onClick) .padding(12.dp), ) { + Image( + painter = painterResource(R.drawable.img_aave_22), + modifier = Modifier.size(36.dp), + contentDescription = null, + ) + SpacerW12() Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f), ) { - Text( - text = stringResourceSafe( - R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, - ), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = supplyUM.rewardsBalance.orMaskWithStars(isBalanceHidden).resolveReference(), + text = supplyUM.title.resolveReference(), style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, ) @@ -123,9 +121,15 @@ private fun SupplyContent(supplyUM: YieldSupplyUM.Content, isBalanceHidden: Bool Text( text = supplyUM.rewardsApy.resolveReference(), style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.tertiary, + maxLines = 1, + color = TangemTheme.colors.text.accent, ) } + Text( + text = supplyUM.subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + ) } SpacerW8() AnimatedVisibility(supplyUM.isAllowedToSpend.not()) { @@ -146,36 +150,42 @@ private fun SupplyContent(supplyUM: YieldSupplyUM.Content, isBalanceHidden: Bool @Composable private fun SupplyProcessing(text: TextReference) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(TangemTheme.colors.background.primary) .padding(12.dp), ) { - Text( - text = stringResourceSafe( - R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, - ), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, + Image( + painter = painterResource(R.drawable.img_aave_22), + modifier = Modifier.size(36.dp), + contentDescription = null, ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + SpacerW12() + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), ) { Text( - text = text.resolveReference(), - style = TangemTheme.typography.body1, + text = stringResourceSafe( + R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, + ), + style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, ) - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = TangemTheme.colors.icon.accent, - strokeWidth = 2.dp, + Text( + text = text.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, ) } + SpacerW8() + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = TangemTheme.colors.icon.accent, + strokeWidth = 2.dp, + ) } } @@ -286,7 +296,7 @@ private fun SupplyInfo( @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun YieldSupplyBlockContent_Preview(@PreviewParameter(PreviewProvider::class) params: YieldSupplyUM) { TangemThemePreview { - YieldSupplyBlockContent(params, true) + YieldSupplyBlockContent(yieldSupplyUM = params, modifier = Modifier) } } @@ -301,13 +311,15 @@ private class PreviewProvider : PreviewParameterProvider { onClick = {}, ), YieldSupplyUM.Content( - rewardsBalance = stringReference("1 USDT"), + title = stringReference("Aave lending is active"), + subtitle = stringReference("Interest accrues automatically"), rewardsApy = stringReference("5.1 % APY"), onClick = {}, isAllowedToSpend = false, ), YieldSupplyUM.Content( - rewardsBalance = stringReference("1 USDT"), + title = stringReference("Aave lending is active"), + subtitle = stringReference("Interest accrues automatically"), rewardsApy = stringReference("5.1 % APY"), onClick = {}, isAllowedToSpend = true, diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt index 383a4b2231..eab1197a0e 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/active/ui/YieldSupplyActiveContent.kt @@ -56,17 +56,6 @@ internal fun YieldSupplyActiveContent( .fillMaxWidth() .padding(12.dp), ) { - Text( - text = stringResourceSafe(R.string.yield_module_earn_sheet_total_earnings_title), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) - ResizableText( - text = state.totalEarnings.orMaskWithStars(isBalanceHidden).resolveReference(), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - ) - CurrentApy(state.apy) chartComponent.Content(Modifier.padding(bottom = 12.dp)) } @@ -85,22 +74,23 @@ internal fun YieldSupplyActiveContent( @Composable private fun CurrentApy(apy: TextReference?, modifier: Modifier = Modifier) { - Row(modifier = modifier.padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = modifier.padding(vertical = 12.dp)) { Text( - modifier = modifier.weight(1.0f), + modifier = Modifier, text = stringResourceSafe(R.string.yield_module_earn_sheet_current_apy_title), - style = TangemTheme.typography.body1, + style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, ) AnimatedContent( + modifier = Modifier.height(32.dp), targetState = apy?.resolveReference(), label = "CurrentApy", ) { apyText -> if (apyText == null) { TextShimmer( - modifier = modifier.width(56.dp), + modifier = modifier.width(94.dp), text = "", - style = TangemTheme.typography.body1, + style = TangemTheme.typography.head, ) } else { Row(verticalAlignment = Alignment.CenterVertically) { @@ -108,7 +98,7 @@ private fun CurrentApy(apy: TextReference?, modifier: Modifier = Modifier) { painterResource(R.drawable.ic_arrow_up_8), tint = TangemTheme.colors.text.accent, contentDescription = null, - modifier = Modifier.padding(end = 8.dp), + modifier = Modifier.padding(end = 6.dp).size(12.dp), ) Text( modifier = modifier, @@ -260,6 +250,7 @@ private class YieldSupplyActiveBottomSheetPreviewProvider : PreviewParameterProv ), subtitleLink = resourceReference(R.string.common_read_more), notificationUM = NotificationUM.Error.InvalidAmount, + apy = stringReference("5,14%"), ), ) } From 3ff7d4b050b295cae1e8d99bf30edf115094d3b4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Oct 2025 12:56:47 +0500 Subject: [PATCH 32/46] Updated on 2026-08-14 --- .../java/com/tangem/datasource/api/common/config/TangemPay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt index 6ac5527c5b..ef565f5f96 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt @@ -43,7 +43,7 @@ internal class TangemPay( private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.PROD, - baseUrl = "https://api.paera.com/bff/", + baseUrl = "https://api.us.paera.com/bff/", headers = createHeaders(), ) From 499351d5a9f4b3c97204ef2741c1d7e9f0b37f24 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Oct 2025 13:23:33 +0500 Subject: [PATCH 33/46] Updated on 2026-08-14 --- .../txHistory/PreviewTangemPayTxHistoryComponent.kt | 2 +- .../com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt index ba9bbc588f..eae34d3741 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt @@ -35,7 +35,7 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor amount = "-4.99 USD", amountColor = { TangemTheme.colors.text.primary1 }, time = "16:41", - title = stringReference("Starbucks"), + title = stringReference("StarbucksStarbucksStarbucksStarbucks"), subtitle = stringReference("Food&Drinks"), iconUrl = null, ), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt index 466c8de420..bcaec6f86e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayTxHistoryUi.kt @@ -241,7 +241,7 @@ private fun TangemPayTransaction( bottom.linkTo(timestampItem.top) start.linkTo(titleItem.end) end.linkTo(parent.end) - width = Dimension.fillToConstraints + width = Dimension.preferredWrapContent }, ) @@ -363,7 +363,7 @@ private fun Amount(state: TangemPayTransactionState, isBalanceHidden: Boolean, m } is TangemPayTransactionState.Loading -> { RectangleShimmer( - modifier = modifier.size(width = TangemTheme.dimens.size40, height = TangemTheme.dimens.size12), + modifier = modifier.size(width = TangemTheme.dimens.size72, height = TangemTheme.dimens.size12), ) } } From 2bc8c29f6d0740c12edc28089ca2a29c3a6b81e7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Oct 2025 13:51:10 +0500 Subject: [PATCH 34/46] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + .../tangem/tap/routing/utils/ChildFactory.kt | 15 + .../com/tangem/common/routing/AppRoute.kt | 10 + .../core/ui/components/SystemBarsUtils.kt | 53 +- .../com/tangem/core/ui/res/TangemTheme.kt | 13 + .../drawable-hdpi/img_hardware_wallet.webp | Bin 0 -> 5216 bytes .../res/drawable-hdpi/img_mobile_wallet.webp | Bin 0 -> 21214 bytes .../drawable-mdpi/img_hardware_wallet.webp | Bin 0 -> 2978 bytes .../res/drawable-mdpi/img_mobile_wallet.webp | Bin 0 -> 10076 bytes .../drawable-xhdpi/img_hardware_wallet.webp | Bin 0 -> 8866 bytes .../res/drawable-xhdpi/img_mobile_wallet.webp | Bin 0 -> 36434 bytes .../drawable-xxhdpi/img_hardware_wallet.webp | Bin 0 -> 17644 bytes .../drawable-xxhdpi/img_mobile_wallet.webp | Bin 0 -> 75210 bytes .../drawable-xxxhdpi/img_hardware_wallet.webp | Bin 0 -> 30038 bytes .../drawable-xxxhdpi/img_mobile_wallet.webp | Bin 0 -> 131038 bytes .../res/drawable/ic_chevron_right_18x24.xml | 9 + core/ui/src/main/res/drawable/ic_flash_16.xml | 9 + .../main/res/drawable/ic_shield_check_16.xml | 10 + .../src/main/res/drawable/ic_sparkles_16.xml | 12 + .../res/drawable/ic_stack_fill_new_16.xml | 15 + features/create-wallet-start/api/.gitignore | 1 + .../create-wallet-start/api/build.gradle.kts | 21 + .../CreateWalletStartComponent.kt | 18 + features/create-wallet-start/impl/.gitignore | 1 + .../create-wallet-start/impl/build.gradle.kts | 72 +++ .../CreateWalletStartModel.kt | 243 +++++++++ .../DefaultCreateWalletStartComponent.kt | 42 ++ .../di/CreateWalletStartModule.kt | 33 ++ .../entity/CreateWalletStartUM.kt | 25 + .../ui/CreateWalletStartContent.kt | 486 ++++++++++++++++++ settings.gradle.kts | 3 + 31 files changed, 1086 insertions(+), 7 deletions(-) create mode 100644 core/ui/src/main/res/drawable-hdpi/img_hardware_wallet.webp create mode 100644 core/ui/src/main/res/drawable-hdpi/img_mobile_wallet.webp create mode 100644 core/ui/src/main/res/drawable-mdpi/img_hardware_wallet.webp create mode 100644 core/ui/src/main/res/drawable-mdpi/img_mobile_wallet.webp create mode 100644 core/ui/src/main/res/drawable-xhdpi/img_hardware_wallet.webp create mode 100644 core/ui/src/main/res/drawable-xhdpi/img_mobile_wallet.webp create mode 100644 core/ui/src/main/res/drawable-xxhdpi/img_hardware_wallet.webp create mode 100644 core/ui/src/main/res/drawable-xxhdpi/img_mobile_wallet.webp create mode 100644 core/ui/src/main/res/drawable-xxxhdpi/img_hardware_wallet.webp create mode 100644 core/ui/src/main/res/drawable-xxxhdpi/img_mobile_wallet.webp create mode 100644 core/ui/src/main/res/drawable/ic_chevron_right_18x24.xml create mode 100644 core/ui/src/main/res/drawable/ic_flash_16.xml create mode 100644 core/ui/src/main/res/drawable/ic_shield_check_16.xml create mode 100644 core/ui/src/main/res/drawable/ic_sparkles_16.xml create mode 100644 core/ui/src/main/res/drawable/ic_stack_fill_new_16.xml create mode 100644 features/create-wallet-start/api/.gitignore create mode 100644 features/create-wallet-start/api/build.gradle.kts create mode 100644 features/create-wallet-start/api/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartComponent.kt create mode 100644 features/create-wallet-start/impl/.gitignore create mode 100644 features/create-wallet-start/impl/build.gradle.kts create mode 100644 features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt create mode 100644 features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/DefaultCreateWalletStartComponent.kt create mode 100644 features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/di/CreateWalletStartModule.kt create mode 100644 features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/entity/CreateWalletStartUM.kt create mode 100644 features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2de49eab54..097bcd4c84 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -273,6 +273,8 @@ dependencies { implementation(projects.features.welcome.impl) implementation(projects.features.createWalletSelection.api) implementation(projects.features.createWalletSelection.impl) + implementation(projects.features.createWalletStart.api) + implementation(projects.features.createWalletStart.impl) implementation(projects.features.home.api) implementation(projects.features.home.impl) implementation(projects.features.account.api) 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 e2ce3a2b4a..c858ffedc0 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 @@ -13,6 +13,7 @@ import com.tangem.features.account.AccountCreateEditComponent import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.createwalletselection.CreateWalletSelectionComponent +import com.tangem.features.createwalletstart.CreateWalletStartComponent import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.home.api.HomeComponent @@ -99,6 +100,7 @@ internal class ChildFactory @Inject constructor( private val usedeskComponentFactory: UsedeskComponent.Factory, private val chooseManagedTokensComponentFactory: ChooseManagedTokensComponent.Factory, private val createWalletSelectionComponentFactory: CreateWalletSelectionComponent.Factory, + private val createWalletStartComponentFactory: CreateWalletStartComponent.Factory, private val createMobileWalletComponentFactory: CreateMobileWalletComponent.Factory, private val upgradeWalletComponentFactory: UpgradeWalletComponent.Factory, private val addExistingWalletComponentFactory: AddExistingWalletComponent.Factory, @@ -476,6 +478,19 @@ internal class ChildFactory @Inject constructor( componentFactory = chooseManagedTokensComponentFactory, ) } + is AppRoute.CreateWalletStart -> { + val mode = when (route.mode) { + AppRoute.CreateWalletStart.Mode.ColdWallet -> CreateWalletStartComponent.Mode.ColdWallet + AppRoute.CreateWalletStart.Mode.HotWallet -> CreateWalletStartComponent.Mode.HotWallet + } + createComponentChild( + context = context, + params = CreateWalletStartComponent.Params( + mode = mode, + ), + componentFactory = createWalletStartComponentFactory, + ) + } is AppRoute.CreateWalletSelection -> { 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 d5506408c3..cdba5c18f3 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,16 @@ sealed class AppRoute(val path: String) : Route { @Serializable object CreateWalletSelection : AppRoute(path = "/create_wallet_selection") + @Serializable + data class CreateWalletStart( + val mode: Mode, + ) : AppRoute(path = "/create_wallet_start") { + enum class Mode { + ColdWallet, + HotWallet, + } + } + @Serializable object CreateMobileWallet : AppRoute(path = "/create_mobile_wallet") diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/SystemBarsUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/components/SystemBarsUtils.kt index 0e40554581..494129175b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/SystemBarsUtils.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/SystemBarsUtils.kt @@ -1,12 +1,54 @@ package com.tangem.core.ui.components import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import com.google.accompanist.systemuicontroller.SystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.core.ui.res.LocalIsInDarkTheme +val LocalSystemBarsIconsController = staticCompositionLocalOf { + error("No SystemBarsIconsController provided") +} + +class SystemBarsIconsController(private val systemUiController: SystemUiController) { + private var count by mutableIntStateOf(0) + + fun setIcons(darkIcons: Boolean, isNavigationBarContrastEnforced: Boolean) { + if (count == 0) { + systemUiController.systemBarsDarkContentEnabled = darkIcons + systemUiController.isNavigationBarContrastEnforced = isNavigationBarContrastEnforced + } + count++ + } + + fun restoreIcons(isDarkTheme: Boolean) { + count-- + if (count == 0) { + systemUiController.systemBarsDarkContentEnabled = !isDarkTheme + systemUiController.isNavigationBarContrastEnforced = false + } + } +} + +@Composable +fun ProvideSystemBarsIconsController(content: @Composable () -> Unit) { + val systemUiController = rememberSystemUiController() + val controller = remember(systemUiController) { SystemBarsIconsController(systemUiController) } + + CompositionLocalProvider( + LocalSystemBarsIconsController provides controller, + content = content, + ) +} + /** * Provides the ability to set a scrim for 3-button navigation * @@ -43,19 +85,16 @@ fun NavigationBar3ButtonsScrim() { */ @Composable fun SystemBarsIconsDisposable(darkIcons: Boolean, isNavigationBarContrastEnforced: Boolean = false) { - val systemUiController = rememberSystemUiController() + val controller = LocalSystemBarsIconsController.current + val isDarkTheme = LocalIsInDarkTheme.current SideEffect { - systemUiController.systemBarsDarkContentEnabled = darkIcons - systemUiController.isNavigationBarContrastEnforced = isNavigationBarContrastEnforced + controller.setIcons(darkIcons, isNavigationBarContrastEnforced) } - val isDarkTheme = LocalIsInDarkTheme.current - DisposableEffect(isDarkTheme) { onDispose { - systemUiController.systemBarsDarkContentEnabled = !isDarkTheme - systemUiController.isNavigationBarContrastEnforced = false + controller.restoreIcons(isDarkTheme) } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 85baa41281..01765f772d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.core.ui.UiDependencies +import com.tangem.core.ui.components.LocalSystemBarsIconsController +import com.tangem.core.ui.components.SystemBarsIconsController import com.tangem.core.ui.components.TangemShimmer import com.tangem.core.ui.components.text.BladeAnimation import com.tangem.core.ui.components.text.rememberBladeAnimation @@ -65,6 +67,8 @@ fun TangemTheme( val themeColors = if (isDark) darkThemeColors() else lightThemeColors() val rememberedColors = remember { themeColors } .also { it.update(themeColors) } + val systemUiController = rememberSystemUiController() + val systemBarsIconsController = remember(systemUiController) { SystemBarsIconsController(systemUiController) } val shapes = remember { TangemShapes(dimens) } @@ -103,6 +107,7 @@ fun TangemTheme( LocalEventMessageHandler provides eventMessageHandler, LocalWindowSize provides windowSize, LocalBladeAnimation provides rememberBladeAnimation(), + LocalSystemBarsIconsController provides systemBarsIconsController, ) { CompositionLocalProvider( LocalTangemShimmer provides TangemShimmer, @@ -119,6 +124,14 @@ fun TangemTheme( } } +@Composable +fun ForceDarkTheme(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalTangemColors provides darkThemeColors(), + content = content, + ) +} + object TangemTheme { val colors: TangemColors @Composable diff --git a/core/ui/src/main/res/drawable-hdpi/img_hardware_wallet.webp b/core/ui/src/main/res/drawable-hdpi/img_hardware_wallet.webp new file mode 100644 index 0000000000000000000000000000000000000000..471779539cf4d1bb6b227cdc55a50b72c7b36a94 GIT binary patch literal 5216 zcmai%bx;(J7wvcHC1eTdmM)hNK|nf|E*B6%VCn7@+=X4bB}Am8yStH)PH6#Y>F#*n z-^}~>&AW5%ocrgQ^T(a}=s*<}4aflieFa%9n3kvkJ^%m!|F`=<01pr#qo$=CNCW@? zsTCY)1zu-(-&1=FgzY?G)w<{5exapo|C*YU)^?B~@1(<{^OPemCtzW=beb24b z(f`LL@GTA%l^}dnY}>&$Qy+e0x5h6~-Zi!>OSsp4n^}Hr9al6(;wd>SiY8cH4c1N_ zQqK-$CeT6?SOWQ54|-_O zQFzU=|7Lxi8Vk-fOt{5<#V*X$cb|soZNl#rj20~E0KAIhXOroK6~H^~)GAQz7zZQZ z!F3=T^@S4AQ6yC7avBDp;Q(lQg8&RHyQ0`1P5s8TJ4qxIlF1<|FO_r8uAGA2;21m<;~S(m^ZYWEiYa4GrGHzY0AdlO68j?rtPK6F zpJjdVWgIW+Fct7idwNnP-@>Dch^g?O)@NqJ@5c+M4dfKrJraC*b3pgTBs63;n-v8d z!-yK9eT9t#+hNWA$d$%Jf1m0H^G+-sb!|p}<5oeC3}t}Qu7EpGGuf)@U5{NSCN$=E z{(9j4rYDt8fZs+Ptb0fBjWk>3dv>_T7?yNpw+1T*+91?)3^i4u;)qee1Hm5eervKA zyxQ_zV{o=r;dx!RQn1x@?FO@H$;EycLot{urc3~7Kz6W=xFIUz&p_p*sPa+_ETR(3^HveF z+1o9T3@>%!JKuoHKF7eLx{%odcRwm^o*e7*N>X@oVZ=^$g1J{Klo*ZC!Nc!7tw$=OWqXZ3Mpw2~z2cp!d5Qu0xcpj2Z^FRX}*MVH9{QFZ+! z#z8?&vZp*LWHu)(+5jcBAHMYeFR#(*g5XgSsTHF4&C4m5TV1j=N#&payiG4#V?w5m zI_dmjT-j1tYu|H*K*oiQ_Xr(Xj#8l%dgZ*_+(OayYaLmfz16TYRZtxTx5%f`L`agB zz|(9V%G*V9^}(!Ouv@p$YX@4849=HCMlOHsOD5Jx#c@dCe{kZYmGBl=53-C;vF0Md zn^JG70axd(ueyt?V&2~Z83h~ZdCo_FwYM*KI^sH>a{2jeQnexI<~2t$S66Nf-=4A; zKBX;nbp*c7KrCa0+vbpnWT>(1EWdsXcZOhm!;moWgfj#GoI z1YqQJD$v#Su>i!(`Xkb4Un^eIE2wdEF9g|tnX&u$6THH~wpVNBMW(OeR&Vb}ULVeo zGK?8#lx*)RV*{B0z9q+@-_rdeAPv(e5l}xrRIy7*Tso_6&Wk|0ukh-kyL?N{Jroq| z*=oGmj(se(9AxL9V5F-OuoLd0Z4l~`)Bv6DZ)k`kLcsn+)(0KumY!noV7SWXLcDX7 zG~|_z7GGsE?X!YT7+hbGYmyYzpppPzck%_611gm408)>RY2u!cgzDhK985zC8*iaD zq0jh7nffM-6*cl*L<2&e#X$*#iCGjCoM0!XfBn~c1ziaVG%jHHx5sdOA5VdTLT4p& z8PyhsmCoPJ)MIjuwPBoRjNh=ywD16Z6G5`BSOsRzAYz$3@U3fOM?5&3+#_0!ozmD6 zpkJrV#(vff?}z7;iWLdo48&ryecz#djU&fh^cvm!q0UA#LQUsUjr>akCO-_BR)6>_ zOj;LM^OlC~#42It{#Z2Np_vF+5_p^AoYV=)`_?}MC2!>69HRd5jBC69W;fncpgLIU z-QDD%OZC*29Ujd9FYjujUlYYVExt9~(khu9?48g6=BrP~VSsYcI@SYdY#$s5z1dAQjUo+mL9mTUw+p*Cheg?NbQL$(|dMMTk}bmA4%Ep|2_M6wyGum9zg{W zqxpx!D~*XJP?p*6Vfq84Zy3!ibRmyw{Rsjh!S7be5g1L?L3goUbaNwYTkE#+SoRtH z3a_MG6E?d9td5Ic8n>s;&Y|Ua&5WB`jt%K5+_*_yf_X}Pkc;u6P34t#m@T=;8%+u`5~0P+vq|jsq2vV<@(qN z+BgH2Ei83W!)`ZLc|)4ul~sBNpKkJ_xeOHG{kwL4n>S_|XB*WzE_W9qSAo;`;gF+< zn3&DUxMu`Es9SRq-XXt6mI_8gM3KGwyMv+xnTYwe;m37cy6C+yZ+#E@P(%VUf$y~5;^)0+4s#;=FC z)BK96YtbF$27Nww)=$zYczdsqU*O>Ua<@KuiccB-CY$rpT3BY=bpsvm`^nR7u6rm1 z09mr#i{SeaVYaH>L4Qd`AYowh=V0rwtS}<$%T$gZyU!cKnD9L-9SP7{RQW{C1VQd% zUu^rsD%OjuM~}G8QIZem-J82;?U9cRe!R97R6~u1USD}|8l>W!>5i+~q%jEXO<5v8QpO=VE|(O!G4-x(aYAx4Kq2>NH&zoFq8&54%khrn(n^WAgzJlGU?e^AKq^ zl{X*^6RMMgDJg@|nHD^6JZcZVciqKg?m$L%6;k%UOPr-gLWAuyYDTo5{N}JodIl1< z?@d2H9MoR&e$z<;W_rxm;PK#L^YzG#2t)iNWcV<*)d&JJG7{CTARjEpCyNh^&F*>K zTmnS{GiYCW1bC%pb>#k31P_*m=7>*m7^FUYEkefVUPzMbys%dXj#%Wo1Ma@iA z*<@Shyi5C_4ENFEDw~^3qg704bYWQgIo3V_-eeTQS&2M~j&pGk!Zf)2Ch2|(c+Y`93j)(c)gTxw@q7!97#(nkqNyjZWxt$m{0aUFoLJ+>ct%G2s2|sXcQ$inu_!CK)Y3Tm4+kIsO?G^42RPrAs_Vh9LcxCD`b8=I(p_ zo~Fdt_FOZ3o<0scclW?eLT8c}U+M~3N@^Lp!OcsYIPlOnNy}=7Mo zo}?jcb9(L^Z|4pD>X8`Q{ZAOJSvLOVE=rVymxi|TDL`5GU3CS}A8{Gd$&xE@zl%L- zS?7B8WR<;UZIw(RFc9ez7-5Md87SwRhTFXRNOyjxEYcXBA4YMwZP|nf#g47nJckRdeHa9_u5LVh-*uLg}r$ zHEuAHl}+`ON;u_%^`_VCecXvI3p!r~Z({KNtpbbK-dxC-FjeL`Pfe)*mC!h7)pkh# zxsxo*-PUVy>?frqiA6NHWTe8$LV;@4&4!G<5Xb3LY~*RFk+mUs*&;91D{9LG9yXI9 zv|&(i@|sK-rZg64bxEq=Z_ZfcA5YZNq4W`#4NJvrcH<*8nFmf?bUea~ZVfygVfrD1ej2)^ReEOqa$aW&U%` zlhxD6f249{4FdefWlXg=06?WOARWkk0OSR}jaUAz##WHS_QEY}=a>{^>$LRLI!=F` z!usq3OkY|&_A<{RkfGhk@*vx|i0&>X?_)gCo?E2CWEa2 zCz%bYyQ*3~#A918^1-HJ{a?(1Pk@e(VX^d_f0$QEVc39N_NZSb3{ksT3-L2)6OaTP z-nyD%HJjfs56+oi8`*o7bu4P9^55{Njux2C_)M$~kqjT5C|PKEt`OalX|K>t=-{Pe zHqZ}uWsxxMo?v`@e=ESDKT+-Es18FI+6&Yc(@CG;1k7)tS$UHV9bE!}qpQIkUBnLOEV>D)uK)A|3Gt0oSVq9mgo(x|H)d`Q`Bc!@mOnkB@fah`Rq+A9fwc7L-y} zgfv(%2(Ji#IBk1!+uR-CyA6P!W|Fm>uZ3c;*~t=}K!Ft1TDt+ru3{0D!wE0rl}A#- z+K}@?w1=kj3@D_J5lCiNIw?7}*^+B9x*PS`X%c7lbf}axKOnV0g zFry3&v~`lPX1f(gM?g}U|AYX&O_Y%_^W;H!;kd^~Vm`cll-a@UHN zVUcgfvi^kLj7q8mGn+q-0KwzB+ZdIYK~x8BS6I)ND9CCyk@OZ)VJh|5%Kl8kWUE{1 zjG7UL-aHcBWALn?IEx2b)n$vsug?3*O5;ZJ<&}VCVImR}O0~E^mkTraP7b${9*S(W zZSvMXhcD(i^^4MXBlQxIb%GJasG|25UTfmAgO6DD9 zxi)1ZVzYR?){mKVcB|;t$48IIsNicSJa60Xnxcby^Anv1BBO<|`c*VTjJ|y0AR-~U zWiMqbXUKdi*0Jr2@|rF?R)R3nnE3+7RuGInv))-I->Q_bI58|GsPV&A%hEiH>1Z;b z7HkkoMsz})RKp=>y(O88JgL~i7w2B_H95DCtlkz&MthY{lI{@Jd;_Pdj~gmx`2wcw zVGJ(jmfq30g4AVtQ3gB*wG>ra#)wsLvPb;BbcH?2&0F-33;pRtwa}`1vZ!3U-}_yi ztiKCELaSihUpDE5l@C)h_>|R`1G3H8Us;Ooq!+1yUY$$Kl2T_-ubGLjn*>F00?S9% zS(}FKulcjj<-f1VQUg0XDz*f>Kh+(>R%A+Ij?nfAb67z1#&bJ1f33QbCN6;(Yj_?$ zf{kI|vmCa-Vx_Ch-9}hU4>i4~{c0j+;7D)`Wiq?I?jntaN{=5sAt5>4x4~-aS%y;m z8keMg5f%PzZnkvpE#TKD_MYj+yK{UL7sl3s%A)=*?G0FJWB{JSHX zE`}UfOsd=x^T@D46&1a%^H1z=Zj<&@Xs?HLcsws3?9K3XG{YR8c-BFa6V>mRXWu&5 z_}pNfs;3Yg3%?ep&9JYmze>CUC|~IkIqW!BMQIDI*G1+%7H4(d3LGS(Z=> z$^VOxmrBEgPqy?oRg}f=kz{4MF5#?ZwuiGX0L5s<5^-sf729=0N zqjZ9Y;ld!UjGW&SOg|EDh$wNeiM7JgBqXI(n* OIeLg@{r6P<@BAOojM&=% literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/drawable-hdpi/img_mobile_wallet.webp b/core/ui/src/main/res/drawable-hdpi/img_mobile_wallet.webp new file mode 100644 index 0000000000000000000000000000000000000000..74316b4df536093428f058b5aae4918e15b7c988 GIT binary patch literal 21214 zcmXtfQ;;rPtYzD_ZQJ_Vwr%%m_i5X5gmb6zO|M1LuP{OsB+ z!^kEONTwUFn_1MhFSvX;dh;!uV*^|5iW&CfKHLNY!qff9md(3?tl7D0(frL5R+hTv zs{?BTysp3mHA9_9r4q@gz2C}Q+$A}Q2B-P*c$y?6Wc@{@+y+}cQjJi+3P>T9)P~=I zrI%W<6RY1yt43SxHucy46Rmb8W{h^Tx|=3VEWge?q&;KDy&aS!L=6eWl9d5=l^bU& zc#FH6cAKSkn=9(hxF)uE|J~W-`04hUdC)eAUe`e=Sk88)u4xrO>=6qV-E0lyFh;3@ zazEqsNN>(Tyt|f6B$sNn%UJpKYl20h7%041Mm&TO1rw8Myl#b;r6zI*eli$rcqBUdnZvASDCoaJ=zdmQJ*Ydej5Vcxm>y*2yHX8ajp z{HUz^e5m`(uKQd%g&G%VmCImq_ae3@{C=I`B>Y~Dsk{AtjA?uQ{hoIJz7zao;`eR{ zck}s63yUQ0vF>*?>icBVd&heC{C+%5`xg{P(SJM?8t9J%FJElYj}?eF3?5f}Y*%aC z<|SlZ*uUQo%hU0 zYUaYF2Stkzkj+D*o0s!2W`bNMxt$STFJF9bk5|&I+~32T>zA+H+vrWo-yg!Yyj`!q z=7S2Mk!N%bZ#v+i%~Bt`7JE9^Ih$PHG&JeV7kdTL=z zmM-3=ht!3DdTrLTVU!TloLeLs-65;R|$hWeN(WZCHG(F8WBGQ z(xUR#jjnLnbnxGi5Vk_3=*%c81>brtFvYxH|9v%%qOww{~qrzX+@fxrP1ocRp7TYiyU+&Mj#r>O#$BYT&@L*3bTUx z`|w(o?ZqNa@Fh_+xQ(VrL{;`0SuRMAvVdW`PHUwQZy$R z*NpFe2jUTyCY)6?ADpo=czXw4s)a>=-)X82n3;*Q-9g03@*!Z8xu_m%roR-BAwkKk z6z!@^m2X5OOh-y)C|df3cisc_2%zNq9i0GlAV1J^2hYt&A5nd&)v4B_!WVCEy}Ujm z1UIRYS(1{;xb}3j!6InB5CV6O72pTj#O!l%lk~Ge!9Vg)1yV9t`;^&)h7%P5V33$d zaSs8xU%ilxy5FeC_0tr6?PJR5f{RjJ;N!u##@8*sYl=&2%t5L{;AK?fhaH99b7F+Q z($!NHb7XvB)jHHB^$aNN&Z-bF)k^E-$&wH&Ld4yZimg)EMr+i=y(Uru)z=RrM!7Y3 zhDd>t{Qxf-bN7Pu4mF(Ge}ffx!Bis#t`@V%#n~1| zXS|xIUJO(?2u!3TB8sEs!T4Zc$&dFY@b%vyM2_!w_(XH8e&xCJGBZx?rN8)lzYm-f zH2t=As%pOa0Zi*$6xJNZ%p-(8Oi1eWTFFEn?7m>uQ?&Q}nAoB3iK*3x1fw~-_vkV} zhHy$kweaaJPo&fk1US zOh@H#|nD(Z@z0ErW(C3V6v`yDuR$?7Y@|>a^ zk7s*jA(xp!*BTvESRS!-jAe=EOO^y6iCA2;^Lwiu-ce%<)R@O?NBS}v`HhHk1$~nP zke`K_WE|%D0@NkWP_DiFuJIlv%$%DqP|20t2abApOWy8^W}#+cuCjYNDHf?c%$*~~ zy07B|8fn4tNJ|m;`}B(cI=d?`Y+_qlH6G>^V;OW4nvsUmk_Tc75#q){w0AoeP>>g# zR0;BCDIR+U2qkcIlfZ>PGp8XPI`>}T!*$-)(5W6`5C|xGW~~+038T1o)#vj{Y$_@Q zMo!9@;-R4>l*s?;hu$CO1=6N=tALRkDZIl3i-jST;vmc2VlsvX=>EY_GR@AUF&|Uh zkA>@3T5{Dc56;Gic67uv z_jd&f2He%ESO?!_nTEs5{N9~Ci9Cayj0SQAl6nLqA9dh^XKO==<;oj@bKch4hyq%C zvQH@&HdZW{=|;2Cp@g?KIQ1;%EVE)hR3;h)QO`v)^GKW@d*rnz1LLUoHjEa-@(*Co z2pBQ^*3XsxdRaS9?St#B6W?|f$g9M+nC8eI^ajL)JR zLBnM!R>SVY;AIhvL_@iU<|wf5m**cVfA(FLkESLo-+qpseRhtyiPO47#aBCqCl;PEVE+V3|NEoAsALs{ZF5qEKun+H zX(RS5mD_6<#O(6ara$g=t*R``CNVWzi6_q}osPIt?3jf*o-f#ON~|ZKR)ubHw>+#G z{&Hf94b{pXt{r{ck|OgM=dO2)5Z#L2VTmFVo|7hCEvK@CmHF(tXtGjR6Ii)%(~p+M ztL;I`k%+iOHjpnJQXbVx3wSy86l#h@uuDS*BRoyU4464|Za<2XKZd#*r=Y_)T(#UB z8K2L(tdZ0;Xolf)LD8PKZ1HQPc>g?DgXT5fQjYjR{+-}yo_%H~U)kM43+8_;)!?Ot zt)CRqRR5mruMaviigeegFa67t3yhrRix{c5i-)DL^=0-`yz6ihB1HjIJy!KlP=wOquE7n^(qaGJGj6)X9%6Bej+xu=vCO533kCD8#GC#rZhgMEqgO4ABZP{ zEw74Q@Ny-@O~VnKuEh z+U9*kY6T`Gl`|-DsHWhZ?F-KLz4-deg^EDfbz;$sWn2hw5l0*;%<{rzPk~;{A~=fs zg)LQ+160-`*&s`=&_xpHrqg0JXt9;3vJ{7SB!aexXaF>hQv#uE$#`1n@dQNhH@0#9 zo>ne}_&KV!L*uoFcKN@`cr}h9lUeN`TzV@A3w;4O%J@7e0`-FyEumYTtLHDielW}T zW>ek}&A+E`-!QN`QFP9fVu9sG{5@Qly-E3TFE*Hs7VjQ8t-+1AAxw-JgUENB!(`7cHL=0C zAO{nqW+L2B<<*eh)I;Fu9dJt9xtOG+^QQv^%|a3h(Q?=HZlV zCPml>bzJRMl=JFg;|xQNHau}t2}g-bGaTryVlOF211o5tl6OuW#9+XUi;5%!>Rcmov`bfccwI$_(k)LA;8a*27BZ2hnByj zN%yxyKQ@BN;LIYL9-V3G%Ms22?;MVWNj^8o8Q+YAXw5|`>%o{3BpKOwC~(N%k~%DZ zfSTd$h&HFFTIi<{$p1L5o-@%eHoaF>e4I%&uAJdvLL+$M3@+8)Vc2NhCdF(ivBw1# zvf1DPQo}kvmjibSyCz!#It5EBn zb3*x*WuDeOFx@YEND)i4&5GpYmJKV;##%|J*xJ#7*41Fb#^X?@_=8A>`k#d;wZ=vf zFehOa&v&wBH%io@8KqB@FgY@H?X}4wime!7b4&jH`?@RsSdf!8{o>!8hOx>^8fBK3OdYK|;1~?=J@1wG2xKB(ELS8H+XL>! zw(%NVKr??&)QDtJwKdb4X%1|mo#qPhg~XYX(p{w~xUhp6G9-bq-b1B6a7ug4NJ%*vA-O$UKS)8k?DnS z5*-5zsi#=3WzLOP zRP#MG44a~^a-!|(#WI||7If(qHKQe^en|$UUR`dEkYLZ;{1W~dcRi0^QAU@@V4cye zzEQU)s?Kc|8R2@O0$b2$pWF4@GdZF4h~Nc3+0X<_0$Eug8PS7*AV;HZI~NveX0Oj+ z7QK&fRio+f@36)9C0Tv~P~k~!GsM$;L!BMGI(-v?o2kR*OqMkogk`;$weDJnkey*$ zV!-#dcz~JyE}1G91fS@l1j0aArM=bfZJGLu9hA`{pVB+n<(@5(O+`Z~PYbJsNQo7r z1*RqkzfHweA+ z1TyHV!bJXKsmQmmw=UUrNsye-Qh<{eT3s@G6 ztMK@vwuH;Seury`ZwM=fs~`Xz>K5CzXSB)9C0A3xr2@y)s)u_zCY0z{-nLkTy>G>OIK z90P=ZPxxE+z-G89eDP@m-}A4-;aRoT;v5*BlSm3=0WUq(mM0G3$KMU8Q*%oF;c?7S zxeH4|r~}`+|G4CzfU3`ttQWms2vmnpZ0OABwTDiJ_viVf?Sdd%*zIDqEYFNdgUR>& zSl^Kf(J(3*ZBiPL<7X)|Cd5$UVNQ+xbg_pXoi#MBE(tX_Zt>i<|9<9 zjKhB1c%xJn9)(gFtt)a>g4@^!Eb@g|1gcODDU(6mi=o(=OkvT**prV)u>ynhN$@qE zMczO(?4Dsw!&2mYK)T`bE|Q;Il(EzCh*dSpeas<-l`Ir-gXlC3NEvLUZAI|S<=+^? zT@=ABtKdUR*??ISxX{ASqQdRXsqG3t!;fQnnc3&zz|q<0s2B==28@Z387dR#lasU7 zinr%G%{PRT00)A<)WLs3S0B-y77#7WCHotYg-K@dl7M6GQ(v{^kg;@e%KjnJhDK(6 zXOSCNaY5{ntZgwa9x@m(Mk%lak0W2&d$IoZK_~OBYyIw9e6liCiu;ThhrA0N?=#&cHvvThm69StA1JhDR_Adn6a zm8~n{zqu*+c-Lee+uUTl7?juri}E*1&X(HS>lln{7p|>V`Q^8z80CtwA@9~|d}HT) zB)X8_d0DI+uJT}}?tfG620Es(ZY!6@oT{mZx9W;<;6ah~eaCHTOj~Vh#Wd86U`EuS zBc31y!NByBB4vMB;pbgl7qd%TGrpfsPjyKw?&F^n{w43z&0>$MfO6z%qT$%&>|hy za#f+9bCuT4x>9{uqD`Lo0$CEfildT3MP&L^LY=&CylNj(Y3H` zGFH}FAtkzXc8hN@aUxg*Y5hIOf?L>01q2VaWgxoIIv_RMabdnelFR#A`ASr0{}W6=R3uLY1@8})yLsh#n`pwmr) ziEh(|yj5YThG*gsSxAOv3}>Us*S8@6-m}H{;YuSc{=Nh9T-Aftf54hAtefH0qX(@W zTO|c7xbZ|2c(_6-3qCh+?L_nlv6tve;}h#8?Chb%L}G{>NKE`u5j9N9X?w=6G)l-d zB>ip^0gT2-PO=D<+_o8pf*RDzfU@s32XKz!XC%nSdELP2-X#B%u9moGaOedvrxU`2Su0Eq3Dq{Nv?Ezu~})!$N$ z9OWvvQ+Y3XuoJ|R^aDv>(R(Z)FyMnInuL3>R#ltS`3ap&pJfeo{Y{j93iqasSX6MT z#L>M(GuyKY(I{Kxa$f>LfvS`;bK4H%c`f5%khTeQ6f2f7Lox0nni>zk;rae7Miwe$ zg#s+ozc&L+)QX(}V6h~O4D*gPO2=HNbNq7SUaN1_J<>Qa-9yfUwDBTCE0lIl!cS^; zBJ~9o=i0-LwyW@$@Hl%^)HsO!H1}Xk= z4k^kovYQ-KHazY-0)r!$Dl)VU(k!--C0(`hRL3%oECH4Q8O90V*aCqbEj2|r zdjUDCOXjD>pMh=ji(dSsFy_)z+=T~ z4zsBO7PjmX>s`3SDSI+8Wxm-)wt`}5HXFb~2?|$Pub2|(3kI5G=%c1)fmap|^CeGd z@ShWoze0uj^F-v!m6NAHS4vp%m~aokMUR(^9{`d(ftDl^?9&s= zqL?G?oUIxoY5ACGgBV_K5RXSHE)RmyO@Et}s72u>>A8m*<=rExmL>`;MAuUSPgO3h zgZ-N7C?@1-Tbi2!)!Kn7kJVI8B1l*FkIo1_$TE>tp&@@6DvY)Mz{!T9H`^{w?cjnJ z8!4xb+B9R8W`M3d1balc9Jb*&$oGaQL%9J(2)wF_a#3Wm>_f(Wdhi1Y3n?kbi-gaN zDS%QA6n5@Ds@G#B8U`LV$fsx~nIt2#1 z8eSQ;4MW^On}}zbau0h5DlTmDBbM!1Cbw=4go7;$4EhIq_@e>Y*2yMxah#@zvVrIG=~)-(^ZBUn1B3mpK-N$w~vp6gw-)U*M%!pdzDHtxQx3nNyVxE(=#= zvLnVy9OXyEK@_2)%@22~{}!4S2iP~$^?IU3_w~S6au)8X-g^Ct;>{}pVtm>hp;{ts zor4MvxKtsCF(&lHs!g#C2TAEpV~HznTWRqRc{kf6oU(8QKd<5C;xM*6_@4hEV zM3AOfrB%M9Be1dx!t2#g0?Pnl58ofDLmoHSKnDrkWVX>ECK%|IP@Km^AhMwXQ zz=zxS$s;5y8g6~+6d3L6J7G)zMZ`kolsHbLDLa2^1~!BWO+yN3OfJ70`Ihq=@NcuO(n(j&FLTg`|Sa8x4IqX=4SCkplgj zZWoPp$BAW0n(Cp>UrI0QG$?wl%6APKacIVYCm*1AoB75*h}aey`}pE%8G#nPW^&C3 zgo)vSey*`~i(S63sPXA%>vsvKkl`fD!+1~muW;b-(2Sh!A##e(MI)P%=pma=r+dj4 z(bU9LLuNBxZ%tr>9I;SeiWGmwa8*7}jE2}J-yUmb#~j(tvCFby0hDQnNCG@fq#RMF zjFhaoIwe!2nDRpKrgN8-k7xDc&54BBk<|bsSUhM)_TTHUePCr%7%Ji>&4f4wM57BE z!4V8>ESN-SE%qjF2-7sB`(LU_?j$0o*p)Hc9MYxP>EN0xOor?Kq~lZTtWacx@3^85 z_zFNOBb}C5rtphi7yHV^;V<@?OiWq9MnFTAm_#alb5YF#YwY&V7>0|O6V~Vo&)1-&$eR#ndLMngFo_>Pk z(S1KHLDSrESgflhv3ACgyronq5*3@|82BWU(8xfbla0H=tn#$S#vYBxFNbNasYyV1 z1Cg&=md6EqvV+kfXptr`AeDFnEtu*coXtUyv0a3R|NSJmJ{y%^7RlQl2DOiF zQLEHh#*sAJ4^F809XoN>~E|7aj>5pe{T;k%maAt3pVdi7b_icX<`>5olUTcka!>z0*ZY9Mft~toP5~Xj{1O3=?#gF90(BR zi@M)HmbAwnVVD$R5(c`4?3SpXrO8~nraRlTe6cso=lVxOqt+&wiHQi0cLdepBZ)nh z_COL9w1^vRadzKQ4E(q{`x$Bep!^U|QLgv4gOw=!?G+Hd^>UN)c$eZ0A7$~gVVaHl zFi&MIh*m5j@W~+Wo%UJJj)79Fhs$ELF@aF93m2_X=`i_g3pRrxtyTZ?qOf^k z#`dshc01mZu9~E$A@~cWdJKW+oUfJ`e?uHZtyWFsQnofIj$a)XhP!pzv-EbaAxI+V z!!LpoP_UPUjPwW*LhK^&r#xOgTlD1l5?z9m%SWUL;uEY->%a1EUug}YK6s&C3~G#k z9~sc73)+q%S70V$8i-C(i!rq{iyF0w@!P@UT_HeU+PleFJ)$zBQKbfHKgjW&Z9gAV znHJ=toeJ?ET3-5@cm%kpvly;s_-M^=iUapWqR0b(BeT*{E(^M9Vh9Tc%15cjAbkZH zT&^I^2qpiu^bb_N#u@`BBz$x+ZY#s`a!_lJ90S#{IH&FACMvb}XNFX#d>%01lfEz@ zCwak!WG+Zm3T}EueqOQ^hJpZY19O78iwl^&#Ymf-+TaVUxtC6jrYB%lYRjr~i;o#x z5D^N+(R@qD*YK*;IkkX8KGef{oaSUeJjBasA8JopHg zBtNw;CG?W1wTMoqw<)OCCiA;;3;-o0L*d1y+M1^LBps}*~FU<3w)@47Ng z$xI;z&5bj)Y-{iR5sda?QXTmF4UFUhBuFXD+VFG-BPa=~Qx`0ZFbscy6R?)F#5e4R zyTS26XL-R%65&S`R&VY)JfLHD_s`5b-@-@6ew@};OTrdVdGA00;hBR`Y^5vb?Cg3zfIL9%5Y4pOGj(#*8-*l~q@Bp%Lw z3rwAk9n1L5Bdk3UxMRXT4kYWmA*WQp&!UhRDi|A)jV=4I@_9L`m=8w6%~N@&2UNqi zchdMK3i62j;)p59iyc@&eV%);su;8TwJ}MIV#sv>iK3{ApaL9CJbP?Mfh{l8ryvvg zg~MQ=HY5foc5UaP&=iw{07rv>lpod$SQC*yEz07Es8E^RQm%wxLj38izeV6hnlrRY z;Q}V}#RIBZZJNLeU#=aks`P+|ELL#inmlxaeVd)%TK_X_VF714t1%SO1f{M+uC*tT zk*J`gM`hWx2T6chaACq#QH8lrOlfPwY(`K$<4$(#j}$3Wx; zk*^{f5_2X$?Xo-1YYM*3*^G?&pQ(of?WK^PWtuNvrFC&Uo}y``HDiK zTQnpx4@o>(XzLJr!JE7f`;0@u(XGy$PCt%)$-li507NF7;xA_AF#Lm4cmFu@o;CGS z#L>JEOppj8WG`qSWofNC3cr=A>Zh5h4nSy0?0>b>jeJ@E$j6Cszbj zHT7OM4jTJM{LGyxj^7PCR|O_hKF4+117t7L?Lz4Hiz2b2kgx&Llv+Y z%+~0mc>c3qZdW$xg~zqb**%x0K+!SBh#E86jt7T<#HJ)USe3SDyc+`*TJW`^&UADU zSBy$QRKG-2QZ9s5!~lcROO*x-6F<+bqjR_%e|zEDd98`z8DuWQGbtUC=e?$7@evPD z?4-Na6~piX#|S{Eu?dq#ys58U-OidGYi=VYOM$19Z_|%OV#GL+o9(Lx3u^TUMAj|N zD8CWG;yUIbgxd1?#AT|LK(&)TC+9W|6vl4lg z66x{dpYP9xrZH6~U;_hc7Z87B|NGPiU*{Q(cCxbbViHYXB0NVsLJc)b$C-m*+0TV6 zZqkx=y@f#&f|omCT_sKvS1&D>E5S8{7BNS!*e!%LEygkP&BhZoX|ur>y^N}=nO_kN z9~U;%_6IpaoLGAZWQ{6x%VF7MyID1@d00b}sxX-<->P750!N$~wrm9sAPrLJuDfZu z#EwUUjVDPiSLG-gx>XBLWT?$e5|YdvVKR1Y)LVWen021MVA-u~z69Z4imO`h4a(QE zz((2smTlI1Gx}p#)rk9l6FA z4&vA><74v)HeKl+S+HB8D`ltRJpNA&A{U%%T3CikhgmYYcNZIPli|0`X!&(-Z!Z>u zk+fikeCAQJtHw1?X~{)d#a48mt=KJmwO);C#0vDqXk*0sDhQm*Wg?bzFK^Jyqy0YR z=+M(MR17g|Y9Jp(g)7l#$a%d;JC^v*ax@($yjhoYuB#c3>!>OPko0D)sVuN%&a;Q> zR$?Iw@jrO^`PL;E$WhY< zAd-Q`$G`#@e-YBBd_XxsRtgbWc-Bal7h>FM&KIjmtR!!F!rtM8KqG;DP;C`R;sy%A zI!QQDoTwgXWOO}A+D+KOMdc7?#&cPI3kA-CW~n)V(mMhOwEpNc1`0HM*#jihRzBZF zM+inKmcw#^@HUVwI142fEhxi2a>#DRLCynOx*BOljf>kAAp*~E^Ddy;p;0bXf@q*z zPuo2cXUVrU?x76>wki=6Nq^irD29~sdS($vXtZG|;-FhJr~$OYhBY%Yf=)1CxJ`5w z6g(OTu6Y>85^}J!zFgG;JRX!K)LByG-DgF(-``(*SS&EriP6;^a zbOcbdAH1AMKd{)g4I@T+nm*9Ph-!XwEMUT>kCj6xSz$6;Pi6=Y#!A!3WOV^0$nqf+ z{(LxEy=9N>$4rovu2_bc<~{R)%K;ntQxF-OqOy%fdSgu+2q>XOtkMM3_lfCzxqfnw zO40)hgD|I5!h_f&X>;F^U8&iG4)kWjIlRnS| z$V)OG&E~%B8$k2Z;Nf25m_d;yn=BeVGmpo$J*om7Q9!jfgYc6C%PP72j-zVCaQ9-Y z1Ft?F&0)Ge)3)nuCt)GmQGN_}cTXB>l0izo67pMRGY{TecW87_@h$ro2+;^T+*h1Z zv(V$Pi@InkqYoVSY(?>*ZTr17M};a5=8vD{bFpWd4Lq0j4P8U@ABaVc3L9Y)Kdu!3 z6!ifI~m{Z8=^gX zm326Z=7zM2Q;}kDcPYO>S8*WV1FI!s0a6m{d9&{hjIX|CB2bdz`HQ|*AldYlQ1+$2 zovf))&bJ-OmMkJh%ADDH)iCPYP;(b{sbHq}^2NbGMa+nKs~qw3#nay;p2ZDUHGm>N zVkC08(yksXHf}>9OdU$VMS924Q$ez!7crul(2)gc8y$3E5dlXS^ZmIEHeDCY_Nk_0nr55?c06FPSmJ6B>wjB@vYz5%9Nj+On}Z;t;BJKhk5y^` zHj8InViaf=pWWG)N#3IwL6D>$4Pz+rSN%N19Y9#32n$kV6Z{=}G@2I$Jr37kJJ8*mwI|&0m~!6@>spkpQJ> z+QH&Q4JjabP<8lS8Fg5e4ugT^{-bx>9uP4&mi8v_Yh8v|J+$S4CZfwEg;RPYMGW%Q zWe1;JP~LJ_lHDNhxSeiGB)Aw~SZC3~(-^Kt3jLeBc ztQL;RCE0*J~Awu{YSp5>*n&K~t6P8N73jvk|7y?PC)SQ7B4S}~x|7Th9b zjP=4+s|ff0qP4Bxt<9Cfyx|YlpQ~a(bE(2zPrl@svrI*Q*h~WVT;qz0OU;^m=0qQK zGk-qsVU8&7 zqWp&k_YHYIz<>UHgDp$CQwCW^knPPW5L#f)KtKM&lUX4)cAjGBgud_MjXqJ0CIa)K$j;xV z<`yhk>a8B>9htyPLnOg)VW2@p-j5mnAzhzQnFw9=O><~25ha-=q&EU*M+Umr*^(>M zY*Qh;l!j<|WW?wRYg2O(r@{`aZlwm_1T@BK5*^C)F7Dv*Y}1^lgdF>^5VQSa+5YNV z4I$zUtoR7~B(s8&vvf;)wDyj2L?rU5HPq76qOC%0Ib}k;NK=x(umY#o;cqv`_dMLs zi^P}EU2F9XT(<8dZBZo3A_|$zr2)3=!Qzx$o}j=IXdw`p!)X18lv=1xG-O+8tb7LC z0{+0Fd5VTgzZdx(-92RUqXr*NVi$MHwa7O8=~JP&Rx3iO03*$im7x>y+Xn+cw~|7! zi2=FGEBOLUtBE%-ZKQG*piF(Pa$!`yA#3L{za5VDnUkUy;ekq8u`?H3DB#7?f#Wq4 z&;_uckQ1f2)RBadZtmQvxA(d>I5WsgXe3#{Ia3XZw7OpDFhX)&`_L(Cwfq?NO1!hW zSPai3q7zgjsQd;vbh4>NfcHk;f4A@9)Y#CG*J6}SaB+e)xms+Wl7yYPY8*hd?i~EN z5I7tjV!J%4xKNQfGbZ0mXmogmWwFIQK6aplQwmn~J znf)m8?)B9hzcoHq5B=RK8#sGs^hX?IaG#!)VhC$YJ8^znb_&Um1CaPvQ^ZCyLoQrR zJDBFCofyr#EA9;*uJ>)#m5N#OMhgmX$2NYnrxyIq+w6|I94Tomy9OIb^C-MG+J6ab z|L*PFqfe)`bS<1s4ypXwkJnuXiqY^iBf&d79Vc3l+iR5hDRB5ZoV#G-ki-J5fzP0G z9o5nQfp_}(eO?9nZ<>YZix3E?J{u?pn1%o(2v{INJV%zisFb9jF=MO`1={M*gS_xm zliOOZ#?4x%oBk7i8|V~djz*Ea5aN}Ou)aTmF2Trerw2ek`R8;K@%47H7vOE}*}EyI z6aD=n60m#qJNT*h{rIC0EQs0L^9=Hw_92L7aP}F=e*4-JK+xtR_`Uo3tD+G8>v|@! zx@(AdVzAyj>HFsk_2+P4_0sP`(AF17F!opWSMR&?v-jKYz8ClR>NoAH`lalH@ICJH z_skdAFY{OLH}Cs*qv_|j<#*HIi{K=$X7|}o@b@>WbYP+i==1N?U1MdR!1f~_U1dFD zoA)d%O*QECF6;kpHtV!w8@=>St?8v?3HmqK+bW(Ceoj&Og*J39`@(-JPB#$!tg5>W zOuiIF|A{k;;XUdrlv(TgsIYcaM*P2T!gnVl6|e;wTerDXmKpV>N^vMRpEHO0`P7&Z zB^8u##BvWy3jU80y)8*EdmJ^?%nw*bWa7@E@WeGZHaQPN_w)j7t2yg>>O9CN(U6-x z79lC>z|FF;!!1`#9H+;eKv4Lkgb`>x45$XQ586)Qrf^>WYeRqMny?=RF#Ytxl=PSn znjYwaSKS7hZk&ss9qnz#zL}c8Pv=5N>0IU#ugdWO4poVD70Y0bZUfhrnfmM-b!@Mc z8vM9Gx_O^Z=ZU)(&1A+dkHpd9GEbDDs1v{PsI7f8If@g29_Ig@aI;>czJ5d%hFJo& z`O8Lw2ZD%ynZNu5l+Kf3zv!|A4qNEf6BYK~FD-B6;C~^mJaIX-q2R>k z?tQ^$`(k6vRt@_4&k?0a5D7J=R96kGddR28>ApuCBR|2SN{k6FVbWZ~{6_x}-nuem z|G(P|V~cq{X5^_?gw(^3R1t^&_?DuyOrFPwy^|FNU6pxwFM3ks!1bZgrp)!UOiXBj zv~?=SC(c!b+Cpx)O39^3N{=6_vsvg^TWA!jh%f?=jNsfn!#MuH^(Fj2b*e#+PCD|g zuahAj&*|`f5ZY|~%zX7WU2L`*5>I=#0fxHuhGJ^Gg1|o%;a0JUqxJtwECL~{wD6kq zP!}>wCg1uFWu$-TD3Z0C(?Jg@Yr8BE7S)NpV2R9aRevTB61kIe|HtzG!H0rLR(If_ zUHRC8vbIN$K6Q}@On{O)e`+EoPXhFR>3Iz*eC?b_i)Bchntw;L3c3F}wBR<>?>a>@ z!~i)EDap>0562`**n3F3-A=g(xAuPtPN9G3DD(geezGEJo$}Mv8aI$gXY(db8x$Gu_dg8sYyC?T zg2{Ah2;>a(3UDm8Lr8%O4?=@I1z0R>W!&g$fzy%(+fQsIo&N{(|8O^nF~E2MW$Q)$ z)q#UHIXE>$mer7Rv6;xl4Q{J}2!cc}1IXn5dJnppBnRh)qo`IQEttKk0Z6UXqCQ8d zR>;QKi-DvefMU2pz%|Mj<^Kolp#+(LPBHnqU`X6@4sBVimvm9**=A{9(YsXYotjU{ zQ(DZ%yP;vP3wri9#^d__36Atf&uwinEtO}gv|w3DXo4rg?9^Yb zKNQ#R9+D&bn_B{0Xjt`)N~EX8h6!^1$OY?96y2nAKAlmEq0HKB95p)(Zw~ZTFTN#* z19boOTQNy6MMZ)`169{LDShf9IO%riyhx5g5u3gI<%2P|_}&QPiZ1`yrX$M@hFj!5!Y0D>?}66cR-iO8sfDW=S|X#~?v%WC%IB z^I$QqwM(*&j$9dSLOw}N&&yNnTsnH9#GgJ0*+NMc`hy`oCN-n`O+V|Lr7VyE!H4=F z53WA|%)X$N@Vfa3){WzEO8$hIGH_?fsGYLd^QXMyS7`6Ty}krq)xjTVU1Ln|pIS+A~v;39k^(=M>C(-cj4P>VJ!W z?nr7c0mxWJ!WoGq&f1#P(w`SP7Z8f~-<{eNYkg2TCs}cS;*}ru4tQC}brWaCqrZ_! z>mYB_ro&YrNMcD)15ThvuSz08o%(wkg6>tiN%XOeDRV#)zbNN+4 zB>WhK(SOxCYaiMx)chZ`Z`TX3u!?fZc4W&f0uBptoaF-fw z{UnUhs~l{HqF@gyrd<9PLrk^vICIrxayAy?kNzk;J?*U=1x$~KYfEupM)FOw?w$wZ z?IdM6h34tmqXcmWmj33Cv44EM5d>+T6f74I{dte?6Hnna#41rC#8OxwCMY48@U$kW zCNg4l2Sn5(ovZ@H)^DJ%=%~+_`57<+6tVzIEX6Iap0`y9+}io24>U>LL5N40n|49uW$x7{YCh2g zz<#f`Y#S{D7>cY|nfa(0C;sWBx`9Y$!tgQru1R2VXdu%4O-pz^;_{>i%ks(;-}5{J zwZB-c_9~+Bm=Jc{< zC5K4C7())TMW90?gOzf0wxl~)+GsxZ6cJ9qzEWDY!2#Oy4;v<2PGk{NZaMkOgT!1w z?`98t%hh;aNCYO8!9YBPyhK9SCz^t$Ib|(-Q~sHZJW~zWxAL0~K72+1sypI2AD0`d zbZ4DjHo4i+#2CPl2I2Q%S>@frgh0u1kAZ{~p^~jP$8gZ9+2wI*cKpNd50QvF`x5n? zF~hQ(?5M42zQ%rSgIZql%1FW>1)3#9#%B`Y={k?ik^v-IS6pvBdXHfX&#s)%vMnHs0uXfmcP{1T?j21%`}CNhS4(JHYbmeZCA~_eM7Jsl zw>iHxr{5x{`vZBN^e~0v)ueDF^rTlyPOTkW*$KNQSxy&;7v}7CAB8c=Pm7s!|LAH# zQ=>pXan&cdZIxNGyGt{5RIbPr_4V`z3NdU5PHDmd*#(8x52!D1dY6Y070CX2MlM2A zob*p-Stbw%8wf;pl=;8H>4v9L-SjUzZKXnMfMkWzi9kig+x7ZLvd`<^_tS zMBL4DS?m8G>enjZH{X|7_ z?;db~B7w>`+2dAIUN~eJj-DkT?nc!*Xnk0XY}{?GQ*k0!?7ze`*OPhB!(xSjiFi4R zOY^VH+6teaD^P0yQBEHY6C0y;d{KE^&sYk*qCasOUwF|g{o@x&dIk7T@cN^K7sH>~ zyzAjd|DOPM5{d2Jm0#HrL#rbSLY{UZ>R`!j`E&{tlirXYc$T;Od%Wwtgsr@{>4OdX z$n!gOeZ<8~yO5bd?SyhbmD~))yRHNM?+3aet7}5+`-yeX7_SCBok+E0-%2OAc}A7o zxwr`m-!Yb5zteS2oyrG5~5*6lu?# zTL`5Kl4DKm_{i`v6yGb=b@Peqw5dGwLTZPHu}+3OvrsJ^G@ggpHdpp_6(P6_1v6$1 zPmw{Mrh60&#`95+bejIgTb@Ov6YQtN2CeB}K9}@5Wx10(D>7{swmskvI(h@~>^khk znQu$xb()3c3U{!7f$7TR!bWAT33Sk&8y(UgBTiXT=s=6?FD2LFMnif6ooQwbw{&0YiBeQO5i}A!*L|9*y2`K< z8gBjsDuIx=&Oj2Zeucr}>l#Hhw(&f=Vc5X`r127ffE-UJt@C-Y6mhOM+L;4AJI$Ma z0$sVQZ~i-#sg>=j{3w`xmNzoF+3%3EamSv|9aTb5y^ekvMBp$h>lv*$ytY5TG@+tm zqSt!8nGLF_AWDaF!zC$v+!9ulhI>zCGv2l<11MGY=EzwFi)DTc{%3JVPF|>1V7D7w zqh(mw7rLmblJ=3jrw-{--{{gP&esa#W6*ht9U+4(F*3nM30kvem$~)M-Z0VuTO6K( zlHuCNZlZh!Ybj)xLStRzsrxOhTt;Q_+&-Y_bYJKG^0Oj*Jh`9kawLD-4?u*YE(y1t zmhS*TR9)xv;#fcUIKtKC;Eu(xbgJo?*%+nz?DBDeD2kep^t_K2Yo{DDOo_zP)+Y-^xHS2RYHA zYe)THMaDBb7JUX(9nHuqd-CDR6w|nnMh464)Zoj2Brl!Hq1rYA>cKRc5Xoa+ZAF-S z#@HD`C0Cy?2zj#H?58-vHJ_|iP;~ZXY^O6fuuOc&z;w*5D)1cPoZrUWfhEad=Y|zT zT)Mz0m2Z=G_e$8cunX>TnDZcsVPnrp}0>wAFrC z9-_E^Un#*{lpZWK53=_jH`Jn~ZM$2sfFu}11K+uhc- z3Lm!=_6tZ@lI}*Ae0VeJQuytKGA?(dO8Vl9IqjiL>tFtT2of7w{TWT?i&{h0&_dJAGTaB~|;+(K` z2%!P=BfrZ@QCHxBL&Iq^C`%ppJk4j2DkD1Le;^-vIUc@uRPPAL)VVSHlh*MqHhcUW zqDAhmQ<6C2XbgP_tL$l$c9mlpZa|Rx2g^Rw#o4d-fBMe9UXGk@$fzTo6%T?Q3!Olg z#(VRv_G!$Xht7V$kc(GD@Aqfx|1+S5)J4oxN;F#F4Q@+@0-v$TgHy(dT}Z`)?TL>; z-bQhyBDly@fB?+`<6UT)fY=T9KpnbUDKk83)Y!{jO;uajHhtpVd%tJtbjbLkYxwrv z?H6-V-Otnc8dj$CpxQ0xlyQ=yG1c7uBx0Go* zv@JnA$<>SNSRDS2Z?@;{tdXbE-8;IqdrXNsc&L=veC@QfCYGeycCHn|TKFXkm2*x? z)<;#v*dn6Oerye1fTjP=#7Q5RK}srHDF_(&&D3Tw#Gd0EMHh{pDz!!=4+wY)W6u*t zs99tujiGK$bdFx#I(hex-s?13Bhi(3QA!$n`i|h!%^Y9~_cO=-bkO-7iI(JAz2DLI z?H2*z9MpU#%QE0s5hL&YHIixLOtZz&;Zbb;z zWepuKqxBl_Jn9S??;8Cs2>A4MH{VASQML?cN-!0NuP(mo;Q(JfO}dOUJnFHoL6sN zC-Ya1RB>(rcoik5JEuSc6xKthj)k>@;J+N=p@mvJrM}~ZV^=u---$S^_Z0lbAotH! zojA3A*8T(Me`d8pCqQMRAvOdI`XXHO-B=1)=KW^b!8MbmTmUS&xx3N1M#Z5qa=ORz zYL8eVK$fI_q~fJf=?herFYO+VatS368HxBAW|yh>1ibWo-!U z{8XVG;I`u?+4jvJ61p*$!-nI+=wEE?Lr)%&Qk*4qJ9Yb8o%Alht5X%4d9kI5P%pW6 zr@i%3MALruYvy_vvh>?xvE(YAXAu~HOz;lk2G*XQQ_AW30WolpMS>LXy>6fiBbbBr z%e^E{j&7ME^C=oN8%6x7O&1lPZHXJD$NP2>H(AnskOlSC9_@b=aK;H{mJ zyI!JNhD7)=sWMOYZ!};CPIyy#GAkFoKO}s2$u7=K!oL0(@Pzjyge^Y>hPLdvAqVGF zwF5B<5^y8Sa8h&bc$ooaEu+u(kut#`?p06sbmN}x$+ zT$zTGtrq!3E)YVdjAkTaP6;XsHevc<(;D%95Pxxk>2gOpzD#qTr@v7d@8hmb4kvcO z*_cNJnZzT{oZj(aUf(p~Ngina?{|7quV`hEprv60_FcN|XEjYJet$hD{KpylzM46` zHC9_$V!V9jNaM*rw1NDRGTZRZs0AB(m#e`upBgb(6ro*V2++AI8u+oH9rwO<)#z1X7s#?KdNg* zfo^OQ*MIM3m=)wJPpYRdEzjvPaN+^BUEfyuJhppytpgY$Sp56?I5_1+peJ$FuPS7= zS?@l9Jbx%=ajv77NZ*p+vx9eVK!JuC5L_}y#;j($qt-}OAxJ3&kROi|EW?1_{xF5m zzEKA?IKIj0jE8k@7y&*u=`^|dz9BXmji>4fW_GKVtx+){Lza|UT9t=ocB;l{LH^uN zPO=bgxD!GeD1Dx=kj>R{ri!g#qLoG$|9Ykh=bU|)k+Ij+z8s%D+ZS8S=+`C{VP?&G zR=uoqc5h8{jD9$y{0BY->vXwg*fum-km+jA?R;`b$p&!fue7q;sAG^QM95MU>c40g zP%(aEn;^4QyfARSi zGaEnB{~3||N?Zho`Fi;DnWUc7r6C8*MQIiEmNf^B7=5@3*He0wSo%OPQr<20I@^&r zc5Ih?A0Bsa`NKu?kDEILpL69NBP8QSXN+OGNUBsXppna?i+2di9t+xbFoSHCYh6*T znT0WRO*_E{IXe2@N(D%`M3)U3|S)G4C70ZQh0C5o?fMwjk^2B1ctkLkC`35_k>~=bP zjM#l)r4_t#+WEe*#B-1=pfcNSKCt0Na$dyt_Ha-AOAJxdIZV#ftl8w@q}u;Y6>THA zYn;RjDDGh4d&cs`6L-gI6>7aR+Z}>W$4j)Mi-`7}mAyMpi3@ZR08uP#A|xDKnVdJY zbpTevmmUqn7_PSr7@2WM0@*yb+618J{36*gztmH8L48tbZl1pVlcno#ZDN93KeXI@z!hHDCG8bCBO^%YYnIsu#^S-9D$R z)gp@BGxwGhmK9pi1`@;T3QsQrHq3SeM_ki)sLQKS1L+CkTfK(u^A=$2vJRIAj*Z`` zTvi(%w?Gq=)NC64G0P7FlgV0I(yMY8RVja%3iPq3!hS`5=c{VNqG)L4%bhG9r6-?h zbPs6XIfR_8ul{NqCkXJg0h$E>VUi>a!sy^TacZ%j4nvKs+LHO`=J$#9aI+V-W8({E z*&ppWyE)D@W+aTB!*8+F#zLGvY~x)k1p+1Wmo6;3c< zfj{kzgoW*b*&RT?3~7fFq)02{%r2h;j>0K^-W~$^Oao5)B9A5ywmWwH1lmy8C+a<84qV5{3aGZ zefBT|277c>^m>91*q%njS@fm4tr&{xogs9wQgyZOLt)HFCjUxYJv^x-i&_xa*53dC I000000HCILAOHXW literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/drawable-mdpi/img_hardware_wallet.webp b/core/ui/src/main/res/drawable-mdpi/img_hardware_wallet.webp new file mode 100644 index 0000000000000000000000000000000000000000..20fe7241fc6b0947d3284bf84b5539e1f97330c1 GIT binary patch literal 2978 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 literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/drawable-xhdpi/img_hardware_wallet.webp b/core/ui/src/main/res/drawable-xhdpi/img_hardware_wallet.webp new file mode 100644 index 0000000000000000000000000000000000000000..7aadd5dc1041714d2a41f4343d43334a5ab07382 GIT binary patch literal 8866 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 literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/drawable-xhdpi/img_mobile_wallet.webp b/core/ui/src/main/res/drawable-xhdpi/img_mobile_wallet.webp new file mode 100644 index 0000000000000000000000000000000000000000..b42de3dea2be92591d21c8c4c4ca7503922b64c7 GIT binary patch literal 36434 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 literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/drawable-xxhdpi/img_hardware_wallet.webp b/core/ui/src/main/res/drawable-xxhdpi/img_hardware_wallet.webp new file mode 100644 index 0000000000000000000000000000000000000000..c9e3b372991c2043a4e3eb58280f1fe8f2a4873b GIT binary patch literal 17644 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 O00000000000001{cCs)4 literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/drawable-xxhdpi/img_mobile_wallet.webp b/core/ui/src/main/res/drawable-xxhdpi/img_mobile_wallet.webp new file mode 100644 index 0000000000000000000000000000000000000000..d0dc780332382e27f6238dad9977103b8c232c9e GIT binary patch literal 75210 zcmV)OK(@b9Nk&G(B>@0eMM6+kP&il$0000G0000w1ONpC06|PpNNd#q009{XZrer@ z?5XRBw%{5i{L3}x{Ity&N&HorRc;=xY~wO zDaL54*j~%n>@JLQO)oF6u3B}Jlk7daH0?x*)Rmhr8K+&i{STzBTqDQxc=B26iV0Fze4~=Pykmz00$s| zKMK9meMxQ`36hkq-Tyzi40PI#$f)ZsGGYQCNRs5J8DtOFm~8ftUf_$iZPT`GYpbQr zF{YMn?Q?D0wr$(CZTr!-ZQH)vUYv8dmmESZIp^BOTJk&^m6X^TQGnNGl`f->|4)yhzL)RY|D})NzyknabZ>- z@LbM&^fRlth39r%{^dAa|L6KY*Z;Zx&-H(<|8xDH>;GK;=lVa_|GECp^?$DabN!#| z|6Kp)`ajqI>7lrLj8|X2Ii7g#q2t(Q+&sR4%kv5Vd3AYl^5*>*IN=_V&|X>DJAcAKz|2c06vxINUpo zLyl#QiCL#;TtU#d!q~QAh}do%-P(#}(q>gO4FCLp{@?%4&nZw8+yC|d{QvyHW7(P*^xz9`Zxi{hQ~%)K_@p>6eb5zEy?h>|*2rc#0G z6LTzKRy;x*4(H$JuR(ko?Z5l){zDA|-&2fI7mpLX&P|aBahS`#oBSe4!l~HiD zP{C6;9!U-q_+k7;_~9dw4R%6POjQN*i(>3_d@-hzXGM{ni&wV$A)cvy*o|_ij;P7PNb(IjbDz zp425iJi2%ErM}g-`ic*~dvcLdYXS^FRZ1$#-paFpC^(D5z}uMF3{Qjo)`BEl)TT$M z2$JW+WiN11@4-Mh&Z9e9L2Yxd>BfzLdAYhcKRw0BI#zu0*1I_phpT({-m3RtI9%qv zd+!}$plB}h!;^c@e!K7ZwU6Jse^vgk3Xv3yB}r6ev1Nw~NUU*zZBG@_(sIXoLbWWM zp>UvU;GGG;l((APO%Lu6!O@X0*4LDux6W%<>GJI4?JwNlYNzSy z?7er-?;IZ{*U8z31hE0})qcRYdHUWviEUk~eC1fwm;j@=I<>-6@D&9@K&?&chyV^S z%g(wK69`h&0t+ltsES>a#j-dH9HE=Mhwc_dQAAWh3;28v$XUoPZyp`4^6*R+FX1it?rjq)aSDO}aQch{?GsRW(|!V9}zje{?Gh|r=Ks+yldp9 zsY3xOZYwY<4Zb1>*50>HLb)PeMek3`7In&ID3wI2%%bG*dcxx%2pPdyFN+j(GO)jj z(Hw!m6fWko+0VKo*785XS>UvCbJL4^ZyAix`435pcrT%U@i#AG`f?e#g)FGFxTe z*xUSazwsM>`rcO7FWnEjC3ZE+=p`0OZWsj4;t@qt)7C^dT?_^P#&}^i2{jdFOicPulde{%`kQQtf ziKj&2uHO9Q_MLvoPkLjbY#lx9vETKbK5@D^n~5Nn6mTG*!JZRiq8PRS!)#da3R6%P z^#tpki%=b$E{}NsrN3l(1wR(8%} zorxhN@W}w4B3yr8NwQlb7R#C(a#rlvPCtG72mkD^IC4ZMM~)xxo6o)&93mz8EfkJp zVcUXJ5E$BQN$G|mRLlU;`Mg2`Db{Zt0YfmdOC_+M(`0Q(Cd0mBw83YfdBHMYtCCT& zOBRDtv2EKt(e>!N{e+hXTKV!zf7Zv&qFi28BgY^V1|&cn49ZrISnqqR2=-7IW_RN2 zZ3&BT0pcYzgYBMx5X`t0mBHAvo&dp5iZE?h{K8W|;G4_|@8QewOW*MSl|eyn+2Y|Tv}}l;C2Q0; zgJQ8^ad%XteY-QEz7W5(*USvc5uB>cLeMJ*Nl*HjMQ4+s9$+04UJ9Z$sS?c^6xA1# zD0p0)-1`dO^^rNL?Hd?JxAFg{Xq!_g_yb&}C?KyFL0Jr04-VAOSdv&!5Sf&Y1(9B_ za)Ajl0n!v1)>Uphc?{FohiYgs(-H=(Ko@j8qdT~EkZ@E7W%%aNH=oZL{d>C&{1Maw zJql+O|I9mpXcbgShdN18Y-ur0>ucaCg1xAyfTxpq7`*d#WPBuWUURK1KNw-r5%H4~ zhIOkcC13HQ@?|*!2cA3r6~8gJ?A^FLkmBGI+8MmC7ILD#Z%$aOc&(`h(xxfX0oJH)A9D zA)tRyNTGQ`IeC$rbC&^lf4m{EmfR9 z`RBJqC`w<64&3zQxL5NTP4yl0&qOL*@^`DK9C^7YI+t@rpcfgADco@6P>Swop_}8 zRtN8syaN$plnv5djd1LTCm#EmzpG;}WYxdBxDhd8@i~reFH+QDbsm-><11M~87fzGFbi;-?fk9;Ry-hJ^`{?eAc#P}a4 zcZY3*^@N3hqAr4wo2R{EwD(w1Fpp2nRqHf$FA+0Mrvk$~8>v7VaEUE1k$|?y_j2JW zhJB>q?Ldt`iJ7nqFOOgQjX!5>4SOQVXa3K_r8W1{gCMZovQvsiJF&>BMF&wW{URjB z;)WO;E#kepy3Ipq`Ca;oe`zJFiY4N|e$imIIf1hK0=`&02993)4L`SG`%eGO!(^Qd z2?(K4GmzWdyxM9YEk&@^5O5FGv{;{&_ePo>@z0n33x8a{49YO79A!$1=knZYtm$Km zJd5fgEI}gv`NZT@9RpL`c+2R%#iydk%!t++|_op2p1V3NGbiI0Q!QKdjbP^ z(M*Tv5Nbm>U`=Z=4P_XKinNi{hjhZLy< zcB?8^g|ogj^hO@B-8a-Nh>%D1o$j(~K!+oQB%qLn<<|dub7YfVx6l8jr>8pdMQ%1~ z#9ASjMQqxW%Z8#fC9(u7YjZ9k_hhXOK`i~kvA%m75ESJ5ug@cRf>Zq{JonQd3hIM; zM}FhxzrGx3(wjeYev2)h{fnQS0nsKPI~8-Nix!o2B>^N3lHK$>xh~xrX^-Z30%x{k zxX4f-nj%puP)3qpHdxwXiWG`C7phMNn>lgEIEsHfHniyD|LLDSy?ji`|H3#zj}vq4 zB~vT7f!q#~DU-m)F4!&Bt5ORYjRA;<&@~Kma7Qv%UL!17m)E8;0O}&^$?lqChJb-s z!U@yE@BFhSJ(c8l-PvXs=LrDB7UU91$jeh(eC(W%4)CHSbMPWh*)zr(q3@@|B;0{3 zW<|w}Yh}D8W$ez;bLkOn^qRxN93aE6DCr64CSpky6XcP9_tAj{z4O=q-IKwl1nCk& zwdx-YPB5}c2%$GmIh5N;%fZc-`$qDO= z12Kl!atTGm@n6pN>_S}r_9xk4?g`i`5!xYl0*vSd&vspI#a zZ^joLm*`Fv{=S}yL3Hiq$&4{bt@z`L=>y9Fj`YtmTS8zg1$&iQO8KcE( zVJJF=bB2Buj=k$6q*(_`t`;3?LVJ=mUq+5#5kIvRX_Y26G;bTMf-Ls(EJ&~;HO4x+ z@rRqU3*%4z-RJ!W!Mn~VfoL15#$g$AyqHFX^@5q8X+DW=9hLr;S~Adw`h`Z0Oq69w z_MJ9}+!fp#);Z9c+_l;kOHFuTR5~U>Sm*+J^izLnv}Oc;&8>|IYWG}NAh1qRs=Tq0 z*QQ~?$}Q#=tSmh+@!lt{?b+&=w%z1jW+_ix#^Ml1^$L})A_a#<{%UPvu1 zR1DSvcuB}K&>vD8!g$MC3 z3YtYRpU>l|zw&9WK@xPqsO38^`z!eR-SXcl_5!2kq6w zaD9;5Wr5Tmp-ZsN7C|iP0$PcfD2Okr>QHhWp-{W)YoEOMh4cWrWps{f@0ii~a&8mE zjptJ_IxagwGXi+_&;08^SB`%7=RJPIc^b175mp>Y)945-8ai4#o6VkF>Qa^O5~eM= zxsce0h@Iec^5#%Z27h3k*eN$AUZdA!ob&Jdec^1Va55u(0=Dlg!w!aEyrcZ>ZTa~2 zm;dl{WJmCTskXXb6p;)5xi+C|d#H@m0t6{q;n>{UK5$ZOen<^TD!;`kEY@8Mj%BUA zSZ6>8ONiG{D=aP@NY7mYp5R?f*eJshI>*oc$$u7T%J$Fy;vLkRBiYNLe)1``f?*Y! zLboiXmdRI8pbzb_YvLgJ&8hB>1iBNWFjYgWAfIdy2q0yPBQ9mA5VYaaOqFLNomvTH z883q@6i2a6M36$ii`+W-HKQjZKJ(L`*`^w5G?-y7J(?nw912>~IY|QAnkQErIDu`` z5{UiJ0iTJigQ!OkiA#3WhvzpwxUFDw35LVAGR?^DeX@%jlSH{E!>-JO(|!8)|CeaW zu5W*3>=>cjaf+xAg2i8$MchQ`-1XxoR0&sfaze;b&^TFN`4slR*dbs3Yf0>JZz9uN zd^0b~sIt|4@P+>63di>D4|)zg+5OzNd4~#x7dc#=xC^xPMr`e3SjnKB1>MCJ_)td| z%#vX< zMDj^j;Z`Ik_A#VSX&py0y;mSn#^4anf8=qtP8P>gDbkfiSj0qxcCg9j5urOq|MFevU>X0p%1q@zh=0kx>e=s`n5@ShE z3KmtU5&B<4yz@1`c1t$ye52FFKDbC?{ER(L5&&`sV=wEGmi<;gl99C1FVJ%tX$=LO zi4e&S@ZbHpR_9*eMCyI!r zn-^2Hy*k#8&TSNn|F)Vm8!TUWT^TB2YFu#+*e`xot4y$nzW)GRJXSwa%&JmsEOfFW zj28MRS*XmPr9n?{|9d=&hJ553KlZGPi$WBrj~oG#qNfYeUr%>W2yi!8{yPFaVe)j z-XqkqjPSG`jb0LaPaOV&ht5t^{@<}9|4&tX&9h)TR1ofurCMNnu3e)`=^#dtfD~;@ z&Qc~D@f31*mA3OJx}xs~)NzCW0O&7Ah-xN1giY3|fph4a5tFuzAzGE0E+@gz#D84D z4cl@09iKuw#xrlcXG@O<*fp@`vr}N%i7ubaz&MbZ+wkQb|Q>1WiFUy@1b7mr7{2upo%aM zH10LUnL4Kz;TjPWZn2I{HaZHqhK_)uD z()u$|BqC(9Cm;Dz-53wOd{It_9Y&LgWkFHy6L8Y7;nv0=LK1;|gE#inPc8s6Ik7ul zh>92yUerv+N!dq`fpd6{tYKKWiKA(*B(Fw@6oc9l2L;6CNGe1Mnm#kZak%|WNAzMm z`SR)Xpjr?`%(#AnSfZ_TkP>3_AZG`6285QgZg={%!zogCKnM8ae^o|om?sC6nbUG? zHiXuQ4uL*yLM*NV1@{`5ZyM;u_{tA`vE(4HVgf7*#?vHfjHEmcB{jw(loNMrAoPm@ zVXmnH1cFh$`+Gm!?$u8qbEuR={$_pgS&BT_>ykBDI-@Vt;H+|j#2}fu^F!AQK{)@~ z4>#kQU+%^fQGPSXO7Gkt)Xc*Ds|{VcVwMqXO`_e~fE!A^@tM&PL0h<%5@C~P^1MU> zeW{4@6A>Da`w8BACt`{<$V_}k1}{|UWj|{_K|~C5KlrWd$&0TxzsS`;@6&4Z-L1ZT&Tjr%opvY@!*wUOom1PuK1?=zYgh19V=AuG+9 z7QCn6sfi0LbV=SnGGs_ZGi}gm@Ja zV3Y=DEXM?4Vob0PKx2wUm17MMHT+NC9lr9dR&2if=1xeCd_U!^Y)0&6gf)H z13_a+8fULQ*@|&=>o7c;nXOVTmPKNku5z3E93^orYb`XGt+?LeGbT2M^2 zP<2>)2^}139(5H^j8`>Pt`7f8f11VIyWW%V@Xt`&n-+z37>1Q~c{9Ai z(Kl$t_-aQfUSLR+DGSAGt5%O-MT@*xkv#Xa*jrE&Hf%vk;hGQK=N7r0JQ;caK8UWM z??mV^lxP$rzeF%6E@|VKP6@+xrRT`%zSS?~YjXDDrjDix>Y5rC>)LYx30AL_N1r~l zr3pe|4l}f4>8zkHy=4jlC1%*S_-~at#hUBQ7AAA`0E00ZY(X6y#up&uH2}VRbYeVi zYsVlLSqck~JGOAFy;R!4hC>~Q6&n7nsEYS19dk?3iTxTcpYQv+#nAJjs=Tl>U|Z@1 zFqjQ92%bA(h~jzTs1xJMK&;qS?uAiR2E-6vG?H-HV)s4>!VhyHTMHW2TwiLaBn&jL zPq;CbX-t{BA)ECjql%9Rrml`p) z$I6%>y?g}`kq9G}iOX#@?nRcX7=i{a>I0^!m>5#jBc&jMe3qWqXvqq!k|k3lS_^klPVs^U??q+AAr&NmxK} zX-thhi0^N00A>s^i(jq`Rnq{Hw&qX>3bKrNmzC0h;i$&3@b^s!a>yPsUU~OvC3mMJx!9+cl7olFHfLa=0c>j zE-e9pf2rX2B`pAul^`9F+a+xy;8qF7o@W6+FC-rF8^-=i@^CM%_|%IBQcta2)tJT7 zAT7(;O3+_GZSR#nfF3V$Q@qKsfUoxFTP5{n{TkLYp&UkyBW8pJMgs1hn+Jv)qrEcC zFK6fz#CW(72Vy zC0tJmILZn4Kb32?B+H1lsl+HWs+gr6O%m7NUYuq8A8<(un!hCXD9xP)V8I~B!o{QA z_*Zn$g5xVflqZL^HSVHf<7$g}Rb8sJ6aJ+@NdX=GpJc=x{e*Zssa|ToEhLEgF2}{L zGf)AN)Duhca)mpH_QW4(_zr2D#|XATZbi!dgaWvxZVP~h79#9=J2o9OIWu65C z!RA(qyvA|wb43-)R=Iw)f4wD970Vt_Z%XQr`buTW%)ZCf4fAATPVGR2YSWZ%IfQOf z5*iU4UVpX^EHi`!SX$*b~vqi>rM>6KU|I zfgH>FLkgRdM1_3e9S_wGJb}E#&BPNa5HK#Q$nmw3deXu#1*UQ&DX!0ISz0I{-}}p@ zbkD9**@0MtIEWj3i{L1OS>mV<<7T-KP*GyFCHJmITkNQSy(}EjA8ULFPV=|dzwVbI zYt1{YA%|tolvApTO6wmA-m*#BLq3a~{U zf7w2aWAEa^&J~Jb0%ZI`l>Yie)69gw8ONKF7G3v%vcB`}VD=B|%W+ zH?t20c?n$7cN_6k5>YQy3KeTXv+ki)#c}+5ZIjn0HXT%|H&j{zI)i>C3{`BgT45Ffv7hETHgiDOL^_ zPq$o1I6T2{)r7b-fH=#bX#UpcxN={kieorR-VN|*tB4mLmH?R&sMwzb1N+bvi8};K zS42%B( zJ<_)C7(Vm65;ufmo5y8k=Fq9nEtGwfgin}Gs3GV&F}~j}%Sbr~7+7=eT9|PSuHwr_ zT!bJPg2($X?mDf)YZg0uU^`VM5tZ@oa5BNGh=<+c!F}P)P$)CxmUz3yTFnu&ZGuM% zWswvSrg{lEj!qj&24Gr<6a+_@=s%|i?Z-M!uHw@BZ-&&uyU~ZIqLB<%S&r~yRS zwcOj?wJx9H`rusB{1eccnTlEc2>4LWMso)p;xCaA)M#L6n-!4M?@2zvQ!|YHsLb22 zo{~D$8R^2+ZFUasFuWIB7}TOW?!&lK3USL+1fv==a<6g4bZ8G;Q4h3sKX*=#meswi zW#Oz;ne%KJSz~?c&#vA`4YqaGIj_FmlZ+&`0}+(~_@C)4yi+ub3Gkh~hp!yC7)B>< z==Q=qu1hec_4+7QtyW6!YhrY{cZn6RCq{x5mH<{n`BpW*@@L*(v1n0VE3zjTjgmE1 zLz8QGCU~+O6HGF--Z@&`FJ9Mp)mkK(Fb$tP*oX~NAB;5%x#T=zWnz5~-68WM0orp( z6lpDrfd~q~owiRWzSl|qmgRZg#d%vKqn%%ynJ1zQr8}fz$Owr6udrTZbsh4q7poOx zd&2n>Yef{LQfqd(t-J{x_Jzg-FjNvH%)}D7zR*+au|@F5_39gaugA3CPXsVcN)pVj z1uzPjMQfbgeKKyVRg3_H#R>+AK0KM^s1yi@S37iG7fJ6bBr7csYs2GCD}#PAaxU84 zX}4cE%aKV?6L>mfB_09+g{l4B^Pl88ukUM|!8|e{kb;BmNuRzGFBn@`3AYb}HG-C^ zWII?#H%^zT+WH|XV!`>Jfrd5IWuo~y7PpJcx}~f8RmkEwLlm7@ohIk@)ZU9yRdcke z0L|*Ied!1+sA7aU2G;GBzx@UvR+HXl(rLwW;KK+RFv0|1zn4fKv4++i zHe`q-CnT&~0s6*hf~;jAnK&P*=rjaR$bSZ+%#Du%xRnr z%STGEKZ0+JJ3N0k_VBoXx)jX80>51)hca7ZIWmSTmozd%Z;a+P0lJ^EQfi?=P%&vD zSrTjTWEDR>Qe~O1Eew?UV~{X_@idjR9KshW zD)vkfx7d*vVV$kySHGWt*@Fua@X|D_iNc2j%YOx z)nE>tq|+L&p8NdjdSkQ3NHKdPp0D7MzE_!%=fmuo9a75`ZwCA7)xct+q7&nA&sPDG zmNeV%M!BLuE}mvhg5EJdU)i$^@-Zs*JlKB8^ioh4!u`;y(UGV$NY#wtkW!?ZH1cNX zoHYb=3lLdm*Y)qN2qvY2x(wZim$QZJtZ3|)$FqR!C^9@jlHs4{9AY_rP)HS_BZ~EY zGv)JK!1a!ZlRKWLuuy^1ydXKGPN%%y;1>r`B*;rW;whayZ(s4|Zy|L(c;a?5HUiX- zAc1xi2*pytk{#RQBYTwF$H$Ap=oJN`eo_+*eRlNSugSY3C~#~vWtIdI_iV%BREBCjJ`K$L^6YfOMOzO(pFW+a5GHyzx^ZEAh>^6chQzC)6RN zW0e^30Bsms8AzI3d;cU4Yx%X5SYP@KCH7D1k1m}(fFUTP4@)1H2BhRam3Sn}))QSg zt)PxGiY7}klgC#`ZX5%@I{O@(6`dHDF5?18s6uB#==P7i`h~75pH;2eQ6Y%MwM3gL z?-#lNzx_rTA^?$6oK(vZF+-TiGUHRCo6{p%l=gl+S&#FQQ zXrCK65oa1Mnp^H*qcKAp2S7xy>q zr;X}m8;}>&f-q9Vzn_bQ*Q5zm-0#sOhy2Q~6!aSp_s-=r$pS$1V>QTBi7vVRQI2;N zdWa^u&u2DQJ0Nkav3v3+x1$QfqSF*E9e*s#+GE=RzLz+6u9`>}A_{Upor!0oA=fwi z_sdFM&^*x^aK`tHEw6YVgnDMKzfME-OA*B3(Bc|Ya51yrgZ=jXejGZh;asHa>dUcB zvqpji*JW>mrIgwQLgM08U2;Ux!qTgjSxI{|eS7A9nMczeqYK2#f1W_PjI#}+YLAa%A`P1x1ayy&S&^M*9$7agQ4 zSYJII%xnC~7l=nUd-u3X_a-57A_~3$3{VIxW_D}DlS$%sy^DfXVOlOsP=;{b>b#%R zFfq1tR7ZVAm1(^U;TPZL`Oq()PA)E)WD$FQ`)!yXsv1U#TcTtOz1PIoIC~SR~e7 zY-~^h{l3;gAE`cJ-oKWr*FXb4h6xX4I;AI;N-_kZB{wizu`Psq<5O|k7G8C!szkV2 zk%SN}L+zgCxY@u?^2Z!)fF2y29;}J5c_1^!{+ZT7eSmt36pC*YyXysTC$1>k#zbj{ zWXH5OmbOG0BPPanW#rP1Ad>9FHR}gEkLNMHelO}IcT7n4A;$&Umm-xzYp2OOQ{SE~8*iufSy`r^RwPA-PlXBI*dFG`o4Na!eYg^Sr%ya+kasSoKYMJL8T+O$VlO1#cO z1#gYuewwzs^d0WkQVPyGa8V<_tLCjLwh*gf!it+!eh4S1^Z})Ir9=EgBN^e%mneHQva4Pn$Oo?&1 zlS%s2IyB{WHR!n7f!IgTGfIbx{qcRoeNzfsx@E|tjZh8HOmG=)#SZ{XktPDs+7x!( zreBmID*)rQV*F$U7*_W9xa;G=l7>PH?S@eL(5%GmzDR<0bVL!ULXMsyXica@t$ue5 zMS!@*r5c@k$YCDDze|lMNm3@*T5XLg@+w{0f@I=Iy^x9>DkkK4E6!$I&Ak|>?_#51 z1>s1M^Y`;xm~HoRBNZGSoT4=TWXmwBM=BKF4k1fWAHp+gav;^2tVU`jB?L84mt2t3 zBv!-t?uU6o`oLK}l@bni6dSu}X&ekuv|{|X&(`27?C&moBDwA-_3z5Gj7NTMTtg=% z+>TWEUu3aM2Cr!ZLhO|{3h7KQ-3<-NM=gc5m(ecXGuphuB{Dql3Dt10u9k}3Vo ztl;MKIwqc43WmX^$=7wKgLumUgWQYVn`{kCs@y)2qb?f^?aI0aToyazKy74*X& zJ*j)~YyAXo3NEK6)%apaELX5jGmtxoxgLxU zUO$fLCGWF>q}`Ogvt^f^fZGNR9_g}#v!(JNj9Xj?)g`G4u)PI1pBbDH-|HU#l`e=77JM`HVTBF*_Jl>vGIABs-`dG0tQXC*! zzD3{w`1cA&yY-t~zi96lR@VgO(Rmm%w~H@dMNNh{IXWcP)98_5VxO8$QG0T=-o)EV zXkFy&r2bvGxH`26q271SO8dpt)Spv2Ulk`}sO?Y<)hffnRTq)dX&(}gdES?vJfCy` zisd8~Q#Wq+vIwe=B44b#VXYUPAWA(!P!4{zJfYEcH`Rulw?o%eoYiQ=q(F%ZjDI+v587 z%4nDWhX1!x`CM=DI$%ONW6_!HWQylfl)SWvlmYE{DjF2bf*xO#{C|LHTjg59K=oY@ z`ySebtpC}0Y>N#j)q!ruB;l191*?|lNpV8K1n1a@|w|bL1C}BEpn6Y`>4OXWxVCfB)2*J;(uCS^vD%j z`C4quxb%ppVvZjqggX_6u^+n|#%hR#sxyDr>PRK>2&_Yn@9&r>;_tNXErI^biJ)Hp z=d+#)2H`w-CoNw))5z8z-U+uL^*Tkg%SEU%FwZB!2zt<1% zT{oWp;%dLm70u$w501K&%+ltuneoL{xhYi?sD1I#jHjZto1piwwyi9mrBj^#@S-L3 zFSb!8ey2Pg%=-R)b&DY!lj^1N1#Q$6SMycJrnP64pchv=3?n4e#z7!y5@L>_Lo_`%&g(gK>Ni|xO!H9W9X>jr zUm5ok;E)*9%|cgpf{LMCOtkTF1t=G2R=L|F@3(B^_riVygW2?O6-4~Go#X6l8G*{I zoY9hlM7KKq#eps3anl^#Wc)`kn{2afG)4Xl@|KSo8?uK|21-D{-P|@s-5gh!|JZY2 zb*hNzi1c9zjfUe);UFj!Fv_0l@_2u)_aO&0yS2R#HLaYIeWIKE#30j&c8pSofr@dG zFceEcFl?BAr-9Da7n*9G$f=xzVn)rH%@>{}xI$=&BYks9`jtQF*%h{!wKHE85DZr5 zkqaP$_T{F5nqXahu`F~fC{RfCyYdI~_g1@Wh#q5muP;S68`;zVe! zZ(zq#GPL&S|8!no&FHK(u@4i_Xxd<)gC;L}a#NcFl!1ccWcg{qH==ykrM?7Dpe&n6 zELWFFkXZq=YZNvNZe*RI?w5YG!%s5O|E|4snC9=im<4d8^?~x}7B9zX@QF?+vKL1H z2nK={9o3md^S-va{?@JwHj<$7d>1_vuFTy}0?Qt%khoXhAA&2j;xm`fgstKOJxrPS z)o}C+Cyoa&Pb)-)!}*-z1!zY`G?&qeBV=LG)Jne@e|ww1d~N+cUYF{6!@f`H&}o8t zov~!omJy=x1`_#z2#IINzyfu{be?M}zdO>Ln-{B>8k+J^&tgvr1EE>t7AKERjF-4D z@fm20;p6Y?ulDxp(A6hL%2~(SNF}O!DoZBE0LJk+d1IJM&JcDQ)PqY|A9+9He8Ya2 zhrC&S`;#l-N!k*pMF(}ICp1Z2$UAXCn*pes+PG-@eA%n}N}DYEey)9}(tHjlLv<)g z&a;9EZ81rWxo0B+T$*uub4F$Wu z8~M00LLf57I`SkZGXOomUq0`N>4Y}Nd3dpAs6J(rNeYiMQ)tH3S|&-z?H zFdagrWKEnFfPdrnfHMdF5$No=w7!<9!;W&tvH07xM$fnNr7DzlQP*~2SI#1rz%TmYHy^KVxBO@JW|G#F8DBtc zT8(&UB_-GyK%qDd)3W&Yg4H2VNW-Wb`g4WmddS|l4Jozo#Kc}DPaieH%r4WQ+_y7RC_Fvc5VjQ2nPONHeRosIotaS4C6RwL zlO}(Tb-RaJg5VWkkU0voiv}hjyMj_1!ozjqPu{quaz&~NgZFzgdHv4$WrXC2r3nUG z#Ipk+49_Cjjyz6FMr&N97o%z`#S&G8(V}YZ8^n??CG!+Xdkq>bL80_5CM-Hlu@r$i zmWuW-JM?2|{mZvRzxCi&k_9)7)Y2hmDAm@J1xjFm(gF8BfK^|DSW!csFukY-Sblf= z5LQ;)?i463gF2tI3F_f6gtFulRAR`Lil1jDZ!p`D8e!N^Zz0it)3(8VVUKMgE5^7=Mb_p3K^!S ziF@VfZ@kFbCpn}GG6t}yo#@4XOb5Ycq;=vrzkW|LcdW8J(*O}KfKdg#hjVGst2gzx z+5jh~HIQ1+`5%%b!<0BbdjaeI_kH-saVnUn8V^^zFcYLrt`FlSNC8R<1I4k3R7dF5 z%Nd)dK=`u!vxws@Nyh)~Vt)NBXpe-mp7L;9${9T$M9j*|2*iWYul4$btJ&a)CZLb6 zG8lD4OKvA?phg3H^%O_2$!!q41Cc9~8|8FgukjMt`YY4EMd`6rSi5E&oEXje3M@0w zG=q1G5ji2Z0N1)LAex!3JfDO@2%_o8xw6My*8KL3I(I;d_OHrgb^BvZau??xC@2zC zYl0^8sb({ihbD4FP8Zh;5^|Qov(_UmmFI>Wq41eSAI76x-+WYi`!2zb$@BIj0a0UW zp3*06fde+U7Zsr|PU9?qgdNlEa4_oQAEg4W*DcjimR>IWC!)!16(v4$p}1YSktMMB zs1>AIKHkq*w^>FanjR@YUx~Lemc*~zmr&YKPD}`(Li&^lTK4Gzdoh`B)|bmU6ttuF zeBkQVk?}HcQ6v@UzqF!&52*s7q9otWJa$5wu8-i$$9IwHV24Qt$f0u0Ax*`IQOWVS zN6jxn{MIn&_3RLV&{9O;S&vx0`e?-Hqo03&!z}Op8x1Wzn=snDPfZw|}#m~~*k1YK46|I5~Llb=%e+i1i*HOL1EPJfHlny7vOL1tAr(+O) zBnw$Dbiegp!j!@5{HG)$@e)e6^nqbf@D|1*i5dKcEPR zb4hLPgYP6v_djWGxMN*X>L$Lu?orE!r4;(0BA3j;ZmVvteZmn{dE4N6vd^zl+GcY_e*3D`Z|oGimHxZRda{| zCzppqYso&E4xg{hg5miw@X?4L=rr1Bs!RlXnuk<7r^mxcLTm7Tm*hIemb)BKwGgXW zba5+Ludh8&O^Km_D|!G1cbTIs_PP2h)M~X4LNaEI_$`pyU)P(|-A|I{PPHp!Zb56yRva6X5F@G!eD$ zS33<`sM`P592UDaPsaf)Pf+enDNH)GNfhbnjr5zcI9tjA5W)R=2)=#YPelM@(Flf-((@8ByAwzOroIV zo>z4gFCA)U#%6X<{DoONHPsrQDMBQb9upent1NWtN4j_)gz3bw*UCljV}LyvEUlCw zBBQa1N;NSBia>=5d}BI=J9N_PX3O|Pz=*%~Ct;M=ompG#b1N|%XcBty&wm+R{HWJy zdjic(ahv^dq~|MPg!Pc#&+SqWS@OR0IfpVIoZdXjqda|-1d{>bduJz#lE}z4#ci6Q znY}qCvFVjuVnovOD+GBt8}TjCd9ICiSg-*&HZ2~h5zD3?-8yiTWG6cK)nCybm51LB zjT9!(AT2`+dN3*R%UW@cAjXvevyThhnSZ4`a-fxH?x#sX_$nk{Mqzo{vjLqDxTd*e z_(*he9 z4`S9VdHkEp=XQ^b<(;m49;!guH5ux23?c{^ zN>NaGsSHYDg(xE^oAY4IwSzC6YRXe*M(a>+6!ktN%5b%z-c(>{k7no$YmA ze$YMk2>;ja#B!;ZD^PlKYSm33H-eJY2A8V0SkXukE`Wu^|NLB<8ZB37wgGl--M1gg zp>C1(%GTYI8NSp{VuHoVc6kQf0}+e`jW%6(Zil;CD>X}3?`T|{lmWneGUeo~zq5mt z9hF8ZT_K#QVr}WvLmpB=Q)97R&};g_dlzpVF?os`HB)+B zrupUL!_87MrQpCU0kvpuHP2p*|L+$LEiF@h5u91TapYcEz4gY8-U){z6FhKMQQa^x zLeZo!X6~g<;;#q|%tS*vf9sPbkxygDj1{C#r)o|$gtdXc`;#SksS?m+w1nYIgXaNI z=q??io1zzZHqL%xyH)FjKp*_g-jCv>zYNtx$TV-Z3l?faw*-8?CIHS)_SHSNnCV5U_!^f+@gl`<+|=G) zW{Sr{U`CmXwmg4TZ%MgMSr2(X*>E9I_JuEA$U_N9Wh*-VDQ|sBnwP3rT*^or!emm8 zOkR_LY`UgrOb^;0f+uodIoV;F`eHxbUhxCozO_3|yRx=q_}lxS++s`n5-R;)xp{-K z7g~_Fb2WuMlB5Jwnb`rEY&*JTJjyy%YM4q6mUXa%*Kjt_msH!-3{xi zH?vK?%voxk6w>LLZ@+>*ID>8lU}5=yZb%hRB`%1*+@9W^l{~q?qNT6poAr~)XjIo~ z>XZwLqt7VdQW*mifJ7FiAHA@C3XvMOywK9urf`}lh&Vh^uC@;mKMr5jpq>1H-6zvg-mw)|F zsa%`v zF~sqOQ>^W%lZwR9T^nL4EPcs+8!J)3YB4YPjH#S zTBD)UU~nb@8u3ZKtgLcLutqQr?1d!hOA+dJodg}vR8oDD$?~01Tn}s@hel;iU((K_ ze!*}%B0^=&Fh$h6B)ZkqMG}{S$%(Hwn}iC{o4YnIwnB8fxKzb4=LUHw{C+{#izIp~ zMifqiq)Ai-(%qH8GUKqSK4Boq7;eXSHBIB~1g8;rhBe{Bt_tz)EarPJvGR6)N>H49 z{Z@UZrO6lsU?#CA&tmnfqET#>KtPUKC^6Ju*=k7fz`+*5GCS$c`T|t;Nn+~>a9(hf z4Y)fe zOsWN8yZzc1?=%B5oW#W?`nE8IS`bU3*@zq39XCR)tNON7Dxc-zYlP5IzG?~m0J0!y zNONE+qAG`Ay9LN_6hUo=fZv#TjIGITOrBRskO@NRkVs#?ccye(oRhV;A12Abfg1E_ zlP1`Vdq4lbFPskGlBglEMfI&}rQW?WI6fQzDox3_O+*uhlgriDjuQ?!$SnS^TzdMF_AxGIGUucY(Cy_+dyf(AlX2D&eC8MraTv~x4-iiyF244=x9AB} zk0cW%w_fNA-D!Kj)hFtir~)_=hefSR5mM9f%>fJJN<*uAIU-_7=?2#KAC9l+eDn1% zlpFxoFACH2Xk3?Ebgf{$5~AL>bDE8-y#WqIrzLLJmjf;#G9h0mLmmg#6Nwy}B2Lf{ zL%LtOJ#e?+l`U?k9X8mE8)lLq5Ye4yqP<0#8Y34;hjkPbLg=()V#Og;6_Lu}Q~3ZW ziF7-CYn*-3pe%<#xCHUh%jXgfQmgxAyqYo&JHyqT%)A~lY7|p&0qqmibO6zy-9EL1 zU|BVgB80%XYw1?`DDfO|M_sDLjI;~T6f3spd;q>91D8B!K~wd945 z6g8y;;K-6T3ALCrvGR45o8JO5;LSNkI|1sWF45zS+lu!C6~G_OLKYU3_CSYnELFmC zr7N*=m5Ytc^OTmM>fd43a?K|hlPy~@!sYduIB)m+FYhN68EQ5eaG6$EOd3HnDCooZ z`K-*l5?dE1ZinTS*M^0cU#poRr%Z1_6{dDx(ianr@;Xc9*R4|Q9HIkpNu)21ZKnbwT#+upw~7Xe^Bcow#F6upmSMvOiw zaeWH%Mj@GTj$_UCBH!9XJovnwKT!@qiDUE-cD0~{vbE^1Ci7)IxAzWWU2IpZ90$;K zsjXlAyx8CAj6*Vo2F6h=hevLK!yud{kMCQz9lm%99cTGIhvC|EBO>r4@uhChVE z2qtJpr}#@8;09LU(UYJ5`a3|?UTP##Zl68^qNoA6u=>tv!q*@6MVzH-q?=0fmz}e5 z4XK+!7-&7i*g5CiobWcCPv9FV;(Lu0%!x%}JsEo475g_~NECe&kF@++Y@*&&)X-l? zKcVI8`bywA!GxL|Moy?^RF*(V9KH}!19-}3c#8! zD8M0zV3mLOWGpMuE7EO>Y64z=ESr>%p~31pX~tJyEnS9HaXi775h+OWY8P2Ve3}<5 z$R|aceg>=qBrK}kN<;$Y_i#z=$sz(HZs*cafcpLw*MZPNt7F!d+cTFI+J*CTC=mH& zK%d9b9*;#E3`$$)ex5`8;#7;#daE?s^vefGtR(DFcEB*A3EYx)sI`jwl?h{nrw%p2 z`ub3eK{|xM(Xp~^6tM&YqKpCg122$(vdm9iM0;a9rCO>maUd)Y1hisN`nUe3>o1>d zs9gH2Tt17M)6419IAqB%gb@adJ}GDD{=GlJsVP-jKo~b$qEBLTFr795mi0?#SqpcP zWo!rF@yjp^ z7=ZpKiBlzW>5#^!n0E!70BD_oI7Qx-c3gxD#$2CF1)vPS{-@>csVEh0LBV3~hd?!! zb+i&1U0$eMn)kl90IB; z)-_o91PT<{Em+LtzR)gE=@IgAk|W0qjn?*Q3W)^gSe9uimzL!x9^r zxZLM7mDgJtl_K0J2-DU}4wj#OB%G528y%oRmQaphU~IAe*w5*ygBS*CFr6Y^qvvF~ z-~<4Of~xG=vD>ef@w?rnBp56zkLPw-Q)F^9Of}r6NZ^q?fLzkNU$666QyfvdBIIf& zhbCf=ub%~0y@H2*;5@fKa96{>o=s(`sGsRwUnJSx6VSNN4-B}Gd$L+SnR4>z9A+6LMn4Jl0mR z?wMxbs=?QA261&d`gZL0%QpjGC_2S)L;$U?&+UUXjSebI^qT?Qbr+P{=ddKES3Lry zOK~9?mDtImIs8s5=^}v0(Mg|6b<;<2=yeQhaH~&qNw>s)yA68#D_br!3U;s@e)?o4 z5m7O{^R?>RZ}zG#0ATP79y!uG++_9tAfHk^%_Ujwz4n>v?Mra}$CPH9brei%E?LyP z%S&w3Kq%tt&R=Z!E32Wm7A~=DrYAl;t&LSfK~NgY&`?r$!zd~kzytUJd;|c3Zmcjt zPR^l(e&fss3v3DCt`{7qE7CxWX3j^R&89L=8^LZ!n3D?0fw)S=*A&` zI!P{_%c2I=B1$fc2KR3%8^u?+ond#~&0v)w=}WS*!zBLVq(6g*UhyG|Pw_s&ny};g zF$BCAUdGhLrti9%vgcnd!(}JFyC%x2ptakVws+El31$dfw$u=hG>)PIMAQ*0^$_j&sBt@VDx+V%QkO8bp2EFXDKuYQ z+5ovrEN~(<4epTmm*=&o5(5q##|szH`S7U(=W7NM*sX-(KWNf&E-1%VjQ`*4g8twI zNK=RQMu<%fk---2q;$5uwz*kq`I_al-ks)JN^fqDg`~SJLV5b*a$6sB(zLjh_GIPw z%%2`9hMoC#Y&aJ#?zis!@2^Om$-v1=Qrrvpu1~Z`;0et1-M23IFWo+`U%F&xID;dN zuuj5(?ZX&)?CjA(;4I+MMMs`cVmj$q_CxxecNuqyT#PC*qwKV~TkI44b>bM@#dA#f zB7(&^RQL1MY^(Bmk5AWEvqgpT8O)YXOD8g#mc52td0m}fS`NK%OBhVCsSD|f^+sVj z0A3|Nl78vKS_`^s3^fF{*_*^|R1jy*YVhkBKr#7Q#^kPXy*Rk`z_CNVsF3ij@3dOP zF74a;g(ZGNq8NIO)XPG8N3s^6uf7Skpb8QvbqOX_S*MTn0a^xU+mL;$cTmIWh`Lq3__vcx^ zp4e=V?p5LuX@Lu04EZ{>;p8O_zC_rYDIZA`ZRoL;ri}kq+mTEBjea?A{%>}gzJUC@ z8!NE^AU+JnbJ9{AxUZ^~u48fzak*bL(x5gkNG^%xy;)ZtR+$yF;|V_7;hiNmtfVhg z(LuIazO{?`4)mqX-{G2yg2kV&A<-e1OQ9974y3<1pM<~ot3Al`=PFW<&C5JW@^)&)o1$5B(rtBft3?zLAkb9` zTO*!I!W*)H{N*nAep6{jzss!O_(*pwYI$>waj*X8eP@J(r6?Q@KF>4y2(`4& zCj8KaY3fCL*`A@oB$`vrJr(m5C{Ix{zBA%AFmj$*5*@egZ|u%B6fE#D@gIB|Izx$C zz{IM_WQ)8VkRSUnmdN8nVym9O2Y<1;3!JSCqrAHt0=` z|4GywR*bb!nj@v;I6Fr=>5I*$&FrH1cuhQ#V1f+TqZ*n?YxkyFu@PtNFA7K_YvJA= zf9ONg6XivHa@zMTE0E+{l<13Uo0_GTAPJKk0-UuDfn|)v{W6Gy5)(f*U&G5q*+e7^Rk`LsRfJ^QXt*Dd|Eh}Md*;*k`fa)c>oxrS8EUoVqI4xp7`(sZKZ zC?n=Eh^Hj4BF;vz`|*#-RxVxcnRDlO)e@l;#{E)QhtAdVXHJcLqQ6PJFLpX|R0`M( z&YuVf^r0d&g_i1D*p{;w2J?~MNBB89F$kTUgVk|~kWgfx?9hzACxn(E7UH^avr}?C zQr_+nh?dnKT~)y!`b_1JBbOOKNtjvOOHFy-oZTtgHZqR zxIRoC1|V8-$cpgnk@u~R_>UxNwVZ3ap>1~aJsjx}pkvgW^=bm`A3)+RW9?aZwbv|sj6zv1H9Deey$oG>#I`T> z=B$F;UgLJvt}3-kOd1P&xn@~DVYbDSiOiBs9ATvds|pO$=yfwH!qN0F)j7iA>QSG@ zCK`)%OYBFtETM_>CLJ$y=*c`dF6AyRJmN7Ar1g1vn5+$2Lyw zN4~S6*!Nd~(1M&i6kE(Ww$nc02z)rGI~fIEs-iD{>~38Q&Eivwq(DYv{H`1uW#tnn zKzO@7T3?TWDrF!hXzAJSJxHiY?&wgtX+v-I)y|(-uoAO&)cx>6Zb)GsjOJso#;(q2Q~y_so%}p7b0bx z^ufBC5!GUSA`tA5gscF1)3}f*X0_D}aDDqs=M!`d)N##20Xw?QQBXF~uYFXZw0dDU zI2E*F{Pg*`FN=JA7P)MWeR{b@`+m+}f<1HQzl z9ohxGHjiE7^f}XK>g3lz(4O)Ndp?~xCl07xX)2SM0O=yOemtI%MS>gX*YBL-cAs_A zQW#hC^^D=erm6r!#*lT6VN(vhoMp6YW%C!H&Uku7>lAalbBd_{Ng#2_)s($q#fh!k zEFj4cF%h<7qjpB~+dS66cfN@oawwCK$2&Fl)FH}uQfqS^%1y0_Zu!=ZH}aLz8MrF- z#A@^e$QJ+|hFPBzAIdUwnAfwGop}z4bJOiFQIna!mlrxbalf~R*Tib@7Y?6?X1JYt zDDZ3g!Vnzqjm|8^i}8hNDo&~_y=d8J(N}x}U|~QoXo5O&W3JoH(&caHzMM_?v}!?A zZd0&pBSHR!{nB^;d|Fy2vIXc?SmVgw2ri}jHVegE^}hI){JTl7tTrT7PdAFGRaB-d zYXhE7vS0j?$gwTfcj_jNph}+oa-1W$WIiVJyHSV}FKr>R|{mlH4nZdH~H|1)xYAZX$Wnb+* zQ(zIC%{fB!IzlL&Kznqq);)1^%c{WK*wZ)j4`sS1%c&glDQ@>}9_LVifkL4p%hanF ztq@2+{JU>+oUP#4yb9I#o3CoTD{FfG5?_ah}+Um61^GGcX*DX-V(tG|%q z(Qe0q`8*2?qHE!}9w{^oA=#ywgm!FD)Cl#jDItWwEx-tF`Ee%;!X(*b- zxo1wOzFHbu8?8E!JkXDAgT?k34czDJiyP`3S7wb!dEn%hwwb&=o%?O?GZ?YslHR-u zk?kVxOiMw5XI*ndFFu7S@MTDs*vD79<3Kd;v_~&i^-R@;3KEe~zjA)#8XFE+m;ovx zI|6jCV&>yfp)BK3yH>RpcK+J(P31^($VE=frQt#|Hq-%`ZK9J!!>9PG1=jnGegRo3gPL^-#dx_lVGUMY%7mxHQfp9Sk&qG6M? zxXc+kx?QL=VsWo2x2(iBJToJVee4#|^%|>b-)24kCws~!#qDY|*8wc=31NKtLZ5mv z6>mA*a4~;psS9X1G=4X*9_bJbi}k9w_T!ke?-O8I051n4@+7G_4(K<2M*Evf=NBB9 zQ~+v`A$+Ni;E*a>BqsKS>}7j#8bkX1o5)vqkra>QG~9UV1cUQH+>c&dzZ3~l1qAA2 zmix3O-EQ+W>#Kk6lB9cgdEe{Q1#bR*r*%9PYZF4}qapjUcQUUVW9*uw+M45T=07D;Lt0Z}hswl& z5MgwOE0Q5l48jp2-*Lb!)}iU$iII&_nee!ro3}^B?HW0hgr_|FMLC7& z5^B*L8_#GA$054`2>S#Gz10{Ym3n)X;f*YZx!Hez0GkULSg(e!8x#Tf^KH@ z#va6{Uz4JVbP$zPkt0HENiMHZAmRFUk9~{n`y>Jg;pb!-4&C_clt((pv2(C+iv6wq zd9g;i_Jo%8E{Cy){I>pTcU$Ok#4ON|gGs=pRn|u8+eruIzv3wd?$bX@hR{Ja9il=? z^OVWEl0Z0MEVSgiqJwWD2fWcqO)rvj)qGR?%Za@ue(ROkCq4C1jGKMYpG!t`(?hoy zQShnm>&Mk~q?{(MC!d(+PHY1SW$njv&;o?Q2pp75?i-&QnT8p{sW*OvFyV4=}k=~-m!sFSX*~J$*DjzZ5qs6x{Pm?c;`SAemxxy8u8w; zNXy7l#*--0)3^$r=ZLMh^Ery9vRPP4<5cEz=*T|z1hFeTTC&4fX0?z2MS>dnKZP_$&u>*M_jgmIis zl`=o#{jj#vtR<+&wl6FJM(u79XB>ZXz?leU@#cl-Ib=95N*$D+^g4l5A=XkNgCkPv z8cDDPDpO`FuDfA@Y$VCB6GS+Ei9Qk3VFD1V91_zbg~j!)UFdejxln1m^vj^`fe&4A z<|tB3D}JHCl{bA2oM8mAl8I0j&0tYQoSg*M6s?JiE#*Y$#UM%{Bm$-L>Cg<{s|a_D zs0Y=Vl+ySi*oHKaP{H&t(^0Do$@KlP;v5qmiC-j$n)=wPT;$i|P9#Cp`a>DTta)|= z2}2vpx=17q>F>|2XGQ|wZ|12z9m50ShpeDF~YJM0Yh>010>kRh$3hLffsgeN1wc zTrhv2Vz&voJ~vTRif5i5N{%aRba;*5EP+T`i-^T>e~(R@-k5^0-ov_RRYQnPxexut z;}F#9#1r6!3>@}C$ZAUH7)v z<9a~^1$1dBf(S4q%SR$wWk5;h3rHz-oKT>!nz1`TAaT(z4Y(f8A*4Z^>m`Z5{H~2# zrXDF>5*s2uO^m|ob-b#J*V9R#@_xrTe>5fp<#$sOV+mr!n9O4+5;%}70F2pmVwY&?2D=2o9#R3)BaoW`X$ngF z3H0pZhyv?ujRC$1qIfE(a5sWWtZWL0;eDISQY5w_`|Ui)7_w3uB~k?kXzH}hhH;(5 zNMafg2wJgWhK&el00DA*mf80@NZ*?p=vm07X&7!mNGd`pJiGcKaboDBT;?P$_p+qOi{Ga!e<9`B22c;7?d0+G;NREcV&M> z=cJ?-?;OJd!AER1PM8Y$NfELLHVkz!Jwkqa15*;c|Ey*Ysk}a8k^DspMaYo$UH3*g z)Z6AU2Rm38t!2OmqFjlD2ouHMnrD{K{tc?WV12aiwJ+N_G+c5uz%D720obf?Nu9aH zYb72yK99~7ukIHUgt9%Vc>vmGeF$x_*pUS^8+QSQ`9WR??0pb-2&f;uSfdVd7{$-N z*B=Gj+n%Y4?A^H(8$v5k1FJY+ozfE6>$u9}oEvlPi<*r3Oh%dsfNP^2e(f?}!}$qt z(~xSdXmhG9mzQix6qVVud``sXHw(xV*_&P^Ejo26rc6jN{+Sf2!B#c5x{JNNLG5Bd})K@^%rd!i7SaH7_wCWqH5+ zrlF?LAwPU~oFgoMO-hwQ?_QIY>ls2Xa)IuZztxlzp_tmKj3uLmMPHy%q7??81Y zoaxsL9SKngQvE@1?rb!Kvs~r~N^wI3YnsGRly)d83I+Ujr3!|b>D7s)d%Hz7>>MjS z6yjDqIXN@E9Wd2G(D)%gb6qhwF@~jlnz1c5g?BXG&pZy2TDd5X$XM@jAfEDe=@9!5 z6VTbPb<9#KQj9X=9+R{$wTHH;sVtmVc_W$t(*r~Z*|keLdMf~W*$hZ4-V+xBVI&Fd z|IYVsY)9mLCs=-kDnl2BjxQzDixZs8r!Gc4lNhMFmCeu=i3z}>P*8=?3uh8?4s#l{ zKtxIS_VN%IG%rffiG3tS2AfzAoZPAz_65XwI2ld6rZ;}{NI z&@Y2FsOjZ8$6b|1w^DK*BDYkT2$NVf+fF=-HH;cT>w*m_j^SW^*6{L63*|SvC4>D! z&E7ql>-*9tP|fo+0E=$t&EwWud)g$fpk{l`^%HTi#N2bnj49tBMydTyB8=_$_cdiH zj1J|j`9AMaXtq_nueFOW%HvP{JlwN~&=F`j#cAH_vmvcz=A?8%XbW|n=hfp%XM}(c z2)n7GvmVN25zvRREq5eVW$EM4qcSK(+b#oOtFtdQ*=wM{U~MqCU35cajjxAl)0&t% zX@NkXFnSk31d$wJsJ?V5@F?nHzE~^R5~C;omdHD#hI5oS0!=9!)#n+~>No9o8uYai zJJ^j?(K+heGy7oct@rT%gY^q+oLM+=jsgeT05j8D7(|&3r>FrJ??UF18b&VU{6qo$eRAn7#nMRk~0Qq3Gq#+n}bodr* z2ty(eGrSTHiqUr5HvLw-C*x2$A$BlncKHDM2M&GxXVjuPhFf`^e zT@pQcY=w0RYne!?)2^!gEL%@sy^x^hx|~fIE4h+Qt#~g`y?6mq8eE zuxPKhzNEtpOgJTjGN)*?TaQubJz4bSN?eGhMQPmYmGF;yQBx%Oz#$1#^2e~;W8oWON=m8%f8Vu8)*^1*q9wBzXw8svVc;|+r-rZ&S zZ-w^<`V^D;KE5N`&oZ$$Bgn&uF8Eeal%TBcRBZ^41o(ED?QH;X?7OHm;MYJwZ zQNnAI4IDsB7V}U~-U$+gy|5+Ve(n-PgUkS8!1NawA{ki}YA3*93&ENg!WBC7l<(Ax zse+#ziBvkB1J%Hi(TSIcC0oD~TIJcP8FYUw7F=>Uhgjx=)X$=3){_HCjwFEvB^E|s zc>|SA*w>UNiJ@1w$h|0B`+kxS6`yU4(WA-MBqul$c zuV07ewx0&O&r8>cKv_-3)JKnyl95aW)`<;rFA>^-jKCWZP+o>>$Q5iEFlNv7zHDc0 zpI68PVD~3j|r1`;d=5Qo)Gozz1h&nB*QsJ32LMBg;Af zx5Rk~k~jjC9ng(CIS^GK{bQ;BxlPoY9K=>Pme4QoKgRG3b|s5+FP7A*@$@7xu+Pk8 zDBd%DLVN;5;zhMig)o(Qi+9NE@+^=8-$*fKL}jW}U3nI(SQD!AqCBZYU;7Xxfy$** zZMkX*A&mzZ62F+h!FU>?x_XXjT0^6^I9h&ChxjCkGNe1MY zY4{Hl&G#tFEHW2aG9l(XNcsiyDl_Vx_d5nAsL_p=rJpM+1e>qiCe5WQ>M=gj8s^oR z%mT0=e8kbZ&66pk`?-FV z1Vh2XwBw&T=iCXal3>MhoAkAhQYJ@9Jqpd&24yeJzkYrxp}XtFPlcJ6{ccR; z2Us^N2g}lHgqxh;yj~7+;b5)x^*3tRDx;3Xm01JZrCl_-Rh6}SUY^}l>;=UhKeyb% zoEeO8gz|v`j2Ih8lCdQ=Jdooplu0KJl7S#;O!?U*N2+RX^qf>8LMn@j5h5b(=7--} zK&Olq9%f`d2&*M}7Lc8CU8hX~W^}wJ88k$|*f2E9he(L75G*N*msnl~Baon4%trLR zcqg1HqDhQkP4wn{Pj*oP$G9&Opl{oLubq5{QY{y;-oAON@vx>6YRGGC0I?YynL#K{ zUxev+r6@4!FLXfZP;(GW3$=3C6^wG@C#Z4F_)~YPVvsJPDTC1B^;QS#3n4!6EmBkh zq$6X9zEG~)GT)?;%J*u^{Azy8V_}(+dC6Hxw=zmxU%!rm-AQ6p4UWKNU0@stl_VyJ zK|~)$mJY_y0IaXxPH(7lYVlr_%VDiv5pi3O4Q1ulr+yg*tpCx|m#`{U-I$d3%SYmw z7r=fon>{GUa=kVS*_o6b+ZZUoDCl|=4CT>}ALd|)-&nZA6m7bn*-V}6G)aeW*Pdc> z9%3!dtyi8 z#jaC>u^&?l0yhlLv6hDjC|n<2tU3_&NDXjZk?xbY% zUuc(~_?Sz(eGRiv+<}iHV)XiI+{UEcZZzWH;0X>fO3pw70nCt*g3^oEjZG*s)A|aA z$&J;lTI}1K@;DU+?6W?L>d62?6LqVlTe2mwOByjEiXWGULpP-%WnAOrU|ELjzMqjM zP+SsBh65ZWKIC(Z6e9|3`f&$>07?>#7^!|O6b`XuKe-5rmgTm7`$yrv@Bxq@F-@Wj z-eSKfmb5R9ks;!cHge6&07)SF8vdboOnW920vaX|nUE3fR5ZKJaxjvUfy=NH+vHam zDZx5f;=TtK|pCTC2T*eycW-lNZ5}a=_-vi?T6owFx2F1l>uL0mnYY^FSNhx&W zhDMNm{)37T9runpeFr9od_dUZ^uaTQW3R&Ly#2|=n^RR zPG5f;)dXHuUm9#v0R`0zNn6#!re2KRhEQ`?kFO_MNF+AFY;f4h@?&2zVYrJ$^615d zC~W*NUg;TAuVZ2qfRK)AEc#|Y>Yly!mI6)Yxb#Cl8qmJFGzGCFAjs}%LX0&N=J7&O zh(f((b;JQ=!y*F3>6Qa9s4`CLO`m&#Pyh`CTJe%m8q9HFBwmP-%N8;}ZS%pFvc7&U zeEFo0rW z$lCm|+^~?iYXwJPe1egC54MbNL2B*5gs|_?Em&-+?d{X%dsu3?n=#qm( z4e04NRaf77vr*P@gvv+DECleOOI2;Bq>D|e7 zxvi_3+yN>E55n+p+trfY>h=kH`PU)R8#d4_j|*t9I7=$;w+Wesaw)!M zY|IFid-m@xZ68q32wbS{4&H7cPVEF4#LmqhO(l-owVu-W6D_?O&lf)V?dXZU(T;>r zvPHs_IkF!jmSR!tm#~X2 z+`}rrg4Ft3YlJ&6^Os>}aLwWXLLKa>EVAsR%iP4e%)&6I7E^d(RRkuwk@Izb#}qL1 zk%3MeXzEUo7F;aszadQ47tR}m0@zhK)l?JN3!U@>1KjuCO67cZjVZczDWZInp}=1A zV8nGY?!=Uj3>z+{T9+13r$fuMXX)g}4s&D-VnSG!YEtZ}!ResGFx)O*$A#L>riOzj1q*Y(-De`+ zfrw&n#Dx`hAqRWoSslf)0P4d5LD7;IbrLPGZ*s6nB&9W68La%LNkr|)H3Gkw*Z~6h z1F%OsWiTndHX%?tN(CvWIg9a!Mci57lGvHyN zen5Id#`bcnG-dYMCod-s-r!`wgmpN(5^P#V7yPePxDYcDTOgPqzQ?IcfWQ>)HcI4X za3|sn0S}%H*%!kK6l6~=FMsdlB`*rzy5aUpmq)SNYQ!2se$^hqQcq-lOlyu1-6_NsBahd`_5;-`u-ep zFDEoS!M3F#$#+^ubq|n~I%Q+SKrx-yU*f=@_E4oGv~4`eAwOSZ067taR&wC)IYk7n z=`pnpnXTB?`oL-^4sEYQyo%|mM^c!Jro9gCog&Oea>~4f_fR45IZVWf3g|HfL*~S{ zHj-ISV>6R0Ljsl{pa_65f{8GRAy+YZ48Z{pXYw=<+-6)v)eSLtj}uam$~LgrwhwmD z=MBmoZvqMOkx18D=-_F>Az-9$PE}$CA!g6f=}u6D90sxEB?`sOSS1W>=nTn0d0J6!a(SyUt4*AHX0DE8Gk+XER;^ zwxOiuezI7+n61{JthGHB$^GU@-!We?I}+C;&Bm6-IjWT1$s=_o9`h;vT@lbo+|%Vr zWTw!?iH0jPvV)%J%&oeGd$p&!Mx>z@-Q$G;zfpx9raNxB%T@CWV8{j92&&p z0%)?K+#_4@*=*%lx}+!ex={LDqB7VkVN?!h5!#T6%1}nkwMi;hNjt!+4`h4WBisOP zP7ILFLzDmO>FzI-7-5Ul6h{bC(}g)poOI%nG~s8;3Y?e14WLl4GeCB><(M(00NP46uT88 zMJ9p>^0ed`{-EYaa`isD6O6RPo_+mem812OC_$d=oH$ZeEey~1M2S)vL4t|7RIqIR z4U+xZA-gp4om9gyM*XRACUTfy=}Q?jn9& zlWH2RSjI8=NeqlxbmL%BU8H1~b!mD^=QP&!%=aPCo2X?r_$_o^D=a5NTTgY9HlI@a zUyb8PyquU|HONcs*5-sbLMWGDU|2v!1dPGPuoA!oJXKaAt$520stdu@fOPqVk5r%w zrr0cn;PZ7>FD$>XaAA&dT-)|kZ8grda&WW{c8!&WPC*Kkk-ffv96^?wHL3q7NhL!T=+T9#HAjYdH5)F~;);ewyS0iD2CiwEV7djA+7Niz+p~ zoZHCd?EWbD2F$RAC{2?QW{moib1Ch|v4jPqBLjqS1&I&>A;^r}@bG!?3%=~bxC%9m zgdmSX#8#d))FxieZfI*2o7vzL%%$4I0L1bZQBwx*PG9`k;r|3VcODas9w52GEv5ml z`pgHA91ss;wH7KYp(LC$ziui~Y0_!KE6I>j6Jp^s zTy-jgdcEXCyg6rY={G(WcEXBcpZTzBYBWu&eczFq=9Cc z9CIwx9i+}s_*(slGC5yeZ$Xop0!3z~1brCg0|R6slsg%P6QrPOfAn5IMS}~U>sWi7 zr1FeFkpqm_JAkd+&9?(#B$m>3d}xH2+%v|ExnW%ZqI_mvlR>s?E+a3>()7S`@(nTk z?3AcML6Vvufsc)aA=p%`n(%&3qXc2`$d&@RMyKmbI9*Sqh-qOclkh+nrZ2>`*k7cn#UC)DuyLz<- z`eEyN<)TTxbRrZ%Yl|yL=%1C{H2pXLUYZ6%9AYJlyC&!R_+T?8Vb%wcnNBW4-n9QL zEs26=|D?bZo`@VHC6#(3TlAf{*isA*sXPG0mUQk&Q9@A*6I8`+&LhD(<61|KNJEH> zH}|Ws1@G>OXcPMMp)bjQf?!x`NL^;82p>VVq#2;xj@#`btFXJF z=9F00G)7Zr3=_0d3UuRYIaR3dqx2wu+n*4nQ(W&@tX=CBYwld9^gN|{T9Sl{JeR_gwsc}YjH(-y zwIREmJOOZ%7J2HS@w;O>Ij5BPZS;Cp7Pgx^Y)ew1n!E|4GPNUx2g=Lbb7bj0E7D5=5bHBF2y zW>;7Sk|Qp?*dQf>MOmnmHKZ0i*ASOoL6~+3&Y*{q{&oA+yFEPcI@B1y^VUZxYe|(9 z)^;ZH9AuC|s4BuD5i$LV(v;%e+6j$@iT>muYk*A(L*?m?L65*)fWiN8&cpanWSm$b z_F+V3IKQ;0?5}UjE>CZ`2t&%HDvxXH7U+XY5B3Y6#P~Cq&!t-6CAxSP9++=4B2J_L z=o0YfP=6s)^PB+?`uT@VYpWr`NI*73F{PcCpOX|+O}8>JW+%pgb*_%e4w9p40DC%QRGer#|@+VolZ;c zfighrD*DAGhkOj|cd{n^F$xRQ4!{^SOEUx}3MH5*TJjJ0AU1~)UXaFO=jVR8pS zwL_tw91GsJLoL1CA=tq-TN`Uhq8|cIrh_jB?nUP{LI7G5&=$bvo}&?0QjD^vdM$@t;AV8@FwE#QuQlT{j1J0SKjS;N%S}Y~%C6ejVI8)b@USE9(hB$L1 zTrX~U=p@b_Y0BmUJ{d)V9Hm^5J|y&-U{zuGA8ip$Sq;;R@SS18t^(?X6nTThL|x8=clDT{*2zHT2Z;K-&$sEo>g(8 zeL`i9$B@N}OQ|{06PZh#rNL3tLJ1D&xfEs)p`&fec_S^;G^BgIVj?O)25ypz$B7Lk zYP{WYzmg?Ag~nu2(T5MvJvKX>7wp(-7bP)K9l5zd$u)~DoxKegIBWOfQb}Cqxc>OC zY(-(jT$PRcQL+vs(05GuxOXq^MY6$MDeg!JRRE(e5_gg!)Mv(EgFzXPF`Be+#%}#i zyp9rug$KlZCzQq!tUr-&DWfmC%d6iCEL)O-Z4&>l_0x zL1lE}M=Jq7DjbZ&N3xFhkW!2=8UY5G`#r9LHR2_Q5eou9<%1QjCz!8&sF!Lq?RfHb zsY~#~wx-Bj5Fs7H{p1Hsa%Z|Ae-wdI+1^&@>2nL3h?|gtm{F2|*EWieE=L+K#x^b9 z(1QI(taHw%Y(0l^g8-Cay8lLy=)|j4K&wMoW!{{<6fycD#gFrP$8xr%NN@j`H~|4q zeJgwUHzv zyg3bN+H11P2=K0HBgPe3R0@{3rsN1hZ1eAZ4lC*WF_vf{p2Oy?0zbpUNqpjL9< z843t9eCZz`fWK^Qisk`6e1cM}m;-qs2_k-A)(eIPCQu+MB%`1aBOnf_8SD`s6s+&8 z+~#4pvdb8(v zS%onc5yir^eY+ZbJ2~tRrZ7-S--wY_no1v{5N#fq8flb^fNPEh?4;K>+4J6=5i#F9 zL7Z-YART*;>kEIbLr4LUE1V}uCu`(-haOv`KtE$oB6_C88^W6a;Xs8gkWKj)Q=hVj zzGqThV5Q5G4fCRL-nCMwE5=8W>sn^Nsxmi1KZ>ngVXhF*f@<-Y_B20QG#+7^A%>MQ zpm=80&Tw(IQnTere#=JPSR&3*@i&)SVoDUH+KMB~fdH2fveX!(s?g%r&W3MxDz&qg z3vTwTmasC#BPCTSf7PA=WQJE}MV^KY<02Y}+=)S)8n4Wj2!jq-sPjT~N5Ch@e1mNY z1q9%hEQBn35VqYW8Ic)@pb@WwtcWCQkW_&RD9B{h#%b$?KXHzgOWXNM(VMPqFI%La ze59C!*AQhVG-PqmmlTfSd6_{)YR8X8$GISD=4LizNWg)FAHE)NUd$ff5KJ*rlbw&E z5pN;#Ld{NA$=yO@DEB6oE^b#;rMWaM$puec-Oj2@Nf@4A^x1~$gbV{vL|DuLu~LN2 zpPXGp*w~A@+767U1=|_qml7R!?nwiw;YQhto5|K*)%r;x`g51EK&LBgA>%n)< zaFm-i)L6Nc#2!Y9Z#`B%636o-I>Wk!-cs-=U)mW7*DhP^nf70}QDm_n56 zLCu;WW0G;lbq0(}8D_JE920XdP;_EA2Z)&# zapv2sUguKqGH(&w0^>~FIR_vo^M~+D-#La9s7_o!%?M=qDc{=#MUDoU+l^wc8ETTZ zAHxQ*=n=zt30b+nhlZ+r3&@Ks^ORw;djSA#a_@TYdA5MQ1hJ5T;lsD+JKE<`k2(ef z<`xh#oQMgysp-au5{mzXsQp@ut%T}z>jJ`hQ3$ah6qNEMNE{1@5rbsOpFt@6yAiV+ zvX;FBgC<@$LbDZE1Vaa9;vxaqPX+5VX@d}{vZ!V%NMb=%oS(>642*doOm2pYPFw_% z*5}&gbztrEw|@Yxc2`cs1g%S#Msw(55St3CpbCJoDe53Q%$voBC_JRiVJ!ht1}2~E zu2BP&5)-%~5JLbJHb)~yS}Cw9Wwl;lRO^4JyD;T`X49~wZ--#Fva%ev4??CpvskyH zYRhJ7@dAP*p;E&gVF+CcXLy^WQue2I<`9Oqpyu2Sblu&(w z^i;(j@E((#w}aK!SKL;0MkZc7kFV0MiP`iE>+;CKxYpM}n1qcSGhvx&rXGJKk{5Bb z%Rz)T_wCB=(vUH{nm&!Lc^z{|HwLMaV=4hEKIB}~kDb-lqCr(20XQ{>1y2C5AB8>i zpK3{0#u(pe5qYas9V&OWh9$*hVo(Izf|T`O*~Al6CJ9W21SV4u7g~sNI%HZbekUidbaM*x7>!_?#`h1}Q6GluL>hs&N>M;4q8A=VvScM5QC68cB*pX= z&=rtuDhk>{D!1#Ca7CIjP#{$NaMFcVh{K6w|WW^ zN5Fo<(4O9R0VQ-K@Qca|TG}Q+M_p~Ccf_E~q%`L<5XuPl3bvrYNUpY~5qa*!OEz&W zRgf|hZZ+&0>?6otY=Dk=gb}Xl1dS6_L?f)#Eq^;@yC+HpgeK_g&1q8i@j%FQE_IYt zn2WLAinEQUEz1Pb)4(zTb>l1)75OSNOpE`&BIWSpM8S%;)4*0V4A_hdG65sayLWms zQhV)DBAb(TT3jS@g39WZ)w>PrC=cxZZNShwaaJTiDjvE(o09`c5thUSyFug{LIj|} zIh7l($we6+W=I`mH%?-etu|N~CFsP3ks?T(kh-FZyg0XMY_&@(4$h19!Sx?FBnDAs zc`>1%n2uMO5ddoBElLc@5^pra+`Q8L;cRx}IEPh12D>Lq)_6HVfmsPdYv;BTZ?hsm ztI+pr(JrP+b33Y)T2CU}im26S%tN%*c>SX)4S5aFE2{CA`F?ljbeffUKFYO(0_Os< zXCZ5GG@TuptMb)pVS+u=J!xIgwtzuDkoM<8V^|!9g#gfw3!~X;OBg#oMd==ZzA3R% zDv*S(HaRHGetq7xj3Yvx=k%j1EEo}=L0KuNEc7#V)3PGC$pFDri{fWXOk=1h&BGaZ zIk8dAW5RK?g^)^>LfeZsNK2u7GtnA zIg$|st?-BY)J2K`ys{ls zGl&xhEScCjHgpDt%4lztWFy0oz#xnO>}|!^FYqzzInk_U6H3}Wsd=vxuP8O7C-a<`Eizl^2Nt;)Jcx zqN57m7&VTVK{3+x^DJ(8gN`+wB_qwrX1rtR)uZ^BD2SqDRV-Ot-<41RBBWszu66RJ=W?5C+m|9+cx#); z46_QhTjffH=Dgp=D>Do$q5zBM;a06DGR=T_xC)o>oSfK3fZX^jSY|cT(u<3fef*em zZ5RX*DzReKe&;jT6U7-RLSh??B@Du4#Kg|qjW4nMODq7C)!H?R%L2Y&jHt*ZTfnWw z;Q=!-pTrSh5ntj!)}NSR1}qwfX-tMu-y;;F7d-j9;dmadAej=+^BL@bJQqn4-Q<&zcAp!pzRG9gIWJ01OaH+dP=E44)1 zPn`__OC%*ld9nxE5~y6+ls?4`P#E4#!b6f7ZVMLabFe9)KUw3Kz`R@19sV@j16@*nU3O z=V(?`NxW)OvQNh!`Gzdt8q>&~%#a|9Y;Q6)EO9t6dv>Og(j7(FCqbEcKrc4OD7}+Q zhu9Lf3YEm_BGB4Atw~ulNa25^ZNoyL)J2+o0XtTyCc)9>Ogj7pU5-6%@OQrnn5iU|Xv8qP<06GJEWD86emayyl2i*1 zD)}p4^3u=0iX8zynUBIud`{(&K+?;3%aXyJ7<5Fcm~%TXxgk~#c!~LgzTrU7usNpW zND3N&g{E|XS=fvjMA(hDVBrE%FT$3DK4bZ4a3ci^dVcdW8FJx9T-3=`iGrqgyXO(Z zLmZ4&&LGpQ9hIO8{Oq5@dNCHCjuI^pqjU>YD*pEtl88I)LnZevGdUE(bSy01@tIJ) zcu9na52sO@>)r{fzA4i%6%dUlS*6c5SkPOEjIV-FK%aYXa=5K4uISlGjIAg=0cHa| z4ZGy%fWYyA&zeJ|U(y2AE!d+vF6jq=lwm=K42o`CF{y$Cs6UK{!fKga(pEVq#YJP) z&MES-vtxSq#wCa!6FAu7YYt^qMU#3kks4h*HGXBLBls1*Tx?ih)ftQ@FmG9@z! z0O|eLI-+5f)mt$VMls&N>`!HQVj;EmlC_jlZKP^h+v;{}-?4M|$YfT?FTf`-r!2#D zKhmNKhW7*+7seMMy+!R9$Y2+TF_4ybJ5kX_Xd%$$h*ox&0K=>Z8u9_ejNobk{@(ln zNk}`v4;4`@=#)_0?hbbXmL5X+CsD!Jo0+^)ooN?gVb(A?9$>DsdU6T?FebZt4S^|;_Ji!*omCWsP)RhiJ;o*a|vq9>LXy`S~}o^30v$uY?{K8nwOu*c7{nD z(yd=w_r&?cwZO);nL5fQQRpz4lg=@M%X8W53DijRWLd(ju>$auDL%KuBypss4g}36 zAxI86dNIN{>30ujHerktciGQiLstk&>%5;Iot8!=)=4tH!NhLj1$s-;p| ziuCg@wkM0i3JWc9{wvIiQCd%&@V{=hh)qPFsP1$bb}y>5Z_*EiEr4Kkr(}({+|Hqt zoZ&Nn#67Ju5{3!N((jK1J^;jur5%TaY;tAI11NR%NWXXTW3IB=&Ns+OIL=el;REV< z(@PNVM@z;jU9YKsaxRby_{kxq^@9;QeW49({a|cPGq{fEg1Nyo1BCCQ6`KI)%?j8Z zTa%I@a=#UeS35<%-adQ+CpBXXH|+ZPsDVAQdp!XvSNSS5JQBEAl38&oV7PG1q!84U;#3sZYHpxYnI7x_w3WUlwUDEpx zHXu0f_k~YfJX)q$5Q~wswMB6zUi^z_Z$HK)l2jyEbi-{R=HfhHZf7bukBP9THg6Cv zw_5@NDf}R&R$Np;a4fDcRs9Hp?!^EbQ(`eXDp&-Nl*x-*VI<&*-_TSPg=)wc@E+*9csdw>+x*Yf z5A$_A%34T(1xpvIr2+3-Hs1Ze*6V;Cv=uP2A96s?c-OYV9JQP~&zr*)z2X!rNBpyp zhDBV&`;^`bp@--i3ArqC2^u3HJMqFtKSHb-L2^`eKoCBbUwjYNhn&G8gP?chSO57@ zrS$+T%jbS+Tv5ecJ&u8TP2hqo|JAuI-On<`1%>3eY3o@RV=jsd0*n~&2iBpqgh?C$ z+npbDVz*e5N|C&Ve!9`+qI@_gFu9jX&56|P9;RqGaY0wI zz1Y_Dt@r?s$mvcYU`iA$%q;6Hx_z6Umd0?ddpyY2^EwLAT;n zrBO#dfL3I(^(;cUfLlA<8cJ?kXcyIZQirBwelXfjUckul5ta%w-%mSIjB2ylGalk_ zEa2D{$f}-_mI@$6we=8YDbVH+S*oZ7vovvDPu%?e#?SjaTqr8e~Q z@8|h9??!4@!v{1B)-f6rV<^S_N=QIZbS-e$|T0h}^Pyd!!hlafYriG&Vbf3=gP<%mZdI;P8R!GYF`o)-)ppCE`P9 z99J){i3}0a?5qU1KN-PF8wnFKf~}FB*w|eB2m-KPw;)A00D<0=IHRErWJ=9}6^ux| zhFN`9x>v|jZF1skh(RqZ5Ly{%AT$CayC@z2s89<^Ftur=YIZ{2Qh8oJf&TECfRz_wAiu4BDzZAvrWk>Qz8g8 z1jVaprb&>Bq!AlrL%36=jHvO8KX$e}Qr4HAj1>keHS$TEo-*-%uFJcMXt7z4Ht#Ep1>@d>{AxAeTh>9L+rdCWVo{^w@Xw!$W^y%U*B&@(3~6X18`%N z2<;fOLe@S`k||tLMo5kLS5G5Y`@JocO;C%QDz>HL_{EUK&x;o8tv|uy`;l#FCdZc4 zoZX$8vSqFhmI8*Kc@QE~eT=)wVXZI=X_yZ5B> zP>>=fyd>jrOl?j5oRY4CNPD0F!xNm85Ku7P7^MVjk$yL5sJ2g+w;xeTL7UU~f+jW> zbt|PE9_lAIFfzPA;szgVR>&tq0dX_{GR&%7PGkpxM`{@I7pLK{8iUjVbYl0^IukS2 z#H}mEVoYTttZc>zBQ{D#EdcM^86+jBtyZpe-1pqL>> z@|LMpHIcNmkrNOsCh5crN`)xF$_g=tOGfj5f+g&m(MY9RPQxIy$kL{)}!6fbNbtKs~<>Wo@etTl4-O zz;YEl6=I|Ap}Z=%JB*ynxPge+B&EEL#U>`0XHjVeMk$bf$kGWKx{qL>-h4?gjRF;n zlmj$5tr+HavF|Ta2EZ%6s&le->UIV4hkUFid9NeP-)OM1KY<$wBQ>r73x(A3Cct18 z##(b<5-)Zfqcid9`OKFlQS-XIr9BV};NiIXIW3F;b zo>H`IImp8fi$DiLPNJS5sL%=+hlB?;7);{>9f`+u0X-%5qQD@;bzv0FkfIYWv>2hX zEko;qY`nY4>eW-O>zdQQ zVbnI7xNtIofHho(JrKvYTJs!GGjan3))uD4IYg^4e%gph6tOCD^-||3fjPgYIVoZy z@C5C?KyIuZVNX@ zGz#MgVr=DTNk_7`Wbi^`Pl3ryPePCkJ24^=WW#lJ&}Ndq(S)>;?%I*rHE};Hyso^; zSd59V$@=L3B(GeFTdJNwSsfKdQtC27ETbvn5Ts`1amQR4%nEi*YGu*=>@*dr zY^fE0bOyL)3CacYDhq~o%(8d&cw9SPjEXRe-Ag$J4IPkT578PA6-&sK#bhl4K~b@H z4i{P0^c+7(Tp3qh6c4Kb8H;iq=n=W z!(8epwl}UXZ;}}{!zfE+R})f((PW;Sx)-*@plO})$m_v7Ay~jE9}xX4GH>}@iJQsv zlZIJ^#!XuzUNS%$oBf2vPR&+eeZ7fvQ3mRhWe!-~BGCeWjM7eHR8sphv=lyEC>{n$sI62fD#Cb`vnoA>9A?%hOP-Ou?$$T7PCmxQGW)XqB zGRirRPl;)KTSYA<5val9@gR~XhdB8OvK1rO#h^H@`_}5Mqxu5&lWrH+@4`4Xkn?&U z!ex`an9=8mY!M@&?96zS0%VXGkgg)BG>7=kIia3(mk>gRhwNaa-GyWYb6}pp>^`<) zb6~mm;y{*;s6DOqR4wk?dh6?}8^*eD(-a4CTKX(~V(7WWVq?aMXC5lG$p|7xD*(%x z$IvjcG>js_0V4Ah6AoCHu*us*fun_01_m%h*@+JrG08AG*aGLaYk3BeUvr=4w_P}f z1qgW$CN_Ao0PWHMx5|?@h!>qR^r?Yeu3|Y{2z+QF3)abmI0mjUECv9RiUOv?kIXd? z?eTi1FAp9Q*C^PeyYUS8atL%vX^v`5cW7uYOBYo$z3q9Tro!S0CWNk=73dN}&8RRo zG|ZCB5Cb`*^V)hnqLpZ@m7Yt{pGNF61T#vSz|mSzFU#W0IIG*G@D^}O;(6)SMx`B$ z(1-yk6d1hZ7O_}2zPTo@Fkx5k`mI~C*rIwaFwmGaT8v(*pbS+(Jg!@5JR1T{1i9EL z#N!GRDOZuU2py$isu0^F$<2=2f+-zV(v&u1Bc>$#O*n((JB%5wSa-fdIjQv^&P870 zBvYkLRXBKNuJ0nl)aIftAR3@W`XD4LNmwS6sZ>xiL)zw7pk%wL=uszeJW?Y%rTNi2 zeiPe^53mju<%pq${EsG!3T*!{?>v~>p%IC4~Rgdkr`goiC(0s_P3kOk0*KXymvuq9arsjL|# zdQ5fU%Nv^7wr-GKbpg?E%=OSQTDHFQ*ShlovUVYcOM<*9BQ78u*cugJIpd~a)~c!( zs?%ST%E?4FT?>5%K<35cNf-p^#M|U51M;e@fV1-~K!#0*+(`F1LW4DP1QOMCG=2xn?pR(-Q+EET4byh)EmNrmvkfn zYMHolHj^=N$st}p($7GOjkq)r=)@pD41{`@o=Yx}!XSWR1zaGDJd1I+ElUgeYK19v zV@t%2M3in3%cU+PI9J1_GbS>IO{6Ck#9RGdMw#+{0%ZnBXVxLKLzRW{Igk;Z*p96F z6hK7O)8o~@eJbk2MMK4|9-ZtE>1=fp5@Z0&G%8NremRQ{N;aKs}kGK`-2!FU&Za7mO-N|Q2r3#D!jAqe? z>#e570`7S?;0HJ2YBS0)Q%<1UGlpcV#8M5YFyn9&mD`>C_n!q{V5W#ag|Aw0c)TH( zd>){&LPX4_HMmSN1T9!tASncDo@cZ{Na7w(?b{p)T*4E=V2Q*jiB~XSOEP%gY{W3X z&@e;wGG~}z4e)|RLBu0@6we$56?nBgQoF;aS?co?q|{v!699rzqy<-oA@3oU!~vRV zH=!|57KW}fA=*?r$JeE!+#vJJiiQZxmPRckB2l`QTnt!uj5a&oPrZ1V>u?lhZr($R{x^#Wk3|$9qV4V z3(U)SD!Oi4*QIedB|2byi5HZ0I>=`<3jk4xwPr|Jr0{JP`Km0~V&tX#I%(N@LJ+>* zCx)cK$w>)AD@FpW2Dlt}8TqeeSE7Da263m&lwXH`BVG!wIZ%R&H6ik=qPnXLhKaK# zZb9|Lie@A{(g_6S(Qi+nOcAoY&_^c+#Tin}Fg%@jC6&BD*2D-CbPl$Zt}%G8vK?&7 z!sS(gWWwwn;0;>-J)*aYT6!+Rfg%tB5Tt5dVmgcg1yQ^{lDAQ>tcnGbM`4ANt0@x} zzTv82$rpx(yofZB45GW`Bo2K-n=7JVCpO-{Z~$hS5tIqq0>f1f zu*t^y!JD?780J}E;))@*h`Su|0Xa$)f)Y5ZZKnGGj_uzI3vYU{cTjGgU2@M@L0u}-@ zXg|RWOfpvroX$fN#*QBzFQ-H@C3(LgYxBTj*8L{Os&~-jmhJVFstt4?`Z$zw*umf8 z1S&Iu(PcNs<`86yOAB#HJ8>W}bM_3+A%ge&0ZZ~(te2g5!x2!3AP}h<^?pK*ODq&_ zj$eH6tGT`mhrqGkDC*rTwa6O!jb5QRe+-Eoi_{YZ=5zvpVWVUgisO2OnC$dh&SDeL9U=wBt3G-4nYQPkD*1fJUoirUd6IOcPxY;I=9Ttxfh(g zK_Xgeas-u~QhsJo^Fk{GaLp8Z!sUS&5diR{SCAUqntRjru|CqlVWF{JzD?Q=`suSm z<1U$+MvRnl#4UtZ`??&oSPjK0>5^H=og{F{+Ybdky92P1Y#_iyKKiyCLGP&m*=RFm zU9Fs@5uZbqp|8ioXkGGowc%?S!*np@-TNGZjY#D~CpH2kyfS22Y@v4K#ZnuoYQE{b zSVrYnNJ5XyRxz7vEfvVJyGOA&b0m5~p+|Q}+AW3Ct(ctBR;GjBVgA z1fvtbk~JVZ*j6i1?Qmm?SGV(%3-_HZa<3j7%l0`U|~BgHZ+J&#rBQy~TzpfSfSdhrx&YU_9A(T=ne0f}^GT z^h#VGwE{$Ku~I9a^7L+#5!LSPZP@U+Opy}`chR^^2GRtm>s34-rkh&uE;hz)PrxNr z*`8-PwlKj5#rjAvZ(#K}Q76XOQCKX-JIMX^j?Joa;b@0V^b#cqmn7ayi@~wMOW-|* zT|`iL$+=r)>_ILY;I|qf z;ifd%?_cDWZAuZ#tq)`!k0Ah%jZN4{t1qBbO}!Mh*VUbuOx2b{HJU23Ij zD2QwBJQZM0O3bs_QaeWz*_`x6ouB}Opf1%>g<(S_*@&GN~jDdRt&?yVN_Q1=*}qhN12nAJ~3)iM_^MV$fPI0v}TG6#5Vd zI;Ihn(0;r^fJW>g!=-^P_bYlE57|O^dhz`CMV$&zOFi`L2Z#TwO~7P_O{dx%P1CR5=n8ZlWM3rXc|sPWA_*s+&n@QP_=QLx!mnH(X5hioWk=X zbzo42zjP>SJ*vD@TJj%4H-`T5D^p-%WpZj##T$3~rLe)F-q2cT(iLS1(ZuqN*)Rly z7;0o}%NsP{dnuJjn2O4fL)lQ6iBM(XR4dYTKQEYMm#C+XYGwwkhwCEei=4xNK=NtE zMwyaB{?r#P1VjHom|WK3G$2@WKi+lT`enbIJ}qn3h7C?DDHKk82s5bNS^4t`bc|%w z7-=9Vy1ct_)K87ilm&9<|7*lG?`5OBp2+vyh;CV>;*{1Zc}jx}|*5*(r*)u)T=9@CYe$k(A}x zUgOmYGA0;a662C)P$y3Y`%*9qz#9m}tv}3k(c0A<#YNQ2upA`+Fw?Qz&k*Bbh9QLE zn5kw`KOEA|GBGt_tPXZ8_b&4B0eNYn;;8%U{D(cWe!k?W4b}!O;8s*JN=Rl}?LOp4 z8ll$NdQv0?xR|JUcy6+Gr3&JZL)?`~Gj9|I4|d`U!wQN}%CPLwjl^M|OyhUHh2PFLKz5}ot3`_6m3uvPh?2WmAfw#lN!F6X%WVgCbhsT2 zok{}GN7P4gA~p^jEd&(V)Pjf*BAlH8@X?MdczZNsI$-p4oY|xbw@o2s`vfC7CmQjx z47EH`kzSt+8Sw_VAwe&H=&D?H4-u6i{IlR70X4~*n-Fe&Dh_rZ>xlnJCE}bx7;B6j zG&d_56%|v#c@c$81cibZ8X&}s)tq%>u!uq9=v>x?JwfXGz5UZ)#nR96?Iy?OVH%ae zq3X@XEq?A-WT|YaO5hSY1GUO=sg-(uRxZqLe*(3^<>0>ogt;^N=a9jKwgH8}HTHNd z($m3$QH~*WZr;&}*96#u0Ev+#2+C@)b4av;wK_^k?>ZCSE*7wVSQ4QsN-+kP(O{P= ztrd>N4F;#k%Q(nsoJkKe8 z``!LFg;8xGt!O037>j#=npMUC3uA#soDXzeWh_kMcma585a&n?lVS05lpKIS_?b-2 zh-@xb&M5#m#pJb<21<+?(vk z0(2jGX}`==ELr550}|Una)~l-b*zHp%HD(y!G_9GiNO=G8qy$0&AsFd2M#mvEwOJ*xh!z z-DrZW)-cQf$KB2`g!H!DL(JBJUud`?1V@6zOYfk)K~@;JFC2`rnvCX=H)Ij4MotU_ zFPz{UYFZU*qM#FRRf`c)AVd&2i3~^WQvSDIXvurW^#dJ|bdar?O1kW-#YQJOEIU-K zbS^YsFx3M{OL%RSPE6jVp;BOEjV67Zl;q2dn{tS;GnJ{p;r=i97Xx%=a&%%?1t7J6 z0CvE+L{3^-Or3&8=WYgOKPuqC)7c(j0$yM_Otjd0AyFcEWlty|!ubMo3qWAG8l(dh z#5j^&{Q#;6m{0`)T1%b~1XL6#Aylad%AMBRjcYF63I)ruswNAo_A5cP&0=UF;#tZQ z8{e14!cNdciXYN&mQAXx1;_%{x`1{$hufuraeGXAIG7_QY7pow#-y#a53%bNx@23c zE(;2Cl)=C#a7QkjY(_5D6wtL>b?)vWT^7l-ZMvS^EG;)o`tYU?g|Dw)W&$hR#%Y;O zGh-peM1diN*ck!ghcICU7$e5r@mWx>V8NM;u{wb`0^rMx323^6n=o5(wdp|YM_Lhe zKnvJQ{`Yf(nn3v7R=U*7jgn|z2?W(%B^+m3ln6?Yfg&;G!3<4i(=52dn7A_ z9pnunn#_j7;7Uc(s-ArepP2eU00B6R0D`uML`JcIDIQhFsa$~cfHX+@7C?#-oIPx%oQ_6>~+?h zK~sR7vOx$7``CwgcM_Tq>IcLr&8@Y_YncIBhHj4=RUU>|n2K5;MZcPCB}4?8UVvwN zX+e$S!tg7HKWL*^aac&ouHoKgO9rw6q>U!1jezuB>GC7pscBS3-p|?AAi{)_6SlmY z2wk1f_qXvM8ewQI9EIe%VgW`hvs!p_D5kjIGOsa0a}o^v^&buTF0r@dTHhl>J@v5`5 z2BsHzuz^p|LE@=uTK%+)skAfUw>xNd#sV-V$I>v#(J=W4r62`e zsm?#4-G^$z55WkPF`iW=v)b|O=f&x^$e2JZgeN4sWb+NQXiuZgM}m4i%Eo&ELzx!* zT>ZxxN(KdD1F4&g`*VB*)s7>)BcOgJM%xLjCn2;Q20+9 zgl~HC{?%M+z8Z0L$0}TF^QkyvvmOeX6Dk&RJ&WcL>L}=&-netZ5RoCN)S}ms(o5}mUo>{q6 zDQsKTcu2bgvW)QLPdu2D;JJD5+{+{_s#w>`@JV20nk)Gud-Dp`M(Ze##&s7$Gtv`$ zifel(EqD+Gb?*G24A#I^0#qoj*7jm7gzPUo@r?sO$e;=caF&XPT3*!kUI?1uBfYgm zMv6x8Xkbmk-|6}%xRt3Brv+mxW^Zekgj$2lH6#b=GQ%9t(o}3x!KFE5;KAVx;+gDq zkPFQVfp&ju-Exb^8(onn^E1HCU#*X&HgV&I(Ba~h51=p#;UpGM81VeawmKv)DHdnc z`_l^xS&6A8F_wv2o~Nz6_pTRcEmZ{(yR8497vCv$VMOT(EwA*vM+^t<>7x5*L*|ua z%lBJ%G%t{j*Dupl5Gr{IOK9r@CIcj8OwKZBWUG{n>93{h##alrJDF$@a5~djSuX0Z zdLiak_GlKd+~Z#Qaz<-M-MSe1=p(23h;kg>5==LNo7nhZUh@#1s-&@Y3dk}yE26i5 zDuM}WBSicZn}DMD3c6CEQx-VEQBf9mhDExpTt2f<35z(hAO<63IG}aXb#D_94a^q-q* zCk>#Wgsw(jg}v0_7lS-1&`kLU=%<>oiiJSeB~6FPP1BGBlY8q=t0-m&JGWOv{J)#D zkaJ#BcmebIwZdSUvb)JUunZ!EQf-9O;Xq)cU^K%oyI!HV;1ZB);%Q<8;L?CnR!Rve zB*c(8RL_GId|yDLArNmCxLU{y_PTb9aWXZQc z2U~QMWoehzb{B-zzIa5VCJg^%*ps_3B}tQuNqc|s$ixS{^$&A&M7i(<&Mbtxpl7xD zWgJ}o&eD(<32w5D2q1fgTs8@BLqBiN3j$* zYsRz1((DoC4jhZAmD)_$PSOvMQ}N>XlU=7#$NSmO1L12*a&5^L-k`on@y?l2W+G)R zVr$x%9#>->-Jzmgg0Q1UZ)8Pu2CNQNd5%@=15)9QLxz99G&17iQb7T{u_iW1)$<)b`&qGifjVoRKXb&df=*7?25r* zKr#EzUgo56y2~|4607I--#>2m=Fa-%?9hX4 zxin~1DDW(t#%^DF`&l7mhQdD>BAgrd0xpy3=%&_D>HcoDf!JNy>E0A?^1~%wdlJbV zz(O%Q8AqaN8H^PcPa7qY2ZCaBl9|HC0--wsx&gxdP#b}(G8gB4a8q>gO|m6BM(3t&QVir})c$?io+_!)~CkH>R(gI`7L9@6y; zX|8hUXZke4X*oKUPRDY@ahIX>U4NeTgjJ%`J+TjOt)`0|g(A9Z^VH#dh5NfR|g|+efL$$6{ z>b4*K8g9^N6BZ@n)CO5b1yvk^F+EY>HR*T^?Tb-YlWK%Lh&7@@m$uS3JiY0DBbiTc z#mcoKc7u#P#0nxn!j#Jqq$1wRZv;E`o% zt{h~wIv}R5Z5d`~`P)Q-L%vAM;^Zu4y4BSB>OQsF;k;O~j5(13ldm!ntKdV=zjn{lrlfBZf3_awB+!il4$r7v!Yb=s zP%8`A?uI@2x$K<{eNUCDXcwKzt@kiVar?f!Pi>A-T~{^9{h97414erqJ;LaWFU3r! z{M9qZ{He~tt|I}KPN3(AU7{8$WoJPkQEgTsaL#n{@k&0^G3sQyU=qtAp7Y0}r-ly} zF>{ycKsOljFe~9JP4Ek1!qaFx<^)Gns9`Avelk zh=`B<3csED+EJz#D2oWj#Q}XgPg@H`(ltoyYAqcvmWY`z)&q2$vym~qVK)YiVcdbu zqwhn>o3*j!E{_H{@)idooz5Lu{r#Q45ESA!6aRHlY);L^?2B}2e4k3B^S&bHwPWXUp zC|O;HRw$`MV6$5U94UoMB4P;)CjIwYmstcK+`T~bFj&lVvlV+G2)7UMk*}G4YVD<& zuHo2|?y#uu=*O}RaV>9COINDiyYcGTs6*NMP_J%*F%oB)OfT83L>)rdgpm?%B-@Vc zvX^c`ql_o|q8&V*86#FDim@-zjiBBVJ%(--6J64hfrfmy)t-j$c*;u{ly4LZZ!@&&2iaEkOl$zoGF8Yo1kd)D{8C1X8 ziDx>v9SCrC)R0$ZRGZY%VPgEj^p#m~GAiBy=CGC(rX;{vhJ#A-B1peE-uqmHLw#?-(4q84pH+A=C_TUJXMZBh zdPCsgzCd@+sWj)L0mMrtgUDz{HL<*i!t!Br?cf_H!R_DEQai` z(+sWQk-OIR)k7HI?hi(?BJ+?oyu%=aK1O|f`rFV+!TVxU8LQ#z$mZ3S*pi6uSV=%d zA}G3?vOOM7ZbapMkW->hlzdv^QH?1v>R3ic8lXsQ_Enf;Jq}qx#S{i(gxK(;Vqg94 zDr1mT@xA1x-7KDpOaWvOEFa0S&Sf`_@Ti(sO^O9e^bQ9Y*%6#zmb?h=2FlXk}X})Np5n0SK;n7uq1(6V=}DU|1FjK<2rE*+7#V>HIC?*!qW?HoZnY7 z^aCh@>_#*@g+Sy$_5g#KK!~aMoU;mf0`~@MuljczE^!`0;fT{OH)^3jpT7^&Yz8UM zMiME!jO*@v({-#C*S#G^DF+Sq-;7g6!{>_Yrz7?1RJ7Q;X2H(&Q0GigyR!`^ArV9} z(l${9Qrhss%W8Byew^DIRkgbDCITRrMU1*Q>wY!aE70kzPU|q7GF`PsP!ZDjy%$W-1pQJ3S_b+|J5A+DCx zr&%vk^Z=agZgyX;}@_fyS22Kh7$ietq@ex zi#;ddyT5K|;7(l%;Urr^vzPTy=#Z`i+s!oOL*Du`ttmmjGf>^Zu1Z_tvHIXLg{GE_ z^AQYYvK~o2UX$>}SxUU=DgAYqF%VT9JrwG7r-z8XF14N&x9Rk^8=ON_1R>Qg_fz*a zLw+Y|BUGVnD^~cQ+TSL;eba&^kX7BO+`e$NI7c*mi$Elo+}dKJCh{YLjA@d1EbGW0 z6c01wr>kczqBxfN`m`oR215Luer^09yGb-NG~IL5nJ|ama`^)Wk1;z+(!x{5ivkOtPA8ME3Q(R9@C4;s^ zqw(PR291zjx5R+%&v|_b4suD7&GRD-wXGOs?>WGvNGi#Xh(G31pdJGfULCKiYKimA zMTW*2tl;l@CAEl}bo-;t<@;NZPoBHn>SNMbS7L-2wv)je2Z|OD`k+QIYwI^G!2~~9 z*xzOnI8~nlz3xMf-!1;6J%mb(fPYlJ|<5I1SEOm2AyTupcVxB?DHszeBd4&}EqL>VfaTR8JA z$WsxsMx3M@$(0zTMd%@4a;|%NwItz0=1vYD%JZI*z<{0LA|A`l5b2mF?!;Pe_5FlJ zn|PLwKZl7Sgb75~cHV}r_Wi1iIhvvM^1j0eR(&2MKLf@CWP*(($f%p`Fb=^9+L;a$ z=a`Ipv1)9y+N^)?{0MlJwgy*e=|(mKhb`cYo}a$#{s!uiy`rZ>5Ih?}aFX3tct7=n z7rKd4&MFHj1*5eh`CJ=CuStX&QJM9UB`eG(A<8|$k`SG+|C0D-7)>D^j=?}7z;Z_B z2rbxgXd((Z(0d6bsft)8Bq=JeFe*oM?8r=~u92xV`5ES zAmaylm>mI}cvnQ@O`;}?Ea=aUZ<*ssaDLrcNdizbG}U2Lv1EVY`DIsUN>M6I08-x$te(9J_d1x)GWjh9?cU}wJtM)a4zUws|X0Y4Nu2^kdu z0L4+DEFfwgP;e0bILYk4(@&nCm(T{7L54E7dA{ZRCb*jyy`3cUX+G z@zI!{YRL84eQR>6!Vmc%%QW`!efv)&DmBj$9yFK`8U> z{ziM1xmmvLx%%$;I(jd9y#M@u$r-z@ML5)7^j-4}eVYG#{*1xVZ~boj?)qBaL3!PN zs(Jg~|K{k<6sZ25`pWq1xk701o$HzWzW$cGLHXGKJNBB}?zQjl&;GBo@9*!7o0j*$ z?*qPrv#XFM3Uw#8GVYGNDyhXyI1Bme6H8@hR}rm*igxUUEZv!(vfPaY~k0Ed4xe=ei7*30-vArSLG;ZnfinbaA@fU^_KtwWj`Sj_E#Q^VJf`TNru%>I?yfnv?8w)$}wK! z!cv^|Lv~}6z%qZbP3r*3Ow3-*ai97my2xmA3;e+jcN)r8G&fUkuK0h1iKHpF_-8PP zZK+vtLyHmzH4$;|9a1}G^25_5hh^vb!?a{dY{@OGsjJS_z`p^-)E99GUZ0#5XY+h| z*RIM1x93*M^05A1(DnR1e%5ZZ8iyS4gt`#~YejKf5mDA|KveMWEYVJNrC3WKPc8mV zl3l0W!(QI2(z`FE~Ua~F{V-||-|{U;Ry+9Tip!b6UAA$f-%QW;r~tkL-&&=h&L zCC>=Db^}!c9=-qX*)5ZApZ=78p`{42b-VGcbo(<368EZTV-sq9uf|VqD1w&*V^d(5 zX?)54{Ck65L$vrY9W&^?cA^&YofyT#I|q)Ap6uLk=n2pFzwsF}k%cDxa8*EXW_L~;rjWJVztsK5wC6(&yZf8wUW5g^9Fo{6ccrS&hSb?J*goK$YKLQ)7X*u5w| z)0F4_Nek#x6QlSQ5wpC0VolKPYrCthiW>Gem;6LAIu2YJf6HB6jlW(B|7&Dub7z*n z6RhD)so~JWf~LvZ+2je01se$7aTp@dX${hXw0_`wqxouQ58zu?z~TMx%;TZwR`d*0 z2KlgofHwp~-OK}!+A&eW$+{B@A>d*6@zyP5#Ufwh!2M51{#UBbKj~+ivZ(-@*c-)8 zVXq?ez!*IyclVG9BR{IV8n-F0pBV9{A%tfnUk3vze(GplHQ6crSI)yhjbiRy4Mp9tCjA9l5@v9ys}xrRM-n*vbvyq zOf8;w4&kZ{^>jn>t70e`c4XGFkH}N|d+!XJdu*!he+BseQd3TqvW`^Sbi1Bb$v$S$ zpA{-zI#q)Pz5Jqt2YKKG3KNFEH}a{urk@Q+{3oXwC7V(xz{U$@FI}u%e;fng5kU0n zNxYpWe2qD2b%j-teYLqmqW@vOg&MI~UN$It(Il0y@lOF)-mQXL4g_co9qkNx{dyu!XXwcslT-<)A z*3f^HBX&EZb|f!|+WEhJ>@TvhesKVpX*?)rE@-lJNvX?6g)?vk!!~w1$PM8IfEUXbMag+gR(#_DnSb)$ zx?Wg)sr3hXOq#(1vR5^xkiWME2hu2NsV0-vMypP5T~B6jrOHP^DGUNclkxQd;tPBy zx=j%p(*z$VgqHexu}rM4zFOSCX#wP4{`rSfXo`}7mCwgyUtBcclLpK4?>kbr^0*Uj zL>iMx#aSmdK;e)zT(!~35{N!9nS^APOz*7-S z30gXZfGW^^MpLC2k-Nr=wSBLb4fEB9&f$id4B(YiC~n)@Tk=eNQZ% zbqN`6&^ChwhQQ|iWE4Ar9DDwkDwQQ{)3WdAnd>_fbK|-w_x#5FGGhifjXQP7^Hb3z z?eT375}(B#CWX}Jjr)Xb=^35joB^j>{G4%94R3G(^(Amt=Qd-YZ^ul{5ZJ9wqz2`w zQyt*n%o{N%|B}Z-+%YW<@;0vwwBd2AxIG}0ZY_pAywxhzwe~XxZ${T7 z<%%{P=|iBAcV`TTln6>zB;LQ0r!1`35SBN0`WGRM-ws0=1XdQgYELQ+(;eO-<@lPwN->J< z#?~m&wbqbpN5Utv$~~=~A>5qn&<$^S6bGyzo+4Zgv)`Dle%XvvugE`?Q+Q^ijH)Mr ze^;f=P{_l%c}!mjJyg7%W>&!{>jj5%Zp#i8&IPZ&+*H^*+{7&#A;0S{}FZI zOs>-+;sUPTm{NCGQ5r5}$But>8-!H$Rm-C$(5a^(z9cDxAC<=y?_w&|wqVF;d9we1 zNxc}NF$CW-Mz;dRl`bD{G>eQA4|&KvU{tM*s(YL&o9BISF_~rK(#n1 z08&2aH0KYHih2Vgr_wcxQ8ozB=QmQ6JkU7|M_A_{CjU~46HWMIqSLg}cFL5?iE}+E zA$dn$5G^a4WZc`Eg2^;-b>>Y09Q#~lM7NtHI4h`@zOvhgR$C2s4-^L2NO_u!om4a; z+drzK`b1r3xS)tTOgf)-^BxryVv(%NACm+RmXLm=<-xUl<-6t5`?t_A@zN9U1iuBx zde7E-;rsV8&3G8u5e}@8mO`=k=ED|`EQn#kk_YNGMQkaz2`4sVu@SGeob0w%M0?C zoi`-S7tqB-#K`T`s`+2NG3Y};*tRPUCc3Gnb@zZ%BZAuuUH;jxLqC>^QJJm}1Wzg| z%qrlNI#Y>)agKQNGYORq`d6C&6)J(!0$yOr{#J1Ig7@6t%OkFfJbBVg7*(4q$4F%S zvZb-z)*!vuf%CF+8}ohS{h>NPRMSd)WB%5&kQ6*>is_*U74U1zI#;}cv|NvYqG6y# zstTRNta!TFUn9GzxP9cKP5h%+M6|pbU{c-5hy6v}D{)&x%WUYn_%6-zQ{2G8e&dwV zz0;94yB@2=%mc@ZB5p(=MV9u6nMJI-;T$}L)uZZ2iNAbZt0PIM2@*NDscT71_rPt& zrOPjA2s&WlIxY&8#^q|IOo43-Ep3BLIUD|`3Rb=WYq#wDO@6OGp0a-}U~=2!0xRWm z&|BlXM8UFK)VcEYU&346dSL2I>l`je>z|JvJ3qkJw+$-bKMx~^nhz;Nh#LmaY0z_# zotZkA*m#oaP&|{(%N09!rh2k-E`B_LOCw)5-P-D}CF!_v*p)%Fg(1b1ZkQ{R15O?= zc(S1pC7UhMmWKX1?EO~kL~bytYopz=D4uf$t?b($HZBhs4|j!Ii9l$dxmZ%!GiQz| z3hC#ANY|1$zi9NjD>AeB48EDmUYYAjA^KI*Ou<4#LMBe*A^XqPIm+2AGvKI zqGRNG9>Is*0oW~)IHw%}uZ^e7Z0%53eUFYL3fNN)_~zQg3_vt%xg`p2DDy#F+YYy^ zOw5{m04`KV?hkg#p5DWQ$T2oNU;Lq_$K|sA|99x^M zNr6?eN91b|CJHNzuXwc}rQy*=1_6Me-54&I_&K1$s|nbaDi8Pq93ixtJso_o=Q^8y ze)NNQ*D*~y^VUM5Fr>U4wJKU7q(-y0nudaYK4lFM(J$@~<{z(vt8&P!M&RRUq+(yI;@0HR@#ccer-#`|y?n0BOW-p6v3( zVdO#k1e75LZ+MWx6R~&`?D*vTzR_lWF|MAF=sV~o`BZbgchcJ?j<|fNO(H)1Nlao4 z2N&;y=8s8x#Dm@h!2FO5>U5W4QFk9qGbnA`;L{g|ntUSpj5#TGAefb8_}&h<{=P(2 zWKEd+{GHF(-uRHwYqi5Nl7~|&9+_D~N0pyZ^7+U-)?X`vDUUFVl*DUyEYlLtOF$x$0RZqDNa(I`pLqY)k+_n|$7 zU`(kEq`Q4l{$ap49x(j}2ouzePO5bmoh&*FYy? z8TSZ5epC?WCQ)`)7?qtdtMgqztbOEQ6Lq^Y;f2`GNXsrSAxvtEOPpkh)&`dn4TMbK zB=8})rAIWG%%QqCSs!wUqZ0AO2`^_6H6wGWJ)`jtv#o>e7FtkZyx*hSA~-PyzcRCD zPHAK6ZT`?-;YO&lGYQs##uC1J!0=WVcW?8l=b&IMZwhpSUI75TiC)(_1e}=}7!BB$ z-!SVk`{AP~2?6@Jqv=o2zV^EJF!_|xk?u6IB!k@wZ#-YwJ$2#bUoC&`=SPK8?|o1O z9J*ns;^xq?nq$5f)&lIK1Ma8Ut1)CJ%lV%@$(qcWExI8x`U&alugg!M2=9@hk%Uj# zC<}D-&O+iekZSV%V8~hEW5E()NN#-U`QG7)_X|e0Y9quZb;@@|k62ERAizA?U^}6&J8c zJ1xJIfKR;9a|$Q-hvoAcjg0|tb?3)dl3p480LpI3<|5kjeF>^bMT-Y{L%qdUV8l(S z;Yifu4M7$rA;QuoMX7$X_&hxceAX?P%DL=62Y94%08VvWjTn6=NCk`hoD8O=sm$h| z

#SuV@fi{9My0qMPU5yy??9H^9brH`1FW_u%6vD} zX5UO+SCA)1Erp8<`k9J2;xc*(kj*wR@>>-QxqApAUCsee}t#jR*eQ?aaJF% z)5%d2eh6M%W6~4I6ec&-YCP`K?x0z4`2{AecR$!iA)=%{O{hsnP8Lgix8-a1^YGhI zDB&~4(sJDB0(mOpX=KrP@2>Zqha(AqC>K?PF&}$zq$1-q`KR@148BWV5EgucKa)Zd z70YAYu|-$U2m*#-2ySh%#n5RD(YPW&ZB_?-8;AHZtT>e^6~4K8#)T@&5^lw|daL9J z16z5G)|07orHa&|WY{lg@%VzisJ(q!>b1XqBg66m=HzS#jXZEJk|PQu%{!fxa=CiTa4E8>!qR-$v7<{oup*kV;j) zYr$U1_Yw3%JNdOeE`?^H*`UYTS88lOc#bq0mEhM5QauK-du3bR^W9;hm7T~C(Kb~) zC1&`(jI_^N&9&{8_`%$(_*popxigAhpY z)ipgvo!aXuc5_<5ekC3NT@c@rbv0-pdA-cApNAIyb~iHH)uQipJpJNWyKN>73zBw6 z&NmVmy7X}b%Hx64iiutN1ZTZ`^;=rW-A142p6?p%9`jd@T+$}-vEu_UqC<;Pj3qAD z@NAcE#i)>c3rsRD9j(kc3h?8i^GKsTGaPH&W_u?1&%~1mLIjGCnU9qq?nYcr!%Ue4 z&#JRcyV=E!qHDd^1$+6tAdl@|A?yy9KSHHbYXwY#d0%W?lG_^he0Mxv8Gw{;{C=~P z$rt)Zs>*(gyobY0ipbtSf=I633&CdBJIDXsFM+J2QftZako18Eh5PxIEvlaOqf&zw z-~}hTK1z5cLnYey?q}+C>ctBsM`&IOkOqM(DL*#4z0*Nm4d*#2b*IPL+y%xWH3Q)4 z=du#fcI>vQT@ps{e<`{kQ6(A`SqnDhFfe-W5VHv#0U)jzCC{zQyEC2p&BaB8b1(7e zJ&n6+`I+UpoCJvFeq*Q1AO&assi~t?q05dD7)sZc1OU6tRv0((F|tj67`x-7wfqVY zZWZl2B@P4UYU`K{kFEfZ%F~_`Mil-Xd63^L1@-G@SSD7f#3V*q^4=LxK#PoHf7t-69WMF#nAO4)=u1%PgCZZ-N_}Hs!5B!xL z#dNC%Jxp}E^jj&&&u8ZW4F?Xy%}tb!YM-ITd1F%Z%&V)6nWEzF#@u{|CN{HHn|-Z6 zG?h!3og5HuKDJ2&$dkyww(Z1xAmlL@goGyxqEZ8>p+4OPR=N)(q4xI-sPRGV)#9$w zJc-r{5-TQfxxM<=+sH^6wt10`t2?D)sKD}W@L8$L zL(|M_^dtMtx&cPAou&or*(0|u{3d8Ou7{%@ z=z$$%vk_4PW@8G~CnfI9%COwmueYl_x4EEj{=TUK#%ZJk-nJ;RfIemb>VwuxW?z=I z-G>#E?{n%fRcrgWgBVY@F=-tt6a4u-Ko0cSIw!BMbZmwrx*mCoIRWadRs6JPXIY`S zqMaDLdgAUP60d|mou}9u7a*`ts(I0}ucAlzvv6D@!<;3!O)`7N-6ndP5M#D7ZQC(j zJYGPER95uGIkX?Myio_m4}hKdpb^84HqBL2(hObcRWcKKk|jgywJQ=Eft7wK3=$G9?q*bt%t{B61+E0lJd!)Z-Krc|uK~XN&eomIzrdcgA++ z$kRqYL%hLR9^q%)uE{iKC*$~Q5nNMh^~clhMmj>C)H1`MoMVmll#tg;U(%Ydkr3xT zOzCwv7^mQrn9BuleV157!4L>#euUK_43=%WXwWpbkj#BGr+LsoaF_qZoJF^!<(Ckt z-yW)Ku+dQ64meiQY||hBg(&FL`h^TJ4#eM`4`qsTwZl59cD_3g{Pn`aeV*N~RivK? z5hZ5Wrcc|gU4u>#n4R11z5O(1v}7#mRdARRK(|w3XA@Qck%!TW(S35H$S=YmJkcLj?jHx|~FbsV(l6WzP}HXoIRm1q;l;?eN4p|o4G z)w+G9BJL7&0E71t2=l7RlieXUHk8T|+0NgjmyY4@ijVW1=z?M!>hlRBK#W{|Qew$s z4w{--HdE190D<=;o3%SF*tjTP;@Pil0UXN3TZY6e@K#I`qWeKtRp03Cfp<%Km_cPo z*h!85UaxJ0fZlciuDW)>Qx-DURexYXQ7kvq(XjP!+<~*5il8a;6|3VKMM zAPVu^BS7jR1fh8qct&DlYL)U{c6VE!dDLI?CT?fBpI`27;bYZ;vWXS8F_W|a7?xV{ zXP^tSnKA9~N!r(}WI3WC6v~dsG?X727bw1ApIj}M#d?iianw2Zm2K7W1Pt&U%Xyyz zb6sS#P)SQM_j9QsuoTBL!LYPcsc#-xx1t&YRnT7ZlhBT>(QPx#DW;`K#awv(s;f0t zc&G*0cpSZI>xZD^!97&nj_{d16Y(y|k|8sU&ax^QCS8K^mF{HkPg=A?!!gu}Zt3Cs*wU(&N#^Q1@F8~1Ih&KY-kwn!*h zhOz?|$_)$DzCZdppl&(}XOD6nfkY4DJoqCl;AaiKInqku*+66gbPqGRQ2XnDsNhmd zYd~EEb%Nd8C%yOwM6$gFxU|rHs|@pC3rhbwycdr^Q{XxT1LV(|%#3>-t+BCs-M79$ z;p_=ur5*>uNQ)y%VYlU#Jv{wA2-WZfr*F+M4!YTEp+0x`V33S8ORh?PAzq zim?hCHg3K&191*k>7IbBnjMNVkHP59sfE?h5F+~u)TMr+k5h>Kd`~iiBmRo9^xvT4 zW~a;MtIRVNa9>y{0xELlOooBAPWPljyQ}G*L!8~N)H~@anB4Mg~Lm~c*IxO|S`AiX2yS@AV6LDwxFXRmv2$F zlm%KYLS}v0YF&i`!C(DWM~HEYo*7+p4WICU&R*c1YL#A@kI6^{t1U6EzlCjX$N1g+ zMth^x!~!2gCYlewZSdDhPSv_SV*aMzah7Q-)MJ;_Dw_T)iOhvGoLFqeM1h& z+WDq08O~9H{1^?}F?@SBpr9X5amXMNU9Cc75F+6s^qn$BB}#C7RQl0{;jxN$ z?%$>~mGv2M+N{+a(0<*bq_gxCg(s^qag*+l@tILaetZui&t?>vL(Qa+gAT57Q(b>Z z^O7UZg93h>I_aVKS7*8t|I{&Wg&U&~DHqroA6Wir(1Zg<@RNI-yz;tO5LWK;J%ZxYR+qeO^fVj@rKu5XM38KC%` zvz1kRsf>CB#?xq258ECdxYTg>CKZ_`k)z~QBm?PGE8d5ACa?$g5 z%~;dYc0>aZXp9nXG}VHgCNF<|{nj&GL%9e`gHMQ&b)=C(US}*F(K?0vJ?9H)X&}8+ z{1K86?YRb0--q-ICToh_p_rMoFdK2!LXzz~c9ryo7cQNxB%DtzOlO@6(7IfPvi=C9 z`4Foxt$@%v)j=i|B>=#Gxr6o&nCyx|?ZOw)X()MgW;yKVTF?Jspp;n_@Gg(8SyDYd zRait&qWx4V7hGQ^lUG|jTz5hqwzQ?ItW}-P6IOTY;dgJOH51l=k@OhbZ2;Lm57pVor4lb2UQ1r6!y$Dx)vp;{Q6pWx`2$F*mU`idUlG>g~r7-WRRwpSxu+vL?x) zJkGwP7wRGnE%g#CyYfS80sBLrN5)=#zTCk(!Mu{s5ci(d3Dw2aPwc`P{K0rs-|m-j z*;Z-+=g3UPedc9&6bL%}62AkT21wZv0Frf!loXPk1%xBaptB`kS7L3-&@yIhZ?ERJRPVUBAn=Z79@mP9acosxw}BBy!#ft zC1jMl02EGtow$$c7 zan6)6l|bvA4r&LuQHqj1lPC1WHO{-q^nEyBB*h|MwnGOrzXDED{v1j$W{g0K&U}$> zZ3yX<-@a_q^pE=x=)#E%`g-7(J}W?$eehR}ywFE9uxF74|M zTnP-s%6wo8Il?L^cgzMm$fcv$YJ6o}pny!d^UU5Br2y)TheO!j^F*@X0%l^<)Zp^l}!u2RC?uQ%RHBS9Q zE~jOV4CY~j&7+)FOydfAQa~)y)1xqQmQbhymXbRmz{L1vJ%de-c-9pS2#glf07xE{ zq!~LHie1ip^}FsBsFCAGP^%P5>)BHi(r=6|El|fqN6oZD^!;b51CRh-qqr zlrZ)DLDDR#z*HZ?rfWr`&4PBYX`tw`X6EO&#({BD2BqDUsQrOsWo-b+vP8QFAH8P@ zj@&{>_*0g^!c8kUA<%t?pxUoP2>^u!J!Z$;uO$wNO_SXv?FdmuFJ>)1&G#Z^(*$CK zYrvxAdoE@05sm6clBgg1er@_J^_>5>cQX~rtmn}-X7>U5&Y_niLD6Y&1rp4v?)5v@qB<0vdqgz@q0(-Hu_@!kEoR4`5VALe6~H^dKri?SI60`gj$(yg{)%=rZn zJxfT0#GDcQKL+!v zR_sGG*1bPTye^bovm^GSy({cdZgvw7XI~iQ)X!|MpaycM5~eE@>)R84-k&^YtByN5 z7CFxX@7Vw9AuAZLI3`TUKAJv$5rS&hlZrRNQQ87irC4u%ce)Rv<%Z99KrHYZ>UOxo zC~oyZU&FRUHJZ)Qn-OW9}MH z1}G~knl#s%rqrvTg5MATajnHIQ(ofqDi!Ss>GYUqDoHT!q5LWO= zs~7FAtn=5Ioo;5^Y z)@SQm?vF!EJVpXiuH{Y%xSV|}ydg|hvd0s=b_)+iZ51Tc@t2%;HSadAvctQaD7Xc3 zo-~#$Y67s}ja+gLOz23&E|Nk*y>q7CM3Cc$a%KIOLA%waN!y4*Hiant-S_+Zq;&`o ztLp+82B=DWS_kr~aKykoSUUQ~&y-TA>NjPKs!50DmR^pDEQOq0f<*#3LR^YP%X)SF z-MnMv^Dfpe_`p+cfi68@x4u|H;AKW_p}}C9(bwalEyi;zSnK!y6Tcxq-YS2=U$2E_ z1<944!8mn*EH8r>X~d|K^4%u7snjXvZ?4=D`P2=;5Jw9p@k@^gEISNGx%(Bhv4WQ+ zj~|Q0#}Wz-J4)}Qz7klbN;psKM%&yAlW>r&5vhb;<*C&8+M=2oL2;-U!s5K;$JmdH z0P(bnNy#kAMCcz-^$5DGHU#9+f`WmX!UzLXvHc1GTi=)PK1930IP8uCFR`HlGn%8? zQq_9UM4+1KT_a&w&)Z`x=A<1SLsemBwH}F&GE^HEExTgARBzS)Mck@GB@rtm4=$qa z_}@`WQhrsc^psPe5jX$x5u=Q7C@2Y&AU`)bd284Z0fM|2RdJ_L2u@ffVLj|NDl3yZ z&O6#50+Q}3fhuZ-%!yAfi*%s8`9|jz+l*gv4kQTIM5UMDbiX(Do?lc=rJX3T_QK|Z z8urtP>IrgV2wn^_L)A+nVTAVm8arU6Qax9GS4s~>;!q`*4kMA6(Mse{pA#2HsmGu- zaW|ioX`~wzC9B;8s>s{AFlK1g$+up%&JTCBxK`eS_v!x0s>$GWmkAVprxx!iy?S+r z{>0x^dfWZ?v=wCL79Yv>CP{Rz z=GihG)IzdRtwY1S2ZAJNq$=OK8a1sY3>$Spv|dw&_x09|yXM*^qp>&p?Vqbr^w4mI z>lc~@^@_(ZSdpu39ZK1C+iTti^PDQMKyryz;zCxW!jnfcy`i3<7v5P5IO~tK zFqg-rlML=E@byPA()oQT^9{AU?2j;3Pt7d6b?QW82sH!Q>&PrQb|CQl#{V$2D+`We z5jcIIhHDdZo)f7+SclmRP>wgt%eL?5MfWc{^Zu1`4gp{M8GJICUh+n?95%wj;x0dx zc)#BOFQ}qf@3J-K2k~HqX4RY85+@@@+QiM`D+kt5>EKJf+a+}{AI~=w9w~(}fGOZ| z|69yZR_C4#u5NNCFh71IXIreQBfHcxpzUD>f$1Tg^iMvJ?J^a`a7Cpa0I%+i*WEFYkfjAfctW`?2fLZtTyd ztcUUsf1))7*N{-1psZur_B6sxh7R4vcO>3$@Qt8yBB-ak5Lo-}R$T3xg0<8R5O93B zkqU6x=u;}zcznlTheQ7eQ+mJBcCT;{gxn`)Kl3iMf>Wdd9V8rfhOQ<&EkEAKUWS4U zgSUe57CVkLL9&mIfon_ZP`H+E`?C9_-EQ3WJG)EY7?)HVtkcEo4!LX7)rDqZCio}i zW!s5wxFl{kFOcmdpTM?E8uiN^{_+e41o#QKmg(~Y2$0Y9!xWuv+a3h90;{(ak=_42 zoc1H!yjSoA(%#2*|zpw&r&dLLc@XO($wx@;$qWZ-bh3o4mUd^zR3w7H~0k8OWu}89s3H zt5DZGkOS9QGt*C%@l?MLWG_+2@+L&CdUls+1^@wcCXXeP9=?B*25vgXY0dXm>N+_@ zO6PDF_~G_RRj)>-cD$njBNDkR!B|VsQdK+Z*IeF$<~p5NwifLxWHcqvD2aD3?LYs~V=D8l;R*xxwg3dwyEM39@h{Fdi=vz%mu&1d z25@BWdAktg7L6x_#;m4-V|lZ3Scs!&f_^Xp0h{x(yaSEmJH$2Q-&q3|zkz0p>a*~} zD;WB@wbCPdnLQZQ>aR7Ge_$F?2B}pkGvFhR%WF9XDZ7MP;$J z#+}gvxTn%h8=9SiiL6SId0E@M?n!E%X_qQi(b%UE61ZMO;)UU#SKwT$O26Hz<4i~242nq4s(SAMe2<%&tAjb5itehpNyFCbQByU zetH0?yM=S9`N}Wok!uo{nUcL<|9wAfvgoN!>F{> zt=(?tt@vUI98n1t&ZwK!WfMR)#0Jzvx^}Df7$XFJ9fw(DxE8MaL5!@ml}XzFhDdZ? zUD!nT7Z?TBH=&lvcV81Dq*<^4lngGk8Y8!sgZFHB^_=;HWQ}sDI1@eXzd{2Eb`vJRm@EaRJ%}ZO);P5vtTMv``jn0e@VPcC`X!~Na zGWGprhsZzsB3&{3s^|F`nN4!=8bzrgDK;K$-gAa>^}Hjm-&fMfDcLoRUj9d9r1P!m zeSMMJ9*WBRbDT|riw?R;Bd}{N1KX3=fp@oxt;>PK9N#UF_l2v+r1&rJ&umRHg+C#? zCx4$iY7qoeLo#P}N$l)m0<6D)8X?p4%`50}UU#C!w$kJkU-v;M=ryBkA!p{jmrBES z7dNw#)dxPqJAJA(eu_MfO86V5EXGf!8gsP}()f;bQ(|~-a-b>I0)~hg=K}UTelt-! zz`*1jtOU{mm(%~D-o zd8@o?7}bCJ_PdGC;1fD)p)gu{F#IHVjgFdxgmkBe5cWmPhl+8Kp6&6 zs>Jnlygo4VrpmsyqGhBNU1kKGr~WndtUtdZnY?xgKLZ~?)?wRVsDdCPP3HeFE(b`a zYty@@Owqi!DoB36RC|8!`{{0gaW-~M-k5*Nq0IWZ7;f|k^R<=URjoZcS75k+xoxU=PHiQx+3_g= zYeC9=dGW}=`QF8Uo9(woII{GOOoW^7zHk5@sX(YkDbIftKv!De=rA_$!xBA2TXHB| zg(u~#`^uomQm~Dkx1698;J^pf>NFG9feoNwnbA_Eci(HXNw% zPvmpO(?>Z;>YJ-GsMEcU^MIdj!`Kj#7dNjEXe;@RrC=Sj1&-wyV4=NLP1Zz#Fh=9( zUV3$6#sWcRwYGUa=2?L?j0I=&cQSoVxWQqp8{;8(A-B3Qy|Lb8$eV45n_Msq1fli_ z;U}}`Pf#@!u|EmNr$%>XW z=(XbPeu*P0JR+IMRc=8{>Mh1|wCMKNm0M1ACO5vO+t#s(aFi$I*=^v zr3bA5c9r$a4~Y(eLD~A1Jb>)Qcp>f(-qW390ajA+IdOY+Xm9|GMVh*-(NMUVvcF2i z5oG3>GhgR)l6l$exsIHx0`Fh( zAXLvW`ds&@o5!6st}3pyw^~RZU>Q$-VwXlUIZs7-ROk66PdazNGx$p5 zCqD-otjIqjL`V8IgNNr>qI$xa^(Y5ifjZ|0=Hj(5g=T zVYJ|*JD~#^{#xXEvSb%WS7OfU`{79eJ5DW%D5BLsCc2sy67#@oEqx8^c3u{JA0mG9 zgp}slanao$n$Uy7od~74tyjM1JESZ~l9VvX%VVKTkgzbbxZCu$4M|UQZ&VGePMO(` zG8PQ6Mrxi`SqDb8c}@pOY3yARHR-5<`=HJ5BA(!u!uYdm;x0jz6n&axIUu$Zpj?>e zGqk-AIncCqLNeYsy`e4E=y-z0<^t7xT|{F<&2+w^n5R*AlR7Z+LdZ2Xv&xy2>|>xP zM3relKbbD%LqSpX^9CrA~(`j8p%R5t#igwqRj0^&9{i zI5{Or!n%sk1Fc?S(G~FiH{O^QF`cW?F2RtmsTNz;mSL2rbjFP(m)AFv!r!Bisz;xjtJ?#ahA2z3pz2K%KvoJ*tYv>)sDm~4% zfEAqRx;~)H00|`bhfF|1h5=Q<&K<7UzBXCG(pwvoVYtSK<3f=HGSeTrrHUc?;?$EX z1hX$HERSr*hy25!Hy_?KpPZ|w&~BV_I+%^*hepLwq3Wy~-Rc*J8Y`Ww(BIATEEiyC zq{KLH;r~b<2-I~AHhW*BB_VH$U09HzivJi|SeW`GVPY?C+t1uc6|T@;r0g4+Y0WX# zKk}jW7!=Lxe>t+R&7bZaLC1MN^HD4v+eRwuS@5Q9V7Xc{ufPiTf90!~zM$d;Bd8{8 zVoT=f1_8}Ks|#TA47E;^faPs}U`;Tk+OR5~GX=@+e9if61T%lhk&@)HkZKCfR&UJd zTowY-9Wx$?@F87i4-~W%Ij6{8Ypjf`1#+$EMD=kqywRs3|Bg0P+47mGW2SE1Rmo5s zJ~#UiD%p@G!^yO->tIC)FRlbRxpIrWykheCGnf~H5BiV?>dITGs}1Io35MKGhxQL7 zjE)HlkKR%5a3=z0H?>IyI>9RM`4Z;@i*@2dsxdF1260RE#CSK>JD3yQLKo<*z*h$Z3!R(uy!sOHF^ z0)&R#F~Y_=Y>nIS5~wTY%xc{5{{MfgH;I82y22aQ3@IiEzuypl7@%r5?^k^IGf?=(*h`Vseys8- zbiwN!B9}UIK*mf%uf}MW@Hld9C^hT&RInZGxKDJc6XkxEB_i3`mS!AlFDrC+$v;fC zLd%#ZglYy=%&>mYmA4DZ!!An`!(@)U0JiUrjf?^Hk946djZ%- z@kAeBC9Sys@lGN*aq1nUrrjFG+P`Xgh?jWG=Tb#;V(bwXVbxJrl^D_8Mf%WQTi%P5wp;*TTKaY$gn1&T83G+%)abaz1e}RsClGM-p8%;fh0bn=7mptu zfV${og`2ZxXwDD%Tf|3najasv-=Gq+IQFbcWg02wnn*qg_l9yj&^Zf8m48yiq0+;P zN11U3sS5I`OvC_2_rwa@TfJ#J^3!Gy@FB~XV6Yw+(5O49!g0efA)qZ+EHx$tXOzz} zp1vE4#UR_GenbR#0D%irQ@2|A`=|m{Krdc>s~G+$9}HXVJo%pUSizY~Ikutc>}|o3 zt*3+0g_QcO2Tz=kMOn7`P)TdS>CTXTxt=AJU8xDYvcN#AP}E?CWxM4lN`93pc!YrY z?*`s6NTiym356g;_wfc%egFXu3C(!P8@dEp#)eS(AgfdRfa>j}vZXs(bEQI@jxYM$ zp5WqdZDq(>Y5DG7-F@X*fau`Kps$gs$&*15oPOqwStFNx&R>ZModP;AibN3CWc8Y` zmsqV{_6~}CM87YyAof%Qm6wDtl1cbeRyxotBaKvmTXR0VnTpr^LGo0+Gp+S+43h#wu0azheY{yqHc?s$?r@_#t7>1=c%xc<{fu;fn`JKOYvVKjX|b6? z)J^en9{^I(X+HpR9ueFKR2q>N zZ#P3!LJ%*hFGk|cRy*0%QwzA`Fk1W0^w=otz)obd^B)yZl?VHXd%C}ZbWJhXi0P$eqZW<*N zYnJzy4HTW-7y5xbJ2G&dUg2xfFVoMK;_=UNvhG|}DbK-(BG=v`(CCLRJ7K>|AHtp` zKtJ|E_eXuIL*L#hcl71w48+EXH|w*#QWsH%z@UKrd0L`7i)sE6#N66(utrAOqo}Cy z6?Jt@7eFz>P#Gw+NY@PF_{ccMF(Jc&wYCD0a;MFaY~yc!RQq-I*&!NW=YE(E)-dMS zPK66&xrI_zJ%bV%PnV#;qh1WY5uTmvZIBQ3Pjg82$&=+@Y5bhE*Zw^xAt-Z&T9_r- z3!!(5lrxT^!6)>6s!$OL zUZ(*Q_oBB$i6J1YqzOG*#%1P8?4kdN#2J-uuxhaXYS>K`C{jlJcIrK)zyx!kT`a9N zpEGI-LHCMZ_n2m=&UOFTy*s(ez?y+`_~K8! zfwso{l>&e77A{q!!4yk}v~?w)t~)3k1QaQ4kfwzwYTI7_e@!*Yi<%9dP3aKTud`BD zb7a47fFSZu(68=0t>-d}8jLxjdf5;ernbFx8i=3WcoX|O{=(sD78&S`Hc6VLMdfWc zGl1+M$Rb<|>$hbo*BTDpn9MeSB|grq;--YHyaM+ZXURS^hEJt`c4EiWzP^Hu%|0x$ zUQO$ua{rmH+^CHD@PlD6rlk~5*Rq=u1G-~=27ovta}eR)EFr9e2LT94k;G0&J(#}; zl?Y(HqFd86*heamnAR?ld-xV>qXZI(9%V~##!&kX7f-QW*Nk6l=oFAj1cEZ`xT@4t zG2^g47eO2AmqFG;eXUofD{Fp$Dt@f}t0O8=LJ16$nm;ZQ8^k@o>!(VD>pADCh=!I& zZO>l!pH~%UJB%Q!Bo(-kSjp(aLFErkbJ(~3Rch!V$37Wq#V>je2N##O!|RSN+10B-qU;dk~{B&EVLBgq_Cpu>IEd_BwSIPAYTg^ULaG7;z32f0$xGI-`&?3J|CqVk{J-W5o-Kn1{Hxmyx#5?^ zbKaO|NAvcgR0o-WFcE(TYLyjLgF562Y(*1o_LsOwsjsPm<5_u8s>jHoc(mXiM%4-_VWe?i;y6zktIcn7E2M?60Xb zq%nsfDSWjS7{c!%(sCrD`|Ad#I>SUsU?Hx)SBM?!gJptJM>xcViR9~A^skZ0t@@Kn zr(&?nC`&OqD*b%g`TQ*H=vP_dPd};a&DksMgkrJg^QX>Rb1S~;C3fQDk6qik$|9&*Z zwYu)Tk9XPLZO%Fwmv!O|1YPlFxzN7j=sP-;LdeVw`K4@*Wnj^<1rj;SSR*SPl@5*99Ve$cnv! z73zU)Whc>|Of7^RGwDcca_?c>30s6;<6TLs`-fy0&+Zz0NLI%zH#J66C`@{Aeh*x` zP9ZbYcQuC7@kYG%@m?^AxDHNpsO(5CzM(dGCIb#uuV$4;A4M*|9IH#z@VDI`?-(mPU|QUG;%$QqS%krQkx{=WuM*~*?0lE&k18NZ z4oYZ3*t4SGX7=8#;S$H)`ZeB4Zg<=4AKIRsh+)XpT^}OQ79Ug@BvsiH6ulBVBJ(TBrcf)DM6d89^|z!dBqiV&E7#hF2jJO1@ku0HLMI(RMWU143NX*8bC!iYM*& zXw={8<;ZA~pXU23k*;uA`>=RN4;V*HYzmw$`S{uK`y$UnDgjV_5YaGgT7`-}cvLTw z%Vov}|MMXTp^CjSM{(Wvn_YI+BoLS{OhRWv5+s9A$o{y0{Lt-#&kXF}X*LeAhSA=$ z$Xlh9_;_FM_1BdJs$>}aTU(R#{CQ~a~zDuwLYPBr<@@LUfvY-=@0U~Wn zM0;WGN|Wm%ya#Nhk!-ZuzSPdQSgWh%IWw_1!z@X!ngcW^Ubbl2GPo2lV;(?NWkg7Y zP`1R9$Qm&8MAY2-PA`iJnXfJ+lgKt&=_Hj_b6aeGgj;5Sr2k4j`&7+zM?gtMkVz&L z<588@N>^hy&0HEA^rdY}ppZ#AMgR6~phKamHjQ6y9FdUtNGF+8wc2XCAfFb$dMP?v z%}AB3^7HqvwaP}}ZM2%50%Io@rznx9MO77B5ll}{^Nv#gMm?~MQnRqID6=@b2Nq)# zGp8aUo2{YS-6kG6tmj7r47VndPVjk}d#U=~bY{q6=2|}b*!cZkz2E)+#9QBg;J?W_ z^=QAe>)y}$adad1i$QAQ@5ilk4k<=_=<;`5v66^V-{gS=E(#Opw}a~1;GTiWy+l=qK?0q}_e& z_>i}`T0?L+e!tHK zFIIh4q;X*0Ca3cFpenXT)B)#Ny{}pB3M-!fN>p~9hw2(|>c3cByC8pma_TExmnu7R zpr7Ln(Cuqzwg&$7JOk14x>78%mDuyz2f9kHnJKP4r;@eXNy9c&R`hy7R@;?Zv1s>a z>PMi-cGm1XRjw>Fr_$}P*DYp1vDzLIcSrFQRK7e~^hb4ex-m1M)XS8WSea=ej#m~{ z`aWJ10d=)x1(x18eFka7nw49k_XOE2<5sGz_?W5XTV9S!ZAP(gRKPY2mX_8O3#h4Z zM5(E=nPfPLE~q4iTFNd^gl<4tMds#!NZ?P8I?WIAG>^cuXb)zrkOX5)C&_6!_ao5V z<&W4b4_*rs(o``{bvu}mnXy6EU}b1}Vp|ZtgsPF$DBq!2$SO~7D$s|ng2;xajUHdC zzUH^yr%@?~3v{{8<~q9Cu+8$nEO572ja?(ZF;=V@N z0*0y?Z5Z%L&E-2NS`)>TC&OS{kw^d5C^xWC@iYeMu+hgK@8AWd)9E}d?9h}k|0h3sUM z^Agq1ZoO!6T%@Hfs6uh;0kAT68D`Xh?6qSXh89W}(HcB$mZ%%PF+(!0inKhu*d}+4 zk33+F@$R4SJ-qxJg`{)ohDHr&M1$JUzbW$mB)usx!BahzRMtjS)`buYcdChIHu6b+$w zx%fo4t9A}Pn98l;+a6X8x?X%dhgxHJTH25{>)$SUo9Y+@WA~!fB_F8xk)f<_sHPdoT|u8Ca!Ujs}(KDk%A$D`}7pi-Hd_hR|prKm4VSH9vk8V|)t1 z2nr_`qp;>gGP9v0^THa$s|oJ6k31+hLFXg2!2rJ66X2ApGvQmNg(JP9;Gq*-pBaF; zkz%}ouO5%9Uxu$C@C#zBk84z*Mm1cV&=QJ<9BhUozdC0O8}f#@Vul189`D41TfjE^ zek^|hSxqj4d0{qcw+9Q((y-K!55`UalogQSwCvR}g0IiKx#()ad7^_^HT9st7 z*YW*TkolC*>9GJ~dJtW~ch3cB;I8*7$3bhNQWwXs?r*2kd0{?}dvF+%y23*=E@6)< z*{F>R^^jGO-9j_=G(cCRDs7_G_4TTYR_7t&T+OaLNM-7UR0X0y>K00!WxA2@g{{zV0B|XBY%Jm*=UWBk z#G3S?D8reoqE#I1a#3K&h<0PCkAkyT!!(|PR~VTkF}aG}V(Q@HDqczvRaNQYpXdd6 zmFJWO$v`UOOgz%gs+a>4bKxlXo0g}9Hfo_)umB1G3EZS@4La|K)U7Hhl7g35W4nr{ z68Lc#1C6SYFv1zGxiIMA*h;n^JDTk9t`hZMN{mua$mt6YIxmiX;X`mHyQg_b{)rZMR?3v0Xkr9k(pGA{s$Qj^eNXD!o z04m$I13|5qYs`v4Aqwk~1?P?l_UsU=q9wW+wMXW+^SsRGu_fnH^}I?1eP-m+vY16# z2`g;o?#=e4P?M0dgjS^442_&PFVrlBEX~~HjO9OWI{`$>l8QvGSr?3y^5uTb+`uJM zgncd?SxmHIV)!!Gj6=YS1OT3Aj+h-)R(CbvG?cuKHTNT^;ylBGkq;X;<2Fjo?! zmWuw`*T0Uoym>~5g{o{zVVBaV{=M|{^@e2w&8I{Y#1i&=%#uypJ`<{rOm{SC%VOoO<*{Om-EJ9aMPxB{%-qWm5{PJ{&COqzRN~5Uw`v)samXv< z3RgrkKObuOW?0_?4X#v6#hhXVa%_ZnBgII%PDHIM)K$BKvYP9n4%RoLdd&qI0aV0ap`sSdmg?_{S+YMeIoH zHLA;`1(sETXf+q~>Ib`$^J?cvYaZ+(u~QvP9#!ncj<9efSugTrd~sRo&S_wsD?t}A zJ8hjIV4)F=D+BRXESkz-d|?JCQBYhNA>zv=SavP&0ur3{D7Ct{L|owocpFBnC_#3( zuxhDSSrS?y*%0OpCK+TJl#L+{^Ya&C@+{Q<09Pd0ZNSiZ=$c6>1f8HZncfSOvTYKO zxkJuQ+5!LdRyxZ@8!Xhlc|{G_2;O$}`Vz}F17x`@-PRL{wJh483#o2UK4#D@gjxX6 z#9|2@3?8#JgFL3x)=O6>aD27G1~%a5c0PB%SKGvIQ{g|IasQ-1^GjPBYa8OG>(DeY z=WyYGxe|T&RK<6`I#p^OzkqM*HgX0f-bvaLCBJY{DuH|0A-EJO$lQGl-{NGfMW^ni^_NrlcPh?!IpS#^!*zVOXIvgx`WDB&TLJ%a~O0=Q9 z!k*ymxi2i86rdQag3e|}_O*-7MyfX7PmeAo_|B=hcZ;!jb(sf0%zzO&dR^T*@yb~T zhYlj`0;=3RMdpll&4It{ijs_{Bz%-T!q|Y)L2`|d?YOy74pMSTtn6KZd0}{HktSq; z9LJJ%!7tA|1$^oKZ*lcmtIM2rhnrbLRATNeJZ6*WYj* z<}}AUCWi12de^09@4rD0MvNz=X4P-LQTK0i7a_{#=Wc=CJ|GMC(LRdifZcz8NV(lV z@0ADdUN88#^;_;hZXc4pH;#F4Jgdu?t1*k+SUjbs9$%i z?+$N*{rw^4w4>`6M0ZvP)pAnKZs5Jo)?%$_Ef`ic+*z?_=w|YbqPJih{h>L>&o~uUZ=On~!73(gtzsL36v(S2##(=nY-~)z z1D`1#aWM~+;3=->2}%@&EOa6$k{8IPqJjb=FS4Vz^4}^d zXP+GX#s8@$iVRlzP7CXQ)=f%kgd!#|(0I#-obO}Q%9DQCg`M(Gcz_`@v;q1@-od)s zdDBzlpSKrpjWYW_>|zsNpN~HhPP2y(nc>U|3&))&Bf|$AA*@P9WR=mtO+C}(M?XBW z(WwaAkX##|;QIKNJm(6S&Y5!k9Jl>NQaA2HAHQ=xyZ%%>RlY0S^83PkpAkreehTF2 z%#jzGjJn0$@)JN)Z+J`Dhu7$GocsjufvdnS_;$8Ij2|TaB9O!cbNS^=1atxE^TYf? z^Uc^jjDTR0!$CMVm8%UZmuZv&w}vaJiTtk6OUF%JCVy$-8!Nwpk<}XPe9$xu20Vdp zlMfs%mwfSV$Xx4^)3QT!`TO6dDW#;=&pGGgQiM=88k7sOo~b47Vp495eEaZ}6AlMR zRP00!SWu53?nePf$%K8KZ$Hy@gYF1N5M%|_(5w%}?SYs&gKs31W(wXmSg%9jqyV%= zlJYI7(hRJ*gkO1`Mn?}Olt*nuUM^3m;fu=1-0?ZEk8?#N;-}_HASJyGVsYy2U;`Z*EUGsNNdy{QQQ>#UCf9 zhK6IWkz-b)>OP3b_+E0Sb&x4!!0+i!#o00V z6XPE*wdh}$%h?ia31Vpy_7r9gZ_M|Eq9{NgBQ$c)S4gZ`q1uy66*M}gy+t6+o@rP< zk@x_(8-WG2K2Y4KR93i1%eQJ0S$#6XaMd zD!y5Sl*kE~bHTO+%(_`MTaRnKbj+Dw9k=8K3!u=!X=eZ#gS>ZQ zCjyp#BRW_s(5om?U;e33+PD%jVtZXO_{)c+oOzKh4YDsRGGCn{Ig92@SW993$!_$2 zxFT}-Lk>KDAe-#Qw0!4gvckF8m1Im;URwY2U!=oeTa#R!fzFS~oKY|i z!(zI2Ae8M#nb~1DuUvtlx5HQrKIsla2AbLUCu*rN`icb^uc&t zdq{DVQ1ujq!Ga7x+;kIjF={`#BHhr>*=Vvqde49z5-*;u%;CGI+sNvKGH%hsnGcK3s*o+bh(l2}}hBhoMhd1#`1*4O!S2K3}cFVZ6yBj7IdKN zOg$UeV|!+!)>G*P#8h>d6sFrZt8sX1nTp$C3DwY-Kw)+vXw=}q8P7UTu{xq_9!~U7 z9X&B-HCs1Zxfy5af;n{Z+=YR-B&^1h!jZTWQ&3_3JvpRn9LTT#_A*`bD+$rXsolDo=!{TD~~ye(|S4kt7jGIcXx5hPR^gJpGFB&vK&)9 zSwz%=jr5HwF>VPeDWNR1JeMw(PE}I5qzdYks66Q!wlV-7uu`$q+Jpq?U#L^#U1X{`jaHRk==x%kH8pI5 z9a%VAr*4_@mLLoJik3h=PTP8f?cIM+0)?NfYFR`4r&6j(x&E`IlzrWH=0?-^m zFq2G}wO9$N~$iQUGMg3T3_+q;bvQfDC;BqX3zd%Qifh_3g!h z?RsG{iR#8fxr5`;Pu(j$qN-7t$^}?#)IbVFNIR@UbWFl+-QFPXfh={zUv5$n-UjCM zeN>ntd8I`B?I1-FwlT>zTGWh%E9R=L1y2OpedqkUQ~`=?R%dR=)IjLvQ7!mk%U*;D z@*WNw9t?EN+dKhI^Q;2n!W)1dJ41x1d5phmtLWn6C21Mg!Y-&-gd1~j9wmQd69$AA zRMxr}eyarXv892X+d_ELBxEpq z@hC>`X$9Xrg6lb|T~MaSWrw>mWqNW1y1k1FFwVX6YzzJ5H{2ja9UyIWnpE6SEx2zd zxO{uOk4O5*1aK4ls_W$gue~!TcsC_bb*1wgMH=|l!#br7Qb=OiQD$iTpM zv-*s*I%>(b$W?y1Z5R)QIx{$Ukx4IjIw=YsB{`f!n0RJ%$WEi(QSUWycL=>TYp9xv zr+i=kf-c>SyM&LDD@ooVWAR(dHqfkE)T?W*&X*|k`(S1%!8C{tUOFd@M0zL8igCLZ zf3E}}@wTFrI_Nl^lu~2Nkyl_Oq~dRR+)BVwjh9U1C-_Ea7F{42(b>2pu^z+4({M;b z_+b-6H&!5y=a$C2zxSx@WQcjgRSr20P&8zMGH`)G=l0$vWAV~XesV#=7XGPa$4Ucz zl2elaCb0%>R)01 z7B%RmV0y=^r34S%zoosLQyv)a3mqhabn?n8GZ~ZwD#L#YDy$w`S)8E;&>6?Bh=#Tb zu28~ZF!#jR7b3y4A z2@u#bEg#04=3|1~C))84xR}cB;uLeCnT=#3+OS@#|9tX3DAyXKso33Y>1!42^JP~a z>8Z5OIL*;6tt)C+^SHbk1T0-&2ASq#B)2n;g&xzZ+=`V=XZ@wvoT2$Uj)@7nqSYXF z15G(gHY|tWYz0z1M@c208AVm}5a38#F6`X*3Gd|W*Uu5p7}iz>sLJ!sYCaAL7|%J3 zC8MeQHp{3`eYx^>0V29^U;S6~dupyHY3HR{qNj@cKo=l@vYXs$_(I(d5dn36te2+*yxSfm_xC>5rF2dEKlcRdohcFsM)UMRA1)4%mM*j*^Bxc{K0nbb3rOTP~(v zz=GIaAIY}_9YXB^!auO?tzPIYrJXb~*t}fVD4iCyEEasM`z_KUQt|Qp;CGcJ@7{fl zv(AG^B(*;N@NGHAyp8PuPm6h;3cUizG%gB~X%(;NRA`Hw4-b-~Xa>pAjz3F!xb@U$ z!009@wQfCaz$BxsLG4QstJ*R3J z=^xOZXyEOle#?UbT54V8H#51Z-7+r`ygPWXdo#&bO zvJzBQf>C1b7Bv%@oMeMu+8LmkD@dfcQ5m4{%bBjJ6EwlVrXI<3a~98Qk&SnJXJq!i z8x8agyFTNhmeLddWtx_G;H&s_1T@(-g=n``DV6+sC{U%SOI`3;_V7$?n7KBOG8+Vy zH(EF|#uZaFI8I&hu={!~4{A-9l_!u3ztB`&;>26O);9%e69DXVOJ5~VU& zGjXoC2fpu-QK$q08hI0vG8l)Dg|zMQDju}$WIyMKcT|Ov6B{{0GxTm!+wXVs0}AME z{~c>wRFlvC`z1unN9&ARNu8BpwtHJf5Z&ZDsO$cY|QC^RaA= z>qR$l>~2ua9sy-%*RZN`D{YrrRYad(nUA0Wgd00wBWJ4Naw>wVB1EB%^TwhKwnBv3 z##UPW_-N#>Sy{k0kv!)oZ5)1ZZVKY$u7$>pK`U|6#7>ODSnNt?7?z)7$4rN5#sTVZ zyW9pZ#h%!VwTOYcJ`9-t08pw`EpZ`J4>qM+7B#(5JGHMJE!NP1Ey%k8Zk@QtNR-Qu z2M+mK6YT(JSmE=YFlSN@L%E|sq zZn=y7a4kU&w4QZ9?(LUbZ&-oV!x0y{$vK`!CW~UtY=CJ;YSeouOwD}IwkqQ&kex$~ zPuz2_{kY1U9nQU1vje4!Flr@uLJUqwLJf^G&#H$f@x9hNgP+cYM=Y1#p^G&LDYk8X zrw;9YIRqY9ghl;;?*&xszM0{(NsHUVXYmJ)$Ge~CdD4g}49-6l^rH>bR`D)`YR1?! zc5%r=aFEo^c9Cq|{n*6PrQ*K>h0D`|0^%Ba-mudp*88wP?LB%2T`CVBzSVY-UQo3tP0MVtiI|^2 zlT57@vVasfj8O&u0NFMjUPQXaVJnIUfgH24vzVK8bW6ze1(T53s1#+DexnWyR5C#Y zpj@?v7J^^F40G3U6%ggBV~;Ki9nk=&70=1;Wjw9j;+yR4D5h9jPdq32-_^)ci?-I_C*mheMvxghTJ_fs{RaVm@kV&`EduNUz zXnYhp!mk-H&!v*u%f(Q`WS@cJ@igU+y%N>qmtA$k&=UFXNSszif{LB-asfH-cR{ z7FgRWPQvNG#{;L6sQau$z`lG+=eu~{!JXmQK9^UUw+b;ugd*ivEpkoIe+d$!3_{_8 z66*hk@nggaqKfdjpq?ljj)skIX+i)<69f$Tgk8OS|Bc#&1{UAtqAM={$w{)b%u)+{ zgW8x6CmXLhd{BVYmbok_9L2t#(vmc5?dX@~qCw3J(GK?H@Vgw(OfrQgX;|WOQ-=xN zLGPsW_l+~t_|V)$IT9#b#K6toHGxA$4lo1EE$oMP)<(LtCf2Q{rn0Vql=LgEcRq@} zsYtlt4uHRaEsslP>G@;VxhIf=boDZ>L<;)hLwBGNdIf4C>fu|8WK+WHIrX{Wv`(Jm zl}?Tn^_dtAj0h!m%hUaCa{R4;M9nnklKoxmn_lTvzE$hCkE0%T{DX#;C@e^6BUag9 z+en^l_M%Z@vnK^Qrp&w}y99O}=*or76eYIF+5loQMr% ztMlRFq%c;pVMYg%!Vg0S^c;R_=ij46nkeAi z@wbFDiF7pew0Q;WMU4$OBA8&Ye4nOPJv$`PUhVnS$+-a8Na-V$g?W}RrLv})ESLhY z63_aJjEY=PNaz>&+K)>X&q{qra0j86JotBZzKGK$T?73zry6$Cwq8=5?uWl~d1h+T zvDPp))FN691^yqel1As7P?v04Iv>Z4d*J-QqSWi;Y`p zl=%tjigRnq9B`tTIKp12&`w-su*md$T>*y$_as2Bo88RDcy*rL)KDs-OF9(8Qfg{0 zJ3{u~BHCk4j#{Kdjf}(;U3XLBzaUh$tABfILlN3TZf&8NU^Xz68q3qZPbnYn5GnZ{ zZhk4g{!;Hj@ff8MrS|1I_&x^`^hd12C$fu+0XOD$)`R%4#M)h+-kQz$8LJ^#F+ns^ z5;Rwv^p&8XaakuPmBbO#ri_}oacpUNZ3a5V5s=wT_-IIoc&c~V>ER(K_WE_0*eTS z_kr$^BJ9&gbNC94A*P<#o}#I;UuSUo+uucfd#lY^hJ}J*Ba~p|KtpS6<*}o1ScXKn zWX)v3DYRND*@g<8tfDIfPV(CbYHn3#lyCBH7%iLu{s4mJFsLbT3>I%@wXlo<>f253 z-nu7Cwp@$6T?{X%FkquJbq6J||EdQZEL!tQ)MhdRJ&ZCKEFR#^_h`T4kS74*Cs-L& z$KHgwP>a+By3>iNcdDO%N%M8r=kEz+_l1Fw--`fyECeY9i&U#0h4*L19DBXfsf{pi zGJqn3yJk3cj*K zP~K0qu4bdJ3N_10mYB5dW^-m$DUE(h2-yZp#)dd z)wt!u3s@0AqwCY;^ip>TIs-gZ)tnd3?~OY4q`#Mbjn&`r&m7!>$ZV55O0=o+F?bSx zkB~20t6k;wyCRaQX{S&JdJz3_fnTX3zm0v7d_L)X9c^b29QC4BfhQDAiCr{}P1C1& zs(G?wsUyf*dXCxT&05#@hX%TWChys=L0tb=KF5S}wMHe- zKqNlaS#X+no&kR6;n%BIsstEpdr^RA-Btk}klKn)ie*r%MA+f8x{I(8_aumGrYXx5 z+rm`GWY#M%6!h~&|GcY8g60!z)N*xzAWhvtc0g2qQt+?{3wY^sITe+OuRqk5PvFF^ zS$)6P7^^+EST^}97#2rTeZd|NEq~SI>8?SMg1D-$lU<2Xd-@m>wix3usj;s z-KAicl=T1k;Kpk0&I_Oi)kf7u23Q5_d4lsQ5QU}*@M@GqC4^$URHybiFZ-A_fJ$sxH-pW2L|j zo?3{1y!T*IICXfu4?N*Q6PI}|?uW*B!V@e`bP zaMj9@)?Xdxy)M`Q0KG1Sg9`A-{YD-lin}}76brKny#*4lPpjGsBsZ$b zvI7h57WoqaLV$Ghpf!3Bs^2yI?@^T6OmLH=PftT#cd<$CV(pE2c;T;PFzj zfxykBMRZ%LB*jt%!nJbKp!>2W-0os(dA@)ky=Zp?b1=NvCflWTUDpwQ3R5Z{?V6mL zh^xR-xo#7$mo9t%DjeT?7HxF#E>a49r`B<*ZwhM+d(9sC2ofn)UXw!Qh^cbHY)1|d3@?(Ppekgt-@ z!3%?!{3&u=BGf*)0q_9l$cUfi({F3j%RhSjHE!vJ#H0?8S;Jk*Opvsn?O)d}g zeI*+;zHTU$o!PYGxAq{1aFac5M5qCfJ9Z=k_5PmbA=Pq&8ytN;t6Bq4ztDf)ooa&( z5{rYS5~7qF8d(RVg6&;y3mHgzItbc?N8ILGdCu#Jtu&Ui7t`zeOe%(-9j11bz#4G)%A89cPx>~&4|p^cFS0X%s}Z7 zMt9V2iF5-Xyw_(S1;JJjZf>D;ms9@Q#hI~tIIIayl**U9Hm~v08Au1j?I|+XJ91$u z1aJzFk4WqzT7bBSK(_)RUAja)GJ+rF57~(loYS~do2S7Ur!Df+WV+=@Z2ntB^dGyOnq#MZox>xP#r4t}Wj_#k6_Qwb43x$s&ThA`;@_(Iyz zi7dfz?OfoSE-+IDQYo?JPCbAMixQ>fGYs}S$vptO=AfLkfnM}!oE|z-j`&)ruDhnv z)TV;e%B6b0Kz$vSpdpC+UP@Zb*S&wkG=OA9^?;KS>0!EA+yRq9;i8agK!vtwWD~L7 z?wNL4A$khm)Y^>4>h`{s^Ob)LE?VU4v|&|EU=7edJMd5h=09dd%6Q=*j^(LHM+W)k zck%_sNfK8uk=bn;xwo_9AQNn)`-#{0;!L$%v%i~w;f9#Tix{st7!G&1QORo)(Q%>K zFN#06A2*I`+diF;1I;M_Nm(TQtA@(Ka9(OVg%Pr_7_@25mdXn3+ENNCA>{XWN#fCk z&9y!|5U2^L0ioROpS8XUl}i4j(hO|SKqW5?hcYQKkYF{$yecA4c8zI*pG2ltGS@No z4gfg>WH>NtQ9BtH%lj)!m!q`%loIg)P?=iYF3?^PAlTGZ&?438nDkEHW|V~jpkDSs zi|sRJ!RQt>ac^n9;)F%fRaxL|-1`fifJFrJ>rXc{{dRAHjvYG{6C?Ku{S)k_1ngY}!+U zNbE-gAQE*VYflk~cm^yb>`X9jVmn2H(xit=2<9ebE)#vi`&`4wqEtJ#uTxRo=^u!F z3^Xr#Sp#BY@1)kT=b!I_MAYTM*N2)t&lqKn#(ni_r8h`u6js+M3>l<^Rcl zjO|yL@o<7QQ5XC-_wpOVq3Dv(v% zCT;xP`YO>n@Z8=uSbjtDC6aT@C1^&2$jES<%a?8-7#Ti8CY6@KfQcQ78-wWALK(a0 z@F^4287DpoMs_99aSPd<^7q%{E6NyE^5Gf_ZUNbnw~pwr0}V`%Nf;C`8)RF6Nu2%n zgzi|rrs?Y2|8~zsmu6>o7JDSk49nzwBu_7?YM_q0&~5AW260lc`K}N`DH0YTVP1i zgo`#q@DMr--nVd~vQFF7nsoYf`ON`Zi=81b0z&oSN&Qo~?Vs*%2;YK4YEHbYmaRVTjN`Ft|t3Zb1F85r==)JBBwO zO^Q}@M`(3t4E<5}PBZvhh5rqUz?8B((y*oEp9sxNmz*6I2T=mt4Q|0m+E+ZJgCyl4wX(Cn$J`KdW65krgv3^!6x z0e7rVb8;pSYhk~UA%=aQwM||cAR9>~^pEzVfLn!51NXi}C1G|Tu~Lijs1MIaXJaUU z@+-EU>zcr#U&GkdMEJmu@ftKl;?eQu8fBY2C;< zy3;Y%E$(6m_n(72kky*dI)9UA?N;>&aX<|QK7qBgwH7e3 zAp89RwW=i03N#UTIlFH-8I)H!AaFUJOOZ1q;-o&2Gp8aY=(rNO)c|IP=O|H=g*$Xb za;iQZ`$xZvw{3co6|L8|Eh&wHQTiQ7O?>tZvR$o^^NK;QQ5uP6<`|Vgl#U%n19Vgi z;|h-VvU)Ykt~B!e#ks&x9`N{3?tb#)O~6M%m|};YQtCW~I|W;#g@T1rcvWAb#)pgosryhLHw^!S^)qQ60Z_){~$`NKm0fhwfLSRBcLB=SdHQ z;AfZOsHWH(brN`YSAnDdPR$X+D}?-2l=q^VObid^(+v*e_w96B)Oqn}VTC7U=l^L@ zpLSDM*1R+;&Jd;t)gWZj?LEziIO3vc%2OB`Wy8t2?swR5GjvdFBZ7~bzGZFMvyr*s zp@zBuRJaeUnG6~pbpKNuDy|Z?7eEVTrAD)!s>niT_DgT7JO_^xSU^Nuz;29WnkH$k zL_LKF(M~NtQpgAg>*MD+Rl_LOM_Jqz4d?xp!`lF_CM)d;(-5xUB46eELHUwl`0ApN z5`ye=_An3cQ?e~G7cEeFu?6Hr;mAKAn_|ELR3ia$Xmqw3j`;a~zz7;jE9f%Ba|tA7 z!0B#~b0qix`n?bKVu8~u7wQw~R%?D`G=*{S4X+hr2>w!|nbRAe7lW({(lh}Rm-o$l z+9CJ|wZo8s==dMsK&pKP*<6x*YmlTEYEQk~s<6~sfdjcvWy1C2Ie-8B=K*{;a_}-( zm}m8gP>`?}n9BngUF-67@4k3qVko4}%>r=-^;(^zj#;fX=Vx}JquU3kSH1Wa+{Qs zfgv3v@v_wgS|$+UEsZcwngwo5&x%4G=7&I>+Srj13; z=q!QC>14+cpx}basv;W`sf1$^O(C4BRqoW;{rPt0GJ#7HhEHP`WGzPp32xo?31KwK zRbQ6W@XCN-bo}xJnr%e9C&A8(&czIFCtG>@IMdjU{uP(}cfK6J1JuL`qb97d9ie!) z>@`OI=>>0z`=%&4CWas#AfObxs1T?G)z5H>U%=ASC8jUuanCucV50(Ps+%9-fIGdO zy+CR&Oas^~_RRnK{u)qe|J&p%LPzLlq2Juah8AbDIOc99_mS6+^DdDEDJjuJJM`Ft ze6)g3w9O3Mq$6hNcBjLncM-@wT2~?6%_}v`(o)0?etOD`0tB#B98>oM$$LS%&u4`h3^rsA@Jwa}LB?oj|4@}Ln>(a~UwmLzDZKjmuwBP!FiEH6WP*v8uZkIvk z7g+CGm>zJ5HSZcR?}Cis=7|r#$j8~7T2~mg!W$~ct+AoV9uCAM8-((SggZe-GP0c`%0rZ!o8zY5hBPAgr7GpLyCocJ@* z$3J^AQ4|HQh)Oi<#~W+!E}v2)Q?ly}VXEek*~Oa>&z@~zcFGF9VrQoJW}}>Q4zwc@ zGK;TrbOnBk=G@fTx76zl7!>!<$R;z+u#X2Is|D>(aedz!`L`z9IKN>w$G4Bv2-T;? z9w5LOKLh2HNY=!UL)glX-UBoLU8sk+Lu%wVJGsiXE$+$fbnN#uphlmSwH|?&j~z7# zp?hd#UG5AoBok8OUfFXA=Hp^oece?OC1C*u8rkSMgo_|M9q%sYG9DxuU#}zv-_84a z+J?D5DOv|8!IOZ8yyczaRc{-{UF}o5$Ee7T{w+HZj71o|HlXPX-P`gQ=K6#}#PfL$ zcOgxIaCNexuq8;+$|UBvdBdVP!Ok8mU6oBZ+jGj)fe^4peJ*9aiw*!;23zFQSU|Pa zX3C%^G1#uo!m8(Cpb*CebDe6k*v#n*-peKzSX+$X1w~4S`cbed`Z{+%Ic>TbzU>~k zG_Zw@fgrGLVg<6}axsYYY&TxdaYT)7RcZE}*LjR8$WH;#D_|uZ)d#8-yJ`7v1&vAk zE>mB8>(-0pKt-N~nV-ErKs|~Kgg8{8fa<6!O|ZOB-8FL~Jz#E5|JBi-yLRAS6=atV zXM26-P5ib!;_tCARD z6i4QhX_d48wS`lnu#QJtP-*}jGHE~#gLMz`$f%e8&h>k>;bMJV3v}onBnqBw?_x7f zI?>lNE!gLin;czusM?KfFM#5BKW`VtjmHL^6eh|C2~D9YwKDgsh3ZB>ke8a1+ogV3 zgdBO^VS%)DTNQ=C84apw>N-)ho^1WEUY6I^O;^DTt1z8> zNB9%$fMIuy(eN$71<|}fM6QKFEjGnwfMk>idxNV*9tM^t{Cjv$00gY~@9f70pY(}d zQhZ;-ymRm+pKBj(arEFu?Gd>!gJWCq?ONe69TS_Hq_NGcH6^?FK;#VKsqrjq5%zpW zWVO(&67RLu20r(vP1nvi@OD6j3g)F^0BwKzugceCjjq2OEw&`aPi&IXADOvm1Gq(|HPHh2&iAwuf-SS&rBCl7LOHtd}H zDhL}BTqZB+lzI{15-_Vkv1muM?-7Fq%pgx*${cbZBBXF+*yqG2oOJ#432#ONkE;Ql z&!!t(G@(aW^%h>eNl?5>H!cPsja*p;DzV1jjQg=+iZP?Sr+psqpxn zvO|tUYXGNk8OgbZM zRW+5B_Y9hU1K75x8ECxO@uxCzEFiM+C6cyI zVfMFm*+Uow*;F}~Hd?_&ieUKwQy>v&m+lpj%(pr%R`BlVM4Ovq`laTT zUT<=hZh~O(K}Oxh6$*$9TSrS>s-)JUAqsK2IpOR<^#awVD+bLUr#+qxv*j(BR)J%h ziv`RR(eERgEff}pB6a=&+W1ANeh-f)e@855u4<+!uxFnfpp)gw<1}2b>=2qwPD*X| z8!N*WItq-3Ev$mee>^PkS(p%N#=U)NYv0fGg5e^%K6I6HMqT%rA?n3D_J@8M<^q)H zn65h`m9TjQQ||)HIRc{DRc8_OwuQzW@}|1__~!xOUrfj5)}fR%edo8q*(r_K1_q2? z$6$h1sFwy3-=3_YF*2eMTPGdCOd-VPU_CNy7xZlZGtqOKnF~XHX7?)>$`F4D%3>zu7B0u zWVe&I7xSThSmbV>7&)yM_PcS$HlgUFYFSyDPD1h4+;2++hiF{?9+>fh%(Y}1g^c-R zo*z$U!R*iX+3y2)%m+m(!Tb91dpZz+8A{|+BxFpx7Nj`M{Jm4s3U}>tF56J8ulZgS z%HE2q_}nb`207nUx)Fy>W8ZE~i_g-K;XbnL8}SXF_Rh4X-Dxu=WIT`CveC53RUC>E z+IWtjGi3#65V)3^q9BUh9zVTWjSKWY zceN`-jhi(cGUE-xl_YCjAxGiwj=o)~q{4EjgYO-~sr>F`CK3o-z4;7RLYUP;W}sn} zEFB#qbd%)p%ft%rCYcP>9kCcN16@5;v9 zBfZYlq@Xi9LL9fo@(EG)v_EA?&FE5@NhXFU1CGu2vB%q_UC3H<1`CV$jEQH z;%;?`8PU{U%L|-r7#bGFm0nx>X+>Tjxz^N3IrfOj6y@Xewle1|>Vu)eI+&?m^z2Ig zm91&f`6ieq8F-KQTU*I@UW@z%c;2NKYPv^xzt`aSAH4#^UxJlxpE)V_XF2h~aI~yo zV_NKRh@81)yh0`ddv1be;~0ptN>nxlMM!g^yGyavr;!bYe7dN%iodwsSidFv@{D0P zf>xv+o0{3%^)^-Td|f4-6MXfQ^S_()6LNbrqpanMF=SBg6)l3@_FnqOtUwV;us9Q>DXD@RNl(}AhpD#6YCj&W6*aHP=?-;O- zsC=TrPBCqyqn$OcZ#(_uGormY;gHz__Cm)Jk$v?|vsY4BrX%G5R-QWh&kj6P+g=GQ zEW@y<=Vt6P-COM-U?pJPY+6pWO{Oc-uBO@Uu80 z$@R1E<9uW5oC#>W&-A^cf1|Ek4(ja3$*&$Z4+D zuInEzr=~s2?AfPcr2%$>rAuigDc1-Y9zKd9_fW37{F9suLqC(NO>M-UXl&w~LcHvR zo*0&NM-?s7R>RBbb==wZz>!{SIXX=qYAj(mgP2i0&`e|mdZvPr3*fCp{ zqKCa%_|vf#HvY5}a@AXkJG~+Tes(fJX>R!}Sjo$pPzv4p9yiQfn1M3GPy&@mX z>qalya9iORvaxNHM(_BCCs9ZpgJI&zVebIl))%fRYkG$yB zzESYOamN>F;j!Bf60F*~2YDYlFIqqfW%z(C{~3+{AmJlc#uKdYG8tlr2#6R5EXuw* z0^x?y=Kjgy)7Gpb8EbMr%Ak?zfRQ6X(s{s`Xp8_M>9Q!q?c#XyS^3Z}P7N)?&iUP@ z9rW^h4DraSl|z$8x^4BHadPY0kQI4JgX4tR#fXZ8lyN7yDdx{k4It@byEB%?W1)ki z${8C?qw_u{Xq8g7lx}R?SSV0R!Zu+K9}g;#`a`6SVF6<_O?4vUe;bfrdr&)t4JEQ! zx1#jf5IbD0X}kbsok~M_44UQWr*k2}i*SrHa4$EO%HytJp#El)*yu807rW+On@aZX zV<$%``zEqN4#@V8*-ZxL#SR#^&8^>YH}bC(Uc35rUj-B@B+=&3k9c@4UBwJRq{p76UQzM5t?F z53x)+@C7ym!bBUN?J$b(83TzY5;`c%I_)ENR7oBFllZ$!;Z-kg++fTx$efM;AsP>S z(EB*jKoP9{V~X=_zts_&K&6N!b}VGgbQ+9FgITtEY`#Jp2xF^@>>~GmA0nMSo=_(` zPV&I)xA9ZEO-9K~e^{ZKiDjmaNO5aeh;((h(y}OH@)70*zWLhM7ZlIRWrwsORr-sk zo|D-2xB@1Im7S@te{$inZ>^2iT~11;Z5-P-7}j}br)EB(yZjd7pJt2(@QQ`i=%cOB zgA#p833=Ixb57fN7Ml`5+v$fbxqPaEH6ZLO;LS#~zBs)GK&TQq2l*w<%4=@#-^d2Mf! ze@g@E(Jmc)PJ6T6huP7JwWzI83&KBGEL5X-PqS69TN7O4&My^yAD<~{n}fWa)s2xH z$b8kv-6)SxF^yv)K6LjBs9_3u#Ld?;8R3p!uQqTgQDHv}PB<<=q*D(#710ym#R?v< zuJMg`C~ly;WE5(wNGZ=_a})S^rSQeT)lg&iL4*ETW}wlq!PwqHIPR=JBn;sbN~;+e z+P0CKK{(EAbG~;?8Iu%ZSP?Q($up-3ehYs>IKR2v^KRD{&N(ZD+4*e%+Y%mc*|S-y zSPM#9YWa$k6yB>G_Ie93G`g!($PgS(k$i)TJ|0cZrJ3i20zz;NZ~`Oaw>*{zn;BP6 z@3^njoJc5^kkD#(%%?qX=3p<_ca4B3l+PCuvZ__}k6`-4{)3j?0$H%mb%emJKx8K;9e$%_AlNBfem? z>saV4z$D~RuaQtj08BWL(-D#Gdy-(7LPJX@gxPtnU`$H|--mmwc0*!*^6mj4Ie>MY zys091AS3NIs=2F=kldotN8!4{Y5wCV3h(9+)8v6Upjd$gzC2wyyo?CV_IDJVKD%@s*`^MnRSN3vXC{e6J?t=`WAX& z<1DFAdtHa8L6*LJL7XPYqK34mmAmsMuZ<8^f?A_Ap>x6&7wtd-k{Y5ktx=&tsV>Ou z4?~+AnF8t%4=bnY;@V>WP&TkXQVmK>a&&qEm}S5UyITYsLSU{JMeMXl6lipk#_(|j z*r{wZh1qD}O>IS?QHdrPL$;HpQSPwB8L&p@p)kpFshbbTl#A$>cdPp^d_bryOyD;ZB#)9{JW$st^Kgfr#Oc;* zh8Job4P9N+;3gdi!;b?Q^fU#NXcm#nOcp5ZJ6hVH&I#ji#-=2dcECQDEo3Xfx0ZNl z*`J9Aj>kG`^*nkqR?cHM7#R@_a0Qb;V(ND{ys!f+v2YP5N5Ig2a1du@q%5s1au2|e zFvib`2aqoTf~|S9+6Vi1kEq=N3KlI4wITT}1orq}2Qqa9+3^Jw3;ocB!N;6wq;?NhWL3-+GUfK^Vv+dF&5D-6^O^#^7$08nIi_LJGc2P4}>DE=E5e2i?V~)>&edm!GIpO$7UU$^!0kjoWMKqH4)qbYCgqAyE)l;-|&HJ_U6rEU!OH1Vt^AZGnGP5HI&~ zWz>QecM%^R!D*m#Z>^0KZcQTQMtULx6QYteUS9Dg{EkJ#*>yHHc!x?WwGe;`wz{sd z-+Y+!#&oNoS;kyVU^o^wEkO-SGnhE!tJ5=zuQ_`d8#8xN&C^o(a(gu%&Z1X)FF*xRNGr;IiFf%d{zMU6kzec;NEx}nDrx12Nl2W3#H2V? zeu8IAjGQ#H2(8Y~Ums;zQoZ5f7t-2SVMdi)x>JVBQdN}_b)SI!!z*YN$*CMiqv^{+ z!iJF2v`GvRL&w5ksl-hvM?wGCnNrO*MuGXeM~)fgv7c{^a;}M9P$6|t3fVhHW_AS@ zPYj+emy1XmXZAC>e}#%5;j?im@x<`YISbxt<~h4{4H-l+L4!*RQ`^;}p3+3tk=ORy z6l!a!ndW$j&-ywTU@B4h5U;QA25R(MW`5~E)ixZ7?$n#^n{Lf!FhZ!A-lj8?!@s;~zn=9+{Qg$HcD@dtdt<-;PYe7E=pWrcLTEAoKp_|?2Z)*oj1Yt` zK_Xj*thki8pv6O>4;ji*I>z;|{flFOs?U{Ie99F6n@-qi9Vk8wbT-Xr_QW~o)zN0g z!Oo{lT$6VKLCb6P8+rqrcSq=^*AM<9mi(trsXoxA7O;MV8091%j26 z>y-wGa{xtP;hM)rj*_w1@&CvFi|t^DvcmO2nor4Ug|PUE?TuvD%e214D!kLV!<5Cf zVILUW7N`J72SoaHr`m!BEr=j&+*xxmNsM>Ex;KYOsD@abqG^HWF4K1)v5u|B?-Sk; zTgK#!MmPyH(ivSOAltq&ota#&1R{T4v_EqaAJSvU;lbC@lKh8N4Z&a5Fj|?L`Q3Nc znSA^meT3~O4&p7uUW)C`4F~U>PIKp6koFhRkmNs1i)m!D6aogC?%+s-J0wJ5CtTLB zKn$@t*e9kp&y)U+i6pv*h4Rv@7<1Bt(c}KtqQoQX-!ZrqE!aN+5!+Y!V(y zDq2=BmA!9=pWp@Dt7P=Z@fH8ulc=;z(pgSRbcg zyi?XChOOM>>@c6IZmoRGA{TdkZ>;){`WfR@wByT9?0zFyx+{ozm_{ZKMHi50)_;l; zO4URddRRRIN>Y+>nACNYhE%DK!O1c`v2&ID2cpim>RxLcsv#r_fkY&QpoR+4*aB0E zimZ;E9%EeD6xB5QPrs)9St^2Sz7XhkqLMz>(W>_kRIKbuW7cQ1@sVyWg@qAxiB`Er zL)@>}@;UWmK1Q(03w4Avy{D-(o#0GY`bE@)Zur!s7YxNetD?=o<6J3YvAOXVO61bc zEKiR#zbD4E*Nc1f0S5XYjxn@0gkvIKKmz zj@oXKOXizQn&A^!@XhrUnJ)+k(CQe$!FxlZfF&(I{^zfeG9MPJv+o<+#ltaEjrVPT z!@Fn;H~O`>f~=+W#^afTs{!lQh>=DI8_AF+y=%=FL!J&}l)(^6FDcV|aKZ&k3XyuQO6{y!h{lVMX5;?1#ZCXyp-w|TE zGRBGwwk1g8(hpJx*FPT_fq6ZeZ^u-K-D2gN&D5=G$4Af@ks9EM}2ZTLUQ_7~~Il77G4R}v32qSKf% z<*K<%_p3O3D^aKEpa5zd@g7_d2&wi)RXHJ6q(o)rTWf3cY1U4Y3NNwXg;&^2^sQIm z7I~%*P&@^9@(~DNaJU4q$>7J>)7o&>>F(|A=4$by51Sro9KfK?-7( z=R_VqexwEb(s*2b!$F=I$%V=in10O^GdnFlO(Zb!43UQXSMP8;k>}eVr5lw3v8?Iq zIa89;ec3lTC_T9V7pu?N;iS*vq^}9_u$xR1BljKnH;Y%3@=)hDGO&DJGNzYz42PpD zIF#{Dk6^#$>N6UZp>=yTk*eX~ux=pPbX`qXFcmrf7cvHk#msQOcX)9j!g9-O^&>}k z*NFq)k;%RiiVHG3;eMU#P4d6!ZM3|bWf{@^lV5*$hNw)1U%BT!{MWW_=59ibO|KT#5Rt*EJVq7FfEU49)vQDOufD zeWMhJEL_2Vck+KYJGR9lgP!qfy<`?xKso|Fe?(9WDeA>L_#gR_YX8nxYlo5x#KCXi z|JvXF<*7tF+Mdj!UVCxm+A5W)%pzgrXwSFObZE|K5rn3)49- zJ-X+ke)w4OQ)MS_yFUVAXOu+c?27$v$R?7&JMpv0rw7OULal zieF;I?-PMc`vvcxhWcYL%W+txg4{V2>K%Tr>xx83`Tl@ZeIkA=fwn=Wq55+ZpDsri z1uuInT^Hn9B0jWl5UsZfwBCs6zd%%de$sFLi`D1x>zEAe?l^Me2U5fWk70D-j;>F*Ax zXNX6+?(@=ESI6@_kAwUW@*Vyu_9AioZ?*VCJLAoWlf-#4O$%~~??n~z#y2b>iUHso znS5X_GF6#zw#zB;TK^tClOR{?;$1_ti99Kixs<{f^p5UudxRB7ou1oU@nklIW0?c& z!}a(2KW*Kc{>E+Hn&F67L9&h|((^yj zb}1w+r}Lc+2hjeRNg{F{q17bYfd20UC7}8g`!g>0-<=`wAhz!rYSU}|)w=bx(X-*c z!HO4khl8I$^r1VOfL&^bgZ6BIL}NL#D)ujd=&#of$Q435hR6#r!J4|hWweLk>Y@T~ z7^D9zE`*dHniHVwGIgDmA~2u$&+z~M{`G=C;PbOMN#KvpIIh8;)Bk(`kvtXY51~2x zaW@U3Qdq7-We0Ist{WOe6VjY3nendTb)Asfo0=sE<(X1llGAC!S{+h@mklOSK$}Is zbr{ul{Hox^!03qjWa?l|dN3ahp8kcV!7*2sW>>l@@tScEjEMT_7IsGwvGM>ac3~Mz zdx`p71ajnL&YvB<||kxIg;KJIODUi%&S*-3udb>_X`Q6Au7l)or+ZX$QK#OoYPYaD9-~_Sca1S90W#la=_QvM z6KUnZ+^81lVBv2>OJ{>vi zu_-sO&^$I79;3Q}lC^Ov+prnV+xU3A^H0#O6;Psjt}2u0$vV$itX8zkHjFU*p0%K8 zQr|ue?0l1D?%=5O0;t}PM8wcCMFQt(Yt=6`v~|aALJhC5yJmj(Xl(2WOs`H)yt~a~ zn;mr5-+=pz5a(a7PPE0bA$Ax1tOW{CZ+2H&0--kk`BU$bx7=OFk&I$tteJvbT^}ar zO$PP-BnCSBKmts7#zohs&<~z*==LS^y4QWmmwy$B_BL9L#SO)88XA3)!a|HY(q7=K zo?nz8$hr$fs_ZQKye(JUMKZStTY1EX6jFYH`XXW2WsK$HgL*noqhWQyJX$6~;GTBJ*Fqmg{kQIAl5~ zH54$qEuY{QCB4yr&@df{a2#__1+Jafl|JXVX3*L{35Q%3Er}hqIf`Zif(-CY3TC*wpX%4Lzi-dVU5YBNyaS)SOQuC+ozSMa=g>p%H%$+jADyHeWY;qY7 zB9?;P;i;t82OsVu+T8o8ysj%RLGUjxl^?15oO#e=p>{M6G=bqMdh%cSk-CS)NZ_mT z6Y4^_7>>*!g89i6Hj_}+WDZOz_Tm1+Go6{=Pr#zM}J*wwh zj8AV83u63S#xUz@f#m48>9<9s&#vH@6>S|h;hCOwO67S^;z(2tVV0-DvUgtA1dvJb zh?1UBeqfADRN0p@JB_IgmT{N~gY{HZ%evbt4r$m0WSVANR?)B58_ zk^#M*A``dzaLx}9j|q#f0+c@Nkq*u)6F3=j%L7h^I?V{_0j@=eYO^)Xv?~3G zK(d{NVZw~r+;S=&yi^?tUpjpt;D)YdyPvO6d9;{W89c|zP>D8FKl3LwBV_PANYr7y zrNg;fEtTnQ9XXGLl4bDKe==pYN{eyCFbcM78)(!U7t*%T)Dg#BJbJ-E9lVaSkLv$K z@#|Q1f`2)lSY$ozxGDmxt-N=(k#Hv?Lwe=E6VJOR65-mc9Tv{=gYmg`0RTAA7~C5Ulh>8LGYe(`DebvW^=O75 z)KBthpLH4!@zWKH8_7JE`Ce@CvNzlaBEf~r>T|rtJ|hLGkJ>5F=a}eN-Pr|jJ7(;e zc4Vgt^DIvD>h4_n#t1t<#clTDueC71i(LWA$9uOrW8TRDu||A)~#KHrK)Je1d32!r7KF{voop;FsO{jnXqch zM?cheEU-eqf3hZ4a0u8nZlm{YSBDSPl#1Wo3V`N;1Rj9hlWgK4g1d)p9(0j?YR&7fc^3l8d>8)!S1&s6U z0uaSgH)##OdlR)GoI-bGWL;3!X{Ot+rkmND0Acw!vVMY@D$^Kp0a^L@8!Ks+$~+&Q zWb(B-3hZSN8K>u<=*jxHWj=rci5e!-(8K$w`O$3SGblsi z4)v&EEY`iha|blV>$GYWP+3=7Eib<8eg`TU@@tahgJSw|6oGLoe6?rLTl!lTVX$r7 zw(Uw6Lnei1hvPg%)>>!kUj5=+jLZ3JVac+h>(unoHLAb3JGzM=>q?DhK$$>Xx8PP} zh}_5vw>p)8j#!?bhCmRL=Eq>&IS7v3Pje}RhElKxu?J7hv>G#C;alF7`m`;0ExM3lm(oQTS?;Q z7&{}d({3`NkvEc>8-aB9DRMqP5bF`tL{>&5n1~6aFRF+cMm0k*PXbX|cAiqqmW}aA zyQghWwjT0##Wir*#}%%@14P0op2fwa9lRIN_k(~` zUV@DRDwjm~8DtbCp~a(--i{p;@4IAOkO1+Vscfvfrp~<^7rn+H>m0{{c6IQjeo6qQ zZ_e#Dddm6dFNDe)$@6zP+0$g@#P6wKgvK*SW0P@5Y*V58rkORnQTc>-@H{u6aRCT@ z!IAElV)@C{u1GYGSk|N6bCQcXdi1q=7Y#GUHQ=TQHvClBF||5w+9on8PA)+H*5}BS-t(3<&KLS{Mg#Uo@C_E1auCxX?<>D{_@$7q zczB~tCRFk!K%*3?|IM?I7IP1C_F1@waOfN1+|WRLC&Ng?!}ihi6AZmLrw%nCptjr# z2I-^L)+F*o4aM?Y1>bmgl>Ky%AkMdjZ`U3 z;^sJSFA9KFqBBA)2elBbOu z^(seNJ-i!#iy^Ol(AE_#YKc>Xg2fFC6E^TrJ(Mg6pb%Se6Ywm6GW%$-t$oC^&t+q# z7lkPwZw28{deC5!avU%!T(1Yn{`SU8$SegP8hm^5nrAd#Ix2R(x$3SnHMa&J{fm{NLg z5^0F1L@`fc^Lwcx5Zhs5e>qc}At>@}H!n8OJa?E3qmMSLVhdQuxI#22{H>~tBW@MF zb0ENKs02b$RB5aA0*%)nATcYHB~EKoo!r4{NVFgq)Lm_O#U|Y36gDQijV4jNqrjto z#D;BaO##$$Zu&^vvZZ!!*&$mkC#<=iTuj!>5Ng$~oR>Gc$x%COI^W7MZjr|3{Z)4K^&9(PYSF z4XlwVt=k?1t$L!2A`%x}Jo-3&S4N@_<*N?>`zp15LZdW@aWu5!L+1&}W40`XojIuz z9*8Tih0q*z65J-@!L88gf*vZwW5_PZKxNXwoT%@+$qkVBBT6^`)E2|8uYxI+yegrG zZzJc|X2~1uFu?rfV(;)5JgL)fF`t0WrFvW9 z5m7`-j#}4ufck2W4@@A0wFLuPd0axs%EN)8l1mtabOKk13MN6ZD(!(h{QLRDfNNII zpR?R%&l(qSXEm(T&su8~U5`y}_%bOODp2m{GEN~Ie4?l>+jljbGG?Q(fNkzea#U|N zuSMi=>@MAO6oiX$X*NO>2ICGEGEyczr`EBTu-R=6@*2U?r4ed;ccjL~ZgMJqcW)n> zSK+}_AOoL0!2YZupS^}C6SNcOf_!i`N%Bg)Gngn zh5qB9JRd$UcSsFr;;}2mF*{$L?boCECYa9%F^3`bsu(qRz?-GcP=O>@vjvPR{1W~H zZfj*)mkmqSbk%8z1Y)|TZm#O1;klA>Ui}oX3V_I`;Z@9ItO2_+9oZ(9;J`-2P`7WZ z_rdDZLS!f76C3Wr$1k_Hou&!T{>@<+L7G){R$F)ibA)yyPp-3>&$|@t%GxCGEW|i% zY%!{zA~Mi-`Y}^aYGxi&g>i(9hKZl;@-ctmjP!Q9iaYEdVyojF2Wc9K-o9$V}XPzzsubt>2S1k9~!Wz zYpzeugN0X4ziaRIq_2`c=rG&7=%%UVsCr)S5Ue3b`v68kgBlAa`Y-~t-OyCLYnXNq zYEQgN0Trw1;6EYzMOQNti}JjIG?5XyZxX?m&tWa5H_7xLD49A+d1BP~B~E0HRDOhc z1~)gtXw1yW;ryBj-&RVP!xo0(NawBEcGzx!VH32&_VPue0>{{NXL=-a{AA8Cyg^K@ zqhdMueQxFM%u*4X=4gNj)| zf@-E*gN^;iuR|8ha@fH?W(TYq`ssl!0$B7o5&Z#l{<*KBM;Wl=cTsK}`8942l3HPzY~%3#tL8sl@-efQd0 zAZAmXDGk}WT93q_vYw^(?Oe5EtKsb_O{6E53sz9k7Z1Q@yco*DHnWyqe|ExI@y8F( zvo*cFn@qF=AW>fn#>U1(&sDd{isW$uN%VBf1&_Ue^Y>L!ziLhR_%5^8%&e_yd)(AB zsy>kx#g|+YN)^l5Lt*uFAb_#P8X)F5*>wsiNPiaSt&O{4eLB%|U^TAde+=Jl5wkk~Y{Ne2V`tOm8;vGXS|NtXp-)qZC;I_-lNyLJj&rh{Wo3isZX zHp>kOzTDT^8V%HBpn%9`WMi0B%X=J$M5bDO6V`C-35lWCK3Nf`NpAITC$N7Rtm>ic zu|(+u5P2(0cY+g}RIO4scA;3_^mp-+9}mG8k8ySZjm0a3=U=KN-8rPpHZ7tQXZvy5 z1;LbHxi?F~5=Modz#0Eh@)CowRNu=O7T?BjLv72%dF6PA83dXv^1MgiaY{cpb7NkD=)u;-Ao5gYNZSb~oZ2B?&0uVw&9VgPdwqQrM>mKT3s zV;1U5=_uPpVNw82s1uFq5{RY7olT#^-Va(_Y|*bCq0ykgz_KOrQj=y0D0I6bwEZsO z#*gWJN|58q`9bm+M}lk|UAAM-l^_=mZqNl262_%1u%!?yqx3up&n9WdvOoGc3c79Up%{D_!LN*?ctoNF|4kyCgs5+URx+Vx<89k6XB@0W1 zdrZwRp~prj(|AZs3-hZyx)PlH=T8ffub=qnJPiOOasr-#s$~81j!)v(^_L=omNy_j zZwCcYnS5x6Cf*Z}0g>(TjxaqG;UU2H?JhSj@|b%$SrJzVej14g6Nt-tMiw!LzvzL! zBcF1(_W`S0-7r!BP`aC#d&L9IUF3s-;^|Lr>Z2RWSK59}t7tjfy^A4zhH1rasXh$!`*bAaP4a!v?PTW800gc*-dd?qCCpBqf`Tj_Z=*> zBg9{Rg3+8=oE19YsA$9O0+?NUrYN(nf#<|Pw!Kdg3JL{P`l4FPJ)KbajC4tRCxjlu z9HF2m?u+a$M%#U~ckf!H4($Jf1Eut@xY3GD>&;x|W;#*_=EKg znL@P1&Q&oNM03*I(E2VR-Q?ZSN_K$03Pkqc0EX^L5q?d;3@M<4J6el?CR=S(c>!6r6UD&Q7?18f*H1ybga zO<&?h>jD@P)1*X@6J_1st^9$=*n4({nBhR;M^l<=J=#uuhU~kLT(V&RZeZ-D*ycnJ z2g3c8o{(zRfHU(Hvp`PK1}UWLt3Q+3Xf@5NP=-E{yYgC{=PF$N+*p`Zb8mX1zppSk z;(zMuZI=s0GeQGI^SaSA&^SP0oz$WL0O~XGlwa!@G)rv2ePEG9Ct>NuDZhUoA0?vK-yX(PKId)ZOrfAqWl-MM?lI_3N?JzdW|1j8 z94`}lg%7#E)B*Fx-HN3`uu$Zy5jeJ*EmG864AfI_pcD{;EeuHCZngC4bpQa3H;Ur3 zLE z_Sz}jDKAc3WTDbyx8B6y>f^o$ll>euC|M$1I+(popPM1o+38gqu1_>0KbT@bx)_`e#jZyp>`0bi9JKHLGLvs0x8>K=HHEYd| zhW1JueeCn`oaHhRrQRc}6J{}|XN9@R`vb3-R>y}>^}Yu`^SQ;RX(R_n;NHWaIVVf2 zVl5Y7se;iHn0}DInQ}PcNFW=R4(jh(E9FgS}{U`f>kj$EFA;i7kZe+%T;n=z7QaTx1|?A=bTth|*D zn>R5FKo%c6C3O}Kg4qsn;Yh~c<7eYA16VA!V^u7!j)a7q8W}LOo2!j0@1;@drcrKY z{?eWY1>aq;L%aNqukw5HOM+ESaglx(NWAa^>!{CWuNY4Lngc=%~m0uS~zA z1y1aa`ttB&Plv7chD4EKp3CcvcE_!;ZGXQcKDQBVIUuNS;`#GBIwl2bvwamPt>|NY zO`uB(Ju`FqcrUV;wI1C2efezM2=e<{zw9>xJ-;{pe_Ue-iok5$d;(4|@f2RdvFwTu zb2=(9pv9l|>)p9*QQ(lqkyDF)L`doS{8)~;wA2@GH|ySIq`^dCP7NbV6})T(-TF;Z zdgD3gwL{{q-rno#?Wx%3W&h_i=xaO}%GIDh7{+f0cjlkv{aQE$pWARiqpmIyw<-Iq zV6~$77DW1AZd=A07_*pPIBH<<@+V+pulAsie$bZjNqO(}9_aO@|NEH)bQ}!2cysGt zp7imz4|GJd%2lFq?9w-XPEHtV^Je5MwMR}cBVLU#dv1OqS}EuPdlvef5l1176ir}~ zl8PZu-IRLd$chsi5u(1UbU^tt36vX4M;q(D)bEZg*}qCy2hD>%y@hyLzpwYcpG8FW zzlC<0kezdXKaAL%kBwS!t^+so%1ABa!4@B2ZaZ53B_8WqwDQ^$Qh5DY)HV|RgdK`% zE+v75U<-aCzn2r{{o&K{7xXCzdR_1TTo(D{YM=(aWd*;D2fvGe-tk6*KPy4Uji9IX zVDIg3;;XN`+@ZY1XX)F{!+j!z<-nYFx;(9HSq36QAEw!LGaK}np$kBM(@JHct0yx8 z!RzP9vwaZUT~G8f>U)l{i6F~UVMoyS8w=>}zcvgO3T{4~K@ZBHPtf;x@XYA(+vQ$C zhs*b05tolZo&JA&x?e3P3ry>U{kEd(M|xE(4~$zT5h z{kskR^8Uu(7XdvQfB)C*DajY!a6jmKxc_?(^dN7yfAA{2cyod{dR_vR zNC=J$*R;0QP`SynGpYKu92{L(wLEYCfR)TjMVr%>c%W_AwAt{tKl)NsS3Kju_t05z zGFJ~_d4A3a@QUDJ1iwQ??VL3Cy5+w&mw$mIk4j{wXU*M>1k2e$st)3vsx@VMW>p=J9%ox zdwHG5Tk;>Usn_X>3-pB3l{y`%ykZB^{VJvG0sV*jw?NJ7jQXjcY2S~>o^McA|Mz2X z(8V&J(C-OCY`(w}K>zioQpMm!&C3@T4)j1zsr-ut8 zE>!iesVMX<>3M=UFsf3~1zeO$>Q%p0sHI5VepN$);aqNRDv+!Z&TeV2NvI+znW~B) zFP3JtXKtLtmT)ln;40*Ap$h10Klpp7KlQ(iJnrA`>%p(%pvQTUu`NE(cUJ%BFzBPQ z5VQw+DFpG|o?Tt-_U`X{_l#o@yU||XSSM~mQxWL3M{6-I(=q6MLXJS{CYP-sQ%C(Q z!KDqo7>q?WJ8MN@%4ZsC{;8ZhKT6FWpxnC9-{}YR@k)!OHWnLNPB8Lmm83ygTKJoL zpOf{4$l6B&E0jDB^>+_6{NL{BpZ7HX>~?N|!E^q8dRcO6OuUYIdTspsRp{y0x+Axf z6bFHgbg68*c@=ml_Nu{Kxc-m}^B?rDWX*h^DeW#7ahZe%mLFMoG2TIK1 z9u;+-LmmN2f@8+{hmws?35{GvLZPcnzv&r@+3%VuqrA(k{`{+;+v(+AvBQm8A-#(Dz784ne>@Z$`M?Q2iMvm#S(@^Nv}$JoN9Oz>%z_nih6f$VfLn>8_uk- z_97dfODz5GrA+tt4ZGpSZs_`xU03Ln)n+OovI@htzgM~8pbMewpci~oa%xn1gzj#t z_?Y}luthvl%VZ82GEnO*OtB)i^XPh+ed_`QZylQkId+#v_62xMsFFLb7~BFVVwfX1 zG>5@@$x_zV`oDIRgZ&k^KQ1>&geaXTW%x4YRfy4W5xHp?%@V2PJh&@FF^Z4K&}Ena z=A(Hd8~yiAI7lU0NJEdZJUt%Wniwo2aK7wP6cv8O+NWe(=wgPKJrTRoK^NemPcgaT zd}Rrk74j&AhN^n|3+ZRiC-40q$65V%nI{yv`TLnOVlP6}^lvB!M0VHd5~86j{J zM$nF<)!3;c%;P+WA@(Fkt@Kr0xO~F5ryU+^dAcutTpe*Tku#*%gBDtgV6nFU>h zrdF`dDA2jaSFEp93&;4yp4`)%MQ7!rE(8Py2_@77nsJ2$mAC3)lm{6<2tDlHQw`pdt)|tMclq-#QR7z_ zW)RJd4zIh9ck-x&6(<;DW}K`eZj^~4o>$BMHK8FRveX(P-O=e+=ZPgR!mJ5TwiWaT z0Wp&=v~Am80z#=5z-C&|ITG$=)SJx*v^_^9=~?U&PHJp+RX6xQ9B$$J3l!e_5-LKW zHRqObdVWZG>ERTgA`Pv4i@TVTMMx}Su}G&3xS=}pt>sCR z$4BU|=pfZYc@-Sd*!Njhf#q_~;1ZITfkG!>e1eh%m+0t_1@)&1cfhOX?dII);XnJ4|81 zA&_JR4e21$47TlB0lh12K9&+RE+Lvv%Q)2g#k7FVc`Mda%&3(#QOzGB55CW>lVJSa z*l}gezxT8B_uCyVV_FfhD2puY5YS9eatu2x*4zS)TInxGjvWDlrP!zx5KFoXwRxYO zQ^DU?U}iQ)mg8<9kB>uNW0yU>qp|kI(aQHd_EUj?CUw(8rjrxgYmG8P15ry)bOsSR z@x;MOs_<^--bI}d2|j8-&(-ZU^R)J zuk);772Wu;6Dm1VUFZuRxoE@BI|j=EQJ@pK4K2NO5~7&wY8l1F!){dnlerqYsved! zWUi5=C28U?kl`UT_&vahwnNC+>R%i$k|^W9u*gzma~;auV&-7aiV`M@luFJ}C?!+) zGPHrX8XeKtyvq>fKIUvLR=H<5Q(6g5VrT-%APY5k|2h?q@$T)s_O#Rt0A+HToCFdB zhtGJH)-9Ynb{l1N5oDYdOuT$I$e%K1S&NsyP#h1&k&Ik)w&ycXaw$=)E*<+K0#)id zrKw0XyNKCKA{K$#N|~<4iW6PUI5MHkfnHY9CD!e%1g`J>BLzoxQUZ+r-GQh%cgHA& zM7VK~KN>R#o}PB)DbBC+wMlw3f5?2C*vKw25d5Fis9{`8YbljA02g*99v%>3>X0#5 zfeDSKF-il;!iJouQ-tMOTw83(zci#-D`=P=m$|wkp5GiJ`gba!$8BCcCJP`iQCGor zIFU%or1grcTsQN~2(Ms$${87MGd6w`tceciAJ>xGJOo=p{=0{d0XyXOEa$9Phj*+Btz(iMfQCX&*kWf#})S{_m>g z@V*kptevC<>BRBj7Fowp=!^Tt&F=@P>#C_VMt}UQ;T9D}a&F9s_aci`aMfB=E&=Z% z%2wpNZO#m|01F~u&XtPiLvxM>Kr4i*YP%;=?I_w0l}k{eAjRIwo=um_i`U>4F^!Dh zyBP{KD;%5I`LRr&@|sc5fl4H#*6WrB7Hmp|c0Le;+pAWoI0jngpQJ+;CQA^rJ_WRM z7%@Y!f5xS}V|y>-rH&unAWf7e01EY)8}6`UD2}wG(@!nOkX$zcX$|(0jZ5f85|0yV zmfs=p#N(3fn^}d@YLI2u4}2_qOW86&`uc{8k})M_K%Sjv|2sV^wC=mXQkNV|L=(4y zI0B9+x$y(?sY{p2;GVE-F5s0%+){gIlL65lXj-7ZE%VOf+Qg(PfG*8Z4!>8_P>;Rs z`H%<(6EKLG{zhK3)BkP85|iZ>Dd0^p3S8w8ucjrj-~jFHilCS`+Q7>pay39kqk(4v+eZ5` zZK2o_e_wuyoWI5SUI1)AGcL9n;Uv_->0+@c%sTt;h2^npz+b;m*bg{QR`SF4F;?{D z_A2?TGRz3uE>A89pR;wt{&?^SuqWAy5OR$DAL;uhGPZeG_LPsldNkJ_e6$d9?!Wxm zoZ0Xu~YMA+9O@DyUops1zxBRpDc3D4)nBJzgG6#T0tTauaZt zt3k4LmtlwivJ{W_Y}d|kHwBmu0h^b1=d&^?H3U(}5XowZF{7AN06#W~HOz}u4KhKY z4(cx_>3y1GH132!;L*Wt-ljdNW);QA>h43vE6Mm{N-2;nr@E-7@wwJpHf~mTh|)zq zfHylg`dr||a7d>3)d6>7Gp8o^hm+;8+>61`>t!z^+-_c}zD@L05SF0@F`rL#U=Lru z$!|CFNe|QSPMVLIUh)2!^O%YF6&(C?OU7|uVYus?#{505QLd2E+^c(x3?;gC*2Rij zCQS9CS}WoS6aI+<;<<3w7F@#?0hVGST0j$>n=vJr_KX?!lIx1hmOw?cPGWDhS32pe*~=G zS>1_{%PLDHM>?XLTmKbu;pX`-3XMWcZ7^N-jjxCd(HU92E6qr$Fqe~=Kxmx-NpoQq ziHO^-2Ny+sxJx!U)~>*o7jkNuH@Ovl*kpc)2vTC_htJU%Pk5UVIB>T}i?opE*+Rq$ z(3k7w8b(`LE^gz9^s*nOOz#Z2lkZDy8~6raNQg)}xTKIcn|tu+P|34nprnSVh@tfK z56;SZNU*!~(lgzVNEy}%${$blB7b2PnqBRYJcM@1{SVlb3O9X#jd6kffBB4=Qba3cRPZIi9&1ucolE z!Kg8v5}5;iG85FywK@_dy~C_9!si1#O!_xSaR?ir;pQS2?!nLNe;t;ba5=LmYF zq&;+YbO#GproC{oKxXUVF_dW;c5k?7Y;l-IO3C4FOww<6;1rY}!0-BJ&I$#|<#a!B6O|9Yo3^AzG4Nar z8NLy#%gl{!#NxTCiAQc)+9GHgpB3}lVPiV}ri^J7IOFQ=Bl-W*@w3niYWI?l4wdyY|qMWvbT-olyUAga?<}K4$JKqrGg{r z;m<2ILqP^Qgr(U^!ZzefGbbG%j9pg$e6ZhGs^0`Zr+zRG4wK$wXY%#gi%(=I~)*n2mh9}nV2Y#OT$ zFCoQlK~ZFdmhK)V};7CWUh3?K);|~f;qe;;_m!GWpcl<>7KM` zA(>KyZ8#Rj)c7=`Y%&_rtI)-DOssX;v4MbF-W;1)4E=e28yeYd~l}Y2L2%S zaYDaLRjCR#Y`%RvGRhDTVKuI(-({2~fRm2$nV)L8x2g2{j zY~J91Pv0}nh9%PJM>ccU*W}he91>=D=rEkIvNHmm<`!7hjno)nq1FS~f?nT7u%ZK> z@>(b%FqLh1QfTcin4shzh`Pl^3xDDA&QiXOKesZ z?8h*wIGSlo9U3hU;*({?66r&-Y_>|*cXF0a*!yD;0sw1zb$~kS3@KusYH%B~5A}gI z;4mOn249$Eq@kX|5^{GQ;3$+o2mG>K-*ZKeF5C>q*!=?SqD~=3;bTIIsC95>P?qOX zw$nM$OYG^e*^L@UjI_%pfF)LOJA}YQ4`!i?s!B?eLSYd}f?@$@g^Fa55WWd5M0?nkR2QC=F9I!L>Qs^SE zV%*N?(Y7>9Mx|ur*f)f)-*|IMZCn}L=|;#l9N#tN9si-RvQwvje-;v@I(M-cDU}@j zYS4TDJMd81WK%}l6qlFY6kuYyfC=w)7V;$gSbtklwlU2(4 zO?&?0c{^#ElpC222tncDXOz8$5yl|F?jCzVeaIdHn^{F<`nJ<)zg5s zbz@~uA<@a_BIiz-an}#j)R`OUZEl8fA&Q2RGMcp!-w!YHAFy}ar=IUAjiB@0R z_L>s+7O&dZpDv|tMBWu0>Fjn*_jQQ)6!b`9YC)?}f3{|2(R=KQeZf#|k*yU(vq66J z1X~;dKWfN>+!?502toWt3c4g-}67W?6M;oxnI3AG!Nc;0p)=>xr{_on@If&fZCMI9;rGP%I(lcgOo z2r_rSgLW<|6R>tP5d7MSl@5o!AEP0F-18`sy6DqeiKkp#mec=vpt`@@FZ75~2BV)3 zR0Y#vokXw@q^E|&p1qFr(XGtp-DvD#I)H%!HmyQ#bHzwD1*;fyhbIT`qoe>^-RzBR z$s@rx_j*f9c76pxU~4{1LS@&uvxZ0bU*c;_Z6}7v27Ae|Z|K z0n1g&5knJwQ(9Sw`Z*W*>`I=CxKso8ayAd51%~aB-8%)yPbA0q)tE3rjz}n2_oA&W ztB>4pO&m_zn1*BU17mEc;5Y)mP<&Ca{un{J10D~|QaeHn(;3Sx#DG=(sKyjuXUhHJ zw<;bHr^J7#V1O*@5!k;<#`brfF(Axe?E&KT%`))%2jF?3^zaOhC>4@D+-M=L1O@w6 zNxkAM^GH~Ex-FOsbWe~zV%-fMJiImjkoD|Y8q&N!KjnP(^3}4~yR=6a_-c<&=>#){0B1j47b<`3zlH04ZqDi*enNN~L^A3PN9ZkaGmi8qnE>@Nr5`bmR5fkEd_ zw?nMv*8OH*rIBC_V`wbb=b|tQK2tYn1c{B0$%&aubJM|vew?bIP0Z#GQ#i;I(Z$_m z^tj~sC&SK@+qsfYW$NwflOKilswh#i{x zZjGv>0qH-N#Uo^$L@4?TVLKvh7TVa12}Kzk-ME-S zht^kzs}YftnKgNrErBHAjA(IMPJ+-(``w?pmfjk1+g%7Upq^V}xuex^2whPqHd&Ye zWNYk|WvrRhx5cBYA&I0^%Zl@O=mXEgVRaa~kR!4y$mkS2Nv;IPoyRf7;>BG^5U0G@&03+C&tkRhJ;`mky`<-b}DsmfGVgw`kbnU{4 z4(sL&cw3b5C4etN5a#OHHB+em(Tpq<^u}wNW|%AC{3&gL`~S^F{bnt z&P&s>47A2qtVA_N);g)gh&h{agy5&u3s7uDM-IDK95?I`mds!pz^Ed*axtk6!7w$n9F zB&)(?S5BW>h!Nt!?Z4FcKA-Fmk5^b!Zw(R*R zQc^i~_!)U8YU2qg@XKz)`9a{oW!J%TuwI#0ie%C}6d%r6&XD^A`__Kse?+999s<>8CRlyscu`-qAR7*bX6SUXsetCNlj4g=S z{=?@CV7k25YdzrhRu(igusE$1tN)#qR)+{FI?xQEZDkpFyOc)6uP!-sRvTC@YLq6F zqY0^hBFnnL#6U8?b#rl+9qlZ9lFn;D)U(Gt8A+KgmMoK1l##B!vQt}&obJ}cybh*$ zUV28b9&j_uD^cre2Ng|x&@KZ(D67v4%ypsiMmYGCLrUu`Vn!pD^J~i)GfVmlhBn4ium6w3Y$2D5R>;~&(fiP8387Ku88695h>s72-(UOj;sBLSYstQ=!4EgI zZKu)X1u-Kj+Q(tQ?UGKBAyUo`(?lUpzzNiR3|T$S=&?2sjW(It&XDRB--VpR?ESDv&xuygzW-DU3uwE;`6inxlsQnJa`i| zDmkhv1JQ%|Kv{-9mYWhbSHNsBTlA&RJ*;JMcI1M;<9tH6b4q^}&K0^>b=>6et2OdW zFhhU9yTy~us4r`MVF?91f59kKXgq#{Ai&RQK}?I>ocWqx6U^pyS2NkW0xnI2|AKVQCMPMpYVtNOF3*AdjOo6 z=)T^~9$&T9{YwyPgw9juxX8~$z$3NsGZc3F8cz7&-ZzE!WEj`RxbT3ta_)WmC9mNI z#)EW@rl$*MnlLP$URJ?8MWd*iZ>Q2Ww$J$Lz7}jPM>zbU*ByVj&y&D)>)XUWIzvwB z;%#Zw6I1fGfCGSkQDGW&@qyUJr7${hx|`w>%fpTIRVjJAQfHt>kj_7|n#x&mX+EQ$ z=a>7ZmUZo+^cWc}R#7Zvlx$qlFVX<4&NHHeg9UaE7$fDbG7(_luUn>1qFFSg5`W+& z(<(tZcJ#vc?xmX}bg3_`UKPWedPe9E46NDW7y-c*qn2$zkR1jLGpKHz*?&vEMVUCe zsP3VLJz#?~;wEO}_t&&jwU6sYWGqbnY5C2*<2xCt`6%wDVIb=AV9TNBKqnMqyiD@-$Ded- zy*9!W4~_T@ERH6I;{9(zvlXNSSe{L6CWp(j%AYcCKyPS)kz_XcW#n{y_p#ngm)3!F zAN^J^BG#YiTp6TCBKVwf{BRFsXxSB*L#r@8R!GNh&s_oiuyETe)?pGb{$}w3m)pID z*L{3!vkhPhzbfio=*hYpzy(UOu{Y~h=||!WjU@`%dFCo={-}lefBH)3dxC?X`Mwx_ z{Zskik7cYlo_((aNX&|8)!mJZwQojd08zmk%`(t9hgTj!Y0Gn1h!35PB3o72A7y|~ z3t3tRq-~V1+cY*vsgJMxFg^#Xh3tnNa#Q>wp5~IIyq8+PB~!W{mJ%uJ-N2FV{{6fk zvF3B5j=%8Vg%f9^t*!zNV$|F`xY;K$>CdXDC()+EU=V}enAjP}LpW3B3+_gj8W-&( zpdsKCsK`$aQ5^O6+hmu<8%k>U_P*f5ex2G~KVBAKuUY8nj>>?q*uk)e@y@M=|qli@s6#C#%4 z_$311OXzX@ps6dsjb`1smpRq3n}e>^L$8*in>$H=mz*E`$1Q{@;upk5D%sWbszpIx zpON(8?pv)2M3#Ny5ChcggNM2L>cC9U1vjqule%`1^ik3zGq5wgGFSaQ!l^(BJkY+ z=cMh#H4PNk-JVji;7I=7RGje7P7z&hlSb2T3r7jrbmbt&7`i2aqf(p7@X5pe&0^si zWYHa~cEecN%43Npzou6PLZokry9y6X&pGu#lqfkSkVZoC zAYwu;tEF{vKZ!jKTiQZocL~Q0_2dt6s9czZ&6VQQ3O7>HouF0w2E@Ew)IeHwQVdwo z{rgxg4<>$?S~f)Dq6`X$b3Z#v1gGX{l-y#An$r(c?sDSnILJSK?vdRHg2;<-XG zhwZO1#KmmXu9MPC@kcg}=pownCr$rn>SN+JJ9}VJSj+P$@NVJ`c~vAxIwr_AMRuri z)hx=gvNOB0SOP^Gb)N3|w_mn5{@!P9k7g*tBRU|-NUu4Pl;wD6RJ2nJkuWl=AUlJG zKfx_0rSFcI-|du+aVHV->s@r!N4`<=$h-@}y!RZ27VeJ(4Mad4wpPRpFwlXR2Us^b zby2?&4YgQB(x<~DZ-%wX`qL;R6NH^858q`zs)vj~;$QLDh(OLrp|r9#Pd_uS4xOXV zJzN$Zl+`jI=gkDcmWoV6m@yXZCXUzI=Jk8d5iPcqiVMcIy7*Z)B}_y3u8L&vZ@x)D z7W@st$hh4C#|%K!w`rE19C4D?75E1;u?zxyFFNrM=qK7K^3jQ-KtF6K#qo1(>8JS^wG6~iIS?E$)Qep4T4Y?>IBP%=?Rd3dmWePq(e zERuy|l5^?UQv6<>Zix5$F7o5(6$BD3d;xwh4S_L58Vz|FL#ETQ)ZJO!7Qn zxEE1vVeuq`aC36RI*3)1oq5kJJZI}d3@m@w=GLb8NE-K#rY)P!-i$add+mv-0n|u4 z@OSI#I53(qA0X&m6Df^9juiMG)Kcv^-UMrosNFDFCB|U;KOmkN=$QPR5K{&l+>ubz zxE9r1s3Zo(ndj`O_c20}tIc3*@0+}5YqAWfDa$SvXgCEc5eVoU{C-PgOoh31s>px6m~K z*Tp>*Ekq>=%M?yPM5D&2+n~trl--JVn=J(N!=^6T{qWYZ(ifU%+Ee>|Y6nHh1*Nbd z(Hg__aqv)H`%`NvV?~B-2H67k(`D%g%+R^e_T-$XNI#NSSasJ7Rr40S>cmlro&%S7 zXL2WYWFby+f2U45ua=1j86OQpa&5ZgDStLY8M<5q(}ISQ@^4KC&ciTM^JmP6M6Uj6 z7}$Vq3}Se;?7kX<4X`ycx~VHIdXZi{liX2$NXBDV2Gy;4Xdj(tls3^OFFIAkrm&P^ zI>pkcbcC+zz_jt)kTP})eFN$cLEEDQFe~^WeRs)DE4>03&AsjwmIth*Wx-e)ai9QR zy*jsM?0*_0!N=4u1=?RkS=rGJlkY$xDiI8Q4=i3Xj{}BKssFUlD*bd?pMkf#7?1xD z38Bv4`CNZ>S8R-?5zIhWWY^^e6T&*k8>%#8b{HBq5n*aLOC?g0*OeO5!O;KF6Ng;# z>)qS*=w>S>uum>b8FoaQh_&GvkM|O6)By|%SKRZGX?Q5Zt=;)aA9`}PtPvLgi807H zT?Cl&z@ZW&*m|eNt@1B*FMI#8t(-C-84@VT_F%p|Xu?&PbJC#oB-c$SRRERnF991E}%Z%(1)bqY*cV8$j+` zrmbK?*{IG+Cs%3fA)?UHmADDjcy5WPD$)xi`UR9%*JK{-u&li3{|OP1Irio1bK+rL zsEgN3C~+uS^EG@hse}EnmBU?XV>KYxfjNoP{w5Nf!gWO3IWlsdZBGxuO`V#lTq>mj zMrPOi=Bbd$#FM8evZ9(5yeiSc)XLV6%)#E`M|%dlLmQi+{cmczSlfXfVO?>PT{e~V zJ7*ea9b=uAV5;~qktuWk3?0zb)Iw|vS5V#E*5MVTWElB0<)gAmnDvIc%k5KLP?9XJ zgX4cr*UKn_AG4AH1?PAb)xb%L;Hh+9L}q^lgaaulhJSX7GoZrp*wdQu)7SD$C2X+; z$es?|(4y3HwcO`bVxN47RDafWcyIMYueSMFqS$39V=_G@_f?_gQd%0YnI>a$4C4+^ zN86`=QjIpi`4=`$iUOr1ng+`W=!mdJ9d%c$Ou(pAMti>m2#rI!z^lDaY|g+;y3k9k z`%!~QU2NM|^idjV=ywW&2{4F8$f7JSYrEx1jr}9-YLCobzG|QUB(oD1llj`}4szjN zC@Q9|nc49P(>F1*xVnLxhms@7Fbk0mS}{Gx2o}-3LIelO>er}t=B(M_F579{2ZO^|%af9Imyx7$S^hw9D|9sAuR z4ovg)#`7(BOhvn)IM`bR1(&)u7Hv~1PLQ$GA7AfwLiqUAVzwQ8(1Yv zO@6@ljZ_D79;Glk;RpY$dio(TV!mBljCgiyjJV5p%<_3iRac=IU;3sK?*@?A5Avak z5fzNA{3*trK`}xfKzn*QItLk@?7>wz%a;@bF7>==w49mx3TuK(1bhy-@ zyhcfbvd_S2$gj4$qKZMZ1_=8$>M#{uW|vog2$Vy89xP11tUm@D@bMTGmA0rY!cVM4 zpX9O@taGtDmujS}Gn>@>&Nuaw%eq>G#f-SH7pxn2OwB8@H4AlN!*;YAZ(-om?{pYY z@Jf<*n&sBxQmX-&2onoeMGNkWeCYL@3YyK0ef!2gW(xt!@;)Ox1uBW zY0c(F+~})qFu^+Y47nWr!eP5KW0|I|#{GtkFHGK@&8B{g?E+y@Gvzoi#0(rE%E=h1nF*9Xe>xtYKPVzIOk2dqBJJp9FgR_$e#y@8n<)9A(Wu+flG&R` z-qq*qE=(?L&CS@Q*p@v#l#T51cCC=&*nyF2j<5F=G1nx+(M_ny#F*_4N-@q*J2I*Z z^9|ynT}u{Jpd;cc@@ZR|`#M~1V41(Hs0=4|qWlir?bzFQT2vqMV9sDQEGt6=+a~9$ zjwr(1NC}I$s>{-{9Apmpqrj@c`fe^Fz@(&+VTe0#KSUP_!TT>TqkKPC$^;OjbM&PXTq(2Fn#ogjmTd@d`R3|ZVZjQ0zs zM!>$OtcI@@t7)(&Ev)?Ww(wOqzXJgrJcqVmeGM+b zStJb63H8Kzm6clj-q&2`z&FGSV^#CB@a~eQZWvN7G4gJogY{PEcb*w;oZHYH9ZyY` zma#E?-74$D9*M=wJBK(*+CkMHdC}m8geBl_+si@JL(Aw0r;ATGt9 z!RyV0Kp8V9iy|AI^(<^KnOGQhoKR>zL<_y^JR)#y|CFr z|Bq4PkvM2^GFH~kj4oIyyUXLB1*`i2)9ftDgf5zmN6c6rFIzCDrhfyP-9Iz`nla$7Gq8yW(DwTmdVdlB(sKq_Q zG=2!IjUWD8=z?LQ3ADQw|4vH4{}SBxH3e17rcChOrIT!xIL2C?7}j&*RPvl}7=f&LkN@&q-kk$%kQ2+KKoe zy=`&@dCZ|^In8BU?c|3<9e|BkV9pJU*z$X66stC~{kFRh>P8rQ{j5;3Nmy6DLNN<+ z?Ou@5Gnrb?kAxy7%FAiHqa`}eIGyABDn}HYtGXZ3q6p2bHf+qR>-l!nY~v{Cp^tV| zX;bsU1LWc7Yeld=O-d?pe-t8rv)Cl4uNqNn@JRdylyhM|`jk1agi(0H{gmAic9zZE z#=BKZ%yxYzR3CYrA(bMB(3!H(dZ}o1B<4cLt+b*hXqitb#Tc3X{O>vjlGRY`V3hGa z*Hp-ML2^Scs3ERNBw$tLW-e%TnxN z-vVa9%CS_q6mK_^i5*(Vo`l8uEZF%nqGueiZp@v`>+|(!sOVhm)y&Gjg&+nvXi!?m9LwOK5IvlYnx1UClFV2I zm0viu{tyo&`(YAQE@>l)LE}jAK6&L(0STLXc+b6Tus@mFWbIv;d?rWLP3yHte$UHE z1^|x75RRG%sZZs#uGFcm`1$qX$+4UD&{2%R@8&@Wg@nul69#f|T*Nw$*3cs*B#!5m z*H^zaQyIZ~#|X@tpgV)GP#vZmeMcfATHMq>61|Uo)lam1chNN~4I?jCB!9P8J(_(a zXsC8q;su49Y6W&7T`r43r3;rj!y>i25MY5b@fnhc@80_J0JfW_1eRHrAZcgUaMv+Z zx%67wNv5I>xG7a&{Bg|Px$;t;SMn)UxYNQ;D8+tpVq$##V=6dN^4The9aqVuUV0VR z3ppMeVTO;{ih&d24VV!f8+M;2FEJb*RQ7p91(hL$ge{WObp8~_1z5Lkb~omr=G3P@ zrGhJ-%ko^DWTtBU$2T2(9#e-`ufdsfUY6a3>i>L{R-<9( z4ValPBi3F`8O4z|%aB{8f;AZZE49*;&mB$qI3WX7AS^|_Ghvv<9LI=CP%HRnBCT{wj zUauOWz0SBM`{3(CXm_Ry5coura7}H}z5X%UplY=#K`kcwqLp|MB8RESJP7qL%)n{e z_{(m3_DMBOO{NF{9al9mVv^+2h;ISYMClWNlS=|E*Q{cGC0{=*3j3ikvr3L0CmP|BooJTOo z%8#TXyH%lSq>1{=lJoSPh3vf%k)Y)lf)f}-SJ?gi#X?F521SU3lc}a{drCU9pd?lC zf`HX(ufDd>ybKYUO^MG^;qmL@5`$y|VoB9*(6$DZkEft|Bi)E*0_#`L9Lg=-A^rOP zf$LG)gKOlpzBUVXi{hc{6I!`pc12m+{72{&96GhCaz|ae=iaukZv8)u%9$GS$9neh;+w{B)anZ>k zCtCuZ8{98_bVC6AQ>s4G6I(Ms71G;r`l=KIbs_JDqD)Uzi-V7KLdsW3=p29n@#T%9 zfM@^2J{FlGsvZIh@K|UQU`ubz>|T{^+%GOXg=to8ojgS8BmEcre*i~7xWB^VR%%u% zmy^ka5ffT~E=BP3zR(rXB|>Ge3nOvOme^C;a(s3a{M>@~ssJ<$AEUM#XO4=^yFQGg zt}Fp_U03)Ns)^!ky5Gy!$s8>r2g@kjkP_(UaEk)yo}ApHu^6jH$opN@?kLqoKE@+s z25u56N}^9DG>m;)sqt_?3qdpke3mD|aRgcFy(I0DE<9*I5eYRkYVo1Wf(TU{BTuxv zvPZ5PN*YJ#vWD#WdgYtFvEN($!l$2 zs5RopO=_J*w4aOEYT=&%Z%;5o)tK2@<>(f5vcY;?XK^s{0YgaeJAgz5-M7WMgSDXL zlsi-CgDK85`)C#XrdW(+yUR8r3T)1-`bEDgO|k7VgfWVeh$pbr>GHf(Ptbfbh@f%g z+xW0d;`8BY2Zh7c(L*8>+K6PHX#RMIBGUd+J{4-d5cG7>9>RIB!gP$|XWhnpBBqEC z{JRi7LJe0Ozu-3XjjuLK{7Q7I?U-ISMO=R#W)G`ZhdNHsOH+gUo>);kt zt8yn;))GKZD79Ado>>iz?eKEbBjlcU3mx(a27ndBN-ep(9GRhMwMXOEZzI-80evtS zMW^(y@%Gw{vbuxG_$NKnIvB%Z1N(6?dM0GqI)}5NH6J6bfXIMfe5V^xoqIV36DMKy zmf740^tSbj_l23 zr33iFAG=I;Fw|3Q#IeA=Y$M@}5o-hMAk0d{Bw=?QWUR*{UBNQr&1^HDvEQK~QC}#C zPQj=cL!|iSI|vcnq0H=;NMX~PUnx|QHuzWOR6p5z7{oa)JsBVMaL`n=7Inqv1>{XL zCFmFweAAO^9h(s&L#x0YnHd4K38m#WD(Eh@MVM~YR3lKQ^aaiVG`Av_Mda9+99Li% zn1vf7?oq7MeUx-u?D3n?WF&|X4+jW}^%!^aClj~d0r>rp?a0}`l7HT}&y=R?KR*wd4LSD8y|5GvM+@7enyH|7;@N`Z<`9$! z3eSrN@@k??xwvuBsEx!?M+YUE@c4Am0<>s4{uBh+#9L|-N7js+h7!um^{rniaBDF% zEI#=gY3y^6|=TpSNf1 zm9?hy)7&D#Tkwz+g=<8g3On|BaH$Z?u$17OG={Ng2q`al@0m2G67OY|V8yf~XS~S; zXW+HPq9T!sh+2rUEiD8eD~N{3zY9r7^yM}vz=22ykmWWXCkf&(k__l~x`^1xC?=W9 zz5HT7rmYRvC4`91AO#k3#Nehn5(y$FEg_Nf@%V5!%-3$splRmxir9d2gyv-U{88;v;1@$A-LP;88b$v>sx@{<@`v~W3+9RNX0&rStilSv61ik@pdCh>g z=2A_#bm7z4x=Jkzp{5VGw?qqK@w6Q51{PFux{8R25I7^U)QPY}5z0_FR%?6Mgbmq& zB_bIB%X_S$0U7%d`{2PXqoLRz2);06cwR@v@dDdtYOv3dK$=L3qw&K z4MUoLE!62HCNs7ZVWplnXrKrX<}#tBz{BA|@KdTUpSL8R5V%79kZj243qq0K4>f^~ zxZaRDxYeHbgl<*c6AI_|uO8i3Bu7eUSxi7Ehs#coq1@^8M2Z$8(*)w>dp(RW21-4?)mVO;BpONp>~Jc-z}Y7 z;zMD?guUudLs)Wb6??=1KU*z&5|)JFl$5P2^#&zcoFL(zd7e-NMfJ$?cHVLoKGhLG zel`1oG1C>6ZVBBJW7lnrK*Ex^0gWjzb^|yUmN7cHe$GKDvAisE!Oc)9-B50}3AgW4 zUB|J+^e4vA2_(UG$-n$dCyUPKA>GWU09T)xL~JSuD2Rb<9TEjKGCMvGmy2NN%>_T= zc7BbGuN1n2UpVa|qSaa8JaNdhN7z) z`?^I$G9m9}?(4?B8}mNoJ-bhpQRO55D_4ySHD}YWYT`Ar*jIq+VL^(v9;?P0@ZQnt zua|4mM>_7X9a7HCWVk0UU(9Fu+usFR^%Glv z1Wbn3TOc2q34q}n!p2vNPmzPc4jDt@7c;A1X&s%n?{8rICSdvPO%Z{~DXXK5mJ}#i zL!Bx{*2|oSPVN_hwzM_C>d*`B8MMgu6ns32u?$CCDw1{a8dgQ)RO%y8&()Opx6=u| z3TsDZLe?@Eas%W`|2}w<3lK@$|9{6EhlMaMxayAjfxP-$2)TRAQDeD`)7V|TK}oGD zVo^Ki`ekxATr139c#T~gO0nC{%RPv!@tw0=viH^%6b;20;genGF*gU>R^fvcP~)23 z!lmFv$;~gu*~`kTKOeO~Wmnlc#i@i-G^R(v9s+C0%E}cCCF)XVD>)sC3Lp;wnWflVJoXEaP> zf1q|j7ibu6B1vz@piBui1W?oJ7_Bip@Q&oh)XRp~9 z3xo3WD+ox@^2fxj0nJCo8MJ@{cx0K-P_{fTE)rwDRaKIG_<7m6#`6$_^CV71T9OIr zh6)%MYP4h0iW|TInKh&=VlAGdAhtg+2w*Tss|k;rOF)I}FTM8+ps*x|Kv{U@E$YV?Wsgj*>3N)Z0cP6CfnGovaYbDMXwOFc}@9hXK%r!Lu0oALVR}_dawNed zw+NNa8}w>jXZ|N|3qjF-6f`Y;W+CcMZ8#cwvS2uSjI`$~Qbd`~gzmEBl^TcVb1KDL zeK5A6h~eRqd#$EejDQqWJ`c+{FCNP*xpOH9y8WPXh18O&(BM{I&eH`6_=$`;A32BX zp*#@mB@jqym`a#{)HpvS?2>Y4G&Y~7-wfn(CIC$C@jx++3Lp7AXBNHx$tbb{=i?c& z^ARfw$9ptzSJUU%r<0^mN3`CutsfE;Lj~CTvh9oILWZ#tZQq`eAkFI{7AD-<98IGf zAPuS{Qq}nVJ18w>i{Zrs$$Xxj8|N73Qcbu|DVwEk?MX*sNZCUw)->idA(Z(Kk5e91 zrpOZ2D!7#&VIfo?1@PzKbbJG3i%2#zp9?SPLxLLR{UmImg|%XojIF8|YKy^*;AAw? zZCAI&8nt52T=O~hN<$5(B3SUxYdJvS4lHa!VgW=I*)87h{`O=-P*DW6D#?w}9lhhj;z zFVl7JZPjEu+n9>#A4-qSwA;~QHlt|^-5_1OrdDJdz^xl#N{?P7kV7I5p7nS>ia#+C zi5+jZwex(O8Oys}9V+o;^?4P+tg&qVrLtp7yz`1seYCJY#soRAUStJ;Ji?kytlX+& zoa5&nF_ZWw1;gp~yHk(_##K-7ky1LV)?0B{66V zQn^E(k60V7YM+&Ea9g92L*ICvuBt<}+Kx;J)0bL{@J%Sah0f9;nrDvn%zHsegy3v4+fABaFSWCvTN`DB;RL+eAn* zgoMl)o62T2VdML-1p=&18EA}+?E5)Nb|1DFx-hz}!t*hpBeVN%R5jcjK67otUBU$T zAW$LF+f7e|1=<+dLBkiO(2!sguLM9T2}Zau!W>|?I;rrGpk#`M3P_DkY{9@>z+sJ8 z2|;RdzwtJRxaZKfJS9_jwAQeBnDby9UHO*+am}Kzc7~9lfM`nS^lN<0V$51Dd#FZ} zkisB}_aevK*6RhhiBL0m2nq%P+N!SOtrerm6LnVjXVXU3IN)q#*m{t`fCPjub5i+r|9!OW$Nj|;eFVVGnirxl)G~*L$Ils8(NY=zVg8v_TC|A} zym&@|)cjR%7$uDe+YMZSIy04eLy#f1S`S#a09J}3T7F&}Y7w%lBKgHPXrE#6Euqo! zsao4v*hxa}{&>5zY`za3n8zcZ_6)UE6)O_w4@^s71+gva`c@uHO*~GG0~j;oWu^xN zz|i|jmSl^xBkWX8a`2HN43uw^IBurJ{FSgmDZy3XQX5Hw61RN~%qH-LqJ&~jLzro8AF#j35RIrqz2UwJa?o*bCgXiT;2PI~W zNOJ?xh#-U}XQs*8%sA|FOpS6Kw**D8eBMCaX818zsEfF88nHwZn$sYx4sUh(;~u1_ z2)xx2ESPHu9qhd#iyt9u)`*xf`GULzXBb~bo>5u@qfOYW>?3}1APo?KS#k-k7K8U* z{^1oIs+q7116T;tf6SSc3g$iyD3-t57K6*)(B?yw>S$|%jeNKR9rlLJ5b9`A+cb&* z9(WCAZ(P;7+T;e~(0Nawb_&aFqV5^O;U!)-2p!o_Oy|}F@Y0U@L7~z?T7>D%|J&1{ zmy#t;w;B}{fo3uuz>9yDc}fY=!GWyyBm6`JwQmikN!;H$7Mv519)YxwbsgNU@}w=- zfQ4HSg@tL0=+P16<~P9q+XB(JB<^9_Mn?YIH8Bz&YEVP>2pL$H*Svc=6CtGXQ)n-K zd@XiT0{P+Np}zl12SU~{hZh|8oRP_q?UJNnl@O7SoF2$N3#6}qE$}i6=H%t|AW?1e zU})IuUM!n~K_o?XBvlt{`MfgqqV7Q-*0}^o;fLIU*`c3T8ZA}^swO`E_BVfdNQyWX zj!a2cjMJjF{vw^B7AH`4r zFw>DVEkJWE0Gyj&yp#kkwnSW)%!YsqlbRCs5z;0qGBom%L{$j9Oxec}S*5SnSBmp> z?F97+Gm|;d191$fo?ckmxwpqlhH8syKMeKME)^M^dJ@hIYZHvYC@!ou{jQKq;NjDZ z*twiG4IGo$;W`;=O3Z_n>fthU{*i{_yKP|WwW5R?xWpNBkQWy*))mb+j9R=QPU zMbuh{<9R9-Z6&1I1)|q)PBh__c6FbPOj@2-!ax!<+|%0F5>HcC!Qv!h%e8c>)3M`6 zNVqI_+4xi8y!cvf{>IdUF7d4treLd4A1KRxd!`q}D7xH^`;@|3=@k+R?ICwtN+qkT zR3B4N4GRMgYOS@xq9~-55yfYkVXYI13nE%H5yEwzyrZebz@=nl!iq=6>P&*k|%%u$P%Y`!8b(kd$q1h8;($jnt zfkzgP7Z8xx-qOR3AjUNW_o6c=A4Dlzs0b4d87_o*5#>-xQdyXI=}k7MQ(T-_7ZtG4 z!spq6I!I}}$TRi!!=^YsFCxu2u?PY|TB2{i{_rBJ_rVm=%1ovvw2U&y7KGWp$o8PP zS<`l=lj345_wrrb>c;^RA&!KFAbfP@`n-rtC6Ii4+kQD@M2f7mY=?I@SMbs&cxLod z+{hScnSO+Geypa`^X7>5rV=jC&{xrr!I0&+9K}&%R_?8$3uc?i^-CqYz6gG zVMc}-nW$vY2fj86UgtsfWhtJm!Z;D<-h{hOuMtTh07q&US})5rMTP~}D^1QSvd;qADl{*~CR)MoA;hpY(}*BN?KMuACvqHlNs>gt96vOGb-achKeh#{ zhy?{%ALZh!P{U+OB!Hq510(?y1l=L(Eg8fqqw~=vA(sY<3KoH0ERt={bn(X;p|8?L zzG^%OpX>`mT#SWS48Rbe-`@Fi57}DBio)lgIA-0@tl3~>sb-y8GL5TbiM(=x)|!AO zJZ~yIRjD{3X7^F7&|TdNyQT0*4g(|hvP_B_7EJC@6Bl^P;65Rmp-YG! zXgxpuf%IQT+O(ecp+w{q7Yz@uv79W&l;;IQa`UbUo9$tixO#W7Tz6t$+M!uCpg~PTigNGQB$rq?bAv~zehbwwl`C?OpnEz3*%s9zB`4Rw@PM{` z(I&C8`vF;1L6(L8*QK^$QGcI}ty@f1+EGx(5o>gxMV~N^F9G|h@3xtdXZT#0# zT*OwP@)dcxnzl;4=SZp@z*lYZ=V9{D-JN>ephe&l%n53MUIN;1$G@o8EeSsd&;d~*s}=CTKFrV)+#LyC`m8jwqk(xN0Vq}O zD-mGmwoMInfk37K>Nqcc2$6eJ)g7c4v-AJwuBia0Y5ZIr*yhc{E`JX7_Or6P$o6QR zYN;JwIV{}MxbZk$dqRSgzPVQl>N-C6v8C(MJ!x$|0S3Smpf*g1akN!$#+ok#JyhEQ zH%NR~0R2_v>M8~;+O)3%3TC0;(h=ca&%O^ABkO$Z?bmh}fWV8hk@B@U`?dFeHC^y9 z;ZsD2Bx_~uCqz^(hX-l=B#f-UZcc@=`y(;%0=m+4Bb)}+;V|i4f=Y)%mV$PgdK9eL zy`FEb{V;ZO=9h`c6O1qn{P+umI>k$^KD6wFTD=96paBFzQ%x-rL(aWt*mr$+mY?nK zNOHuhyRZI&+5H(MVakhVIudxzvlXyz*xzQ;({Sm?fj@az z?gHyjva)q@q1?aCcirggf>+pCRO?Q2&Z}_Tiy6RqTNvoQ!4lyUT@{lAQxrB;l>?Yz zifz3Sjs#o$fNJnVvdrg6BKE83gs@eyK?ACQg{QLg0%4{B-olX%&Zdp+9=q;INbE7b%`-O&;{B1EdlZr*_V zCSnr?JYWqmz>+XrWjeMHpi#0LgbWSCbqVH6DYpv#;$acH!7N&EVcghi7@6ME^nTq60d4SSI3kBjWyxhL?x#^^gLgJi(<3{yz3LBK+uLf z0b&7$jk(Kf%O3)Pxq}TJMuR7+EXqDxlSJ`rcUXZ8f%nBLe!rfu)PWx59Y=_jRP1|J z0Y~G&!93w`aJQU3fz(bTeuX7mcI`RKpzS9a3Pm#vB%KezC7}u?lnN3)I!!l=kUbjV z><|ZQ{+m|z?e4k#EK5VgLBugZ{`j@*>EF=Ha{N85kX`_3{I%fX%tYqD+^#5<=oNbi z(@o*PKiGqystbu4DzWW|&(PNDhotA)L}>T;eNPYao;WOLhpp zv7$G{@zlhyrG>}w*koZ*{4*C(O8<4*xG!dId|g}R@e zc@Z+xw`Uk*-JAimq{Lb3>GOs(+FhxY+(e{Adb*9 zED$v?H^P)CCS|nv>B*hHv8X5k7e}b7)liiD%HWkLh+T`}gt6{g|KK%G$B>?{$E2Dc zfR^}2SvyeqB3zeno}0-YIe!Ey3-^%hQIc3FyLUdWa;C+C43mN~Z*MKh&&@*>xdUVNw{8dB&5RTv z)SS$6pZ~93I10K26AYQfFd1KZ2^&2dQib?kuE&<@CJ%-bISj{HuocLTK}SgmC3fA@ zGU%SzkmZ8p#`q6p{)(0n&kKw5153(8X+(%e8yGA&)V7h9_Ar3m1f<}5U()DnM~7`L zgkFW>t?taKLkS~;?|~`Ix*ZtVcb2$^B~rx1*1@?&qlA8nxapmR!Rz`Qrt`EFLyGMy zOL!AjhkZu2vy#h0F={WA%k?>J>p>jWekQCbXI-4zv+x>g-DYRX@~1}jz(xn|@y=aa zMc9)~7~>x%ku)oa3E2nl8MkFb8Fon=0RBw zQ)(H(A_R-6DwSb+YUGt@KJMqEUOab6*tp}>aIGjo^!02g8Mi}Ai!k3zms_imF})s; zU@v&-)s@YJnek+}SP-L2!P8|q8{>CEkCrcP%jUNk~G4fWMy(y+y5$iS7tEcIYyDDfN@Shj63z&y2p3H&f<5#tux zPPF#`QXvuo1&na9SZeGAUir(&T>B$!@nj(KF%jk#~{QP$z!tI5h{C~M5l83 zNZHTIt;Oa+hk!5=dx716Tas5EaQ?Huox$8w58~rT<q0ByG z%SrIkC)$K~Wtb7)h=8=QwbD8eQUfaZfplN+_BW-0aQVEcWU7fWJdcNO0A?Pn9<=if z0_Nec>{c+S{Bw`1{PWmJ8LbiphED%bP;|O};`rblx^DD+=U~a9i42V{N2llZ;+$bp zgb5$c%b7Az;{HCD>NdVSS|ADjI7te%fi*GaC%C<&F41bPn*gvT#hN#eIFQ3v)y>Ss zu$v*Mn9q}1e!De`2v(^EqH&ro`WY?{+%s<8#poT{R~X2j~Dn)JPnRsjSX=Y~{37TFq{h zWdPp-QvXU^ ze8Z~Kc+Ub77m55YU{{rDyG0LN*a5t}N=Iw=u~2%VLt1f`3a zM@{rH?niVgT(GoUJ9-@jJ_+(S(k9Bm!!tmUB)sPM?wm$$&ixy!6kI zLGfeZLPQaI3*X0al1gWcxFtn|IP6%7LsABJ%@r2`-nj0;#Zb*xx>X~PW10bodt~5w zH5f>NU{YQL1dRrwR3T*~K$>jka4saBpOzn_mAA@f>KtbVH36HjTtf}P<*7JEbl6U% zSL4(V$O_y+Wc)z|U}zH|4;V>WC**crImdLn znN4{6H+fZi0}2q8a{b^an^59OsGw+w7@vg!f1cm{JMdh7`}Us$fpVd(F9Gnj)by_W zBic)jUQiURp36zhA!5fi?#|sH0Jto`;Wx>C3|z_H9gf+8sQWO++$s`gc42rhf$aNk zjF*S^aYXT$1_2m#h+-|%7ZkWHSTaxhEz!EQFomqQ^f;2TDe;HD0Ue?IX@RGwJ$xM8 zJnq%A5OBC!!^yxF#Ky?~3H(7nH4(6!Z&0sW|Mv61Ym^ARYV z!@SfGD)(*ER#nn~8ejhgUMvsJ8H1vMng5cIjk%QRXjwZB+aEa!9MoqsPcPubo7uO- z+Fm1QPX@5Hh}?&>6uyp~aYbAQ%RuSLB%EyM5h}-`Xv2!Pg{8S^A;!uQna{KMx!g3C zf-S^;a|B0LL+7M!-ypYN*!>6~4eU1)fisAR8#Y7YQjK~-@!r;N3A2PWHwSOtK^ajp zH>E7+wm6GPBI3 zR}s&i{aCJ&vcfKwC~~{iMjvFcD-t?uiRZ>W^j%V=OMyq|Ud<=rD2!C}W8?lUy!d{e zCiSNJVPt0_B>G!V1SV_iN%qP6D17~2@*5)zSb=&XyI{spOetKGv>?M;FD#XrmCDOV zKOdopt%a2vD~dqQ0W;~H&2);GRW-2`5JeGBakuQIRXs@pHbLw@IwTOk21<$(G#9jO zfkH5W8?_oS*gKcY6oeQdyT}2U7KR7RD6oTtpebx{s30Pgl)-mQwIhsiQyJC9h~^FJ zq;9l_Gat!#(hjnQg+vihpH%BJhV5i}Nb7+`{o)Xx&6P6DifcnY1adzsAO5vCZ=^6I z8!&4v#4$reR9mExlNL0I$$KdlX;={4iEV5D7m|rqYZiV&*hxVX{I+h}pb(y=OUF`_BZ|ST3!J z8dq3gcdUjzP9P;qdIj52f8U%B) z3b4J5BsT_Z2$wd%X-mp1FA8gkY-FnZjO3xmsU>Kt5qo)5(F_C@qw?lGz8{wFb$N$z z_|?+ELBA?mJMmI|$LkSIZPzI5-V|UH$Sugd%3{BMaN(9CW$y6u$0Pdj&78f0P3D5NLo>nDe#b;u_F} zCa!H649iy{p#%x+7fP~T?yL51a5=7pRlCVbGzEA*4~mJ%Iku@~7-Q(icBFYbs=1No z%83rNn{-TL#q%UbzRj-SrB^wC(LfUR-+M?LZQQuPnpd@nvV@{N4w9un1w<-$N-&Sl z52)6n;ar;WZMt6GO{eRA4jE;XY>@)cS)VrkHzaHT;#fCt-sOYDt(7hGR_3jm zL)8&s5|y30@wNJh9e(Q>phyk`$guE|M&I5Lg=9+DXeEnCoTMUk4Qg#Ul{^-v5;uve z#Cu8mF0Pd>-+(pzHu?tkI2?IHkdd+VKOmu+X#Hcu`b3Elr$Q5AkPhO{T~&)}YQ>Jt zf7;Lsa+D)OXn2qf1j7ib9#>IUzmn&w24Y~SsP{hV-=MrV9A9_)UkVl)mn1G6IL5sV z;0Do)v4hHWnh);{vyWw5uZehgnly@P)Pcn!`+XBULf)8Ki8d_s*()6yzuLS3T@;8d zb@m)JBz|!ltCXA>p=_1S0D=-51$Qcix>95H(w|Z+1loaHj4l`>N@+tc;b4i~`uXTy zLyOo;bL534M6WyDa88bvlv<(KPC$N|Pf_cL?6Ykz;Bn(}8in@7DgFK3rOG(m!~0i7 z^kFkELdyxZ*fdC@d^`YK@uA42w|MDTa#n*zbmAwgtazL|shFfBP8X3R3Q^K#o4!=d zQ|75DU;ama|62u&U+lVeK9l+2rl6?RV$?{C#-K@4AT#5KOS`on8e+olz9k)p#}=6z zf<01e?pQz(+i?4swa1fnLMp=DGshm;qlm5u)`Aw0IYw57m4jvuYKsPS@cgDE%`?^Lr`>*5onLhG zC6KPTnluD0eD;<1E*aFGhksTJQhLH>>9OhBXGJXXN~t*&08_Chg5wemP=W=w52Y@* z$b|%1j1+VIgdVFO=|UlU0^lBjCd`;jQgjTYtqm&~CENqkn?AnreC@Y2hIXQmtt-7v zsY_)P{>%w0#A;v3i$4UkvHyVso_i!yk&RSP_Pc-tlf_onFodvf|M}`wRys@psZcv2 z57-_hyw)FA?NZyo;s(Hl8QJZMwvO?X-+-UDSEE|tPg%Xalm+2s+)Kc&>9=(Fr>OS7 zJXbB1WAsQo0LQi>mjVZiBdYR-v*Q>p-0nTRbq!64dG+Fa3LV z(g$LZBHIVM-z^-UdtuVxxR0Don6?oU7srBSc~yJ3PLwEYYu7+hwy)SuH~s7=PoU*? zUR!wFAyJne4z`wp(SXDNYR_wZtN&u;)+svHIlI8;YX~b*Ex~bAdX-F)n62s~c(^d~ zth7~O*(c)jI#BM+M?8=5v}JX?v~?e+pEaD$NeKv+Ue&c{;4t>TtEHp&q$&GZY+4BX zx*|A=lu)a?&>F{~s#1YGx<%fW|4Qys_WI%wP>n(IJ;;t!0Q+p}tKShPYkpZ2I-w1@ zhB>F0zo5849%PKrZ!Qm3sd2R?vuiW*rH}Hs$WRd}Cy^YrMv9ruke0kShg`^oal|+s zj=Z7BgEeA>CkzCXX3{iC(GP#velfc?QXq4U@&QXDj^<{11l5*IL9D=}(alal{&%tlzSHVZlooI zcHs7p#^1W~;%LZhM3m5wmDVbHf098@pb=7>Xx?+?t>xeQ53)k<6^PiwC`cTNfBbEz1^xHU!l+Xe+-JJOB!&eurvg=U#5T(3DQ}r7TAUm`TsXJ( zo9b5HtnlnHRA%cH@9~?H+?p{++LXx?-UUeFa9Y)l{v5s>35jO4-AGj?dDu zO>jwKlrj^0qM(T&Q05y53@fMV61FGlqENsSq!u%s=7Q#hsLZM%5TdFim|Nb1iO`6H zZo5-dPkVb)+RN*xsx*TspH1BaE&L8iOCvhZ5g3C8BA_u>;f4CA= zvTv1#;P!fa-*fcanr33w=aqGqW`VGI4x!;WP0yHyTVqk;atl?-E0dQ|pH5!)>o8^1!2b{HGVCDnxv{NnvSWgewauW01Ts1u@TK zH-GJ>rA)g59cl)TlR#clmKYKF_Ig0fP-Nux3Vk)FS9e`USPJa4v`?FUM3a_5i;4Wg z^+)-5xu>Ed2$C|YtqUvj$Mf1n%ki(J739{T^7D{pOL^X9jrekgE;8l(X2d0sDYvGP zDe*ub65?3*f0lbHss(n98j5H@jad*5(c1K~_Yv8iIf!*bKOa0KXOm}i1znW}4ls}o zR1@p<1xts}@+ULpl0Jw43Li-chC=`#FI=JvsCKzv42m9FQozo~Uz`unU2=q~AdIHi zmQkPfY#Oq8V}EnVFA<;eJ1PIooE$v&j6OtfrR9`63t9seC2p&ez2hf=;(jT11t0TX zEc3QqFaP|Z7)LGRDYz-(`_>AZ^6}^c?^%$5B#SLl#Yg(1O2aV>)h3o|mMG^+H;#iT zC*G0r(-T5Rh%0aYg``sC!IjT*=leyPs4I3UW#9y|V`o&XQ*gGZOf;Mv#44?CC2*Q^ zLw(BeVvyP#n*bF}>?xlbiVU35O@rwNFJ0Q3Nd#^*l0ZPv-EZq`-Y>uIG?w+5jg?U) ztAU_|K^U5(l)>gf$2x8AC+ml>K#E61$t9R00Py6fMl1XUHS8E4V-Lyh2e3?3KVxys zgM}z=!Z35E0WL4Iu_+aezt!%LuoyT=2bGFWBCM=_87-#GRv>~G5;lWx5Ra!qrG1xJ zw1bg}?>O-)=xxa z=;6W7%?l|&FgYO*#RX$2fY^X-Cja!?>UDL9gve=B@wCOH&Q$(PHcn4IJru0JZrA~c z0%%x%ad>M{YS;xqu=w;vR%W5`rT5ZhR4-se#-gmtFdID-3YGl@nF7zw1YhS4R7^Rd z2j@g8@h4GPxk#&t?*xSijv+N(prg@RtJD;P(6e_YDBk^kq1R($)VvoU&2@q%PqLAp zjtl2Iy0G0ji1!0U&?B{{s6$hT*I=_HTd;&3HgmNM$R$WgU={?1*c!p65G31^cb}j> zKt1##{)mponNhU}FfB3v<&R*`0?LrkD;g3PfzVwhU*2&6q5`y>%j)Q@)yF;kENvs% zG%|UDgqS;Hu4Ae(c^I2*W4l{p+5L%;SFC(bx^7P~bblL4hcO%+TTsNRC`ghb9gDenD7 zNm>tS)0A=p{p9Y)#AYI!LSxNglvcPMTMp_SUQnjV$q+4&^snS7e$B%S?Gy|_np-FF zxS7Zlpm-F^w1}1w#i3 zbI)|+Ba@^omyzVd0?VprxFMPm$p8;qWGjR`sV9f@l%ZYt%xP(^r~2l{Y#MMSCugEh z4ffh}I|IOoBV&%IWG4j<`et|!PpMBMq7>;yB{BT8@xn;nK zx?&n*#HW|EKw$r(nh@8pM1?<~pGNEDEt1Yq^w}%Y9c*VaPs+|%FCB3yFLFtPvK5SibA%*9U6S8GcpM0;jGw_SyHf?>j}4}y1Wi?% z)L-GIO7WEjNLA=OB&!59($)A8YCdw8!3QP`L@|8|wKWr$Bh{v^2UfhC)xaXgIKn@J zyVPh?EiE^`sA_F*zAkEx=B>RWM_}(!3YyKN5hwWYT<{wPNQf+m<#E-Kv&@@vRPNWC z%RX(1DKc29-l7n!8b%?*SyC^NNwmTa2r&aEreE#=M9R!r^lpwda@pbrSbFKZUw)MX zjkhfd;+TK(8D%^#Vw9f=w_8gTKR2rJI?p!8&*G#Z)|;zy_LVTEN8SnmD^h37GdVN9 z#`7Qvz3{jJRlQ-W4k;S0m@+V#N-l#8#~40lsR1JTbkhIjLleAL(}Y0k;zB?EaN1ek z$}W(d%jvzmWu}CW7}LI|9AU+9G>8L0xJVD+&w8ae1TI*`SE`YfA)a80D30+yzCO(t z`qU4b5yH$HbXE(z6rvZG+QsxEZgRmy!e}c9_3%oj2`U4GH7d>)P{lA~7I`@O z$VJW ziPIoGv?k}LzLGCle2CZM;3Kt;kCCc~5ElSq@}Bah(nL>3L~L;V#gU`)l4T?!0s=%z zkKeNW(m1umn^vD7o$}SuqN5|VSg4-ZlIfrhsI0&OS?vOYlCgxMjwfa!3D}s(!hhnT zhUwj!=@>VHVT7GY^RfX%A*x*g%5tecPD#0x40LidLjF1KQf%-($zx95qR8D+SRr)) zlub;Q@|Wu=2BA$mbO>JKU8noD^Ib3|PDYZr9xQH%Qnjbh(C6NXOEz2~teR03oINK( z0ltBfAbwsGT5X4Omc#J*ad;|5r;S^ZASWWmS4xP6AbMZ|>XK!lj1kWZYj_es&2t*$ zc`pFgDk)#?P#>mr;h4+Zdk`Ow%U$qoIuN>AJAU&+Le2m$+@+MK^m>b)vmaT!??zxd zjs^!#Gv(}?!H=;X`k!8!di;AU5tXlA&bo4nQHbM1OZ-4V$x%Q60^-~LhUaa3> znjIQGZZXE#Ac(J2meRGEf&fcEw7=O-Bu=S-?Q9Xv@Egp>7Fzvd9Dy~Rov!zzz#?-k z{4tbMLHRmln7WJh5SC}qoYwgTvFbNEut8x>1eXE38iRr10EHI;P@GFWm?7L=RclGK z+%gcuR*{kgCq6Rp+(y(#&>)G~!;gM&nUTr`XbF~qaIc_56@h`&(&J&l-Dz$KRrHew zp(1#j+@F5$*+t>Z??^KZ95V3`-d%OPT|A_5_J;-bJ%^=Qby7?&D09oL+`d8i1Afa! zVY=67y7o~1_y?j|!cF-&_+}KwiMZh=x>!m0lpF&ILO8()SZnM3uY>l^T*U2f*zshW zFcB!?0Wl(#FW!c$pHluXC=8-PyG{4JEipvBb*A=@kB)K41fiflL#thWC zL?0L9kGFJ+NI#d$&lXxLq_*DSk*lqYUHMAWLPjq(IrIQfgVm15716@;@)tD)8@!C# zRG@?)VwL;kriYdm?#J0@Nv`s-y>N=j`Lp0Hk>JRCd#UHR-^=zzX8=1j3V) z-po47oxnPi^1DyyWSt}+W<&rspqFuCHR_nUpAxf-h-Tsg+vM&;)%R|9L0%Bf5j-O5|Rxj@MSE~uG%p!?z>|8Uy3MGk_O-h1bQS`ip6d!atd>QeKa@ZxGf%e44B_Ls+|2Qmv#km)J0Ixao97qi_T z4`>NyMINgeaFdt-%<>HoriF(uR}i!)r|7iY4ha8ZRdMfA$N`ZNjD`_3d43Dhu;Zwf zBQ0;@h0>^5c2Aau+pS#0%Hxu3Hp3R6@TL2GoQf_G3Al+{B;Mn#49UlBmi#jN606|^ zF$oRAD0y*m+VJ>ukI;PH_#ll;NeDVDL`+9T@bzOHspf_P_(n9dm!NSStM@YRy@4*` zSZj8cvwqDtSczunYry(b^xd-svjT6dcvzJhL)zvJ351Vx!!&~e%{`A^Bt@7NH`CsO ze^e-1!j-HU9*$=IOf`amGfe|65&~YExiCt(JVHNHTEM_OsPOgwD1j0&vyI04{Qk`W z;rI>o@hXC9&1{YnDurd7TUN6heq5bN8Z4|0gly1q>w${-E1zH`u(bfu;x-s zULsoU|}(%<`C1={>d zya7#LF(SuO?EAxGa~YNSr(0%GxX?_?S%YRa%k-e5O$KxC=n^Kmi7RS zM|=4)h*CUD%3iNuOssQCg;WGH6?{R+&4}maW-22lT<@YQfdy8_G=ef;EC|Ds8L;yxd$gdRaDLRw57o)2cQhmrzQw# z;?}795(<=DtX=~?Z)%XiDRCovcA$cvD@A}IA%__1_4Y#gLbaJ45&%8`@XK&$nK7j^ zXz$1ADEG&r_J(^9ii00hS$+(@kVcIza<}wRXzTADUf3==?wqmAu_5Zq<6;#TNtdh` z<-L`|14%;A`8Wnq@Wjk}Mh{C0!$~EeMGQ_rTDE>1-+i;HAiZ4CxV1FIu@Dr}qw>H~Ng!7TzK=9fcGz>T~ZJALM3+=Gh?S~Bbd8mNN1Kcapua{0U%SNpjK zeMBy4YvldFN|X(bR}cJ>5MPH_JQyF{Jmy0hS#pv=5WdF$)yn3uE#m`;+Av}|LYDvF zEYBmp-&RqJqJm%;)u@dlBrcZ(bLNwvdKoD8CeK%~0nuq~_hu#{YD_LpGQ)xd1Zlmx z{@Vy^(~I|fTek0W`345UZVh>@(G0F*jPzDfS#dafci)009-xzgVw(EB`2Ds%CQg51 z;8E=NefP>dN5o>#7Mc>JCsQi}T8@COs}@jzYf}Z`w*$Q-??N5+^ojm!gAsw+?@_it z0@DNJRjP~yS6-!!*(ldQj{y1dmk_nMIUJsg-o-O;OSBU7UjT!)vp_Pi2SE1YJx)(h zmc5uUiDAxH(R&2N>%zZzBS2a0Pg&qLtTdVL~QDaq7a^JF{<|@NZku$4D`Ov&xxA z?Hcg4?Nx!+c-LFqR~6o5&&Bg79Dqfd>#dq8D4$}vC*GRTqVZ%_>t2vN>8XvdC7kl{ zZ(kGQTwVBFGQv9qYqTTWTXO9pIWd+cTLJBnSWMbSBrn7j+hUGV=SEXy<8`0kU#4Qm zO427pN|dRon{i*98UVTEjciuAlG{gW_g+3x+S}oC{|1a~$0_(hX!z4i%@${dkio@C z>T>}8=r})tBZwo+WGtuvWGwMscZU%r2CB%mluBe?@X~vFKtR$U8&GOs%9+xY02Ew=0Y%n9qzIp<$fYzB84i^wK6FxGkh938KRaYb)svP{ z=kds?mWX( z&j9~}n*%b8mUurfTzJEMu$41(lc`=Q*uokj{b4*r(eB97y=)K#v~f;4(0dR9P{RP| z{XuY@AiW7YK)f-rE?v!N2arkMm87DLJ^t`jYJM!4PJIqBQy<<1I)S|qV2;0OG5Gxo zIWD&{X9|{Y-nGw^Q+tkBEsv4TPhjEt1W;N1%&%))kUyogC(ErV!EEcS=^#%jExgPo z&qUG>zmo8I9L)JjxhF3F{XPSkMPHeLo&*Plb4VrjS`u4YT z>*xI(8engkA!3*eMaKGtG%dY(+K*eG9^0*xEk53A)S_PM)gTVH@b|+@O9qM19`L=7 zG~oHNI;T&Q?xQ?@VBq8uwdB|>po?h^K??3&TY z5{~Wz==VM@pbtL&s71A7zVh?@y@FJ~lHSfMLX(&cl$+C4Qn&_m5Im)1Gw5i)(NbcY zQOrn6j(`aJ^{I-F!|5BYmp?P+c|W{5^c5ZXrcDh-UEW#~S&21nB~JmaFIJrHae@_C zgx`;R(yGdjRTeeimo5ant{PJnPY5?Ty@i(z2K(R1>p{LuvuxN@>j?MTyDLnPAjovK==) zo??WcriA>qOv=GHTTvQt7xfgL{**RLmptVhVS+IK~OC~D2OXU zj7h8sXC*%7Lw=~|acS6f)H={9M8Z>o4XwJ1<+XL!T_U*!^O{AUpny)9qe@0{h8jWK zr1z(a-hjhH`eZ%PBdK?ET*{_(*MF#0B8CYR_^mPjm)-n-7I-wcHWf(?XCr_p4P_S% zhSVgA&ok-s4M=#gu~#MBwJ9A#e5_^4V4bhLEcOf+!@LOUeJ5Vj^zQN#|-%? zfu9Fr2q+T~Wpx}I6l5P4>0-(R5}cBs??eKmK`ZahI^M77)qhuxV3NA%JQXf8loZOYv2n(4}cr&%;CWpmP89U zd>#~@G$HpsA8_z#a>dV(uOyDXrkNtEC#3t2R*G=lixTBNmw4&3Wrn!=IyqVeFu49_ zwE>b(SXk^pK;!BXY%x>?=cJw6K+@aJo=Q`J*3dp|bZusF;bQO$$a9OHmr$ZrXwH&w zbfIhAv7V2msj}$@I!{eLl_|0{_t23$*CP9no3sKKPMw4xx5WY@PahWwk)Z%2MC+P4 z>_2131p)%HxTiJmOt8;gSqa%-%)W6mQ{B>))xMIZmzLr|peIu#E~`*Ue5DV*L5418 zbMOYQ2T%BPwi#L~A=@B`F{|711olt<3|yiLt;uWt0f(y^!?O27?4KvMHS5S_67u98nv;uvA?TB#a6Co6rLN-D46-Qjm7AR=S#qUEp0u!Xx#bcqI;f(mg58UVa!1cf1 zaZ1Amiz$>?8?27#wCC}%W?`}DOb{8Q6ru)s$INL^J8ijy#|N z1O`$+#^>>pRP#7O6o`oV_VMDl!O3Zt*zC>$9EZ|6H9d@%N#((>hhmZ&vkP?G9`yk~~IW?ydy?vgj6jv`$yV=oRHV{1$D?fEP zTypp{R}L5{WaTXjYz<%|{oPkm2(G0j6_C}_mh=)zlMZnsgACHn(nupFbQ^9NpR!m7 zuN9FiVEf_hl(Q|QK~ei-+HQ07j98Hx*n#GYd}4k%SN_zvkxA5u!qiRDwF+mB5$*LY zl@^2|sh1cfJm>Pkd2H!}+FWg6XIq{%aL-wFl*!ly$T*SH zU5>z+JvG!`gNHSq10WyQba?P0HYh-I^cfWm&jgXs=k9$Zr0Ka zM)tOD3sa1($vq$K?wnTeI^EffN;5)+u__=-zUZSjco1=X*l=*7dsoc#Qn4nP$6ov? zSY#jmV5~Res=;4%?f)bZA$zGF6SU!~06@rsJ?^t?3~nwq;fg?-Bdbzvpwv_CeE@5a zq-%{zIc_D!6K~`3@lh5g7!hm!ovK~4QuWbD(-LWh>OnjWFCp-im^U0>K2Q zFnLZ)fS2?LpPgur#_nKSR0*1{N8sa6>?F?E$oHEn$_Rb#i`uK|eBLHDCxxp6NSPSL z=4gpN%jA}9&(1UfC;jMTm7h zQS4vj&c!dAH@0C9W-hf09hmi;R+xGPi}s_X)V(|NT?%k=D$jFs984FjO@qbf>Eq>> z@ghMGjTF;0pFadD%?5Z6fd&Q${FKG($1VMnE7pFH8K5pMTe}bHhV>KpG26r za;c@Pf)*RL;YwBuS(0*D>SV_haf|9AalRmoDN<_HZ0@nOUhNnu912F!DRwks^)PlQ zJqtR*^fv>{%Yc?X=+e8xTLz^W@~{};RTq`Jc~l}#UFVkoxTkT^08Xt9Ess1bJ(U<^ z5d+3cuP<#Tc(6TGEH_MBFHxl!424OV&jg4B<9U(64%mZ@9U5eTZ7WR1q`SW>Ar4;3 z(jg>L8Oo%|F4-fNvn+(Ctl5Omiw)+2E;&o4CWuLGlKNYlTiS?P6D-p9t#xPGLn2_6 zKN>*y(u{VzqvZlIWT>4J6(L}`VfmY0q34PrSUiQqz4Ck|Tr&F3d|ar_klMKj#8ijY zMm|XyYM)t4nS!dE%Cr}Z7=aYSfm9?-SS^VZQ!EMvEUX!bCL%9ia`85Xare3VtS^br zW;tlh* z(~d1432nbM9}{4$(}ool*!(vvVuH0McovdO%V@^pxu;B%7XaQ1(6k1S2*-YVEN(}z z9a};Efu8^!s86Ve6qH#&e+GQDZ5`5lxyY^vIAk_VhE;r)hnI=pRl;A!q zD!?-8GovFl{(92R8Ua6~4^Y#-p5el-6bsiO25K`OgVuVaOiGpDDKEX~foJjk_tA^Rk zP(211gVxTcP+S;RRZY-R%Afjiz^<94U{q}NhO=D=R8woUQx*3dL@$T;ss)N0Bu1Cg{2UdB_)RHs?b7_5)KS0F>*aQN zH&iN1i71Hn`41^22#0UgGQlrzR?r+WyCrM4IWa#bYpK9PEgWP#ltAm`5npnortFAvOn-6q0HbkD7sWhQL`JI+YeGH60R&wArNGc zTz{fqv8QPU!ZL16&M@PyX+Hp1Qg|ja7PUU+fr!;yBV_~?h?Iz#!NQAs$gVAMUJ(o@ z@_!-`u_@H{sy=o1x@835t;6QYyIxE&z0IHs$SayBuj@oZ5&}n*S2>5TIIgvk2zIHm zb>aMt;|tnC0p4DMJKokH_KN6?@Rry@2=Iz<1tW~@fw(86Xt+>TcVSYXyc9U3Fa*&u zgzH2?qg1MZLRYhvB=}q&yd?)c?&qxo)tX>z3J6kYQO#Wl)zMQVC{IBPE-iqcJ9E0q z9T&8F1)uWR3&F(6Xo4Ir81f)1FLsF}Br;WULm}~RQYm5DZ7k(_z#ueZ>XZLNZ!khs z-&DyARJV#M;^W<+p$0vh77e}d<}IgKD1s&)2f|Z=Nk%1QwgaT?XnQI@=+f*KH!iF~ znw89Z5^{>jortg3?CJiYfmybkxAq^EAZ+4-Jv|TFE%^|%bnqh(St*NqW27Y9TrxR8 zhK70YJV?h8+>(+8kU&^pokzl^W6&an6)(0QyzPyu^{FMdRgr+yABMo9N&(_iI+EG8 z)@Y(t2HOj}_FdaqDTK4pkXAcZut?8oI2UQlSnP(I`OOocAk;tV2Oywfz~=(Fgeer_ z#+3;uD=8vxgX%VlB;C%dO;V||j@yT6DPL(5Y!$(nG|gPR@W6fE>O8n;BIqTah|#$W zBQrl9gvDM-YycbMLfCB`&^tU`E`2WxNQLLkkst+1#0G(ib{$#NpW+EkAti6n?Rgc^ zRN>z~Z#9`Y>;9Xq3NR{hHlrNp>->vmK|n%D2c@VL{CR`&2kC~K9xcs~trFm&Z@$~( zP>d(Y8c-H^-meIOzi^$sh%^xjQ7fYIv&?FL zhxq`psp9=1p&?tD*^P5pZ_bfl#*Q~NotAtG%;3uNqJ|b#t(lY{=5fQ9+i}`?l~0VP zTNj|k|5t@E2+j6`vxPFp*{ASkMB$M{6cw*NM%lpKTB9jCDKh6M;xvdgOP4x>W^3rr z8Nf-zI?=R7j8T2O4p7{~QZ;C~sO7$?5KL37X?W7xK2My;Lo0<+P5MLU+BVz|6~?T( zB=4Qv#Kx-M1MwLVNXrf0dMffUo_Qfq?3MKmy)g-E-i$gm!ws;ZiHos|9UVZ|XgsctE z$gAcS(y+a}u+_7>bR_pk7<;hDN@1%-oQYCeul2x2382*!Wr5Yyipe4YK5X$LLvlq3?ydq{##v}>pgevs+Uo#~YpT>vxd zo>}L#ADoW87h}BY)baf7Cya5l4}KHiBZBEzO&KVO3>oV=gopngTElB&S zw`0gKIuyL*fIF*OG~xscq=5FlORG%>G!~wC@u@B2Tc|^tBeH83qn2(7<6&N@Y zdid=qmgKxh2rqnn?PEa**{~b)`VpW`Ygg9W7D=nSvGj%OQpu>lrT6>GTe5uf%E(4s zC+kvKDm=ZTsZ&KgN!s{ZjVTImxmdiYroS`O%aAm%k{7)6k=3rtTLa`)_H!%1z|s9? zO!zXg_BaJVx9eBZgB!VfkT)5Y^(XI+d>iEI?$|UJmlv7H@YW2^F0S2$>_bbbi+9SP z@t(J*M6?pI$&)Ohh1*Y=Q1`jgq5LpY!G!{$2?zW(jDGvQOw}l?V+D1HE&*;O74iH0 zN7*01@-)w7iwo4{k|OU}WOx)-T@T)l>*bb6TkC3wgPa;|rS)dY;O)ds5G$a5dCzZv zekFdh{koz`%aKx?AlknEx;Yaa$FV@Z(acw*w3&$+jB~JmN4)mZ|EHa{5~(MUa{H5h zu0oMElRE-xO?)abcK_B#Cx`{KJAkYS#gqU@@CnHU?_fi?RQ%|q_cTSjOp8pf!blhK zx4P1M2o~vq;@09cGrxavsxG#8?n8bi_du2y^zF5p_7M`NYOetqLOWP^#6Z)&G#d@z z(zSYD7yzq=Eb{nGs6xsD7iZ`;4~t>sk=<)mK2N6{KJypzpZev^4mlcK{2evqd&{0_ zlH(i{8qO2LjEse*T5Mc7p3mD_--`%W#VKtLP>a-P?V2&+dJvm1ex*@;^Hc&%0uLw( z;A==c!Q69T&5Z!Lz!zyq;5Q~^4pYJZ<(xprCT$XKv~DtP#K;{d%1D6$*{a8 z$ku+ig*cOJws5Nz;IiIXO;I~qAiNdPVtr`U=yXX1s$N`!g)jZrSfS`KK*{3-nmfWX zQlouuLJ07$-dvr zPFQZMqNln!PdDxv-@q`*Yc`&DEtD0^{gg@-S~>UsqD*0I z%yOm5<7GKzTaZowm+dq*7%)oHpFDF*QVpUk2N zNRAHZB~Q>qMG4)+vV6=xhAmmj9%w}Hwa*NdPyum3izsBot!$jj=k=v{ahkWbn41*i(cCoUDCxDDDjFBUmH=V|sE{1;|$i>2?fedS5b zuakCQwz}Ti%qz?mj1(B;`Hjyfjt`sghQ$3OINu#nm(@G34^WIS#(j144f zeQT;V4Hj~qHz9c~P}vXl4f`W6%`5wkX+!sCbNL)l-;w)eCd?+8uKGsjGj*6wM?p44tGhf*6)2;LyC^U!6Tyj{bbTOD@0u zhc}pNi>gM&R~kaK=j-f4UpU<|#K7Y0?zJ*5TqKrJZ=RTW68s`U7MW>py=p!-jCZ=q zD*|EdoT#?OX{#M8SYir13$*CQAW7RR$Me#KiW_rH_=0nQ`Vah}LVZ)IHJ55q)azxZ zr8b_AYCZ-@OYfl!lD25awipoKK*)PJO&7JRUVD*_#+Y{b*=b7o)zR^z`1@ z&f0~J2$m{e$h{kP>)rT%ed`IeUxfTqH@#q}2XdDPA*`rY&*u@4`8>!Lqr)`>iXHuz zG?mf36RWpTZPH^mX1|9#&>z?EGWm*R2Xo;m{4X5gI4PtFrUMk7BNC)bj@PIP$Fmb( zczs0T|JfL8PPA}U=GOaw3qy^%)bwA8k z&7cR9enTAQ8JM>sSXMh`#yDjd{3u02 z+?H^W5h2y(B}>}I6mf&D9mk02U(qWorsiz5@tm?sf$)&QU3cfxK9A7TcD{X6=NerH zyl>4cI4n)`Z()YScx%_?w!geC*I?lpNvE>7XGsNkU!?}6G`QA==Q^F>k9%4WQ&|`^8{buJsIs79H?(^jWNHheYx#rqkuC#;M zH;(wc(#mR!=zQE*VF=31swdco)xpZ=IudG2ef>d*TxXV~ND)ylL`FngcCf@|4i8L* z6kYK7_|7&rR=;jx_AuFs2@u-!zbhfjuyRw!*{oNC1}GTUkqHyga5;m3b&6SJZm4g) z+V{)8W8@r=9$jq4c;3SkC2#gLn|Y9j7tQH?#TK-V#A1$;iE<|5WhyIe840TY3q9W^ zobVFw*1^=os38d5VpeQ7<;;`~za-P#(2W+N#hI>nk;iefV!@WBDbzMr%d{dg7n{?f$><{&Fi(s7oDH50=l1^gfo_NM-_+ z4jO;~$Ao*gN=ihA-F_5qtOd8%?G2(tho0?)R45KRB1&Cf|6yIqcNt23Lj(U(9T>z} z8&OD{qNHx9Nl-HzP?egtuDdQ1cr$rYz$5-^!-(kp1eP@F=b(Cc+0v%8;<(}-6xz3b zbTkP6~VGzmiMfb>Q~bkw{<&4{nf}tR<+qAY&0W zKn}Shekd4&v9s`U5@R|Z*s{-4Jw`eX0**I3t&*+kk8g*hhnKp(*$!JNOfQ-NS&vN9 zNl#YGmqZu_&PJo3o}JP*bvZ)fHrg>v11e7iROH|Z4t@RU*1(#@p}WG>O9 zyi%kR^9B*(I!NZGIaaw+RkDrr5_Gv3ND(R|Cijeu6hWKN{OA{->v5SGs!u&vlx5^+ ztO6_0cEC@HY^z7;NCw;<*^a*~cf$5quw}pI5UIsvSvJSTbXd5{rGnGLq2jvK;c_ev zy^f7^O2ZcfTLlyWBj2oQ=0-a2B`aDo;|4SX5wvuds!Umufp;4D znLlS<@k|JO-iy8V($p5B7HmZrT^cmQMLSu-#O%gSLUpCnVmfx&AhlO|L!he7X}et) z()>ILPvu1C5`JRLivrIZLct=?II|LV)Vcuq0T%-{aSTR~$R7x16f8RahnPjuP?b=K zv`BY{3&25=vjq4rIh)Hd(=QXTt%>lI({M`N#)D_`gHaBMNz)67Re?c}B#VO2v&6_PIVx+V6hC>SW+Ie|Myfp~2?n%6#Lpq-FX-A6E+M>+HuzC@o4H z7SqMRIbEzX2dy_jYD9Qf=r6()FsX9eO0|CPA$Xn$PGgU;2}QcZNasgdlf>>nF7N=u zGsIUK-HQa8kI%ie3NVns0hHK_ek}G1T(UJz<;8R{j?7l4=u85QIJZYJZzI3m7$y?z z!L~(`WBGx#x?CDkk6>r=B})Ii#}zV!dbQ-?$sWz%{!HEsrt2Zp*afWNsC1%N6HWO* zG1V%otjpnvLH8VNXLde95P6P^9uL?=#bfKz4o*krv@tK$d@Lhyu{r z+ZRxgVD^pNffxeCFar`nw7Cy^&g?nQ{Jc8etu$?KkQCD)T}%8shC^njL0i16EiWiN zR*knM%u>|my{!IjRD2~rs4kPh_bJsFGl@?>5kkb)F7jL|Ds*imwa1IS5Tx0JoOXjD zl(h~E9R5rQOnw`(DSHe=BZtb|Gt(LXU>Hg?I`@80PVrA|T~ zNSI6$E)4c(4gci+-W4~yMF_t)B99CBP}S+GBe5C<*A1A>2@?0+KV6orPm$@6ZxUU8 z*LE{%yJ^I>Qb0T)5XiDAHC}c9ALA>@@%ZCV%a&TqiOPib^j^x?B(OT~#VYzYrOZgo z`>dg-OF5Tw!RB ziokRlg}AUR16DTF51e2UHRY9~~VTfij;;u)5BnASJCxkiGL(p|0NX z6tu?R67{^{Jhoa#ai$oDx&(al>kZ*%G^1UG7K*;+>%ZKQeewt!EV(3Pa01f*v!SUB zQU8;1V}8CK%K&gGBiF=1qU9G0MUv5T^_GDui(8}&r2gk*goKKC&&LR>pb}KuVZ_5U zFYCCIEXBYFok<0R7HaLhN6bF%r9S4i(;N&XNt7@E9lR}O`8<$w?QdGfYxZTKQ{a-@40ky9 z#=*@vC&(_%bc&aV(Kv3$pZ}X!;5?2wiD7YUk^f$^^kFK%-ZC^Mof z*}x8Dx?Wx)A z*idC61yCmPaab`?Ay@h+bkz{O#s1Eg7IPH+gw6?_0{D4;KDHoY8`}Og=u-CB)^j=) zU^8jntQBbDGDxZR6sUlBRAIFhSr1@Oj=lXcdlC}~X(H||b0XjafXhM|@}~OIyE{$b zGTbpoihHgcg&>zL6ptV@&qoB%=B&)j_NRWcAo3xSkrMZgVbsf?PRiICCZ3dD90h#y zF_M|2?S={_iVdGOoDPx14kmWev_-8|0nyt5*NDwyq9P_Wg5ri4q!`c)h|EAbl%o=_ z=g0_8RdO7UW2NhbLKLY9_zgCP&&%xw)IFZQ{ikejp@R@I^BP(`95yuy zU&7~AjKfldF*dSJH{S;v_7&s{de3r+L7EP!zWc95yX}1o#g#(53h^7XG&H?AkhP$ z*Jyk?N`U2-YB8B0de@leX0)O>qdkO3Hhl z)$crcFL7O}U7A5V0Gz7&EZ3eVc>H&_9D=6-Z$*``kulQLyo#nmvZuq?3cA!?At6LS zIyj6-+l(064?w~Ifrwk#7*hWH--)T##~;ja-O;kOLWj6#&LFyelXEF~fFi&OhR-#U z8X#&^@NKc0d`(b z7SI&?QPyEMU{Hp~0kJlpd*rtRqhm)5^Zl3S7s)~p`pQ2t5o@SE>v6j`o;BAc{Z^F- zo2P{W=Gb^Ub^ap-?@hXz-}99mSCZ{~kyOveOr_F8v&Rt}GD@ z+F?b5&w8`{_t!Z16wL_sJTTmfc>|cvb11h1bYwaCJf2>kO4^Yg3N9^*;jN21hwOY# zlmZ5_=2jreUEuZ$fCO@2q=XN|bFUC1p1L~CTGsOqzM=gEk~Ce!9-|;;2Epx(q@@Eb zenBG)W!!t9pp2rOr%uPzVu~SYafF$Vt%E@Y(uq&s9x8m&t?UskzR6m7jyRX|Tk-~m zluYFS@THG0eiQ~&&KMyK00J41T}5uO#O8uPBDc@*yX=dRC~^(P&C`aAuOf_-hFWp#@f_lNiLFcQGGZhUVOx@9)6t8*WdQZ92Hp}}@DO6L zVu;+x6cwq~eAp6-BLX-sQHv#yr^L44yN zB6)e-fjD4wKiM*}aa9H6`Kai_C1yDaKCa?0i@WA49E1_t#o8~m!UKTI;raQzkOVOW z+(z^Bu8snac`Q!5yNE1kZ0hT?fp zKic3d!R*>bgdERSlhYl#MI}x!9uku;y>0!k@nHxm6WkXKo8d?284X$b?ga=p6^1gY z%FX3HaYKV_3AU1_cQFITo3-oe&M{=EdwhF6GH+ig*38LlTzhbwVXM7m)z($8-@X0) zWC(P<7%`kzw1)+U*5-*x&eDuSnTEz_c~JKlJMMN-nn6-d85OZAy1j_C4xz3R9g@2V z%PI>Cm%k0go&u1=VE(Q^CIhX*J!$Z!A z>|4gLz=X%6kE}W95jAisDa$ul3ycKYq4l7lmtwOKo-r%nR%3dBXB=i&T-E}D+13GZ ze#+;avI)1cD|OSf#$03#L*x6R`qjiL6YNnSM^F@x=Q0}~fl$xggS^j-+$AzTkGI1d z|0LkE=2FlP2QV~-a2&>ja=vp@x+bRDTlqM1ntZF$IH)IKDV@0%CvJHv#R z>j^`$nWbzjEbnn_dnKd1{!bfrlP8KnEnpk8>V2Yly&Tl6o@Q#Y%#euk%L$Ll69!Bl z|4Kb*RpmlxKqEJwCy+!#8xf$}PFe#9jl-%)ddoa326li16)?K4zjYn$Ly=;rA17I! zm~Hx0ve8-d-J)&D6HWyhe(bLHikruOC~tgy=W6a;{*SlZMgq8`N_{Hz2=eqvNI`xj zN4P`njKQ>v6O+J{A>c5K0GaB0mtZg^d>rdh5OPMynQ?LxG+~2N?KQQV62>Ln zPV1@6cThqpL|V*+uCJ85Br8#AH|f)go9xZh$7_7j(aihc&WNq5qKLzz!6l4c(CUKN z_@7aj#^vOEN}cr)CuWZ@EXb`46O@Ky0JP6kdo>7wlsZ1H$dVMh@1VdQ9I!BQ?E<&# zeg|XEQke>I=^Ve2%8ww7Vzf~R?!g*)GuioO6~N9jdMXXFl%u~^LqN;n*6r?0pA5k$ z4z%9g0NjUNtyM6n$!dz%HS>v;RKnOp&PUpp>(CH)!IiuBylMyzBkyfAY-X`>=v+3& zChZBl3{cB%qSS5=m5gLP0MI~$NzAE9#2l%H;6M3c5wjT6XTSEueK9X(*^52T4W- zHfV*Ne~i$D7GlPqW>i3rH>!zt@_BQMm+Qisjm*&cSp8RjXE&IW0}-iUSPdtlypk8M zA(*f9rC(x|lVSMOYR|QtqZ>Sga0Mp;Cq?2lNuMlPnb}{k+Tnq$T9-U5AWESb@l2C| zzJBTPKEi}Q{z=jPKJgj&^1oWQD1*svSbj95Xhk%Mo4Mx=RH~N(^wmFXgFvmW$YKp> zAP6y%_h}4fkM*Sjc+K{swo&)}%4T$uppJ4)E6XV;mV90f)f7?{t~~M(2Su!tuoEP~ zU{LTOCnVf++XL0DIuf*e12eAOfX-NXxqu3Q_7;Jhd=*1XoHD@pj}3lWt_pkksyV(A zlb2~dR9za0995(?y5;bs8oZaZ9xI}Nsd^#(=(mfbju`qq2OG-qg8!>gVOFR5eeB6^U+^FJ=DPKhJSw z`C%6vI16BaYS^T!|V}xDag>2GO9;DX`;xWw5BKxbk6k8qx!5t zmMTESfi?=7xMs-f@_dGuf}Z641)znOzPvZeDBt>g^+LQ z(nScMF3Fscaxev20V>axh|}>`PMjz&QgG_i-H28YJ-WN20Bnb(KH+{ zwjG!T#;;UJOa-uGIGXqmb%eby+fVE)B8U+gYNfcj5Fsm2wQ=4Aj(na^W)6`aZ#bv*UJj6yCdWAB34W31SZsaa=m3` z73_k2P8GFIkJMv8f-1G>d2t19@mFNTG*FNtgP*&7X3GFT?hlx_fm}zbPK7^=l0)q< zSGR?cA>p{fLW&C66S#kBB@Eb{{FKJyNhex{&t_VsUytN8>Cur~`*$G?-n0kuSSDqW z-ZR@#0ch^P`%C>)JRiw`y*T?7IJ#(}Qr;GfjsUO6icDI=&&-T(#xDO=0`RyNaPer0 z1gWIZ%c#h?gsBdBUmQ#e5RP?;Py`c_Yu9%vu%HEn*mBJw)0r|&5Se*hXYGj)SRHWf5wh*HRd#Vu~8T@*C5*9Xl3r~;h;X4h@j!rV>gzBP; zU|g#qaqb+oLmNM~+CjqqhC-@35GZG;7yJE@mTEd)3JqBy z=HuR~>Zh!8T310o$9?Hb$kn1Xm09r}T@Y(xE1YSSJ9-etj1~=&0xEPHB=LC)gB!)h zz$<@7GHn<3VF_esOFWl!n;GuE0RF zgm02|DI+l|)(YMzhrzLV>l39wGfhh&1_VngY@)^IrqgSN$(1=M2;bWA$|$k78>-44 zNDdAWFOT$UF%E7Dbe7wN%uOEo3rMEG9qSrQ$L!kw%a^Ly7pM_Ufb|-ZDUo)M zYq9|bY;EJC(4e8%iGYmamcr+iR8J-0Ucv-PMCnU5@!arUo>x2*Vu>lOfj`>_9xh%5 z9pSCW6Dc^4ti{oczW9=zF}y%;h|u|fA9_}>AFB$_EsWVrwsn`!k6B|)6YP{+)`)Cnh#_KOB|MDx|r`9D6C143rc+4je*^Ibd9EJ59l^xUPAg> zq+8gfsbndm0cYex$6EyX<>degTGP-+2&ju_!Ae_U${4+gW#X0b3|2QO94=q?#eob_ zCE`-Cj#x21!xF&i4oi@p3Feq)gkliHiEg753lpn9kNc~Mm$_ADP6akANVg1X;!`@i zv9#q9R8NC3&tOIrzde;VrCP;hFqp{^wU{dpfJ0K3@aDZ!-UD?%;;YJCCMCdXKKD+V zccGD!6K@ynYmmeAEL4mMrX)r$KF{qHA^vp8!0sc)?fYq?(~RDTpOU7L&S`GVNkQ^P zvsKb~a^rEejHQQf{%&My&Cip6gYV`**52PtvxQOvUr_)EQu`jjGfKt_Ti$f$E*;BNUp!N#RZ8-u`#zDF+_j`I2;xEgj%$Q?L|{}l1*}q znxJqgQ4AZo_nI5zpen;V8@$sZr%a39p8g;=hn2^7AO|QrQw0eDbT|Q~hj0(5(qd2D zIJ}iK5@5lK*bDag$lwLnb;}UBf8J1LPY}cv%(pPMOUFD8Y=QJG8d6>)Fn(+%O99E- z?De2dg;O-l{tJpHNlE7id+u4Ux@XS?6~%utg>ggXTM8GZ=5e=B4V{Lq8xYdDco#g+ zu~-A)$#m4YXH$#i7rh6R6vDPkL6K(jP`&l$I-|B`eUCH(;~R95=A$EtN;D5#TEh%) zE1?yo;1OK&4lcO}lq6)gnI^tLMp$B=$FC)bufzbn7BskCUXpnL@{1pe!&So>>PJJW z{~od^0@<0$)e4ETowYn)<5H?7q;JdPULY$_JqgY@Uw5vV$J(fK<|D=f`zve>r`r;l zb*P|wICMf1$d4f%09FWjC>m0v!$*xKz~q~OMgR^HQy2Qy-J#HhpgfH>)D1Hm9T_4V z2qe`c=LO!*+KA?0Jc5#s&vVV85{7Y=`GOGgYQwo~kKkjpmR-2jQiUd<PTBk znp=Ut`Fb}IGxU{|!^6+30oBb>Pu=VgFGt)0O+0U*ovb{zyfK1;l$m*sfm?D1yrk`k z)nr?0vC|+0b=ZWg8jnImjwimQfE#iy*B6>sCjU7$FGOmqGlF@Z1A|!~#I(T2MY&6S znpy@!pg;j0J@E6scokSSvJ#4H`&0)ZP>^$=R-`tJo@5lz<^--CIXQFJxL!eszJG%#1#|Hf&(n zPR5v2W{Hr#TdfEY5Y2p{n$s||+=98NHit5% zwoA>ab9Y0J{LzxFAV6}3T*y}+kPSxRR)PB7@6Xi0Sje@5^oH^3k-Y=i@DR}=sK~mN zx)%|@_xavkW>j`-5K0pP$jrUOek2Ahc{L;Qw0u@SLiIiiz?C_Q4DXg8`!7Og+C!&l z$ZIVv8w~ks2p!OF9I&>ohAKnHK)o;xA^fb6(YJrCP?-AGn7aa-`MUQ0ZV&#>HA7Jl zpaQ|tVhmZC_-_1wAwKVr3A>xr`DfQ=JRqtwgW|~G)uqvrUI!2()GG#pN6_`a5vv!K z2nonE(J(upk@HbOP<^av6cM@16OC+u;wYr!3TL)M=mL0I%HP_xP}6MGTH1icxt6uc zq5$iuU;PG&Gf=pc+U1e}y~7kDZ7$yPsDjnuhgEMtD|5w<%@V2TqmyQp$a0XXo|zgY zS$8ucq*cNGlMOKUhX5-8gPm=_!k1rZH{?(ik^bzZY}J1$gN_z2FsV1=+UqA3rbS>{ z_`Fj-fS%8*n0?rrxE$Wng4s&3dB;1^`(ZrbA~OG{1YGJ-QU*usqWoydSgUkZNrX5P zgxPm(l@@=p1avk;#A%@9KvS&hb3)A*E(X?DG<9>%p6l`-Y~E+|+>AM@#8dFeqMKd4 zsD+@AcV_I80~%S4z1BlDK1ZowFlF3C6Q`nAxOZI=t(1QNyXVDAAm3Y zpDk{GCKO2-3gB6}V)*zt)U4amt~0Vu+n6y-PW3iT(w}-i-;MmjpPd-B*rgTJ@L;CV z1>XTtRcQ&(B3uJ%LKFpwRYLmhw6$PMTvuW!yfv&oggp0Kw*5RS<|$D=WE10}J#v=G9~{adtJ{IdoMiq^>Ic!ubh_%-`AK4bL5Qah&%YwaGBc)2|FjI^oPrV>8hBNcj*=k|PL z&4VlaEI=wZY|-r~#+3?GP3 z^J7X7gr_VI5uTD`#VM08xA!gzuCx_8(-C#L1-f1&BplwJETU|j-%^_6IPYK-%HEf4 zf~uT&@I+_MPX2d7te!xBMq~(17IsLS=rM@VM3#n;La-u>nMCVM#=mJc1vcC)FTR2J zykQ^#8HAQ*RBCf8vS^4?vmnRy8nU=)MdD32z;2y;9mbrs3~f2x9VE5&UqKaE1xT_u zRLL$4U*POqHWtrKNIRDk%^nB?3fyEIA6lA=f`lqu=5!~DX#$ZUXYoV{6u@|tErfxz z&;{~vl^il4G%+V0sED#$&<@R%3i{|;{qqK-spReTHK|kUOhPibc6!Yp)`;D(rwZe%SJ3hTKakBC_DG6`+THkB zX1>A}V^ZVu+*NxTFATE;=qd^H1n(ElLkI@l|7c08ZYnMrDeXpu%s2Ms$+C z4VEMqr15!u&Du-6aHzuXGmw!Q+F{fbJ*h|NJ?T) z_s!ohJT3N8EL4c$#~?3qLDRx%Q<x)OCc+jXLdd`3CCWAmW6qf)jyx{Q+Z}u<$>(5n?nK(>=P2t$~tr zh@E-8&E_rI#jSO~7n%;I-ms1;e!%n~nipg_jep&zLxQfHIqMy$BCNR<|s@TO8loU%*E zq-l;*2-^aq4$ST$Gh^uGI;%WBH=p<6l)}jcRsay+#^3sRB%}p$iD%|eS?AV>ilgmb za$1EIzz9g8eMY@l3YN*$B^{{XWkB~+cCV+OOXLtTt?3PqlGa`67sL0ARacLIAzF2Z zW~zZThyqd_rxjT!1+o=Y1SA+TC7J_QBIc*Pm=sujjJ|N4sKNG_!^(&)l@}sB^r_Jg z`?ip*_&h7ptl>?U$26fYXKklVFJFuSI!CJ9L!taoBISQdKk zS@~mAZ^8Ph9gW5+S{9H_b#%nM<2?|o@PCqq=!muF-yUU5K`BN_SU5n8{m`V?BeYn+ zGs-FC-@tqO))v&!!;&m)e2e#!t9P;=DvX<_@4Y6I)FqhV0QNp6Y_ag%)L{cv=BxxB zZ=r}Bx}sx#Zd27RLL3T5sHt%6?&AqiHOwFexDg0pZBxSgtq`3+Ea6QCael&3l@O?n z9Y-v8h7|Q3-3i_F)$(MampJK(LghEQZj3Xmcg(A^t^G~Q14P?PqO zeK_9k4v6}H%k;QK&k>uPh+0=XHNB&yi5fEEXki8H!Y77Dn9@HI^+Oshc$i$l$zdXd zwE5>o!}G#MLpc1Xn}aaK6{iX`GglFC(B|`K@ylvD3et%7h8dwUu3Rzi$;H^4IbFq6 z@QkwP%w`(@81Rw4lX=rTNl!>kx$-NZ}sy4+%`%`{F}09V|%AW*~~&BWA1HbwFrkW1~x%i z(q|3S(aBNgs5-jmiPcL+$SsBlj_f_{+El`ohKg`Srou!8x(D>aG%V6n(jlrdnk;Tw z0wJa%L@rYQ23GA6Y>VK*KxVzYRNO2=7EP!pIrFD8C=9JBrAR!g2|M|h{r=j^^Gaj@ zB)I5gL_Kr{CmvVLP-^gtyn>EhP1H*r<@mO5P?PCRmn6AcC##W1djgcOVPf|eVEVoI z5@4k%fc-skS^M$_op65v27E%B7M-v)T%6lv+ylKZ*2ItL4O|!Xn+dh*j*p{*{R7(f zez~Ks#H7wiiT^Bw?u;Hvh+cEqV7bTfLdphg9m8ni`oOkIzScH;%~B%mr8~?e3%;a3 zk0!j(SlN*{J@fGZgPdIBd1|jXuiLo;u1Q|7E(4oZAq`SLO=cbZJjd>7@gBmdR@0Vq zb8U(pzH@x5ioLwepeC^{szWLi-OvFpI@6^~QkQhY4XKxw@R0=L3yVv~1rop@&o@5Jr$tL#kI+DZ?(1kIZn|r6aoYZz4 z?$s^U9>~A~LOH)di>Vf`=YhDzs&!0^WiopYwKoQI3w_=4 z4Pvs_Diy=W%%@DmPH=o)K(mbt)l&^xE1B)5tz$J?>!Df@5Nj3l7`8Ah!IQfqrvrvy zYc-DZK)^{viLgYH#oWe#*m$;adm28vjUT8v#e_)-R{KiU_bwHClA8Mux2fJ7swq>u zl!V??IJ!(XyrX9pMYGm;i*+f!c39k0Z_R}AfQe|a=Irf)Q$a1Umw+KmiZ_wdyCLN{ zCY7{SBf4ReaS3D9r2SXd@brR7^zk#>vB<>D$ZZi~h@*0t+m&#kea@UO=*$M96?QQn z7bDv!UA>Cq=&#zc*`qX9_0t163`_cwC4a3Hx?&X>_$S)JF%1KG`ymO_=-PRNd{er@ z)DxF@E*kzEp-^$SB$EeXdb6wgbfz1zKI1Nn!hzv800!KjXGC0m3Lzo|FJ~vFR53(| z+!M=Mri^xmaB+#?9(Hnt2p=KC(k?8WF9|mft#sF3Dp)1<&?D^vE&06=eafgky;Z`Y zcOBXabi017nOr+*m?hc;6D^tV-7m0wdLdSDQ!07`hzLa-0-$7z5k|R53qp5@v@tMs z1!smC0C|O~wK|n~gS><+d0xHzN75HS>8fTt=GE;5zG^yd`lXuMnrjt{X~0`i7d<<; z{Y=!-u0|K7)oDs#*W741xzgvc!K%8ud?c=sU{PMfy!sG5CEJuQA~dz}WKOtw0jRD3}X`b^Xjb^wFZNywd?zAoaJ| z)T=O#`BFr-&{*F%dKz>L_i>NPfder6L@-Cox}@)MFuGb{-0ekLqLwDeWxTE1=yA`? zYO<|fU3CQ*v}oqDB!dS3+E$g7Dhaq&1hlGo9y=9_B0x7UfT3YbTOZG0aP1)H@(B*B zPM<0HRwsqHm@{nx*vNc?p_H{!v4NC?Da0zQWdQqd3c`t5vQ(j_+r`3wl}^+pm}@f% zEPYG8nWONxL%>qE;0D7b**5AedWm*yhVqOJyh;sDpBQ-FCj~L7DKrZxIgrBp&y(^afNyB?rcEL#KHIERx_ zZfkF5k8vUNPNbtr4PkBa(W04(%lI$eJm0D~2t2X+1Ef`Hj2+(yy`2C_1xk|_Or!U0 zS-mP^J`YoZ4T%ud*&`I4Ud(q!0*#R(&qqQ`?leHJUS@|HhzEUG#PmV+;(9`u04c2@ z{$ganR19nlStV1~rH6=VWT@%8Z;u*H2<{q7MZIKIINZ7~ebicbFUjXl!K_*|nUA0# zL3Ak-F~W#5xA|*bfke+RCESIDr16y=8CV|E73#vvkm#;t2wj~ zrvHu>0hBRhj3%f=N`*CsfT@uzD<%HK_4sdizL+j2P$|fkwnXl8i(I}2z|oMUz)<@$ z>VwvdWaKHa4m(W~grsk&f&h}-S`1q$tN>P1wM!Y!Tbt{V7BxcD@8acpNv1go63{&*{Hc78rzn%^{LR%V$BGkL+~qzWZ)Y(!Zvscw`q&m zU;CB8b-lf7j7nBxjr34Erot;{8nb^g+G*R7&inz8PZ7-q9$Gg=E&A-I&=06On<2pD zxX9e?_QFYp5hX?=O!ACW2?8c?)bVw}9o~~GjSQ+MQGn(2h{~JZ3=xLn3r+|Yb`z-# zq=qmbOAjBv$l3?8=}WnwFJEcFN&}f-mn5G>xN#@O(nLgdPqddyRf4reF^fUKN!kP+_q;3 z4|cKIk7@?D7NvLZqBn8jDKcdr@s$qV#l>M%Ql1#Av=wn$;WR@#;_Skws4j-XMWs}@ zq{|!weUe;2o&>O#z)LHkvy}ON`?s-J?y@@+zVr{X9TX#o4fax`_a`^*fB%tdl`loZ z#Hf#H{k4M=u0^TuMzz{EU}jff&KokRxmLK+xOSMvRSJ7CMTI4Q{)fb@$8*0Hfw_Vl zh%@MLA#u|!AXm1#WqPAVo35~A2Pf;ygWNCz%+C-zHq! zfHBpz_AoX}Nb2V~Jmm=v6}&Q}B7o`rDnV0bLo)<8)5TLz#u)X}4Y{-w)qESNRTO!ic@EJp{rEVa*E(w(h?HL7at@{Km+BfOdH0 zA~EtLu0W6!F@Xs7EKXrXK!3tTf~8tU1oCm>x4Bj=aVJD4{5(~8E#q^}hVB|ATUbL? z#i1n4$0+BVE&PD=4zefVIF`y7N25Rw5SsbCB9po?mT!S?!CMj5eB6RA+y-1K3fSYY64j~wmSB_M zOfKJq>{LyW5!2tDn$z1$9T$7pGZg`{c?r;kN2|KpjRXREOp9CcD98Nkvcj-xO)Dwc#mSg&j1a1db6#SFnOVv>eD^2p0(IN-J zMPN{YF+M39Nq!;sti`#qc~1w*DjF(?}aUf(t=>osq9SVQ^02vA=p({6k#PGhxIy_n!rX)tfhO7Rqy@2q$`7 zej|V{8RrJxVWq+Cz!rFkrHWPyhomT`geZ-}swS^KSxkCTkS#PJDE4d=ldM%k{E-UP z0&`1D5#NAew4&3Zz`~(*Wy%Y~)E~lI%S`3<|99FHea$T!gIPRr^UZqon3cLv0#lI!pn8Y0b zCaxNWuE32Iq$KkLRoF}4nv7)Hlp;uxU12Hnd$}ClQXa;4?v*6+;c_v&k|n|%G8VBL zH%!Tu5|V#rk~i-KTdYKB7-V%tRbC{A!=*Y4C=r^`oxTJq(&A`&a^~%3>RIe3&&9-8 z;j=wdAU_in4^K)3lc^|m!yr9jUDbc;TDEg1c3cZ0@2~b$-k9Tf6(4OfPm9XO-I)bt zdHK~4S~%pEG6*6pv>r&6TGAnMGQ1Qf;pd_R(PI6$jeQ2Jj?JV^+*7yEC)H)#L4fQI zswe&e#O}l!k~J6@9Yz8-jg)d-wC$)wbKyX+`+`zbN|zZ66+q*Rzbsy8!`@McVPB(O z`ZQDdfY1I^6jPA$S0^u$Zbh=Ag<&6>nd8bZKAca_P;othHwDtUMev>{O>wSP3EAv> ze+o7;gV{3ok1>?x4?*+bmn2Tz^d+*qqB=UBBa@@3Ng8+y7DajnAmy5w%hUqK=XuP8 zA)YSuRw%PE1k+kzWrXA`aAvD`mHbP-f_D^NBGFOUHTgV#&=0InaLG`jrL_pAK_1@u zVuF;FzoFy@o-p}mTw=(y&DzuBty))nkVc#c3bp^s85z5%NZHiFo>XOGJPfS?>>r3g z+t@{}4+{yj%O56Oo6(z#8p2a?Xy)DdD6PgGmWE4D-9FVU!A?mQ$3f4Dre;+uLwN(2 z$u_g*Syx*271jL0q~c$r7l{aK;Y7+p!P$921R}mLQDPT!AGYEi4aA`m4IlX!>&QH+kU71J|iDxr{xPMIwA!W6OIX}ob9`j#cr z)hMr*TisC%xc>A6o;<7*o?|SkW@#f3$<)^Oq-*b*$GsfpP_8{eS`b=P3)OowQDdT- zG9<21;Y;=oeEhB`r7a=L=SNTF@a+E8BVr9d@3G~uW5FUybod6TqnHFv2i9j{o)&7$ zv5AryBmAxx4G-}@KIDuTDu17$0DKcTGHWeBJ~Yr}B~0z>z+B^I#MP^2IF(#f3&3%& zSbmWzTaOIh`ciE}%-L{G-4dzL#O>^uj8)=^{uBGd#OGTWxMA+YyQvgrLur*D=HvLb z;#$}Tas`xMU5JcAGDFcTDmzFS4AG$u&qg$80qp8q z2a^EC45nR0(V$!tgsU`|x{RUH$np(JfdL4ONULFqNNZOZQs6RHLzQ3(AZr~XkhyYp zSp@XQX=Zoh-wKO_*LRe|OzE6#N6qRfV0DFaINThW|R!03wfyFIakPDMrL#zq-ecY>* zpfmwkw(eR8A(68qMu;RFm7kdsTDFNfuIOZ_TRVPmi)JV$Ai7w;Qd7tCrkN-EEhXBD z6Cksxc@OYv%@URvfxDcRFJ&w#I_gr6fxfHF;+GuZ*%;ARu`B>*ZB%WE!yRdy-yWb1 zf>8FxY&sjSxl{oen@t8Jpms+My?s;t$Wi1>;v=a}PEw+Yg|dV&-&$Jo?BBrM$>-$*fp9~gzLE}Nj;i|uY zL7FqQK?$%9tc%;NoNm^yi>sz#0p64P@N?1Pg6*1W1!ws9Jg6K1-WzmL1!8=*M;m^( zhFHND{Pt7~_Do_>5~Y*49`g|j@N%$dO~Y5-n{V2ZA(PfTXn~YZ6tdGJG4G8E&U;R0 zt808m0QpwUW!J-!ObeJr&3GpXDh>7;2q9fVXI&NUO0!_HE6d3e%1}$Pg`ij})i0UB zJrs^(JOWu0z_bxkCSw0|ii6;AQ_YgNx_0uU84NO+HaKBWP7@FjOPAmQV%`Ib`cK5V z`1aLTFrO-ru|}nspS5wzH-O2DikMYWUUMbbE$Ar4VCVzykd9#;h4D+KrG$mFOF@+61U20SiN^d5Pm{|NKDc!dt zJA5V7;L;bO7bX?+l>*@iTAZ1^;j4)nDM8`uhhVm-U;zZWU3i=k2BGxgof^(0097Ga z%@DbQ$#1V#6@TcvvmJ57=;36tZ@f*DhV;{0%vr!wxa12vSV)fO5jj16kzEls+tPl) zqWZbT%G=%(&&Rz7XY!IOY*i2qS%kaNd`P(d@KuvbtZXk-h~DyT_>#bU>1A|+jx|xa z8$hM#Bch5e41j#=t@n&VMZtE!w-RjgS<-vp0f~Kc44L8^eB^f#G;C!jDu?3IO9VSi zZCDh+v^Nv;9hH~#r3`ug(x=ZdA73cUDaJ7XMW(o%DXJNklNCUtQk5v-O}^?8Jh@z} zZ3eKmlLu(sn811-xnI%AV88kT+^``$Gz<_tI&9%r_S|n@h2Q*PLFke&qdT%MfoO@T z?^)Ma7jV?-tSGMBs&M6u=GhRUnVFm-$T9}qsw|t(* z$_#HiW-R7?vBaU-NIgoJ(ev@tvXB7u_Jiu)N?{IU<$1>)ARX~>ZH-?`N_lS>s*T`XoV;8V|6(cqP%z(sI8;--rb@qd z4G^a6y!VtMHv?E!K|c8_s|N_R=iqpdU0!j@*ZG_?J0%c^RHMl*-ZTdJ+HOg8EjXeL{P<&A2j4N1#Uy+B=qM^SsD&gAYL zH_2|aT~7j#5>-!d1UXew!gbh|ZvueTRqjZ;6}a-7nQOwA-dW}13pScU1ws@|f8;%! zm`9a8J5I1CetKMW#qy=Mh%Cgg5NG3pV3RPgQEBr>vk)ueD$P;Q8b`qm(wk}=F*>`L zn_k5uX>D-heqU9D^q8`q0^-TCiB~jM7;% z8plmI&&1!_dN^}L0d-Mxaiu88djUTmzvv=CngG0zl~nM&=ppi|NjTEWW=~?z$4-uF zDn5!)5hHs$x8}LUM=Hq(v@h>5a2n8g*;Z z1j|4SZ*o9ZhMtt+=8p#wsaPM)RNBPh88v`vn&yyM_2G@QC}CG zAsl)IL1~8mHJ;LJjxlry7};zWRVI#?`3oj~!7$z>pVt;jqACQqgqxT=*Nxa%{Jd0C zUKEw#LQyHg*_w~kwiNjiUK*Wdm3k#FpwE<@>JjRIu2Fd{Xgwc`ADkKVP()_NDW7MC zuSvFC{=!@3Ptd(0MP?Pr&*=DSID+bWQSt5DTo~rZrgYYjHrHl|z*lmCBVy!C%yhGi zuq$y=fpU<27RKlr%#Xt@-P25Jl37qDID~3t$2Ft|n1q2Bv;y`nLWDAePu5&mdIDXU#P_y*oG05}H_W>4Q+_@(BO1n7eLOr;BWjj1IFUDC&zFae~w-{lHUd6t8Q z;h+W+#DU_{g!_+NLQ3Gf9N`FyKifB%`>S%WnB4H0HI;~O!Z^sM4jh#iDbhFz>tWId zr_-cX1kbu8YI%Y}i@WB705u@!hSN(u1?;4HFS!G{N|B4Cof$Da?d6HpD24X|Bl)`?}@kdzWY zx=B-J-(KH)2R-c-Y**I1raHJ>))dj2}(Y> zv=C-Cj7C^D1r6MvSf|IqZq3aEoV8HOVIAX=BpombSjP}aZ67H z5%FDu_J~Fg1u~4J7UObK#Lg5FAP_R+{inYauxDP%>S)(u>Tc*4vwc+i^Fu+untbEU z^0h@HaeQ97IgAW*>Ojp_T|Gz|DI#8$N z`p)HmvCxEQ)oO#xizgyRLEgIjvMRyDR*e~>-#aR z0&p4F7_`XID+2Syl0j%qGrtM(Evg6j>p@f(f}gBB&q)Llvt@oQJtSgFXCk*Kj*`rc zt8tC^#7iRs6rWdlY^x{ zVLdLJT<8iq2oyvjW`Mmo43-o|SJsOc-3?bFW#-#ufce%SJ{Z|TJq}Pw+){?cmoi{E z#q%S!D}T=s?Xd)(FP99juc*xlI>v+Jm$Nq$mlVaB|O( z&l_PqzF;KMtB92$#u7piqbCdZqDg+qu}0&GsCf_Hz(lzQ9EBUs)&_QkKf=O~aAXM? zvj1PL5%}VJ%McjlAw_fR=%O|rCybNT!Lc9;RYb@U3PMT}SrsuuEqd1SDVlIseGmZN z_Ejd}AcCohos5dBc`PlY+K12F=cAU2DyJs`sM^g6ugC71d)1QL84j5N_A>n?>p8G% zGe+%P61<2t(FM*Tg#I`|cF1*5ow+T*=W}$t+coJ^(rqzQu9q?5rplG;LfbySq(^;j z?9uMJkqDxwyvyhYC6YE4z`v1NR^(tbTfd*0xOMa})T-GFE(2fVp=5l6qHkX*()JJ~ zZ*ilJf-d5C;oLG9J$C%>p}rEnurvPRfMkWW zq}FuO6>5D(XkebM38>Q?D7qURMy5Mo|GKm&UJzV z^(mG*Tp3F(SiO?PO}<`OMhAx1d3MRVfv1@cgl%!nVn+!Zx>B442CYX|ZzogP3qDy( z2mFn}rG(#c)ND>;3gY)Q+d+V7Co(c8s^as+wr-0Mt^kh5lSWv#QA2bvpmvusIQJLb zPC9;`<8j1^JSH{=YuIJBW@^f6IU+#R(1)eEkF#dl8pF*K%~X?^7`TqF?WT|6iFcwv zLMGqQghp+d_65PPERnbOFiMhMO@JGOmy%?JG=$mjBB*MQa5c&8^Nb#B_w+G#C_7Kc zKg+As?yPx=1O_C6DMX5$EOYhkKMvlQ3%H~>5T=YI|3V)eaS3g{zuviqyEx=DWvt>X z8A1SYblLK`M!H?5;^Ebk+UD`{y!8ak3NtNcc>t>#MIz6FCj#6B;#7_S4HwiBNtMdS zO>S|FwwNh^UEde9Q&Wf-gx=a8G58{HnVSy#=(kQ>u3?>{i54iStYrvlO1ff+pK6wD z9*zC}Ep^%pPrpWn12_sdb9p`@ac$3iIs36RmfOu{8h9eh5;GMHxJ|k%a``jaHKe`i zQ|2Ys;0Ql(@Oa@kaZC*gg@%l!qr(M$vB21ojri`wc_u=+z}%7`jDm;19&x#}1e0p> zW6sF3DONGKD1w6hA!kMsy6K}v0O0zio1k3C7~y!~Fx!gYY^@6eQq*1$I`rVBzPKzo zIV!Z^tEx4JUgDGx#l7ZJb>#VUZ-t=uBAs|GS~Q~?Lu#hc%1hw*nQ3TNAjT@_hQPM* z!&+1aXn=AzzVnv~z#AZ<^efD_i;^@YShHaIeFRfauHC(rbQRd+Hg2On`LbphTH{Cz zg)PQV^Og*?fy8(#u}BuvG!Z3k9Zk?B%-nOP46C=#?T)xk7L)`?qIn3x=tq#U)~w7_ zI}Babg)=pWOW*luQ&h#pm2BXwOhSW$8Uo-eW{0`S(;fn^T|@}3WipPRl*0$s3nO$( zp&rO7vF`5x`$0GL1`3M9lU+FZovU4rItoPvVlRy$nBW3pT1(g50|PgD0#4FWKx9g# zwhl7)C^7b3TfLy^@QwrO%7qInys6EDM7&&e%Qs-4MS`Gkh;viW&c`PeLB+U?m2Ovq zp~b8lcHl?cn%Sl|IMiOEz&sHq(m@suFC`3AVS=^?U29$vyJ=W4?$wke7cD8*F2aPz zi*)NqWNy*Vt-2nI7$0Pp_o16G0tTCc97I5fNIlnU#x@uZd9Dlcd3Z=jglTe-C*YE9 zT~d@n02=vabKOgkMzAQ)z)$Pw%qCKR0=j6>qi~*ai+!C~H1HNY!NV zm5?F`*{EFW+uu~_Fqf`tRxJ{Poy;g<2qW+a8aW*DSWi|!*yqd(DwqJ&4&h;d6OHU+ zJOBvu!+8)1msim_h5SnT(wjX8f`tI1N7y&l32t1#v_xpngFFVdrx=wIH;6Qyrae6o z(+euRX0C-Bk-g^VnkDO^W-Id2wGYX zvfcXO5$@R#u{+M0kR7t$Vr*WGZ$fEUXZt}*~OOMj8-(vlMpK$b5rp~M~EJW-10=2xk?bJuoZJ< z0t5BpSQI;>5GJJyV-3Lgg)^v8Qx!SG;Sh$)`Lf!0kPsPX<(#WBY2p0Dc997;Q1G=_ zI=pHJn@W5GVrypBFzS_D?xkdS6Z(*?7_hDfev9N1Z+xg?EcTj1z@#$K5#Bu$g#BbA z{*vOMwR-fFuYqKoXs3k%!hs5+qpnehq1lqbc$?v%5W@8F1s{J0r&2@sq$2MK4-D&G z?vN%vi@reV3(At}g0*^-l?3hZIz3FTF?h8)#Y>G5=a67LzYv;?E!`)yJz)vbFa-YE z5p^RP#*!jRP!F%cHEq8RUACJ>7gMc<$xrPB?@P3qRcbqM9KnX#90K8Q+% z5AMPu{o^XGRrJ$BfaXdq=quV0q#)*|JwvN7BBh-Ui{Vq8&cMoBHOX0iMzY=r%`0Ko z*B86p!q71!NW}gr`ISCpMm*Bcs(_gT3nJ!I6-8L!L9;n;4t>}r-?Wifp`6pW2XS;z zEAjy4b@WOxHBv- zV<28YU6rn%Hwh+@n&a06xJ3Y2VV+4r8Yp$m>o*)@6P%vcZ>m4S5Nx7CcS2recy&Jq zbjFSOFsB7Z!L0ZT`-f<`Q%<@ZS|gqJ1~RRC^1L}Fx8NR5rB9#|u>?s7>TI}0tilaU z%Myu07TqrB-;QwL3v=nOhisOmA~BEB82i3>3kj7;1PG)JUZY=`fY=JfC|T`!x8=G{ zemEytQ5ytk^g9vRL)9;D9F$0xw5(>K7)@P}rmqAC68L&BX3ovsgFIzypsL-HqBq0j zkWfAW%b6l^51(zMQYk78grc~-TrdqxGR#r}nK~~~I!0O`S#X>Uq-vNW2WQU#49%qi zqsD>2q)7%zn_R(FBog+7M*Q6_*NlDCKK0K?oS3H<)@s7)pQzWXhnK)Qq;t3U;I>8^ zsn-&c5N;|Xq8DVzP%2r-dHansFUiahE4C3V%-y1TYWmWlMzz_yzhud@n% z)9vQanml6$2S{?4i7sEs^Z076omPHAyhlK_PL=AvmS`6*&4q{emo8;|md$o8#VDUD zR<<=`L!)GDriWNe(n^vEV@+OSJi?jaN=BmPnTloRh|WKF5u=Z)P;x!toM= z5muYafhElA9~rWHG_9Q%CI8E0N0H&Dp9puh*FRp5CyAI@Y{fnObi1^a)kS_X9Leji3Wl_HoM{GDN~BHLS)6Rm zn?=Hr>Oo7BESNLSh5y(h$X~4GkgsJ<%(8j7>4S2v4J)4qGl%iEZ|v95Q0L1vEIKSp zx~}+Y&9L648FJ)bQtMM8&5+_O#ie0Kghh*6RnM)4bBbOZlHB4_8K>IzIU{T!6NN5J z_V0n|<56h37ll4;H$=f{Y%-9mU{wlv*yVY3oC_2b!^H*pj5ikSi%}Ux}Dsa!YErQ`w>%t`qHaSDqFYdrF93 z{>I6)6UYW~i%isr=IZ=Ar?$-2GD`*E*gA8)ZZAeNXl#??^Hi4-5kspMeM_-2#6%^Z zktsr4t_jvmp?u0wdL@{n?dYO7&tyr@j#{T?JaMY1#?WT*WFA$KR+utEhsWos0#JHk zI9ve-+Dk1YvtND#YMhZ$&V!fCh9pg@F}e&SG2yJmk{d9S7@*-SteXMUGR z8K9u%ay774(FDu#N*bKvk`!?=Bu(CmOp?Q-qo+C3(Ym*at9RELE94U7PFIAm>Q`1f zaefF9{iV>3J=yjpztRE`jhGfysJ3sAM0dGkd@#@?#4zb_by*qgr@y35P0yu#-0a88 zt8QfkUtfer9Dh6*d)5Pg^> zqUpt@04XGyRAyVdDegrJB@Hvb$7b{MMy7**8U{w*MXYA+0G%dx@gN#v1t53#fa|d* z#$J+O>$=31Zc`iGRJ;j|$cXL~hJmtTv~3BKuu+6%v2vbi_7gZlnI=87f|u`>e6fjd z>9dHskfjX{FFz060dS5|dNa*PW9tSrLj_{PC;tPOZV7*v1^{C~oWCaCHaRJWy$iD( z$j)+z?E!_U<{?yFnF6=svg958YkY&|Mv5)<#p@o!nQN1J3GC1v4$oE2L@`hz$f<^` zX!;OEo$W7pW-751GcFvET1w@fg9{Gew+$!P-(}n?-Z{(QLrQ zn=F)k>A$nQLB60;7Z6Z$AZmSM5N*@-R{{&6*~;q4EAP>*^lx0nVwXfG2xcwD?A`H= z=(Fvd}T$XdL4-*U~U=`pOigUG}8JT7$|ZPPui3y6BD$?s(BEp zS5c4XAw&-9e_Ka}&^3>2whV)?YB50{Ui&a`Q=}48RLh7pi+{!UimMu(X4B>YQLtgE*;}Uk z2KFwRP~%Eabsyq+!8qdD=)42*c}^WsUTw7WggCavQIL~F1x3=zXoyf3gD8`Y%^PA( zURm-O;4t@7aKP2_m=g>}NJbxfB5oXa1~BPCg*5C0!*fWr?RpzeJ%|GnIqls_qB4k_ zv7OPH&85ai7eMtCp&E3kqK`aD(8(saPAo%3!jY_)$DkfHDvp@6WfZkIq}xQ)&+D|1 zw}?EU5OpOKRcxzyEQegrGy~Qr+wj`z$7}?v441G{15E~|LF2q6F+v?u9+v!G2U2FN zo8R&A?RU)hN~s5M@zGWiEm5r2CgqQDEl!gMB`uXA%qgnlo)!!~POv5s!?=W*XiK_Z z;FqdgsssS0ZY{}cp9+4oPh^VwpJxorq;3x(Y$fMof&lhuR-X}Ii_&!)lnDJwG9*Ce z8z@*dR?&FM zNt7$gArKP}NvaVYZ#f}PJqA}E2x@0hY;s{Fr+g)ct9_-K7Op@$iwee4V0)l;;rj|1 z@;E9xvf3Vg@y~b9$29aej*UFd%Mi8kCmxL1IP*aR_ zl{%JoKEF@C^krHL2V|caff8l@1zecXIchUAs(umbK%Op3w< z>A=@n7fyJ(>>Rwsyfo8DDjLtnwNw%_bkUX!JHt~)Gp!+3(^7`>XTd}8R*Xp>X&AZV z?cU`wklR}b`AX8_YIp>}apVa@_((ETWosb%((8T9l%^jRfxPgK!#~UL-Y$00*u>Yb zU3!cl14+sjp~X`;7Zwd5dp|SBZ9FYf0d&1Qpjt=zT(Xo9!`yVOvUS?wTugGLs2 zV+{0|SNcSNi8R=bhn^qn01shh*o9N*(5g{!8-LFo_}WTiFD3krF5{&++6+3(3%aKC z))b`bYzHF(7^%8cp63M&p!U8%EJ)*UD4|f8R0ix*Wr7(7Ih3wV zfT7|5oiWgn&JCI|$NXDf^G2=m$*HBm%ZU(!AO}{(I#LxQ97Yq{wDR4Qht}g2T8+CW zQ>go2^FI07h(TV%dIXc9{t(sw?A?y6s5tSOeli+%+2IjeR1U#RQQ{iJG!?>U-GWrw z%E{J*0Mzq%jLW`rb`Ko`EgHY|3ZI>TJBkK&c`1q-dO!u4o=88$V_FBgw4?@tslVv0 z=~6WJtmdtnP82M7?Ok%^?@!8?IHM}?keIUTv?s%e=+1Kfp+T~psKZx^01k{{v<Vv(TT7K3x!Q%P zN`xqRUh1gnB55GJssh#f91>%?iIAWYRy>rqulI4o81dn(+R8A@Tg<|y2pXZ8v;m=v zLb@mX29_WUQo@dJGzrvz2;>;VO^WR(BOuTS)UPloIVl1W6bY~o0&N?p5R@ztw+PBb zszgS(;*=;pzw#b+D(zOO_SPA8S;ND-C;^Mt>zim)5$}@df#Tlfq!UxahEPs*l(i&% zbX$FnvIoqk3q;G$%P|< zZsOo!J}1%+WhR6ncxr^0t!1xks*0N+=nI)1xd>VcBbBt_1ERU-nrqp|2&RVbsz3}t zkxakv6sQu%LITr{6WosH^AL5>J)l|*VyLDND2vs#^igTy>_$h5F{}Yf3txZ(rY~c& zpK`}g6KjY?$idQg<6M?wk$fuy!yPunz;GR@qgI9C(~()<{Y$q~q>inanAVVW>N z1U{)czPzHw;q3=wdutJ%)s5Rqi6jLw6!!ubi5W4g24Ho5YKmVi5uL_4Fw`djYeY?i zFTGI!d^Z6w42(dmKpB=9xEX_{6lL7LK7UP;pui(kSfL{#vj+O-)8a823m9464jYIPTAVa3d#aN0}`%p!B-3b&F)XJH!Ak(;DX@wBsOF%Jzy2@W5TU0suWR2|-A3&lW` z@=M3eh6KOE@e;$0-1~YyPs12f+tLJKb?--%fFN>sD(z3sh{`;*Rps*(gtaSHwZPb^N1^qMKCv6mby*lFsO9!dr{ITbL4ZJX%MW+WEYtXU~sTMxgrlG;HR z7LL8eTa-~#p*d!MIr0ukMYe)_5doQ606|>yw1<;{cHU|+N5AJNDgMBqI&8}re=Ai) zz$RkUIAaNL8eXk_e4e9{Y2bn#7Ibg?q0c6<=2xKeB;e}}6VfTF9J3S0^IGPDW8j`} zxDGH?IHu1!=#5<<4rebu80ll2C>*Nt-p~4#;>VzIR)w2J^(%|SujV*wNb>+jRr&w$ za+s75&Dvqt$~VCL)S-O)ivZ=y%n||$M@dIg&t?XLLR16|x*n_nGl=mGY(5UIYv(QA zBZFy;L4B!ahc)v`LOC?aUHLRG8I@Fhp?Ytx>w0Z*01>U(%(AGHNXb}2J=!uoH z?TV>X@fzX07%e_8CN^dEHBtm{_?=AvLVGi}`3+LKqXl$29}oYYdSQSBG+d>y>%+6aakDEm= zmt7cvI1_T6QS~wXa5vg|j8I8vs6HVkbitLe6Y`KxtHS9(JLn@R5t|&E7Byxu5Qob~ z2{bZv4)#H&RJkr?iGq|YqKeJfO_LD-x}|IM38x8LZkiBz28dod>j8dK`QnX!gPGBh zrmIwQ&c|k~EgC|)=1U*i&*C1Po?{qiIcRAU@o)xF4DxPI$r3Bj3`DXDeO4(@!wGnc zbO``0oNAs3v1lWb++ze#YBbu!@Z&OMD}TxE1|Zq=CAQY%b*#-~$+tp~uee!5Isc5E zv`?l1#`R1R9W00++1f(nPr%Z?m9eiqI}=-q&VN)xBSFvjB;o!Rbb=I**AY`J4Hm^v zlzAhkSsjdle&ucNtYfPUEzOb^QcV^emR37HLKh%(W$gCR3u~ASw9?p8c@>*dP)AB9 z(5Pg&l#%Ef7%n1%JK~ks)P%yP zrIW1JB~s<5TLkwy(yEC3In-1%X43=dqs+&bnBm+Hcd&(_N-Y`k6j-4P$BT~Wa1>hu zRI!)Z)Dxz-*u6n*!H0v-YjdN5^|~FHmQPHg0=``jeoG*S$<$g!ADK9qu)i%1)3@Xk z^wtG!kSwG{)S?bX#&2nIE}hMkUdS1Od$K9AmJ-yURdhsS5k;zY0hGFw@uLvYu3y)T zYdX1oC6c%IZJ;bDHzFc4UXRq93to4^m+&%1B(SVkWBdu1AgH9+jQL7~FB^g#T3?Cg z^1yRUxvi;k&t@0=Xh9G8NUxJ6Tv0y#%cI!&z=OS*T3E!dBvTr(SEjoJ z21b$!=Ck}M6a>Q&R1Au!Va7WAtr0-u@Itdd3gdVC5vk8(z#;CrF-2S9ptO3*)ul-A zwPPE@*>Z;W;+p5%S0n7V@|5$l_0+Xh%9JsTg)tjgg`9#?D$0?Pq0a!OWfe7WJzI| z+O#f*SG2rJJatyE=C_n-+glJNOmyf|DW$Eq*rHirI5GxOkwO+@hGY*wSaC3Wg$~n8 zWeH}PVxY>#)VY24*2R}hreoL0CGb>WDS&_~mffhRR?*=fl@jF^(~c0BWE!NRhm!i*P{6>~mONCbs$^f|* zsFkoegitmV4FK?sSuhka!7(1Bbpc1nLBP;rm@0@Lp&SJQPOg9m`Y!m;iNGtIQ>eYruD`bDp@!c8PteUmR1CvqB#jxqcry%9>`0!$z3ITB*;pN z8hZH#wHfNNSh&-yPy7%Dp?fd}!lbw%L1DdlKL)OKdp>0-93~GYfQ`jTi8=Yis)`er zV-)G$-ie-|26I{D1>L8$>@!l;QKw4$?(pkGV#X(pGnkv05SFeE_{EP?j;~^fGpb{remE z<0Ur?LnAv%Si<7M^6FwhB*9J_tjV8yYfv+y-3II>DZ^0HuOB$w3 zBbSDPN+?Opd!z_pIEas&SS|OYHuB>0wUANS?-eR3kwb_kMPvATKzjI0G7Im*R}R8W^Yq*SU zq6E6?k>U9VdE;R!6PWy>AD)rr#FbFmQ7;mS+54?Wfp%uq#WzpRbEa$XWsT283(nIYbz$ zQwH9nJTd#L>LB7{KvK5qWU5R4(hMnH)>Zko_!DB2wwKB~Kiqq}onii6=Ox?PstPIf z$j(m5Mniy6d~mx)<>L<0hzeqN1K7%1%@HJl%0Poj&4C#Thek_XPE;rc2&qCsVSnBS zV(20iuvo5$+RgsN<1a2JSpZX+Rbd26s-@`xE-8@dlFQ&(7nLW93dh+(-^`_%(!^Q; zG?$+Q+b&U%&=9)WJ_IWvrZQx~=y=9y7bMiWG+EGSsH(enJ1ADl;34)sF6q@gh!VV zPx#m3oP`RITAcUf)s%=Rl?P-?m~tg!rB@%I3MmN!2p*v%L}gkykN^f97Q)uOFEVUl zh*Q#oa&vf(hRF+)ukRBfiW?u6VCUlnZB!E!$MbI20}&6%Hqodc9#{8*`wu)Hhz@4|EkeTUg;PMe50277ln_-;T*r9^R5Q z4RBvXCfb`dNrw~4Lkm(t09X+4cxz@n?u9Sz0$ge;X?naog%aaeuEj^iQ6P_MTpQa# zg=`6lS8mXBB+GlhASAg6>QG-$1@zVL6l04@OtneCK=~HKsZ}cUJ79W!OGXuv_;bKL zZUSEXE&_~L0vHeZk<37;aJQuC`cyO><5{pOE%bQBVb>S+s^PNyu#-tY&Ijnl zT?^EP*j3cx5E4j@bh}36@JtXWzB%zcE=vqdCA5)&hLD00krBlcVWJ@(zY-O8n>t|N zm<+c&Swy6OGq@BVt_S_!iRh`|v1kFh6K-^eX`NYR?_BIt5`T2yEW%$_fP<;WRd_%Z zTR+q3ph`+b83=tWebxg8Dy?1qF+K*#d@ZcEa4CCS#Sr+4D2=unmyBh1+bBVH4CbGY zi|mJ%7#kz2jl`kbMzpV1C^M}YmG0ACnGu{OFjro>;&%if3|WOlIwY*_$c{L3R2{24 zwB=|DrceM>4EWOXB0>0&FFJRvsiAOfxYXCm{vAz51hg3~oRf^NJwnazN@qc^ zD^K&-IDk&b!g@o8=s3^uxJ3&0um^$2L)-Nxm4+x}ve5G|-9z$uza`c(bk$D5sMo7S zG>#B!0qvV@IjI_S;e;GWjXxAxKJyd~v)*v6J?1DkrliAww!D$7K&zsPG>^BM!bheD z*p-XCl?;1vdKflX+%N+rXDUDMG+b`90`kCPQe$I2CCG1vt4Zb~zk$bGNF=s25i}ZX zJi}cbT5=&y_%c^UsB@Zm5><%~RST8a^%~MZ>!qUE9pIGp=X%Zf`OQ+dTG2iQ(%Jyo z>04A@@c3xg$ki}JD@{8Vgs@+&*h}iXn!DhIIP)tlGvj!C_OnSeI*_<%Env}^OZm0m zzSLHie$CZFj?o{cS-~v#hN~q|7(MdnYUlg0lCYm$2?p$HfOQK2SJWLi6^dIKya*5~ zCwa?&WS3M=hT@>V)Y0^gpNX~M?k^gE+i(}BF$0LH4MNgEv6MhTh=gv!BMC{Mj(B@T zap=5D^=|peiWXqzSBgz?UCXnd`iEWQQ=^YOdU8!)xSY14RLM7UUTKlH7U>|kp44^T z3q9rX!fE0jbP43m%0qHzeX_C*ybb;DW2k|L)P0F8h?S9NOQeDXp%+;sdWkonB>}II zpctT7A(aW?0*Z)u9;D=utI*5$`&S(GEs=8tc9*NCe10$dby zyGxvn5-kMje`vjlAJTR{G;Q^-jhqRxyUDjGrIaRptVKOK`lT~OU)zpI%^nob6GGGO zNCS<9AqHu$1l(xM>rO8`Btp*(x!EdYVk#alp)~jmu_Tg_0-&B?lI})uA~x><64BP; z)+!eW6i!K;kT;Yvirc@vDnz)~8Y=0+nRQK~CD6pZ;MLP`N8&*k8Gu$~^f3y+F2Y*@G?NOu)gg6J^;?JAwlSXRDb5;0M| z6<`J_GO)Yiv#|2WfIs^yWf;oxXMmXB041Ktad@JHklMl29$>4I zQUC#kU10o}s9%37Y0uD0cWIhQT5dvG#3e=}fUQUnkVHQ?C#{D30*MVwOToyP2x0Z; zERc3DfTkMWAck-Y>Kw`kjj@D0k|wY(czoV(+{)-(jh+@5qK+dViQ~s$WGzL3qDU|5 z*13M8wkfp44X^jU9Kk9v4a@8LXp@b#?uxPCT^hkb)K&)D6)-n@O<8FCyu4MHETHBF zvn2=tm^dy8m=Xl6m_3y#6T}9rBmJX=0b9fvM?JujZjOjppUYkMMhu;1B*itFQ z#-6ev4Ox8WVBsUdZ!7j%l8>S7iP}9V;&(}3t8;DQ5bU}7;INvc*xJeFFAz$VR`!tS zz^e3%x-3WPpLrAhlu0TjR2mkRb9RpzE#V$i zE}D>UJDdA!Fa}jiXMo=)XOR66)H{m~tPw5ALjh}I80GV81l3YW3y&Oh1~1$}b4^WT zI<{s7cMnL_Erp9)tqQj1OjnFaCW%1U0#Y6m1l~Jz0KCi|M_8MofMfDCHcrWL_07xJcJBTc73&8=n^%~@`CT5Gr zdk<%BXMa{!T#-XS{j%6XGCbwpcGQ`nWAf%EDRmaqP7{6o%C*J$txbU7=4jTM#^qk8 zEkbYCPRh;mL+tM;Dr=Mi>BjdP>>{rk^p(Hv$mFc59?B0oDgYs-X9YO-Xd%Tm$KwQU zV)dkhY-WT}I#sobOJL(i$N}AJkdWEki7Uc{P>+~Zsv5)IQVbWK(8cG~t40wux>-mG zVb^G<6OHIk#fS1k@Z{J4PmM7OU_ui5B^(5WLF{DMI8fyfB4aYBWx4`kVTE821#z$J zj35s1+>76WD_cg$gy9J3<*z;>XhK+=d>O3L%cn#$Qr&>rN8HMW27^&g;`#XdLH0%| zhqsiG#EgfqaZFpYpBi$hq?v2blB&7!_c6M*T7a(c8cR4isMdsL?Di-=zlUQJWDlEF z)d`4^N4ko_NH{jbW%*30<8QAE2N(btJ9|CpS zuc=sk&5Jo~ss+fRtF#;pII0OCybL};#TO@7(>a=J9rVTx*r6=vvu2)k#S3 zoMHA>q7K4+vp05aqV%$6;gN3ts}O~PU#S-*{yacOM~xPE03u3!rM(h+&o45dZc+L- zz%5dhuat$tAz?BtP&RH5o+&|exl0HnNZS-PNyKo70B}T@I2dI8a)bz*n}}Net`2RJ z%UFh-)#>BSDgaQNNO-ruhaf#3$vo&7?R*;v=>QxyzPI zsW4MR2BsH-($?f-dKh6aw%uoh+@bmOOMq7|$j#K6d%F0HY+^s*4M((~c%0FX5l2&T zdw7?`QRY%jIip3BluMLM(1Wr2jiMo{ajB(eu&X~b1r#YdYw^exm-ix8n)C2r5W6f! z;dA6fd`VB+hafZOBjX-!uxKB`vNqoyh1=pbyP5YR0A`S|J$cRf3}m|<>Ts{Z#i7Lm z12QKK$rf~YlQpQ@1%uXzR>>)?HCg5N_a;H*z?Z>r-bA>hZq2OWS_4o-Aod}z%t+-X z{-vAhAFH2XsVuToQ@qb_<05Pkw1y`YSnbGJ^y8AXZ>A|up9oeO;N<5GI^l@73|12u z3Nr{w7>XW-&Q}!@ChmN6O@ic|I|zhfD>drmD(OoS2NME$Rech|YL<9V)y&zswsdTO zgAb7Fny)0@-Bc9>c`*>Up}{3K_V`G~t&(L8};}|AYrYE}05k3TJT)Byc0~ zBZ?8}n>fQ@fQ!jA=!73(1Mm0T2_fk(3h2C&AkQr&s5c-f` zqlqKBMM5e*?ur>kF~sk2YldA&W1t^%cReN^bh&)^g?T~rNBOASh^fr0`nAm{U{S;VBrg-Av!RCV=O zq1dRAv<*&MeIj-$r@-kuD_$6ZmCxH3i{+_tn}yf$|vxLzDS!1FTzr=+1o)6TMZYE2y8^D)kIKNPGlP+*%1z!jaN`1 zl9vQ~C;2#zxY3;Vpz(aH&4|5;0ZDY2f?J$SUgVOjAhsf8zvYDy-xkO*ZJ3E%QSPE( z&@(s$Ea(J%utmUs;SakAt%S6Tuu3yu=~vf=Ez>>5X(z{RU(|;W`R`;pBxJ}qgl=lN z$339pF3rPZX3+qcywU}>D+T$TJlF$PB#5v;YTKbM3eU$xE$R|hRMhesU}1P83H}oq zVM|*52qF~2+&FY)4=*oy7eu9#f4&LuX_|&eV9qbZl=rU$pdX{$vzGCZm`}(6l0)oQ zMfEx*V&Bch&0-b6x@ zlVuIljaThn`Vrdj))Sktssv!%&5nX)9nPdc*yPbAL=MIzUv8qp0;xtXDfj9Fx@2s; z20~b!tZm45$o!;y7+4Lt6KaJA#iR=_l$7tU5;yh||)-^fe`&Qn2gvJCi= z83h5y5>m-X+hw{GwuE=5EmskikV#9&4q~x}P7Up*%0@IH3MH$>HxMAb2wBh>TfNAP z=X!AwxG8FTaeWWv=m1x%h&!2vNtz*pG0%(aFk;QfvzMq9kpbMe{R+2ui*X_;!gLq{ zv?bRP_V>dgeDJz#a%&9%!s0lIsr`41Es|?Bh{{mT{BAymaGp&4O0LpghnE9O?`BXK z)q03;P#miHc4)pUo4%dR%&j7|Z_$)rCi6gJvY9HMd@YSAAI7C`Gze9XDzoWU#ZFyb- z#L!VS8(3^BSrdxFFnNI$g+ir<679Y1lxP*D!I4K96h{*Q>^in&Dn1H`VUTqg;Gw*K z@l0?xhJ#Kd!qA9{LmLApXq%rU2Hc4=u6Jj2cu1KyYq20btu zCUc%R8ZiM~V+^QTkcP$Wz&W@5uTs3X}a zYqPu!Vamoiz7_6t z+!QTi9^XJqE6o5}K?HD1TC`58(h}O@K+PRqtO{w5V^@gOij1J8ppwP9G_&bihAn{y z*>{`b5WL@8L6CEY2H;tMI*bM-R|}kx_1PJEL{lJ|c@(awcdzo1kv2yYZZ{-YoC)Bi zhstR0@T(G$L;6isNM0`_Im8{5zyKUl@GHT};tJ-8SVF;^>}`aV0pkq9R*oh0r^Bla z?0^ig&`PA*SB7?w1Ym5S1OOa?zClSi|8)X=MZ0er@^Tp4VDah38r44@vIN8K(eA*; zXH{PhL%9!c+3b;0wWValD22%rwAfvvC;}iz*>TfgYL0c(A9n zay8V{E@>*a!uD^l2zXFJ=qhy;21g_?^~&N!B!Sy0ZIBAqnrNR%`NJ`*$)(G7 z3@S?-rGs7+`;7K^7LiG8x!07gTGr$|OG}A;EW~F2k01_l!H**#hi(RqUngxkteiRUH5waz{zER@7+$}83SxRF8TAVF5 zjL~Yo3Lo)W9RJ*Ni@TOqT8vR8O)_Q!$j1 zmOrrFl)ZB=@p?+t=rx5oD8WnF_qa5xdrnsMG3M;>|I<5nLLbF%W1jX=ZVIa{i#`Z} zrvWD+3}e|=C?dg&KQ{t5#wt?6J?J;w__se{Dn4(NoyJtP=fjn4ixsxSOmEThG@7&& z&+QQw+6Zu4W;Rh)31+X8?AmrzR_Lz8LeMSQ7RFO~N#Wl1AmA-EWr>v75t{}P+=7=x zd<-_Lb*?^&4u`{}%<~QMW%{83 zb;#hAn1Q;p+|OwRp&ItpFcD@A+BN=kXXFKIk%t4ZGH?;NX3M5P$rvi7xP(qK{yXsf zHfirf8GbIJs@AWF!zQcc|e z^X6@Ytt2HLMYI^H+|Od^xP1c>bO3jVgt6w6fY|kY7!-J|MFu8bt9g6n3PWEG6WGR! zn~_cegcT&7gj}JQziVmQVo8xM0D;&x&N_^xNm>I%`XZ<9GJ`eI;))!6|0dWxoM!IN zi$1zNjZSSLw_BXgmfFkaPp0A38YP96m9;2B&P8qS&9p?>3;`=?fh24tD<4WpG(y|z z{_$`sOI1>oyw>wE#|8piqOkrf1B6o;Fj3YgSgyYS=u+phwE<-|GNzG;QfQ1CDx z7|P6aLF}b7foLivD@9s%ecw}jL1zq@k2yw;5#V-kq;g-#fESOaXbgQANA?85AGwBZeZ`w$L+j%gh@k@fH++H> zDWn4xGNe$ALMbWBBrb6K`spyp&DKQ)@7kd2_CD?R@P(j4%oKQ@Z-X3_YQ2CoMpAIT z3P+?WT9@Z#G^>c1s-O!~h#wNO)hsf~R$;%i(nHY5(jyb85n+y3|X1ckpo(+%(9$QL!{5d(^SyY%x*QTJZZEZiv|1>QEg4BHiW$Dgy zdH~e(aoOuhx+y(sFOFc?U6Pb;TogW36EegKiCk)MB}4X0!6nl6hEbDM_)^_xNpu1C zQ|j8oH^AAJC<}lf;RhfsZU919ArZ$iYF{3Z3_$)(qEiz2t8=QSa2;8HrJdgI?NJCjsyZ=ST9SLm8^P{6C!Pi!Ue0-A>AlC=h^#i%80fyT3NON%W>MrY)v>IOwR(^<@F*-Jo| z4*IVcGOlyCKs0p1uFin|?}Il6mXmFF3(#Q>qqYkU~yX1jq;lyDS zj)tNwvLi+7IbbLuyafPS{A4N9<~PWg51<8F;TmHrC^WkM@BE5=%KbRKp-@cg+B)}3 zh|ABL6h&V5s#`CGfowir!wKRZU}u{cSBBwjnQ#jI)oa<-2q6N}>IU+{D~M?}wHYYQ zC0t<$1|LF*h~qIN6JV8S#Zod6Akm-MN~msKFe1cg7r2>r!jvd{Smf7`n3B65173=9 zvWnFOrMi9BZ`bY#(-v_dUw}`*vElQ)?WldsW+*cOjp$>GbEpQvR4}$%j^G8rC1KQ* zz)e)jG7#RH`P7ioX4hKEf#H5GRS*bwaP4L1)Z;8<0D{s2vd07ow&O$i5=cAw3sg#o zM!^70I+PuGsBuvNU5_g@IqEOl2B!F>wJAZBMSDT)m{>*i7c zk>-X`iDM6ilBO)M2ti-(N&6y(Yh6DBnbR89VwBw6ON5pv%Xcc~UPG)GNoW)~M@3oE zOU~!kOYDe;S}~eVwswk#2!lcI)<-a85^=0>#@K59Mu>sp4F34Is+o^L$iac&ivRRr z=FDKN`p)e!099|~Ftp;*!$z=ckf%}vh(LH=iKHb?BErd<3yG2{i!3G9A;XEarO9R< z2$M3dRnQ@H@$IJwNJaQ`^2tW`Y3b=AI(kryNzJkto2#m!qnQ0uka;LUWa7dQA>$LD$M#jF4{{ zaY#A25~gPm zQ!cA01j;&kJzhf*Khy|WI$kdb?rn7i?Ms)FMGFfda79Q_mhtY#BWj6Kvg;l&dh3vu zwE_#RmlqvdVXC6#d{-zz?AGpwFthpT0PSc-6Z#ZY>EoU00t zKZX`2*btmRY!yf8GUkV4oBh9*M?%nOGB%7jS~Bw-XR zkU<+@nIPaPZGmo=n>t~hBn`K2k!u^FbGHrWbDZ>GAYCX+K>O1u@}gF=hY|XY^HQFx zJcwOAC!(dWRJ82LS{iMPseH)<2;vhqw70)llS^qV9)zTn=$gbqO)1%O!jnB0$X4x0 zLFE9%wj_)y zvLBNTPm2Jf9X|cn0?{&jR_w}bSOm07hP-#0c^k@HtY#|#8)x>hqHBD2jG|eO6Ji*P$`qB1wDwrR`mALtMxOh5aF=i9BR3#i;7W#m=HdP3Pp~$iX zRZr-|D9A3UQ@d8znyIBErS%eQk3mIjR0e1ZXL=uVQcxfT&ft?FljX{(C=-(tLY)=V zk!hIghUos9JHZ#;q*8|VS&yzh#Z_U*15xdu`@QuN_(i^!7ciND_efeJr~6n*HzW$# zn<)>krHqcwew<=I^Q9SbOv@Uqgwnv(BZ*iVNrKqMzex?&?_5s8{k1>#lV;mWE4M9I zMys|uv27HWDZIo|8MtkoT^3J1zlXC%&3#m_1Mc12I)txDfVvOAPwB6Rv}l($drOO> zcq*aN)p*HKH`fk(R1;mhX^SzL7X)|-Nq)8-C>`{UkdOcs67O+nq@?U=9G|yL&U^zw zFo+&a3UzYpuMb?C5M^Wo5hXZDF7pONsN!k})!vN419-t{Dq2EDG$-XS;OPwU!%XjD zj25dRj8*5LB~F^;NfKV_UE{KLq zPgT!?DJ7>b8!j;oh_fz-Yt!jfd!YjsUnz}Rk-1u1fIu_oF`5goND#7E5XOg~tgq?1 z%uIC1-<`a6Mk$r(4ryoYBxcV|H=S&K1kF`rrc6AWY=KnHj}Uu1obaZ5QV)%>i|<^| z3bAf-hp+jP1Z>awwfMBZrEp{P5vt?k7+3RgA5xe^4{BgZDY{G%ytx#jdpmF7=jO|DTM zXwhZ~1xqC$Q)?a1y8*T6+u0O{dxM|S#*?MGv_-CACw?U_0g5ogIUNDVy|pbrL6>GC z|6nq3fd?znG*uxKI|NM>mV%%NVE2(31Yi0%`+j5-TwN<8@|C5UoJO{x7z94=#2;lW zp@sYtv7JC5uB5BGrbYw1{ftYEhP`tzG9ewjgC}1Vmvm zj{+5mt`qUXfTX6*jSLLYVzBp zWt{mqq;9inYXaJvqRJctfz$0<#&7s zS2LI;sP|lsILiqEm18R;2RVv@<9?6>ig*w&Jm#q~(!%)gqKr)4WUgqCB2$=?hZr4U zSKYw(ffWc5vY#;6#VSMPDG< z^U`3s_b|g^Gy+*iCNRzm7a?EHSH2SFIle(#L0w`!Fb^>n?11wMP~Sx>{zPNfR27Ol zW6I95qEn64W9pVRykfm%b1{@QRl6?K$583j9nuWQ4maIIKZSP3A!VkmVV4x;?{DfE zni*)uD2Oe))oo<87AX;TL_rBivw7n;+?(7c*93-t&0Lgi8Qq#Gsi;r3_TBfI2nNM! zY9lI8MHP7*ws0@eDx*|WoS7pNY~oP4#Tg9tIC_-bxV#qin!Ki~GX!=a0fllUdhzGY9(xR#iW1zWk zk#8MtMB0~c!znQe5v<5pJ9gKmZFM+-Zgtc8kS9@NHz6VRPF)ywFQlaQDX!H(6|Se(H&;d0hOR~jqjJ$-# zSw_0TtBHvWP-+9(TyhIBnyvPLtXohIkM4(sF`giN212Y5%~*`LyuNb|iNJy~_f1La zp-x>e%#e|fDRh`af3^@Y;F-5%QrCJu_TVL1R&b#Az$j0d0tq4n8-7d?s@R$;+wV1$ z0D(+QOB3~nWe}-@W(!|Q!Dz|^cn!fOl&X@0qAq=sG_ZSr>xAmJrketo!dqKbYpcfFY5}Ws$>jl=SsA^Yk=6IhKszLE=V z9ba{Kb8XBe;RI{W1?7mLC5Q^jOLMFQ&P;y_QcgoO7E`O!0C?$Fg+^-uCCfFb*hOR* z7NeP`8f6P2C@&b;c(xwlyGy`Gxhc6Ym=tXRH6Mr5xC|IfXK2;n9$wkM9LgStOGfI& zM1?v;$kDSgt;3gXIVFi%+Ca;qI!J{WDNFJ=u09^tBCLMF%hFbaR#h;BZ14?yaZieoxTF*E2DFI4i)6JVLBvQsv`JYi zr@{#HlvHyD8;htmgeO-H2HD~43UCP1OB0c$o>PeQ4z32c`HMu_fiBo$l2i%1h8v}VF+B!vT_eF()^S_vM= zsvQzn47B@ExV1dx@Dy&rzPpkfAoKBwZ6vu)jI`scP$K~+#|%*t(8K!N%v7En*BrG5 zHdA;Bs_8>L+^tuligPnWjwzqKh&=(-?9w@f_P+yBv@B@X(A5+PA&tnqC9uSIs!D>E zrkq_M8)*KSdxq3Efy#o*B|(_|OqR=5#|nu?P=*eE-e%#FQ7i~{O7g)h(Y|gEvynHj z8e;eLp4?NR8rI;V5#(As0B@sfZz*(*T3+@;;f3wieQV}QYTv*Sc1_R(^o-mYfkK!^ zUUN8pLj-M`Y0)9T6E^9^puY1HFtu{0m+iH&Bhu|SxJ zXBCApLdl%OQ1l~halG7A)U=s^6h=Ok4$@vo?uJ%gsl=(LB$EBQ0O585pEl6WzaEmYwxuEH3RH6JUf4p;bhBh^5>$;SUF z5ew9cd;+CtmyGKIYH1kqlhzw0-=WFq*1;FJeYlr4){ND%5wze?kHeNp`!`5a@C>IQ zP!_csmBHj@079yw;(9}lDZ1c^k&osyEQ z_31;aMUeLCNSq)?Y9e$=5J_^d_|QmP3(#4n(jhLQ*cM^d%J%6&tg{zxg@Z;>D<9J~ z|DhnHJAiZMI*Eg)<v?&UQ%qSt0BE6!7Ys0XFye;EnoW8T3FL}OSMLtB51sF zrlp}J%diFSe5Jj_v>i<2S)mxai3f`BX3)XV8ZYE=-s2*aR|`KcGo&UgeLZnAZ)0LW zCRkb+7Tkd|n!)qJ6NCmU_RX_oVgLnNrx)e{;5{gI_y?_l714UCs#rS@KAl^V=AW+= z4TCe<@H#um1dcd~4_z;)BwGOf_)xSSRLJITkve61$s;PPG zDmUW=r8j3j4xUkBgEv4#aB?Zg0G%r4QwGW;P8meeMxK#N&4DW(DMU4-B|{B=hXb%% zP?mRgLIFu#p+FkqdAA5%dVl#EU#ZNc%3#5?nn@w+5%f%DuoyuxTpQZt`H+7yM4~0< zu{a;lGR%#on&=?bA%}}=3UD(Nwlh66TM(D42iYx7xEeYI@pPd0*PUr(qcii!2lghN-3j3y9HP)g3Ibc? z9IphqFQ6_dBY+}ZhVpGyGo6>RdvMza?&B;9Q=S2no-SVqEv*X0jqqr)zvXC#NtFp| zF@hc5k|0Z}c}pS}N0B6#(uA;q28cmWQGnopBEq*r_M=3_fDFk102E{n3%=5ew_*+# zfp+hO))GFiWYiXeR70?e3fz$>I3>r#SR3)aj|r%%AS8#qyO~(f!`Q8Zi)P?v#KI4Y zGJC}0#x6CNpNso^5dR69GM;_61kvE-$E<_yx5E2 z{)>W6XkHg$rrLzckWu-3B^9XS8W_ltz+UQ{PRr;_-6sbqCH6xY~lR6eT z^hx_{i5v*@7SSS(hOn-9al%DZiRbM10R=O^K?)yLAvd;>YOBU9c<-M!AbHSvZd^mSkX+AE2>IrF)g`!fFj1b@gP>9dhp`h`tiguLAZ#P?PDnC?qY7G0pMG3z zD5RysnJd7m4Jic$>XtZ@_z}YNnIu8;k)TfwT5O8qc>(>-6=pKymH`i9D|hI2C79MI zHkt!8L51ofWHmtYVHFPpEQi0KcHI5y0#6E#OsdUb+7h~v zV}t@+jMv{Ov=1s%qqUAmuwa}`Zhx=K8X^QNV=YUy=3y-=xrMIPY#P^QY9B4^G0CMC z^#BEPQ1u|@^PFKzsDA#%rSc8zy5I_5lI0GgO&2@FI_F3NoH$ym=o?icV*#~IMOTA|ws$6{2 z3?pldOBs0yoZ4_sTP23mkrmpK2uae1q^hbyDm|6T;B79AdpWS>DY4)&*73FHGDCtc z44PO<1food=6R+CWUT{RH9lP|#+(rsQ2`(jJE84x!P>Jr$=WO%Ff5hgGN3FdS(RIn z3dUAYSj-H{MzsiA8)^kah;59Avj~b)yK?;ou9Bc9#Y+IQ-i?~lY*&w(-5T9eI35tY z3IlILaYUdKVSvVrZcvWhI_wUW=kds-;VZ#zk>cV47QcaB{D~FfoNj<0X^&JUu~)EE zrkZU5H)W@&;H+*Me?v}8Vm5f17EiBMCG%cRO~VtcU4*E7-7!SI2*S2{26*#%94Vgj;hiUQZ*0XX&u z-Y^v>EeT0b4>E41(0tx$WF|}iKPaN25vl;ZShJZWgUxBnEGng?Zy;1B`BJ90G&^xW zsoqfqmMAWOseifJR({lldm2`R%9;WTVXFcHdPY!6T(1^RMtW|*Ph4-Z1E0hhX2=O^ z^2nmMHJ^mw*psnao{IiL^Qha{;9l&@11aWM8+?O30PzqTvQ1|`jv$|T9yO_^mee9* zWDS-zHWITXxyP)DdhD%$$g;VnW7NGVcV0@eDto{RiQ6%$(Mr$Yp8ALcauVVz1+5=~ zE~FOKC&zRQfyr+77#m|l4RkI-HV;p%NkI_kz6ZWBa{OZO+ym;jtV{uv2=_NpBDuPd z(|B!mN-WuQ$fU$e6Q-bkm$XZtRic&U{ZzVX!8j=yHTzRDdKJ57w1A#(py)FqmrV6^ z7Lhz9;0^fYkU=}jHccj$JrjqcECoSzX@QHCs;gL;K$&Z! z2%yW;l+06CQNw3Ll{AbN*$Z+d8s&6eiQ%k*V!;xPW=ClrivzNKYJm}vRaTu?Mz3(% za5^7!gE8D9k8DpB#Q}5uO29l(SX-e-)XrZRD`@H~j$t?+I6F^>{xaoJ%A*6c@ShlVnS)~uD z{YSi_j7q559w$^`akDK71#2?|!R4&1Oc6d;yfZ()0a2yX@(l!Jk2lKVFT!AT`tqV) z&b0_*1<;oMcQYYIfGlJV_`KhO?HA^}?8w5dY;NrrBBxmuSigZG-S`uWHp%eFO9k>8 z(zG;3*jrJ~D4%*L+qFWIBa3R)vE@dY?G$kUHBQN%u%)aCzcgRyDue(Pwria$xq6N; zRepY@c;2c>+-9)tfCym=UqJy*5g`IF!Ulo97D5EjHI;sAz?_(6^mGtA#ub|x zw@v>9C7zOlxs`zTX*CXm%6NvPAjXtR z<%H-q9&pXBD!hLc{?ff7o9`Od#77d~`LPl%x_N30TCuTr;*i4s;OXNe}os#UCwIz)3&#gx3cey>;Vv^Gh-nN>?M`4L+s4!+Nj z`Ii0qx7Bth+ovEoSnG7i-B$N@7H96o;9*(&x(OK}2Y#tQ-70l~hIBHO6mp3ll=w>8F4i9ko!>x1 zEV1YZV<|a{95BGt?1p**0YFi3SJWQNO)(_*^LcSceTx!`IMtkR<8ea@?!Ja0LO*+8 zCRlDP4d0-I3Tz(9EMMG1TukwHML32 zgX!ndAEzMG_9h(lB&2px6lA3BhS{hHnm~VsOFssO@Lovqur58c1FDdRwi$ zHC<)8!IP^5MXCYI&6>e$PSY3pN@tBg6C~bi>KS&^WI7Z&_yz1Gaf#t2e9%@|vuFDb zR=205lWHa2n-TbKxf~E})J5*@C2Ed$0m>2`1{&)m%Ia)GDD{fX@lW1>QBCbE+bJ1(C4)l3&Z%(&5Lt<=ml57etQ^rx zimGzE{_Gg=ieLdCMDH}b3PwN*&kdbSr?wR5tSN48!2CECVns7=d+j*WXV7saMxz(I zkR&U=l5?Ik_vD&YF@gY=d)pWmTG;EY0~&OvG?3zg!4R3ys=^4^nhBMvK1(R+gm+@L zz8I+h!{cxuhu5j^hKROMT)OX8)wE8V;X^fKxMldwGRjSnCn5D`XgS}&S&S1VLGcy+ z1VES$Q}Hc8fn%~bK7qi;!Iq^6sqlCklF(WR6jK#h$6h9A>m;J6U#04XS-UBYP(0Zgy`oAE{lxrNk(y6#PiCJR?C1~zc`sQ@5%q-o30 z6hrJ>6J88lszp;0;N#kisV&Gdcre}r((47X)bF0&G^N9>?H=;Ufhx$iYi_z-)D=@K zYdcme=(H` znV4JP#i1HRC*10266+;^RJte;Ym)MnBB%rA6{eA^gz{2I z&9VF(SYMw-A#8CX3ItEsHaWSLVaVy|yRBsiD@?v-A8Tx2Y+Y=a>wawk)kkZOQ%x&) zM^03vC8fk%|F^WLYB1Bxos2XN))p{b)m>>yG&Dqqs}bVg6EUf^{MJ6~;&R)##l;}Q zG$xFtHJ*FT8~GBM$AU_zl4`DKbuY5*^I+^Dis12G>GlE&4-2m8_oQpCq79Kt zma2E7tR_W#x=c_f4KrDwWWd-K~nPfpcrAvn*N2r_Y;X;cX9yysU9mtVtZ!5lmd(h3j0NSn8 zS}txAEc1lT)mNe|QJU9V3*G&wD~`XIJ* zpixssy~f8+i5GTJO;l?nRA~peQ+f!9@s)JDnzX~V+S$l?Y~i8+Hh#53ArJtR+V6f^ z5lDdn0UjZ*gPlf(Nw`dXol6MzgTqJZ~yRvjJWdI+t#%xq)QUx->_0n%|sf+kLv zg1VlGfpj^X!iUS%&_TVlL|1aHrpoO?aY|-FvG5A~>#Q+J0HAso8AO`<(9f@eJ=@&wASB?)6An_jm z;r5x^r7X54R5C^@m1HNGB>(zxy!>f}OViSNaDm^q! zwnpD58O@tJ%-{ndsw=lZZ8YQLc7Jcnl@raRX9=y+9a5C3ABTl7CDk_Uk;EgSKxCy( ztpb6`X1bO{vs_^kEE%v*y_gMEI(C_21B?BoT@fXTKnW*XT2~u_4JZA~3P#oIV!|hV&8%VFF<;t3Wq1sUjKh;|NzGSR^8v zhuwn}oGYi=oPF~iBzARaBhF1K)CO`?kex*D6&=XI(INy8z){fRb%~v@pWKSQ0qkbce2N5eeX1F+W;DQ{~vK-bn1&T|)6E@{||l zks}hI6w^pYr27q4XowB|dOF3T;jz_HW;uY6MK?9H^mM>nEk?TuhgT>a5KvR51*2YD zD;phaNreiLXMdh}(ybiW!dW-rC%=Gt;T2sU2T&=zTu_o1Lz37-KKiJb0H7X{Kp#OC zG0}SDRT3GT`9#SCs_Phux4Oz~mSJR?RMbrkic}PEhO*sISiw_}Y&V<|<7mbR*_^=& z0}>~Nky>+#FonDx3%$s?vVvc-l{QA8YL~PiLfQ7iK_NMY1k8{S9ydbdW{fk3qO6Sk zDqJYBSTB$aqec6v6lU%0BxXNrh-|a!J8tR&I31?0I8>QtX<`%->{Pg5BduKY>lqvv zeEA%tp1S7`cQ>;Veb5Jeu%uV&;W~Foc1hHmNnove^FY~^qnO^oXh9~To40LI`36G| zeOV7KUZ0{qF9vHDdiI8@eWse$165-=gGB(?;1dIcna_g;mQwwa?hsdYF)gdFhK#u3 zxn3SW(^ao{`w3_<5ux>d1fbfRY~aNpDV;rK`%27sOowFjS_R*78q1+UY>N*Si0A@h zL5kqnR{+g`G07ECP9eV1Q5>2+XIhfRI<0KOgFtfA5Lwh#Lk7G+s9MnaKoRbRDGAsJ zs?DWLnOWh>=gr|RfeYG8?#ghFydW~^7&dGd`$@lqU0Uw|DKAVNlSKrC;RsxC90D=P z8i$&f*n?Xz8?;QNBLS*1eb<+&WlGcn`fz((`HDS)pZfu_hpzLrTBE)of{gSb>78q> z;?&IWz;w?A%@U*1{cvJl4>{Bc#UN*%&Z>wk&#s0Hw*ftY4(Ya@Tv8!PvGh~{fZXk; zLRN0CHbU|oN zC1^fcy}&R8jsc5!X{zvfcwrC}My8LUZW(-W5W2cW-HnYcL7}8}(Dsv)VnqZi47jQB z_gErCc>MH^J8mn>r1O^ELn9TRg~_d1&){q8RFTIau6TB23d@WKBpeFJFY*Ozxl|-5 zzmnqcmb%PjVFsetvz<($x`qKOyKo9Y`^i9IPmoAPBRC$N!Pk0KpLVx zD4EuFwSUr62yWA4fNvYduN3jQQA>tJGt1->D{8SX9YELVNQImQ)Od0RcdKNO`?#Ae ze%1g+vI3c!vJx%l;F^Q91Qq`$IgA~LRRt4%X=856;5Biv2fIa4hU*EOmO!+NLFt4E zoJ8DNF?3<30|2D6pAE@cp;N{N?h z)a_ji;+k#=yej(TLp#!?&MnS4iAysKphXr6_b};H)48N{4-K6G)T!qH;RxEQt|OZik!QHkHV8$cG20Om`06$JT7E-`I7v>@#VhHsNS z&#S-(LD)y<>8`8NQoQ(A%IC?Gri;e050?8Zf0jUA(mB6UzTS1vimt#kN#+|g@D$w> zD2Rb#9>;lPs4!vUk-C7BiKH?QUgpqzbVS_bq8o|RJjci%=f-b-)rA{-T$ zbEwsoYMT#^s*2-a#ONm~sQ6l2eyS^K$vKSL_KS`t^T|{RkNaLlNdsO{w9NT=JcJEX z7SA(>i2aAQiU3KYy~TFh2rndaD{?({^De|h22iA|YY$tt;dJ$VY7-TuEL&CnTWi4A z@(_-Kx}d`bdfQ>wG6kcHFBmC`Oj!g$PX(nm9ctDiCTpS>52CaxTZ?G4p%^KxzfrSP+Lp!_?X*-=^6hRrXHXONgLQJ4sn80IyH#$rQ8WGgG$(W{? z4k%(RyuaGN?e!!&n0BGVv(ui?F-yJ_$Lvb;mMNyslo*Ubgr>(RorZi=BUxZIRwA6~ z>+4~yVglbCy_fTINs5Hkqvl#bhvP?_gw|>z&JYSxMeQKsc^TkS{9?#5&k}W&s5b{>jqnWM~ z#5F^Qn$*T}N8H+T`Mg?&_vns`W-T2R0ub$BiL z(Z>)I)e%ND5`XKCWh_29adx@bhz5Xx3M#NoTfUOFMXKY3eL6(<`L|u48uw;O0Y&2u zM@J>xh@uN`D|)%YtX+kb8a-06Kw@0mud%I~3x`!e<#}5+Sm^Px;*Fv8Lh+CuaIEON=nV@`w>+4-rsT;Q0(N(LdguQ31>!7J8P3Y!@9Qd0;SxrZ8} zik|K;c6PBBRKquhhhBi2wMJz4Bh3H?jYt4g0#C?N?VeCLJHU};#RJ*bQ6`a4K}!!&?*qW<$z^ z?3-gqQsm%xC@Js$j8lMmh`VeDET-j!wo+q8j|6UNQ&*}sy&*5_SED~(QXC>4c^%izcTYe~YsOPt{*VG=x8&=5yy7@@ z{R^<8fGGlh+y^>7kH0s!j`O}#poU;>H=LJv$k2SI3iZ@HfoWmZxBdb0%QgmtXZZ$K7oj6u5#QcDrpvR0Wp$( zoycPav5UQvZc&d_&51z41?Y#j=z1Wwt0=rDsDz#}AiCA#Lvr(^X0sfdl z?d5cEuZP@D42srKFMzTnWg;lUmu2>#J@tccM20;uTq1^JVS}j5@13V2&m$_fAgC%O zaV&Yps$XRf!3kOc_)VA~T8#z3nl?3SdlMuh3APLS>&x($`8#@;(WGJqQmIYoZj+zIH6K*2YX1_ zPY4_iReVplmFk8xIK7#$C!Lc7DdkQu>2T9ETRjRvZQ> za73yX34y8VfmQB1!vvFLhU3lKI(+<0%8(OE94~Dx7@DN#ZgCW2z^8lsAaAtA7hwr$O7ti?RWNz-UC(a4$GrdaNMGhMj@ zn6!~vleFNNFFVQ<=w+YMfoI4fUJ^?YH1NH0hul4NF@#3^1_mf&jdm-5!Fn@2XIM=@ zi1bkpp?XRMu~h0_?m$a8c31&y(e*w82uFd9Dbfnzz>74qa9i=)NH~KrRov>-Y`Zg= zmN9zU*LEdv_+3g|<_+1mEDg%)s503m!AcNsqug4}fX?S-ZP{SYUloq)K!}>Q!H|)j znl`%aXV6(iRCA?#G4pg{=6S9~mi%Z28fK!ijavw>J^0X_ zd!sGM=9r!;BTCDjdW$fR?3~>Tp|f}>PF@MKLk?{fd*+pTT`r0t1^UBw@``9(TM0(U zPUZ9kA*dniJ*S|1vTwd~H!w!Ax~5#fXx+P#SG?EL)e%#05Glyv673}jQc8UwYm-1N z8fKXoF|UFz)4DdhG^)T9n(08W@KF+^$_P6lOxJr`%t_ES1hUG8F62M*GqAfl3h$TS^d3dtfE13>vL&<(r9grAvG2D%< z91XK=#Y|4z`!7vO@b_zfa^73DM)Z6!nr$f9D@7+m#cq-)T4;YB8k_WTF{+pQR1)?>g#Zcf+89CtIU35rX1~Ag#c^7rEOc>%_ zX-0O?i9-fV6)Drnp_#c7ybRJ?^FX7*l1ap+AIaCc6S9yS0`W>D5x-T_gSF&E(ozAq zGRnqpJ!;2qBpO`<7oNNvmmwCnHF%J1hR5+F4G|QlvRm2=K_HAu4fF1WCxXF#s{^V* z2`jcH@Z56=+eS=?8`da4ZeG)@

`--YB&WYu)b;aTBd!uxbhFn~IN5{9wG7JGK8EPM&2TjHhTzTU&89^{dAqAdEC;ajAE6kMI{6X0k&`_wD$tK4>4e8Fv_a8 zU_MRa45UI5^GER`P%4PwJ%bS1l085bp_;f-2_oyo0lla_2nuI|f}Q}b8+TzIhSMHX zVc5#B?EeKx9gC6xTE*wJDJt_;(x?tq=64D2YhlrXT(PwzBwIB{whTZ}<4b6TiLNUuL}nb*^I5l(u00vh z1!)9XyE73;rp~PydMFi$*LbRyfGn<}D1<#iMw$%NO*--}%G_>lRG?DcnHHT z)(`;3hxg%=PV*)r+62+#66H9P6i29$yr*Jq5Y}anpx?&tlc98JAmmPL7k7#i|A{`- zITix!nFn9sOB3E2Jk_z_OxNC>}(B2rX2AVk(pip)V|j70(-S^=q#p;(aD;d<1Cit7er~cT`a^ z85+zK!h&>4eWzwKdo>l(=*JiN+8tfilNvF5c#{#y!l@-#6ZUR6{KL`V8tU|^IIJZ} zQWMkzuNf2%2r$t!V4;ScK`w&Vg~Bpg+gQS@2v)+mU~m8Trg0Co#27jK1*>f=HqgX$QQ0b)B_kZbRjc%EhCZl zUkFS)R}wS8-9^@m@z+g5y1EbWi*0KsitAuVHrd{cwK_f@Fb!>KYzPFj{hEOu-Tai=nhph9rQ* zF8R!M6OP~(D4JnOH+oGiU3*b!Zf#w?4Y#l`x)2rorV2~1!U$1vkh+FaPj>PRyR~G; z$nS>*;Gt%Xww%pwoeZ14e%`L+C00F{PkkmULfDP{r+v&SpO5MTPeM&KC`>RYXdGWS zS1*Jbu9}g)3|3?d+lDieGxoZIY9sh5t$1BRqAy)JnLpX>>^IcaVN(u8i=Q}Fv!tDSE;YM4uXY8ofyOlq9Yu&Nloa%g zoePwHEuf}{;b2FwmH7cJkjrA@DkcVNX{$gWGcA+yc~%xe06BdglMh?XV@NWl5g|O= z*)jfoFgw>bUltbWtF)bGjU8R|@YN&Om&7y|rg|8&i^XI|8w!YIa7ez)J7!1E_eoAQh@@s=IYxE=2rOqTT6)%XPZ9TzJ z=`T<0l-J0yr|YVLb(~dP*|W9cC8=D~e8`XHOJj?7Jd*CM?b1w@9$q9$e}TG$jhIN~ ziMry!hSF2?I3Piysd_LuqGDOVN%~+XF@-Sf+vgcg;Kf*N;1Hk)fw)d#+sHK*gKE#J z%-JLnfRbRe;9@qXt$0J*lOYmFwKCNi?@Cj+NB|>e#D|)hs@tQ)XlNsig!w-tNZ1K2CWDt`U9vAY8<~~D#Tqcs$Cc_s#?3ljP@IH-HJ=xMsp;~vcF{&D zCDEx2txy&BYS-J{^pM?4${braVvL)d*|vbpq6o&1JqIwl6neOXr(fpQbd)VD zyPTHLLZ?Wk4)kT#gT9nG=@7mJgJf?A=qcp0gelrm6UbW19q;It+eBTO=myo`UCJiC zdR%^)rw<>whKtxW`*d=lBm@L_LI8&+X|@2Ia{;6@WQa^}EgTMsN{)Ig30ktc zFq0ZIhL0G$ck6()8WI#WlIim}Ith{y3U;UMSs@EcmqeVBZvpEdW`wANIN>x7s}xWo zZOeCEZ)7nA3>eBXijzn?cWb5?!I5t}p2Vet2Vc0!jV7F+<++3;jNLmJNk9fp8)^Y# z3bqI(ut1q=~D+*6}*&I5sXOz*=MnY^4nB>!2K8bA6o&d0i zpXBhALfnIa67zn{Hxf;jV(LbSug#Q1c$|qN2{NPZle7dV05cp~ju=hW?AHv%_{OcF zLspb!{-U6v2Gc^OfUb<7KuJTtxCjsMZ-Wz5CozKe=(G~-%zpNNeWc<>JldAPuL%0vo7;v z1ZZ8ThAsHVzPhUJ;GK5hLF}FnH8_-7^dS>TL%zM#9?ceL!E3rT+f_u^vMD&X{-a&# zP;7}=&Fml^4^#)7!+n^I9h8(Fhd3{1$Ij^E7W&!!APQJ^>xt4NtosN&UV37;9N%b6 z#afr97)R8@9h%7=-d&@N32x0~d@5>+4k}13L1_KrHS)}9iSz=i-Sbe;;NDCX6L(2r zru4Jynf7ggh6+R)5ckQQNM=be)3`wszzZxP{91&t=%XP-iHTm7b}bXsg=?YCf^-|){9_-n(|xuJK+r`q+d)_h(TyA^ z9MaqiW@YnZS@%zmy`?MSFoaV1w%ZVBw*!B<3JoJ+Tj_-ed!}fIbv%#=V+_p+AsAN$K@XDaC0dFM#zh^FpB8VO0@bEe4uUV6vm17;>yCBo{uH!j$DdY z?|@1vUqp9H5BAX(1&-F5;@-=@SmVNLTs`TcniAPNJOVS1j8!MHvKLzo5)?$&Q&I>g zERuqXONGa}gn7iThZof3eN&fZ>#}XywkmBqv(l`zZQHg{Y1_7K+qP|+x7OLMork+? z-9K>W>u7DxF(P_I^oTLP9$5h)1rEs)JAzB(!jyxs{hr}3MS&tDeOe}C1ypg=3vFe2 zzztZ`&$5TIbiK*=iH||p{lW!X!%MZ7f)ECsEyzP)jHKFw`WX;l2wD82RJNtY@NIAn zC0GIULeZ}EW=v6ZU2(fqb@aehbQ5TG}gj zKx2X6Oe~<`A6CMK1} z1M~!7V8&f63cL8$rVmHY=dr06h;jMO*Lm1ktl~_wy={)>J7Ek*s>zlsv!a9?N!Cj! zR|HCBt%0qmC7o%7jFCDAs2h1);w$O}l@v#olphNd|BO`nRI}0da+!Tsfysaa{1t!J zEqrzjV&kyJA#e!Tz+D#1FlvDU02_Ja0W(L&;X4p8Cii)WVvL3?=wcNE$b2ifuAwoz z>xZB-z0Y*w%wr)AGd62zK@@=u+l1aJv(s@X7hraPHGF87YJXa?x~Zhdu4=yqZ|00_Y7cT`o5s5sJ zq661>uw6_P$Kj-9V)dO<(dh5HoV%ONP%4xACZNM|A6FB@ih+Xk?vrC?2f zkYUN+uBbmL2w`40W)&S%>$ag_P8&+&i}}$5(>gdJsrG1|p|RVo|-Jr_Um*tIdX%<1JzJ?yotD+^S1zrv8T;Tg=krwccV)|6n=0421FlJ95R@U zFZ&1SQV}-I)j-bKi)Ey+M2ymx>Oj7{q#@}f=d^hpI{1IiRms_Te_w0ZQ!HUgWx0V+L z5;*F^O_VIPQB42+TvtxwyC7vs$5hs%6tn$K@Mrz>M#fX@gZu_c=Z|EF!Qzg+--J() z4VgA{+)<0+MqtwshVesiblJtad6>C^eVhdsNQA^(o-`8G{bXEE)VWy0o!o)uWvy1J zNrWhvjxchJrc`6xy(1Er7Si7t)6W9*RW52Zf%GI-6`t#nbw^cLr@%te^}f&{WK+s0MYN2qoUCmu8Tj z4nHU|5!w=8CWm-xlK8lPDknHT)=q1pyLt~IsruqhN}U=h$$Hd_`1FPGbN||3Gn0-9 z=vs#z9Ru8TG6eC@QfOF*Q79!D@scN;C=%KM1v?VUacvI773df%{$#zTj&Ncbt0bpr z_0QO!S6+|D`Z5>N_Id{-k4aMY2|jzjx^xret$)q7u$HB;Q-47g;>?5wyHSL ze2z6YCAGGsp+>h@jwXpE4PtG!FzolXFf|9lSBdOTmg!#W`zZZB9fVS+!%+r-4ZwhP zOM>MsnTOh!m0*$b5H~N9ylbTbpK5BCeBWK1sQNVB>G;8M4%IrSDd3YfkV;MTz&raK z?Lu?J`BC)(9g;7xf(-T!cDW;Cz(XApSel<%AXQ^|$mk18kZ2UvSe8!_80Bd0m^sAJ z7m7$asM?rR7xL$&=2oqF85kdnT+qYW!^4ex%4cAgSL;N8D;H*Fu$Fg|CwkK%X>Q}T zJe>qHdlVQHn^o3giTn2$u3%f<2k(1P# zt8+3}_txCS^2rme9+ii*2KA&wN4MIPM^%Uv5}ICtcy7o*xi-g}yNIr>`vd^-{v!*} zcy3jEsxw?_;}q(Gcb@S=VJdt02hxbpK_^qrmzvQBaN(uyJbFsILJ+i&Cu`Q^=6rzZ%jsCISJqlU z!W55&&S~hYYNJ3D!J{R|fOEc%jn%1lx%oSh*?Avf63F-3PSNMTbO5EK_dG(3d>DQe zMHn-kfx0%))Q8KzT?8LGZWLSKB26Q-`o#wQEvCuoX#euQi#x}NE)h`a?j*LOJI&R7 zq%2TAO8v25c8p@|IAD6-4-yR8h$m;I!F-63_>Xhd^%;0>4X^ zg}~^T4+`3I#60RQ-R4Q1B@b(qaG*YMB!2uUu~5stLfuX^dMh_rFE4FCLUVMRrvr$b zU*o#+Xf9&kZNjP(ZV_%;gOgME(Q@Kp<&8&Bvz&WJ@3^xrNOsZl5W7K2K57AAz$-nP z=-Q%?$3drMs~{?u8Yv6mwquUZDmaxX$<#^g$HU%CvQBn&W$NSN*QP!XHz1*it$x=t ztJFam0k{nZ#HhMK)B*qfp;iX9083s-QdtHi^$%xJCN(L5CZrG$hJap><4G`9faZiT zeP^``!VXQop<-<*3Vy3e=pKgr+9Eot(_Se-Z(Krzk$z~T{&W+gIS~2rchW*-=sCY| z@4`4v7!R(u$Av(%HsJT`1j-|Cufg4jvAfe6fyGWI$`4>z4xO@yVG4Fw8;)Fk>pZ=y_8 z@3a9%)JYnxhnVocVs_%LyQ{vpMXcqJpXQ$9Qih-Kg;kg_O$4~QACp#K(Q#o@XX0!wwzMoSOlbn6~G| zRLM!P-;r)8g<2>GXeK*DY7}S~!QyrIM~}slt$8De&>J`W&s0hKU1agB_S>sZ2Vs<6 zXiiEHGClgw5lbou^khVydUv2qMQf%?#=7^UG4M*Fhye@q`Euq79H(9ivte1Tq02{3 zUgxG76a)GV1`A%wEU+m-+CKvcZxo1Dk@LM zfm_RpBixCh$UPvsH(hps%2XS4!yTs7hKQ~meHk`JO&|DM^nv;Z49-|hIdA@FoRR9O zZ)_3jN+z)gnr9zh+f?oWDL>itGjVyuCi{oM;3z2qcjCQXwE{OnIYGq$ooaBO>znC* zgX*%y=12Gm3m#kxt3(95al1!i31H_hhM@t$ZSb}?G9YsC!5HxOw(_ls-0i`Sf*Y=> zN;>pV+%RIViHR$f&F<+JyoyQ_$i7Culcr{D4GuMna1fde?t;wCX82RHDbJyho4FqO!r}6Cm&0!!|e%TfbX8c7wOhUT=d66ZL}V z!RgySXAWCf@&n{UL4e5ZFwToHCG@y!5>Q6>(;OXrBxwZ9-o5$13>{63;9}7s3+qIa zgW}^(U0q9AH`E_Ybd;UIVDC&7-m-HIa676G36mUo@%#!29XdWN{>3B8Xx6QMFzh^e z%X1SlmEQFpj#}Q&E6pDQB3#U>=LfO4C)0Qz%m2|i1}pQRVipCu@ChabnJUy@<_F>L zTdq11%24cgI-s$-)ah$9WClAJ`}n5xattb#S#;t~2`gtSE5(O1%gu3|*puBI{ThC` z{B2S!S*=}4*99jVi>U*TR`van|0O|bh>u@x#^ZzjxqRNO{r%{JjS8SF%Z{I90{}47 z50nW=SpW(F!V@c+C0SU&TbQe$+J1usX=Z&VRogrXK>T>HT1)~5bWO|}=JlIhCE8pj zvsw46c;a`;H;;$-1IZEK+xUFI1o&m~<#Y%7+qV4o#wQbvl1 z_xtcoN(C-fyB$m9p=tt{ZQU_v0&BFoKX^ZM-oCH5O1?6`h`v-_vwiUnc~^Y)zCE9jzjT6*cYLyeb8dU*rnL`)>9GCdY!D(~u6G?z7`=|-AB9vWe{Vx1#=$%nNDhi2Oh2uBnm%EP5 zznOZ`Y5LqK{h9M*g+WGgEu&Q!n+nY4>lzsdsrZm)hPG4@%ZC?YSAhx6+p+>txYaR= z&aX~LPA8@%^yknh+uI_F{j?3Qkf9Ob!Yd_XuD&raA&(d%A)5d7{Nmc>aGjY2=xc5uJT_%4e+(hF^!Em z$7)Ob>w1GZfc^^i&teUx>1t3sON%08{54TD%+}!Ct)b*#SbFVa_l|$Vxw^iptV5~^ zFjT{|&f?SHhV^8W&g#xAjDuMyeK*itEzmZuN&*zcjT#|jnFINq5o_;YdgLPQy`X2wtYo1o+;llmFixyvO+APEH^qUNuRwj6h9}_RVQo&GwHO zq%9(<0F0Z6?IrG!7yoV<*AQ$uVU5hAJ1j>n@ok44umAYek$5axb(#cJ{Lfvc|Cg5o zmp83AxIq#!ITSM|^K@vkFeg)eZVU-)#0EDy~mh?J)mZ*6y`&j&58-4cs){u6IA} z6oH<$^Z{V1$$xT4>`DN0Fx$DI&(7Z+5HdS*mGCeU!qg7R>>%K_ZgyH*{JbD;0AO7; zq&kfNN8lzab{D)rw32p|ZSbG3)H2Rc_=22`#H^yCuDty#B5~qC=70CRBZu)ogm?U7 zP)6A1_9egn?p&=O9dt<3A(k^_!x!oV*%FG3!Y(5{ zbeu0QslpJ*0-859yvX%$Fu%YUK0ffFPGLg*F){9Fb9{&cBL7%S@l#`UhBkH5=Q1$H*SOGKICUg6OIukq$_3fQHm(iqkp(%6(FY7mrw9> z^o&oVlFto%bhV7dt6$v;X244ZC)O8}A&^8CwB#AQMH)+w19UDL4}**$U7RkGjoVM7 za$=JrT(^?-nVf1HyA}C^#Z*IVn!lr!s3(@3fLs&@o^B>8vFo@GjxF(MV*rCz)BEB8bu3UBFd649SC62|v{HoE`%1eaY+FV&3F5YT%*>1THv z;Xt_uMH14O$U=9sP=MGsp@u$BId zP2}P`V^H;a?+3G$Bau^fdx#A1?-Dp7Y;WpBLj5F}m5ND>>_S7EhEK_S>`@-p37ZG2 zAxG7d#?ECM^L9QuO9je6`xd4FlZ^j+`xKwbE~n0M5?jktP;) zW4^>e7&9CVB~&gaElnQ6JNO^l1jOW(7;SRBB>aius?V^Loz6K^^)RnDe|5E9 z&TzNydV67?tS6J#>p|1wj8Dzh=c%k>Y5Kz{#8psaB0WCb;!6k87*@jiKXd=7sgsGF#+fhUEeNVo$b!xuX!}JTE$w zwQDksD5})V*aO)oq!)oGE?#Hl{{?b9Fp->J-i1oYT;^j{aD=)6*aQr?S-Ag&tN)PD za*#C7^tK%^C>xtffoZSG0O<_Iw`*R*>7HbzcMVMDCK?gkiXBbqggZZfC*%Lrv;ShU zgF+J`@s2U`iO#c#9K1>?k5MMX@;lzdBX8nks+hH~UUp7Yo^F3B4al2EJSFQd8Dydl zBG-P!gRbIw9=z>Nq=)6-Lpcs<0yhQvN2`=SStNS2!1~mDaELOCXwPy`DV_{S)ppT6 zweb1|W4SVRsG%VsU#yNx_*~JIvdOFW$ZMm(2#?hoLO4ubO{%UP5WWp;4ES#eC>ci`6Ue&H`|gA4l@-N`gpx4XnueJ?)6 zwiGcRLf<9Q_d@_Z z8+(rP5&+5Dy=Z3N#F49QcUmupYt+>v7dwf6#e}!4t8fW(%NCuLfGoN;aR%?{7HWP5 zJG!YzloM&zqJ^A}cL0VLt;*bT&Df*j=6e)RUw1x}7+@!g+vmqO3D(UFeJ)%I{Jb#R z{V(G2pOz$SPQ8}_mA-XANs3$IX`ePJdye6ArQCPwRIu4Eh%h&bLpiI`l(v;rV@ zP|<5*_IQl|Ony*6Ipp(g0(FAzvnehqICYl4*!d8 zhv2G>XMevsb;%X=N_@1dn?rtZl6SK@!mXxTu0(WbP%`RCK7idvmr+l4iq~#!11TP{ zF*0#NW@nZ+IMs-fA3=}cXjt|p;=i51A3Pq(_!UudCAbhKn>PUguW6@-04lSIHE?)1 zTxk2U{*?mVVu|>TJM3C?dKpEm0iST`@3dzL`7&ZD#`G5@+Ox<~A#Fn@7~QfI!m5*A-A72tUAz8r|+G<;sJ9^_1`JIZ~38Z3&X)xXio$r(pc`dcVoo zLqNe0m92>nFlO-Z#L^{-?!RCyq93cNCZXZ}H!k&3^&WMuW>rH)--MQ~(>NkCd2z&| z=p$pX%htvOk(KI;L5F#R`QR`bzl3F7T6AolYwn{)eUiNMHdpY6$9Dq_<$k1oMeNnS zzVop|FFOaYL6bKY1ZC#GQbjp2H;US{TuZ7y8e~8{+QfpNl>wU-v28sKmqNKzzAU>L4s_p(-do4d!YI!*~?yJ$VKET|6OAL>C=dM zUX^SsPvJlMp!}CZ&RqsI^5`b*VFF@*wIAG$Rv%1fYH|ARja-%$`R;pru(hHa1>^Wn zR{VWJ6Sl04TsPAN33e6?XR^5(T598eJ`BlvfVyM)cRusa&#P|II%2x^eoDP&EAV{@ z(luFdr0oae78L$l>QdmugKv2>7@yW&sL&FwHhpVoohxEn8oIe$4*fMwK}nFzhcIIL z)6WPg`CTO}Z~i0-8&QYtUnxGjUoKBvQRPyg7(D?{!g=Y0*A6e8a;NCgSnY$Gk)cRR z(jFZ=%{gmy+;-S8TXD^zs~b9~5c_Nyb@-wuRmCIi<>7D-y&D@yuW4!>2(WcMFTkcF z+o~}z-h>cH(mL{VlZgv1v@7Uql?8`6G}51`#@{^QGB%( zW!*u&f?;}VRN1g)WEHxtpx^r{bo=EDCvTG)-Cq{IEs z#t3%pTVD8IWFp~Uf<^gXU8wdpj1Cz~fA2W4Sj{xmkST!+G9DU}%lDD;>)*ZuHE}_{ z7Kb@r^XR{JIpo&{$=$-*Yt+lf^DlxEq`=9^c@6`!8=!Jj%WP}%#Kxt$11SpT8}c4J zSfS^bsBZnNlG&+TE@PA`@cox`b4SC}p+$U3Yms_n80NLWSDG^0eNT);1J)*W}`T4=CqqX%f9N~Y*mV^Y<%4Ppm zD*tz;i3$Ml^{svb@b?egtdYC?j+gFp4sH0%YVm8`VolF`Vn$$K8P&Lhyol{D;iUk7 zF=quxokC2q+Yp_f7lz^JNNM(FKfFz=ZGJ17sJG-Bi zN(g!*Dj8s{&Z{T&yZ4GzMG?R0!7xfqVweJU5m0$uSgxMJx;S#%2H+iXLo76FCjAOq z)P$?iv~{gp<5xqYRwP#`1*<_PR6DaOq5F8=C~gkyTk2~LebPa_JZFma$LnbL3O8a!M9J2c#Rfyn z;Qg>G<&;4W+$mw-l)`|Y^i;_BeP-3G9^RxnE#)G{41D{BIl&2SfEFDF8V#YD8|-{8%yZff&6-bE88gdw7MXxylA@?px`<*Qv&!+<01rDI5NUvT7sB{|@??ufKZpsD@OTY7Y z0oP_QctE3w>oZ-Lf5~wNiJ;Kl3XW10uCO2lMA0cZHW+YjsYI6ZUHuS@=j;4YT5J|R zJ1g;{eVp1C^=Qmg`}5CeM5T$>yRtlp9^MXb-_5oVI9~*i@Q(^TA!L$QnXWVM- zt70uvlgN*#kVilmycW(AP9}#)-HX zq=50GL!Nz_!COI|{3CObQ708G=WTUG zUWGpdbbFNDZ|AO*tbg~rk=xZ6&_7Pu5d=ca#b_bi#A$F@UExzeCx=Jmfnm@Rx)UPr z$=^67m|Cy`2@)IpTS7&l5)c-l4N+DP+^oc7*$QOSy+LQ$-7qEgsq*K1wJ#hYawppP zSd}+*xm(yG9p$jS?{`Shg%nb}2cNG5nU%9;$cKY#MgTJkLQURbTUqF_NA;|RS@4C& zsR3@MDcg2Z&JU}7^65I7K`nlxuAS&qG`Hoc)0Eu8yS238#zSaQdG^z<+ifLHm?fkL zl~>UV3jjzfCx^NgoCI)R7yzjT`qLvC_2;Mw{wl}OcA*ac6(9-ugXULL8n5$(GleEnZwIQ}H$KttAouTy!2z=#CVRX81Bh4IR@zE zB=|xk0~w5cPg`#fyj31U>vF1b-HoXr34`>fM2`=a8C<4sZpIEkI|Sb=Ds@^KCup|a z+_6VN$P+Je+}V)U{k9{}Ti&+h7qIG_2DPv1`Io*fRy;EJ7+Q2ntc`Z%oDN?Iy6h9D z2`ELoMNo9lNc%1uOpKIvY`J2A`?c%830*pPz76<;j9j@uL~I|#w5opwm{QWfhB)o7 zg-yYYKXOWukjRkZ0H#7-Zyn(T%xGpsS-X7%COj4mmHwIbdJ92CVMY z3=;N)003z1r{|05p4NK11QT%2PLy<6Ff~#=$BvpW-?DJjdsuTz)E_lqE<*NTflWdq zjq;H+B}|Ew9Jg96_w?Y#oB4dkq0|bY2LP%+r*MS==*>?Xk zgfLTLtk3@u2$dSILbhrKMwQ?Sz41f^&^V%ARow&mRw-H4VsSSBYPso0D|&j{z*WwjWiu zEXt=~8mCT0y{sd*m0~CM;ToUk44S5f_pP znutV?a<5FHLZgf_!hyDiRN8gl<}xGGt`UF*eRXXQN7#nT*k%*gRH*(0+A_wMC!BS| z4+dXr-)DHNqII)2W4QTy!N*1l#WC-w%s&7vE8qU?q9{^ek@yrOty~sx!~Vf*rt1_2 zj^!yqGHv^3d#DnnqlM3IQjt;Bp037AJTc`tVl{$dqv@U~GscPvlqHNf5i;uyZLrQK zUObuc_FZ}D>C&JD>Wwh`wQjlKWZR`NT!U6~XWw=@$lTh;`Rv;On_=KN0Mb-!G@&>> zbi#+kjOs{UIN7miXtoBZXFNLOMvOQB_;@l%fCNh>b0}Y~?F|j)9B5{G`$WKI zv%z;tj)p6A$B9{ADqP&aO| z+37lw_bLdwIO|DQ)!MW5VZX6iEd1xeR_YWPIfyvsz4yj)%X>&nvR zE6rVS)2i(QBV%sW_0vf0hJg^jmE}b?4N$J)i}pSRAyyH^L(G~az}m<>68e5o-c_Y= zH7uXDH{PMD_XLR^Q~|d*&&tzt8MApqL5uNN>Nfwjkxhw(P7w_!+NN8BOtZTx=~@Ue z;~0D-eo&Rt@&WJRu;)PZLALr~PGXpoo(q2Hy*TTM#f1S|e`uD8v@GRY&4b0qCZ3FI zPorU%hv^enG9tte8u&@%Sf_0_wnGg0Jf;%UpY0>j9Q_%>oe7F|_`Zn0#R|}r0RV^s zPIZHuNza-E1?jyXeVQMRyo=Imx-E}L*W?_9+R>maa+wt_bn`52@{JHH(ClXlFW#0) z#&GW`06?xe2@}|3{B$V!25CAu&Iy{W4o0QaDQg%I>6kOHO<@S9eHqa5@Gjggd-9_< zCI^6SId)qcdAz8((ax=xV&ht0_qu*CcbCrDz3adc^m?8AM61IO8iZpQsIA#i%UFrW zq+Z^eIiLYtxPPE>Yj?cdO4x+m#u(@)@2{I31OOFxG=34ovya^x{qrXCUwcN7VaJ=9 zVyI-BZ}@B#HVPf!BjRrI!!sECkuzk5irdP*{4^S$)FbNPkHlr4^MeS1&%p3N)T$AU zvR;<^CEzu`G}KsxyOV5>--c&b@+0aiELk%6Ky|^QO3162T|-6D_=JDEFhb;4$d-8M zA=3!>0vH?x0|PZniB^GXp*LoeD{+Hc`ZS1kD105deb6^@4hmRmtw`*~mYB0?a-w}i zxH0*hqe)CWD6NN3`_qa{gD%xdaSHD`>3-3po{Jy6GH7WsnBui<>@d)(z$v6lUGkYO zg6Lc=DSs!_xuaS6)SNGj;H~ehk+*m%7tT;)(c;oqvY-n8(A}dNfiNPOzNAu1=}ZIU zn|3KU>Ro(-$$`{hD6h={HJTZ|xJhIQtLm#tV$8TxaP$xQxd*r9Ze!bfhK-HVlX~5>?Yn z?qS*K6qwP+SAoO-8dPlNSd1uc^km#ur*o{E>4VT(J9tdR&YwVH(dR6-LsX1dtBE%7ON)n%0RS!}hNd8sRi40>TwP2`uthyAblYl-^dsUc9iw(Z zX3#9O;#9oF{O$%cME2#$ubkkTa##7Eu+;=p$AVh2K-CEch^hdq-dQ*WK!dhQ%6dUN z$0&=wVDDSSB-}ch6$bze1Fn{r0IR-!rd5AuXVY#^Y4YyR*0k5w6Sx)pco{d#o}7xp z9v|=7ffPdYfF}zpgGkKPVO`vU;kqJ%2BukerI{zz*WhED$hNR@P7(T`?Yxw0lc%0u zmI=1xkngO1_x|C#TV{jn0w;+bin6IYf9r_ir{}t*SK4jn8*ptq01&s`+dt@_MuZz)-OmPc(Ew3d4j zp;B0jiobsoO4pM;YUsKA+!try>CSpqvkw40N~%L1-=>FF znxJj;%j~p9@i&sN9P39`1Y@E*Usp3>k#w3xp)DGidg053YS%uQEH`snT z+S<-B8}5|vm#z9;4#w10%K?m23vb!*BoOm-^jy18Uxntde_7DguaUG1cWd|JtPvQ0 z3I{UAg}72t0ASc`eCCv>AthEN2LW8gZPNKXPwO<HG?fh*PN1=#r9|lC^Plq(Jk52nI+D007ZkX|!0?dUgf? zvZiS7cZ@Yb<7%X(Jc9Q9!ovr);wu@R6gFcGZA$EXcXc=YIRC)1C&sea+!qEAp0 zv~Kj4?r z;3ACz+utgsONv3%`rqw6+&6rXb4#wS2h=rv z>|q_`d*||f*6vuXjNK!O-f-R68hp=cCU@y-sI1X`-q>mD$maZ-Niut5O^fmc?52ql z;qJN$(&FVp;J%}EeXoX9r4upH5lI($CuB>o%}xt@B7uyg-u!0zZ6M|`03aB-sBFr* zF18ZWM&A2;%qaMQLspC+5CGUISlR3VjLEKR8{^usd%%h`KRC)Fc*nJlhGR*VgTk~Q zh;)%De1SLo^q4!TdLEv5JmQz7gi=UJ zc&$8gibxbx9g(q?Yd4&@$*z=uw%XCi_IrQgcI`xF8LHS8;Rl;`W!8eS08RVS4xu>W zRA&sc=*TjLJ9*Clg})e!v?AjXdwePiQa?9KxVE}SkF+znx!yN^J~Hf{e!nzsiD1%_ z;Vg2Dn*tRLt+qK4yq=ahWDafv?Ib{o_(s`$ScRwtELmhf=Qv<+mT@sV+N?@mH@0HJB4godY_pZwyc8J?zCqL@78@ZI+4NePW)Ion*zyTf?X#RIKWr9y zJ>9k&t*-*%FkhJP6zbSq1{Xk87Ed89wJ9wfb+TkHqe@4WLS1wl0eCb?7`}|LsdLoJ z-D4!+4#cH{mHT2Y0*Dht4~3C&qJ=vdpcLAB_Xi8$b*4V(`1$dz8ccq?&HS+C+n;O#D6*4z>#UvoT5 zTKQRB&oMX##9r918QRXwgsfzaXf&->ReSuT?!#ax!_k`JeaP_Z_%LK6 zsyZ{J&!3L5$kl`w##=BxKegqM2?0Oo_d?sj;25YN^jyfKp72d%kpvO1TkuYNScDR@ z|F4B!_948-R(*K%fLb&_B1(okN+^$tpPw4dD#kS|N;5aSW>kv~H4jbruVaO4yx=a* zK=+!&=mUiEBgV>!??(qkr`8ou9!RD+msq}XfT=Y4)NKX{jD%g_>$aVuf$y|TQ*IMG z@UK48CpoIe90pzinrjd|cbNBP@u~8RulMG5;iznHBGp{^5|Z>{OMMyV2+vv?Ci*8< zpL9QJNK1k`0ybQf;1ODAQNALmyi1am5BZ2p9l^iFtQ4b2gC4cpHpp*>MM=RU1dRYtreTQ z4Fq<8&chkh6Yd9C*NiT>pT7aShrx?rS#f*L7q{NwZz@kSizFT33jP$|KWktv^U|5v zvFfEn*jN09OMcu9?$Bh8hAN02JqwmwECI_N8iyA9#+aT)LFK$fL=7IbICx9oapv;;GT#3=CwSuc%<6?tjkL3pvxv zqp2UnXfN0j$rssx$_-Fk+4||%$zl77?d);8zDwM8y!Zls33y?Or6Q4qZK#Tp1j&T-Q! zKeu~dU`2_8&F`+opn(V0s7tQ+HS3!ZXw;sgNj4KShOeezoGvo^9RSonC6?SXrWvDO z#>y#)pBk_&lh5SxjER_bGAZUG>6pshHHZJ$4t_#Yz}j=YK%8!{Z>6Rt)VvzS%PO%=FGOXFImIrW&HHCFKD2d;OWooFjfIUTGdZE=)EnW1ahc$0= zMH@ztGBtglVqa#rkjp~pW0L`fOaDnRPltP6s0vgX#(6G5#1o$3-41linEi@1%W9>avw%bg%hkhq%ClsOq%2v>iLv)i zqenRJbbAf`vo5LmI+dysuaJqfBRNu^!=*uHL$dbELQs?SVal|AOF&S zco~a8k@IJjg3u#AHNBjmirEk?{&OA{YVE4GW<6xN6EN{IdL*@C%(!0`}RyK7ES_i?~9epudTB2)C?7FkRcwwrfB?Z}KCQe+a%t(4BJ52-rU2qM&= z8{J8*Ek*n#yJUGZJpi5?CRe^AE8=7Pz&7>MY?joFVZXa;agF3%Zv5%_@k)s+1t9D8 zYwjHgjuHBndO}rW1H@W?PYJrnp@W)mh}^Ex%lV>OnMx{tBP>}qc(?~>+{mOtFwPK- z$^jAWkh;b+6)Q!R{28sY{M|0Pa_JIss(H9fg_SjOuM@cg;(m-4=qSVMJY3lzgM1f% zQP$eE-GWcJM58U6_Lpz84V_Xp?Mhcuq^Im+WQJLVjgu!s($87WJL1(H(xqR#ki8y5 z^9gfra56O=y>ZQ_ra!(X5lg{i45<{;`GQ8_GB_tVI>Kw7(JFznUc9i+)i11wIzmkHS25r7O+eyvb5E1Lnad zd&Ex!hbLV*+>Cay57=R7lf6s=czWatu&FL}hlXzxP|G89BHhSn7hWnvDI#!ah9XJT zX!;&M#ozs|1SQ4K=wBCR3D#$Q6Tu%Qj$L5Xx)g*9+8IOh zLkN!2ncmC6N`{P5hN2>WoMA0skVpexYEos@(|5JV62XQm|PGNsSW z8P&S7r-gy{Z6~@%ta~Fgh)WDw-AEAQp4Tz>_yD({mJU2(xh!$@XhGec=dMjkTQ!~S z#0I1`PKhMtoq>kxz#<7H<&8IQbeW#H92`a4*p)4(fhbjBUD^sXK5De%`LQHt%Uub0 zI@{-k9X8)UzXdC3Ll5QvdGn5BTLg7gy1QveV~gv@>g)D5P(&*DIfjit!7MN<1h39A zilou?S+cWzl**VaaYt+quh8bm+~hS&M&~YfYi+w?`Lu*2S#4LVQ6>V_TueFC*HxvqQc#wg94mCLpk4jn2Jg9#qcYNp~cGAmc0 zJk3qJ66+^)t3Im_@+p&r!1>a)K-iB_A|+Y7wz8w(8tGub(1Jg5RK}BVWIOTnppdbu z1T*AuehJAmug86TX5K_xn9!@;d?=O4Yz)1Fv-M~2k4 z3EJCnkc{{E4E}o}l2@?U?(t6)1ie&pDcngn^#Un!aVpg~=iH9b&pn9A?#2-Kx&H>W zCsAuF<$r;)K>8rXE&gk2%_3I(wlZURt&n^`ZmRgMWur#PNYvuqH#Ni(=`USyMo1!; zRD?(;!gN1sZ~W>1+`!YjoTq4nV0!0|KYm9L@^bExQxr)JJg;Tlg4fK3Aeq(>>gPC6 zW(wT`7Bme!-=wtPEkg}RDVI%of37VH3||=0tS(*^5ugO_9+C8-$!Sf!S5v$@^JG#viTPg*Kx+&!PlIoujS<4%@+4sT2LJ#7E;CJzGqN;s znH^rW99?H`CpU?ft&rB$tS^dNxc+D4qp!@^v@1k>%rgzT&_SV)9K43xmz%} z`O*nNQB?v@JV>0C?j$E*IOct%>!Y@MaMnj&*4Xz!vQs(cPOu`^-)afR0hK#EOs?J^ z*Zm{rFeEYmNB8ke*ILu@oSZ>lp2uVGAb$E8f%W1|>5&LssbQ!BHSO73mo4xY?_3j4 zAlQ3lPuBcsqCnlvj`BZ(=W&!bQU#MB_VH0g1F5{%?RK(F&VVJ(e;a7pq7x>k9Nw_V zboLf>+K!gV?vjgg%p6dymzGQKQr%XUr|2+52zs|xln+xhkU-N9;j#@&V?VplgAL~7 zVnnbcd&W1j7a%)}-VO4vl|&&trjR`f#_Bbn(zA$t0dJ@9Vll;xpP257MOz(UIc5jm zv1YbRg`TAyL|thiU)%+d$g{5N6;Z1PtSzm7+8sBMzD<7SZ~ZIFl8-x(L;!Ra@kR${ zm)ZU3VfyZ?RuN^nwTGjX*PVvrsP({~XF|fuFHKQ1z_0OAwN}!ix-R_rwXcIy1CZei zIr555*}JNgTn6LkENYOmkpBl6Dw6gRh!mx+|tb~6fH ztl%?aA>QvGQLg|dMDOLB`A;|}qI2!6^f3IM2>;8p7V?@c$)GtJ!(n1&-OzpX2HX&_%TVhD_q$LNq{P3-dNO+Kgz~{# z>TCPCl7nH@Qs|DVX1=HVy$}|-@@VFYHjTY?~W8Onx@5G&S?I@eqyHOM&pCo4{!f01L%nc_u7H z8^x)sza6t}=+2eAwCti;!+#K=ISz@TJ&~KX2|JtYH@sy;NnBP%(-KuexVrK(bXL|U zWoQL4ipd@fK2CF8a4zW6wCcI$^Tk0{1=J+@CKbkYlWDfZv~77rq9M}tVURB* zNxFW#+4*@x@XzE+SkN9btm6n4-$<$-{sR6uWWJ{2q19K7ok|8;bOx_GLqf*iWPa^C z0!n7+TW^_+V*Q5ai}2`Q96|eIT&Yk%F_(Bpe$URSZR=&wz|k5mfa+KZu{;_Bg=*rK z2G*v%i1nuHli|9s_p8|-nAIFFT8?VixT3Nh#79wt_7s^;#`$&gMEsoM6-V@M__w+U zo{hXVWf|S-`@qJ_X-CBN{5(t+3z7l7NA4&QXemg(2|cGC?V+47nxCaiBOeZUsqaOh zi3a(aZ$40OYPnhnd^&pD8l;9!ZwIBR*KjPFSP#^Y+$T}ebGD4mGTh@ zCiB}a883k372`C0`dR#jcs}*POc4G)5D~RZa?${rOd&Y1j!J7@8nSnWurE4Qg4uT8 z>Ri3WtD3qZvf8|lOyS85>iBE-SOynzvxVP)@|&hZ2!(ly00)M0rXsfqo~3XeioPcM zC(ka zR-?SuqJXvcjMoL0oR8$EvSB?IJ~{o9x#-=1y}fPoWw4Z?_zkz#i+`9D6GXfg^^bBixNj+ zY{iZU5~CTIX``W2{*P^vX9dsD_?2QE1itO~=M?W9wCJ#y{du8A1XZ*YRU+DDtHVBJ zHKTY^wmxz7;7T06V!&X9UAfP{1=kg)?iQkc^)^HK+2d;Lry3J-ATNM^(cx{bfi|tl z98e9Kew4bL-Z0U9rDQA8uu%Si301f8*m6av=afU7peuzzX|?)|qgJ4Sre=-68cwut z-O4{4&9^!$tT4we*-Vu2G<^^@OO<9y91dJ39Bc)#(%&jd`Hbm=BAg}t!TQXZHSmmY zronE1mr2av3{ek7TP5vm%>I5qMLQE1_hrCIoV3QDCc#v!q8I=mFAvFcUOK@&ugkgb zJMCDW)1`zN^Ic46N(MuK00G=Z;NMp#E5T8*?E52&`tTAq-smDU_Psvbfk4>P)f`rc z*caJdR9ta1YJ7?O*w>_>>`jw^mB9~~RP&@fFfZtF6A=d;U#quev)f;zE*arGM1}*E zK#TmEzt*OOVuW^P1L1r~@rthkRRb0&Bl6!K=}poXx>;4113dmbS|7Zv%hsq%ra>AMYpw;J^3%DADXL0+9TNBag$E`E^Yo->!PHN%L` zer%Gy2eU!CxjvUI5<4BvDGx+Xy2oK2w_Uyp6-%{>;hq4(7g07f)1948B90O5abWM` za1&LRq9L2EM2ExRPmqn-fN>ck(2<5Uw{Ez{ggp2F0y=3@nC#8NQxB}z4Y0Kr-{~BHsob^28Z0Q?rFn7p%ZojeQvvQC(B#Tiy1vkp@ zE=TP+5?B=qqW=+??PjC)_zv+MEDKSv_)+SRB zw^li$u~V9DIzg9&hZ)Ur;aK;P^u`49RE3C4b=Na4Vziv<)R68V;V?pJOppRFfPMjj z1`bo+okC51^}6gU4V1PY1Lg%gi!CE6cownqXrxxup+D`Q2F(#h5@;PN*$~CUnJXuY z9{tNSk!KY@|B{KPjY$0Zt?nwBpR^i-Q=2mGC!V2l<%i z%n|j=KrBT4eTCpf>}sJpmfyhDwz4SD#!MchV#}pbzp{uv9qCI@ZRN4m#xr%5DP6r^ z;A&K3HSvCvB@}|v(;9ulo@NHeET%9^NCLO4yEI*u3F^Geo8?yWwx0Ob>jdy1#1BPFF3f)OW(7$a`lG z?miQQzM01&f77X z&FUhsOEQQ&R*3Z%B8}{s(X>H8d7CO{21;e^R)MC`A52Qj&p%}AzzW+`#Ps5TniHzJ2>|N5F+*f1?yX=*79NU0 z+1C|z9Df5dyj?`<$db?gh%pz7r~KbuR24+_7Ypc9mqCd_sDO27@tQ z6-sP8SJbVcG8cAsk^1rNZj5=B6jVshcAV6T00W|<0#HUx3{$Lp7ghswnu@ImTDTHo+W) zP7W`YDW$F@T_%CBjsM|Myi3?E&-zkDP*9zBWHwf|>-z~%FzEyZg>}m$po{=SA;LC~ z0P+LyN2yNqaX91XcR&wxNNI@016XGQ%rflFq4`&M_ab@o`&|O7mxp1`W^h9oh^~o! z_cd}C=puxN7Sza@iIkYBWP?6a_4;^3u8E|EWvokgg!pp|Wg5MORiGBmHJ1}l2WR&G z=12x&#Hmf)y#CLc^$IjYzTC|Rni)MUQ!BL#=xAA-xW3f#NOG`jd?!;aRX{#$#IbI6 zCgJuP<1lzyzmNWmlOTzKDH?+5x)Y6G54va07Acst4Q}mc2tRUSEam`ex6GItX$w?| zfsuAk+NzTX$Ta-i#p5_rhQ0Z(;h;#08|hgP=AaaZtEj2x#X68I6 z3I1@zlj|;-`%TvU?72nnL6FAmwbH(yApbDebCPx`ZVFhxMH;tx82vhTZ zs`v00sXX4}$Iz`d2@^rgXU3}llK+5|rkx+SYoaAA4zrdSo6L&&B9dO_;?J*8=x(%oL@Q9NxXW&Tb+=WP0P|9}K&Lv`b23PuP z{dwopoFAD3{%q3nm&7>$YqJ8~;=!=%{J+)kT8i70_xQ%AbK_tQx(+4r31u8-BUhbw zl9ms6oK?bE&HeT(()(A-1sohIsCqqGRQHmN_ZH4fn3S6_&$1I8nIP0I<7=buB?6Ke z+m?s9Dnc==nW+&MMlL$Ij11(rL-Q^QRLDs0Vm*cwGrD}>V6*wnugZcAH9{Q1r$CWS zJ8nkI{(U!35M(DwYjn4*H9zPBsvNZYuEpQ1$Ry@2>@_#S~K*MNW zb?Xk~{SwfqiB^at&n%v@d|}qFmFS6pl-2h?`aq_<=_?el{l{;m@}Rzc00C?yZaCOe z;93OQRPL(2L@^|q)ADvtzg=bz{bwyd1G?NL19Tj+Py!Ov&|847L>ZXo#5&lPdjV=rhjW6g>9h18D3clYoxU_{zqj` zTgQ|30jMg84&3I%uDXbKpwJv-5>>v)eC&%xu9N#UPB&0cn9;>4wanTiBnIQ)nmD3Y zJ{%Qlh9qpCV9nZ`Zcy|^o?DU#U3G3S9l}i+v+7;bU^>IsGgi;%>kx>L%lRbpZRalW z9B?UIAX!nF5ex{Wj+&G*?h`U{Xb$U3^8u=XgLYR#UYQyw& zGc6IEL`yIX+}+R>)rPCwdvG`>F}y=hJUsg~Ridf`j)-Wt!0fDoC`lqDBJre2Rc@t{ zOEfz9l;QYIb&w|dE3pn4#rSSb9xkzO^cp$0l2jbydLdZL%1Ir1xkG(IbLQTk5QSZ* zH-2_n=hdI{o;|^>S?5(;uvJ#KIz1*w4et=Tr#%7z1%|jwLF4v9T?|%d=#)ja)px`@&ExdRd+;-8HUQ_&Nm+<`j0>yUH<+jb+OkC*>UZ2?uRn-X!xS5h<+%GKec@oZl`GiTdKg+m!koN%BlU|XH*?SKZE9u{~&9$fa-iEgQ(`4J^WBj$@GOG#%pJ z<6!uhVda>xl!REuDpvR*EM8o9>GCk1roqq?xjQiY>KY=?&O!OMR@4|_6l zkTP`u7K<&-2ai4Ycx$SG_;$O>?|ws36{Toc=<0LUUI@ye`z7%Y00A@#i#~|Mwaqkl z8kCC~$zQY(KS~9U^6B_m8ppt6oIQwhwMO1tS|Tc2(|k0&sVLZ?tC_H0NxwCJv;Xg5 zbRU?@?wzMnS@wp*velYajzpL%>1ywQ^O{}f>F-*m*}yUk}y$iN7TR}_h^+&}=k9r9CFW=b8jV@( zNp^fyd3Qe`E91=HQJ;W|gwE88r#V0Jl;NhcdoIdGD24^JzSlnoDc!sGc=85XnTtV= zxWSvvR9y;o!ufg&oNUfii#=Jpk)`?(vW*yu$cvS?Q$bbRu>Z=u-q>q*0_7k2;bh?b zO9A8+cN~QDZZ;YWhSx@O@!e0mx7k%3dfPn?010HbdQ9y>GhQ{pj<;iGjRkp{H62l1 z6GQ1RLWEjC16#AH)IqS{-<{iB>_N|V9%7M=Iq`SEaO?@jbVgi!E5|cyFGm!2TtS}M zABsV@islf9ej?`9q^^V|Kg~;Opf)@F)ViLyE(>RLzjuD>`3XqSht8@Kdco8_9D?HV z!R2-86fyzW;8quE28LF6QaWieZp}^ZiALP#A`|B_--$fFn;BSc!Z}KOJ|JTVKx|fsl4x zTt%+f?q`8GYjZV9Vhl73lSrQApn|Ob7S>|TtOM_=q$NeQp$ntI@RkRlqT&BiHd~CoROcaKHLY|23rxnjrN5}Y9-yzUdPE*MdE^FQznJ`v*Nb6> z;r7(=3&`#xo|CDcCF4g302W&%mG~k}4G@G9$A~4U3G^ItZ~-P#J14_qh zCn#~rid1hmGhfqO30eOc>4x<7YQzfD0-J_q}>zUPcU048Yd%PjnB-JL3h zVADED521@58KW!4jMcH)&~2p$XJY%_7|UBX3^aektrE*4N?e*_()H-~>;ihMQ-TnL z>dYA?ISxL^wT;P21>n9|V`0LoTUKs6U`qUH{H$ZIihOX8mY}hVe~hhT-cy%`rO?iq z+##Lc1)nO~;=CzK_VkO5gG{oa133+n_;Ah*rC5WobdyX$SVzNPw*|fS%ZK|0trJ0LbadLRDEda_5{(r}6N)pB98-6Rm6kP@9s9E1pwGi(CQ@d#bFcFwvpivCb8dNAko z``?ZDb3TD%Xfh@*p4YdWRxdGxN?C1Gw6g21!+5MILA>82<4B-gc&A*Fy{@RQn=i9{nqSl_mc#$BToae8#U}#fDE*_V7XCtqh7B zOo2(TI5E!Otuu>cLzNpm)hGZM(gsAQgu=7q3qP@NWEs#EqQfhdKgIo}{Az!qu}dqW zQF~Df4=0d%z@HT>wHR9#$&r=BlfkXy0E7;mq>0d2zbv<@NVSGYkPng-XmGt++dh9z zDBS5VsFqbkkyS|H>3?a60C~U~kCZrd&FBq3Zaw`%0x*6(>1_&!sB;|Q|^>W?ol z?gN`bb7st`S``I_Q(YY|i8h$R^sH;y=nri>!g!Hyei!srYf9LxY62$b#Yut15zcjAm6PIzZl%$Z}HP+{Vcf%C_3b@t2&|v9& zNGfg)A)V%YuMihKMO^cqeh)+_+|DEBOYofXvpLVosgxFM^e7k1rK9DV~~U`DE$tX{E?SozOMr*z2MIiC=KOx%U5bZkX*dsPPQ(DwA> zd^^p@00F64uQazAYB269JF~;!C1D==w-5|X0=5=TZ0c8o(N=#R^Ed4Q?-DYu+w z4vd!IGxrap%Nr3gytqV*-z*pgP4ymJQxDNjBEq>BL8~vZr%Fv)s7v6zH7pEdJiO+S z%2b8P)sxXIIvX7YQ4lzpX*@MaLj|{TU-7MO-PdGZYJdRC*X)k6THoxJ!4_Z+=eEHM)MHguT|gcCRpy4QW*&7EburwudPeV>{q$`w)XKodXw! zLXpi#z>P$w$mudJK*6sB3b*!ZN0~G@e}O z;Y4gsd=3+yoIGV@!Wd(#A-duWWBr$^t~(U8Uk0mIAl0L@K+uIq4^#GNKFZC>VKs{}Ja3RhuhK$=&mJGiKJ!>#pq0#(yJ z?qzxUvr%O5Ge8%nDfJeDQeOY*dQGqKvnyS5rBF9Nu?<{_pzI3SAFO#43CV{~c{;zF zkj&B}A|Y{9_n>vpKXQjQfMM@@_JIhISOTHN%HP%atwYP5Y0%#y5OTc~qHmowL%CJ}JWUfy5XexY4b|Nn zl~y=D2&JEs70$j5@imV-G@tEZAr_F-g+6g7N}u?JSj@$zqhtC%>qk~gocjA+;7|ZX zEV4Gm&bva?>uaPj-d6T)X#L+Zg4oE6ol2Oufz>LNA1sTybf){3NTImO;>s4ICL7N{ z8thRp&p{q)livVc&I?U8W~#qsjI0G)6vUtfsd8CZY@ncmBV>JFLw-Mot5d901R$SP z;%=6*X(p2x&-$-o{T*t>Pf2zgcztFF5E(~MP+Tsm$%(dQv8BXS13W{vxGIevIX`6J zubm!;ZUUj=?X;QhDS}f}n(Tb6#Xh^d}z|x8>`+WNv2%$9%=bhCct?{EsDeV~| zRO^d(X+54+lIZYsA>l7cjLH|%h3EQRiAZyq-(9S0I^lFMd{B7szyKa<0A;zQGXq}M z)e7X?SUS0$U=)8+$i&fSH9b~dhdRaa3~AklBv;l7)F0PsYgO-9y8l3_0{wjPp;sqG z$)qeI5#+46ah^#}a!W@OwN6p6b9Ao~b_<6uf_mzEU5$t1oel0wQ)@!^QKlBEmFTQ7 zgUBw*N$frn%f2m0LEH4g{j>*JjhWBZ2H1h=m2ikft*O{*zxUFIqz?J{22oucAAqNE zl_Py62{$vTtsH--y!zv?bF6PUG(NXh&zuMAwmS&nh8y{SwkUMi7T2T=C8K=YB7`Z- z@EOHtOsdT`3XW|B#fTRMBIsZ2*T4~sn|Avi;+Zyh=a`rou?GMKVPxAr5kb{g->kx> z&_erE%a=}M*s?BH?BjAkryo-0bn%e&4cY1(^oHsJECDttSq8Ubw{h`Q<~gG75@+UQ zw$5X(|AGuVe4qdTLpsYux|sEt^VNrbkQ9M_2prLSC2%#W6F(m$$eiCvCba-4fT;~i z9D^J|v7aVEs}d($QADDrX5jBW+KGUp1WBp#G`pjj*e`i&m`o=V17u(0eL*arLc)(J z*Z>3=45a*`=sjc$D&d*d7`JwahpX+=6^XLqK%EQv?nI7d0j&7jF~?M-7v%@SZ=E+_ zZNeuNkdH`@|63$yLb8^mzMX;=G;6%UeRvvitHIKXjpG8ChocEmw-HYWfnxqp!Xr(_ z8X+773(f*2O&geHw7(qcL zvZBmo!`?xdqO@AC+!pGPsZ!&jL(W;4JGqwS^gWOHgsNPb{fMvB`( zI0t?`yT4BNMa&J$WYrm7^NPsAe6K}vk1Q!w+9OTi(r#a#@O7)MYDco0g89%8l~y?7 zBmf7^!|ZNgvm=Ndf45{N!@U9WZ?xrKTNjJAgOp zgW~?A{=MG14wK1sXIkFkNa$?f3=QGY>Th(K_kVAD7Q~2)r`qdW?&51I3VVhOM+2EMIjltjHk?g! zM}Z2L%gJ|ottwQLU6`0IBF(32&~>kh=@5U5P^U&bHq{Wl7s@v|3upeJV=)8WuUUjm z_;9DqbZ6s_Qha8yrpRd#MQ6>)x~n$NYcQVcWVDybLFREMv6seLWpZRX5#pvg~BIa1`9s&co?8e7#4A}lR*QjR|Ux`tmTiFiYD<&?#c0=LT`9RE4i@03?Q2B&lkrHg& zIe2TAK`{7iin7+anWTx0*u_*DlmGyRn)0GI=u17|E1tbfSnu+T8Rwa6yCMDAafSp& z)5x;PDg|ka2Mkx+53vaf&rEM3!hb@_DnGW9oP!76xByC^w@HndUa7N?Bc}Y%5w->Z z(mS#H#htG5AE1_+z_fe+>&axWR4D*DB~i|)9K#!93<@Vk^92+d-(dAwsULgEa64IX z>Awvl>O&lVWczBd>$|crf@x@LM=G>JNdDsa(U$57iW^_+vDq0Z)+GpDsKqcm@NEwx zL;5EP8O2br(UQSr6rQ4StkP|l#PM15vFf}_hq|;Tw3i#&O$L1=b@3xFCD{e)-v3ve zgw!N&;6o2BR)&}hqW8f8z0%F@K+IPV&t?a4fPorV(-~iwkQjol7j~UJ5|l_eOh<8C z!iN^4wIQ$nQ97*70w_Ro(8Xw`VC4Q==74{mrBirV?vG-xpb9m$P|tX1wKo9s%{2em z1CRL?WRago#t>};@4>AF3i^Ah_NbHlEL5lkKB#|pg0~kB%Ilx_T(@_B;<%&Qs+?M1VYTtZ4t@-8|oz-!eCESv{~6A&#>-fC}vP z9{%4?cz`~g1OODemRIEf0>aP$8cZW~U~CA=+p6E$-&5@684^vmsS>UXcTH_HcF1qt zSjaCkO7{wAf(*FW_GAFRAY84NQ1DYTYZ{;67z}!GW|>85l6celz}vOndooPLKEG;c zIyL>>;R*7jOA`-~RSF`$IUDVUXVpwn{d&NLb!__~0nTS`T@uoz-)_u1uN=(j3yvc; zIo7KRWe{&$N>xUe-|IM&Je@9OK_%`~Yn!yVo$W*@dInIxF|#X=-gLC%Y_b;~5^2b& zKgtG8pk!rvtKM=C;bOAF^CWOmC`Hwht>PNX&=-ss!XzO{9a>L@TQFm z%`9#RWmvW>fiRm@%Id(70<4%oYTWcu%>9>pT5lh+a(X0lIr_{u=qkIRyKhrP^82Of z@MUb7c`TK4K=y$P!*6@mNS3v2HHxGe6QB<4FxAqH3CPrb==)-Gx&BXGv*C5Z3obe) z0)=ogD~yN9EqPEIM%lV|uJVqEa18#Qo!1zJ-Ad2>6L^&rDik~IZa}>kHgu**?*CK! zM~5fPGJPu3DK*Q*B?uPKZtLGLy>wEI)&l6K&v->X&;c85`(U-0TTghrf%PFvk1((l zSprVp*KyDY4W2`9xeP z!7e9VhqfsW{CvV?Dj-ze|1%$|IQwLJ&Oo>zpz0^InA5i^5(4P^p~L+x5HLush+%?T zVtkfKVZ@y|6-&nt1JLTWGoE!%3UK?L%vLiiuzj7lP%ni8M?i2;^i&T5b^3{^BG#{P zknCSkhTBlCdub?2Fv?$8|M2ji?#VAez@L zd`U9vAu-B^5cw1IAOM(U3^NB_)$QxQh*SD-5*Q-~JYOaFeSMZ@d8Qmw|N2rFuY{BU zGsWK62-CuPgt|v;KZUjc3W?gctigtrMe*liT? zMTLr_7K!_0qP+~VU~=@u^Wu(_MGC%xRJiW5fW$b=?9R7(w*rMOHa z))8AN3W&=JYVY^S7hcU*xEAx0_=rTf3uB*zi5}yx^>U-QW#f@2H2o|Z-pTd+aTIKk z;g^EY0iOGa7gt!ovRarv?)fLVbYFKum9{>7`@7-sUA7DCtG5~=DtA-$o9`!)^`qVB zzl=~|ut_ar4@u1kjd#Vy59O~}OP+2hgXh8L_)$Rt{aCGVCIn}RkUNyw>BdS)J0C#= zM235eFTAC)Gr0H6iKf$4i#gzRL#x%)l5@86_4yTLf9piMPtlyA=htu-!n|idcWdJ}zRC*Dx90fNR70Iw(V(+Jw3EU<#!C z1%lV$Z}aTP>PowZn|;ZjW_QO0QQt|WELDv)BRwg-sh7fh?W%D;-wB#cF27uk{~oxw ze`qcO%|+wlDnzLc3(7P4bz?$MH2Ol|+J>k)je)AN(7*Qa%7pduz}<646z`tS zN&1l6tbLU_#B4sE&xTqbLc}XXL}1f0HZ{y>NlNQN^ch4;}7c zhw}G_PPfgAh}c-GB)<4ddi-zj5n8mwZ^qQsnR^55?F-KPb;jfcXE!-=JG{EyoNB7> zpKIU3>Mq|k{J&TFFcf01xMj^TX@VYj>`;&tvx2A+zZDxuxLF94Sy{;J^7P2$Wz1aC&t|a_;gQ z3GjjCQbiMGD{oec(q?6+u4!;?*B1z9lvZ>?a*fYt@jE^Juh_-m@m$UVl!0ss6#S~) zG9&HP#vtdk3Usq*zEuBg1eLB)K74^hFuD0oD~$pWI{O57{5;7uw* ziz8b72VNnMLO3bZ3V~kZN;_p=I-MhyQ!oZolG|?lHO-B6%{0u(LO5;jU3{h37%rgC zaj*3Mq0->6!n8p*fcF)7U+%`97svs|py4hb-3w(|2j7(Ps#UrSik1&UJqc<=8udgq zZ-CS_l78mUUqg>9LG(V%g%8xSs61q7 zR!%}k+K*g#(l@Tdz#0RFufVU50v_s&JKki_HP|5>Tmd0w2TmZ0^5#-MY2i1H*!U|! zd@S%d@cRnJMR+HJQx82q!9fd$0NRM$oj}2%W}x*dQA}XT29+M%lq6}4V>B&l1h;Px zkSjl)!+6&Sus&Q1fj1U+uoshCeiu}I5`_p3$jW8HbM1K>##dY%#43Lc=2>v-a`83( zDycum>qxhZ#YzAqlqXw^k-A(SOII9{LBF8AT)v%CNl#^KX?~=+Buh@Ez}<1fv$=oH?q z?FO2uZLSxL=t;^C%GYFjvfX6xNz5l&CA~hX17;0R+YX5_X0PF6--s)E-)L+QjP#X7 zx^Ec%gEKx*n_C!mbMU+1dy$1vTs#~|b0=tjs)ZldondQ5ty&~vyqQ*0^C!~msJZMD YccQ6}6CMBn00000000000000002tA9mH+?% literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/drawable/ic_chevron_right_18x24.xml b/core/ui/src/main/res/drawable/ic_chevron_right_18x24.xml new file mode 100644 index 0000000000..b3e1a0461d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_chevron_right_18x24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_flash_16.xml b/core/ui/src/main/res/drawable/ic_flash_16.xml new file mode 100644 index 0000000000..ae87edfc41 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_flash_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_shield_check_16.xml b/core/ui/src/main/res/drawable/ic_shield_check_16.xml new file mode 100644 index 0000000000..3b36f1acf0 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_shield_check_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_sparkles_16.xml b/core/ui/src/main/res/drawable/ic_sparkles_16.xml new file mode 100644 index 0000000000..8add70e924 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_sparkles_16.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_stack_fill_new_16.xml b/core/ui/src/main/res/drawable/ic_stack_fill_new_16.xml new file mode 100644 index 0000000000..e5518cf3fd --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_stack_fill_new_16.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/create-wallet-start/api/.gitignore b/features/create-wallet-start/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/create-wallet-start/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/create-wallet-start/api/build.gradle.kts b/features/create-wallet-start/api/build.gradle.kts new file mode 100644 index 0000000000..8c60a57f79 --- /dev/null +++ b/features/create-wallet-start/api/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.createwalletstart.api" +} + +dependencies { + /* Project - Domain */ + implementation(projects.domain.models) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Compose */ + implementation(deps.compose.runtime) +} \ No newline at end of file diff --git a/features/create-wallet-start/api/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartComponent.kt b/features/create-wallet-start/api/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartComponent.kt new file mode 100644 index 0000000000..120293bd9a --- /dev/null +++ b/features/create-wallet-start/api/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartComponent.kt @@ -0,0 +1,18 @@ +package com.tangem.features.createwalletstart + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface CreateWalletStartComponent : ComposableContentComponent { + + data class Params( + val mode: Mode, + ) + + enum class Mode { + ColdWallet, + HotWallet, + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/.gitignore b/features/create-wallet-start/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/create-wallet-start/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/create-wallet-start/impl/build.gradle.kts b/features/create-wallet-start/impl/build.gradle.kts new file mode 100644 index 0000000000..6909f9d54c --- /dev/null +++ b/features/create-wallet-start/impl/build.gradle.kts @@ -0,0 +1,72 @@ +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.createwalletstart.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.createWalletStart.api) + + /** Project - Domain */ + implementation(projects.domain.card) + implementation(projects.domain.settings) + implementation(projects.domain.wallets) + implementation(projects.domain.models) + + /** Core modules */ + implementation(projects.core.configToggles) + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.utils) + implementation(projects.core.ui) + implementation(projects.core.res) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.datasource) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) + + /** Tangem libraries */ + implementation(projects.libs.tangemSdkApi) + implementation(tangemDeps.card.core) + implementation(tangemDeps.card.android) { + exclude(module = "joda-time") + } + + /** AndroidX libraries */ + implementation(deps.androidx.core.ktx) + implementation(deps.lifecycle.runtime.ktx) + + /** Compose libraries */ + implementation(deps.compose.material3) + implementation(deps.compose.animation) + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.coil) + implementation(deps.lottie.compose) + implementation(deps.decompose.ext.compose) + implementation(deps.androidx.activity.compose) + implementation(deps.androidx.datastore) + + /** Other libraries */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.kotlin.serialization) + implementation(deps.timber) + implementation(deps.firebase.crashlytics) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt new file mode 100644 index 0000000000..6441bbb2e2 --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -0,0 +1,243 @@ +package com.tangem.features.createwalletstart + +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic.SignedIn +import com.tangem.core.analytics.models.Basic.SignedIn.SignInType +import com.tangem.core.decompose.di.GlobalUiMessageSender +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.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.card.analytics.ParamCardCurrencyConverter +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.error.SaveWalletError +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.createwalletstart.entity.CreateWalletStartUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val HIDE_PROGRESS_DELAY = 400L + +@Suppress("LongParameterList") +@ModelScoped +internal class CreateWalletStartModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val scanCardProcessor: ScanCardProcessor, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val settingsRepository: SettingsRepository, + private val analyticsEventHandler: AnalyticsEventHandler, + private val appRouter: AppRouter, + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, + private val saveWalletUseCase: SaveWalletUseCase, + private val userWalletsListRepository: UserWalletsListRepository, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, + private val urlOpener: UrlOpener, +) : Model() { + + private val params = paramsContainer.require() + + internal val uiState: StateFlow + field = MutableStateFlow( + when (params.mode) { + CreateWalletStartComponent.Mode.ColdWallet -> CreateWalletStartUM( + title = resourceReference(R.string.common_tangem_wallet), + description = resourceReference(R.string.welcome_create_wallet_hardware_description), + featureItems = persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_class), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_delivery), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_sparkles_16, + text = resourceReference(R.string.welcome_create_wallet_feature_use), + ), + ), + imageResId = R.drawable.img_hardware_wallet, + showScanSecondaryButton = true, + onPrimaryButtonClick = ::onBuyClick, + primaryButtonText = resourceReference(R.string.details_buy_wallet), + otherMethodTitle = resourceReference(R.string.welcome_create_wallet_mobile_title), + otherMethodDescription = resourceReference(R.string.welcome_create_wallet_mobile_description), + otherMethodClick = ::onStartWithMobileWalletClick, + onBackClick = { router.pop() }, + onScanClick = ::onScanClick, + isScanInProgress = false, + ) + CreateWalletStartComponent.Mode.HotWallet -> CreateWalletStartUM( + title = resourceReference(R.string.hw_mobile_wallet), + description = resourceReference(R.string.welcome_create_wallet_mobile_description_full), + featureItems = persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_seamless), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_one_tap), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_stack_fill_new_16, + text = resourceReference(R.string.welcome_create_wallet_feature_assets), + ), + ), + imageResId = R.drawable.img_mobile_wallet, + showScanSecondaryButton = false, + onPrimaryButtonClick = ::onStartWithMobileWalletClick, + primaryButtonText = resourceReference(R.string.welcome_create_wallet_mobile_title), + otherMethodTitle = resourceReference(R.string.welcome_create_wallet_use_hardware_title), + otherMethodDescription = resourceReference(R.string.welcome_create_wallet_use_hardware_description), + otherMethodClick = ::onBuyClick, + onBackClick = { router.pop() }, + onScanClick = ::onScanClick, + isScanInProgress = false, + ) + }, + ) + + private fun onScanClick() { + scanCard() + } + + private fun onStartWithMobileWalletClick() { + router.push(AppRoute.CreateMobileWallet) + } + + private fun onBuyClick() { + modelScope.launch { + generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } + } + } + + private fun scanCard() { + modelScope.launch { + setLoading(true) + + val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes() + cardSdkConfigRepository.setAccessCodeRequestPolicy( + isBiometricsRequestPolicy = shouldSaveAccessCodes, + ) + + val analyticsSource = AnalyticsParam.ScreensSources.Intro + + scanCardProcessor.scan( + analyticsSource = analyticsSource, + onProgressStateChange = { showProgress -> + if (!showProgress) { + delay(HIDE_PROGRESS_DELAY) + setLoading(false) + } else { + setLoading(true) + } + }, + onFailure = { error -> + handleScanError(error) + delay(HIDE_PROGRESS_DELAY) + setLoading(false) + }, + onSuccess = { scanResponse -> + proceedWithScanResponse(scanResponse) + }, + ) + } + } + + private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) { + val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build() + + if (userWallet == null) { + Timber.e("User wallet not created") + setLoading(false) + return + } + + saveWalletUseCase(userWallet = userWallet).fold( + ifLeft = { + delay(HIDE_PROGRESS_DELAY) + setLoading(false) + when (it) { + is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") + is SaveWalletError.WalletAlreadySaved -> { + userWalletsListRepository.unlock( + userWalletId = userWallet.walletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), + ).onRight { + appRouter.replaceAll(AppRoute.Wallet) + } + } + } + }, + ifRight = { + setLoading(false) + sendSignedInCardAnalyticsEvent(scanResponse) + appRouter.replaceAll(AppRoute.Wallet) + }, + ) + } + + private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) + if (currency != null) { + analyticsEventHandler.send( + SignedIn( + currency = currency, + batch = scanResponse.card.batchId, + signInType = SignInType.Card, + walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), + hasBackup = scanResponse.card.backupStatus?.isActive, + ), + ) + } + } + + private fun setLoading(isLoading: Boolean) { + uiState.update { it.copy(isScanInProgress = isLoading) } + } + + private fun handleScanError(error: TangemError) { + when (error) { + is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable() + is TangemSdkError -> Timber.e(error, "Scan error occurred") + else -> Timber.e(error, "Error happened") + } + } + + private fun handleNfcFeatureUnavailable() { + uiMessageSender.send( + message = DialogMessage( + message = resourceReference(R.string.nfc_error_unavailable), + title = resourceReference(id = R.string.common_error), + ), + ) + } +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/DefaultCreateWalletStartComponent.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/DefaultCreateWalletStartComponent.kt new file mode 100644 index 0000000000..598669a656 --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/DefaultCreateWalletStartComponent.kt @@ -0,0 +1,42 @@ +package com.tangem.features.createwalletstart + +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.core.ui.components.SystemBarsIconsDisposable +import com.tangem.core.ui.res.ForceDarkTheme +import com.tangem.features.createwalletstart.ui.CreateWalletStartContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultCreateWalletStartComponent @AssistedInject constructor( + @Assisted private val context: AppComponentContext, + @Assisted private val params: CreateWalletStartComponent.Params, +) : CreateWalletStartComponent, AppComponentContext by context { + + private val model: CreateWalletStartModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + SystemBarsIconsDisposable(darkIcons = false) + ForceDarkTheme { + CreateWalletStartContent( + state = state, + modifier = modifier, + ) + } + } + + @AssistedFactory + interface Factory : CreateWalletStartComponent.Factory { + override fun create( + context: AppComponentContext, + params: CreateWalletStartComponent.Params, + ): DefaultCreateWalletStartComponent + } +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/di/CreateWalletStartModule.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/di/CreateWalletStartModule.kt new file mode 100644 index 0000000000..c534d77b9a --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/di/CreateWalletStartModule.kt @@ -0,0 +1,33 @@ +package com.tangem.features.createwalletstart.di + +import com.tangem.core.decompose.model.Model +import com.tangem.features.createwalletstart.CreateWalletStartComponent +import com.tangem.features.createwalletstart.CreateWalletStartModel +import com.tangem.features.createwalletstart.DefaultCreateWalletStartComponent +import dagger.Binds +import dagger.Module +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) +internal object CreateWalletStartModule + +@Module +@InstallIn(SingletonComponent::class) +internal interface CreateWalletStartModuleBinds { + + @Binds + @Singleton + fun bindCreateWalletStartComponentFactory( + impl: DefaultCreateWalletStartComponent.Factory, + ): CreateWalletStartComponent.Factory + + @Binds + @IntoMap + @ClassKey(CreateWalletStartModel::class) + fun bindCreateWalletStartModel(model: CreateWalletStartModel): Model +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/entity/CreateWalletStartUM.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/entity/CreateWalletStartUM.kt new file mode 100644 index 0000000000..58f58d2bc0 --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/entity/CreateWalletStartUM.kt @@ -0,0 +1,25 @@ +package com.tangem.features.createwalletstart.entity + +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class CreateWalletStartUM( + val title: TextReference, + val description: TextReference, + val featureItems: ImmutableList, + val imageResId: Int, + val isScanInProgress: Boolean, + val showScanSecondaryButton: Boolean, + val primaryButtonText: TextReference, + val onPrimaryButtonClick: () -> Unit, + val otherMethodDescription: TextReference, + val otherMethodTitle: TextReference, + val otherMethodClick: () -> Unit, + val onScanClick: () -> Unit, + val onBackClick: () -> Unit, +) { + data class FeatureItem( + val iconResId: Int, + val text: TextReference, + ) +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt new file mode 100644 index 0000000000..9c6e3fedf3 --- /dev/null +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/ui/CreateWalletStartContent.kt @@ -0,0 +1,486 @@ +package com.tangem.features.createwalletstart.ui + +import android.annotation.SuppressLint +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +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.components.PrimaryButton +import com.tangem.core.ui.components.SecondaryButtonIconEnd +import com.tangem.core.ui.components.bottomFade +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.createwalletstart.entity.CreateWalletStartUM +import com.tangem.features.createwalletstart.impl.R +import kotlinx.collections.immutable.persistentListOf +import kotlin.math.max + +@Suppress("LongMethod", "MagicNumber") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +internal fun CreateWalletStartContent(state: CreateWalletStartUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background( + brush = Brush.verticalGradient( + listOf( + TangemColorPalette.Dark6, + TangemColorPalette.Black, + ), + ), + ) + .fillMaxSize() + .systemBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + navigationIcon = { + IconButton(onClick = state.onBackClick) { + Icon( + painter = painterResource(R.drawable.ic_back_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } + }, + title = { }, + ) + Box( + modifier = Modifier + .weight(1f) + .bottomFade(height = 24.dp), + ) { + AdaptiveScrollableContent( + topContent = { + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 32.dp, + top = 16.dp, + end = 32.dp, + ), + text = state.title.resolveReference(), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 32.dp, + top = 8.dp, + end = 32.dp, + ), + text = state.description.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 24.dp, + top = 16.dp, + end = 24.dp, + ), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.featureItems.forEach { + FeatureItem( + iconResId = it.iconResId, + text = it.text, + ) + } + } + }, + imageContent = { + Image( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding( + vertical = 12.dp, + horizontal = 16.dp, + ), + painter = painterResource(id = state.imageResId), + contentDescription = null, + contentScale = ContentScale.Fit, + ) + }, + bottomContent = { + if (state.showScanSecondaryButton) { + SecondaryButtonIconEnd( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResourceSafe(R.string.welcome_unlock_card), + onClick = state.onScanClick, + showProgress = state.isScanInProgress, + iconResId = R.drawable.ic_tangem_24, + ) + } + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 8.dp, + end = 16.dp, + ), + text = state.primaryButtonText.resolveReference(), + onClick = state.onPrimaryButtonClick, + ) + Row( + modifier = Modifier + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + ), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashedGradientLine( + modifier = Modifier + .weight(1f) + .height(16.dp), + ) + Text( + text = stringResourceSafe(R.string.welcome_create_wallet_other_method), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + DashedGradientLine( + modifier = Modifier + .weight(1f) + .height(16.dp) + .scale(scaleX = -1f, scaleY = 1f), + ) + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 16.dp, + end = 16.dp, + ), + text = state.otherMethodDescription.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + Row( + modifier = Modifier + .wrapContentWidth() + .clickable { state.otherMethodClick() } + .padding( + horizontal = 16.dp, + vertical = 12.dp, + ), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = state.otherMethodTitle.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_18x24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } + }, + minImageHeight = 160.dp, + ) + } + if (!state.showScanSecondaryButton) { + FlowRow( + modifier = Modifier + .wrapContentWidth() + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + bottom = 8.dp, + ), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResourceSafe(R.string.welcome_create_wallet_already_have), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + ) + Spacer(modifier = Modifier.size(4.dp)) + Row( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { state.onScanClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResourceSafe(R.string.wallet_create_scan_title), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + Spacer(modifier = Modifier.size(2.dp)) + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = R.drawable.ic_tangem_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } + } + } + Spacer(modifier = Modifier.size(16.dp)) + } +} + +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +private fun AdaptiveScrollableContent( + minImageHeight: Dp, + modifier: Modifier = Modifier, + topContent: @Composable () -> Unit, + imageContent: @Composable () -> Unit, + bottomContent: @Composable () -> Unit, +) { + BoxWithConstraints( + modifier = modifier.fillMaxSize(), + ) { + val density = LocalDensity.current + val viewportHeight = maxHeight + val minImageHeightPx = with(density) { minImageHeight.roundToPx() } + val viewportHeightPx = with(density) { viewportHeight.roundToPx() } + Layout( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + content = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + topContent() + } + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + imageContent() + } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + bottomContent() + } + }, + ) { measurables, constraints -> + val topPlaceable = measurables[0].measure( + constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity), + ) + val bottomPlaceable = measurables[2].measure( + constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity), + ) + val imageIntrinsicHeight = measurables[1].maxIntrinsicHeight(constraints.maxWidth) + val availableHeightForImage = max(0, viewportHeightPx - topPlaceable.height - bottomPlaceable.height) + val targetImageHeight = when { + imageIntrinsicHeight < minImageHeightPx -> minImageHeightPx + imageIntrinsicHeight > availableHeightForImage -> max(minImageHeightPx, availableHeightForImage) + else -> imageIntrinsicHeight + } + val imagePlaceable = measurables[1].measure( + constraints.copy( + minHeight = targetImageHeight, + maxHeight = targetImageHeight, + ), + ) + val totalContentHeight = topPlaceable.height + imagePlaceable.height + bottomPlaceable.height + layout(constraints.maxWidth, totalContentHeight) { + var yOffset = 0 + topPlaceable.placeRelative(0, yOffset) + yOffset += topPlaceable.height + imagePlaceable.placeRelative(0, yOffset) + yOffset += imagePlaceable.height + bottomPlaceable.placeRelative(0, yOffset) + } + } + } +} + +@Composable +private fun DashedGradientLine(modifier: Modifier = Modifier) { + val density = LocalDensity.current + + val strokeColor = TangemTheme.colors.stroke.primary + + Canvas(modifier = modifier) { + val strokePx = with(density) { 4.dp.toPx() } + val dashPx = with(density) { 4.dp.toPx() } + val gapPx = with(density) { 8.dp.toPx() } + + val width = size.width + val centerY = size.height / 2 + + val brush = Brush.linearGradient( + colors = listOf(strokeColor.copy(alpha = 0f), strokeColor), + start = Offset(0f, 0f), + end = Offset(width, 0f), + ) + + val pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashPx, gapPx), 0f) + + drawLine( + brush = brush, + start = Offset(0f, centerY), + end = Offset(width, centerY), + strokeWidth = strokePx, + pathEffect = pathEffect, + cap = StrokeCap.Round, + ) + } +} + +@Composable +private fun FeatureItem(@DrawableRes iconResId: Int, text: TextReference) { + Row( + modifier = Modifier + .wrapContentWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(iconResId), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + Text( + text = text.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.secondary, + ) + } +} + +private class CreateWalletStartStateProvider : CollectionPreviewParameterProvider( + collection = listOf( + CreateWalletStartUM( + title = resourceReference(R.string.common_tangem_wallet), + description = resourceReference(R.string.welcome_create_wallet_hardware_description), + featureItems = persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_class), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_delivery), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_sparkles_16, + text = resourceReference(R.string.welcome_create_wallet_feature_use), + ), + ), + imageResId = R.drawable.img_hardware_wallet, + showScanSecondaryButton = true, + onPrimaryButtonClick = { }, + primaryButtonText = resourceReference(R.string.details_buy_wallet), + otherMethodTitle = resourceReference(R.string.welcome_create_wallet_mobile_title), + otherMethodDescription = resourceReference( + R.string.welcome_create_wallet_mobile_description, + ), + otherMethodClick = { }, + onBackClick = { }, + onScanClick = { }, + isScanInProgress = false, + ), + CreateWalletStartUM( + title = resourceReference(R.string.hw_mobile_wallet), + description = resourceReference(R.string.welcome_create_wallet_mobile_description_full), + featureItems = persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_seamless), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_one_tap), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_stack_fill_new_16, + text = resourceReference(R.string.welcome_create_wallet_feature_assets), + ), + ), + imageResId = R.drawable.img_mobile_wallet, + showScanSecondaryButton = false, + onPrimaryButtonClick = { }, + primaryButtonText = resourceReference(R.string.welcome_create_wallet_mobile_title), + otherMethodTitle = resourceReference(R.string.welcome_create_wallet_use_hardware_title), + otherMethodDescription = resourceReference( + R.string.welcome_create_wallet_use_hardware_description, + ), + otherMethodClick = { }, + onBackClick = { }, + onScanClick = { }, + isScanInProgress = false, + ), + ), +) + +@Preview(showBackground = true, widthDp = 360, heightDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, widthDp = 360, heightDp = 560, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, widthDp = 360, heightDp = 840, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewCreateWalletStartContent( + @PreviewParameter(CreateWalletStartStateProvider::class) param: CreateWalletStartUM, +) { + TangemThemePreview { + CreateWalletStartContent( + state = param, + ) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index ac0557883f..d69405d8eb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -279,6 +279,9 @@ include(":features:tangempay:onboarding:impl") include(":features:create-wallet-selection:api") include(":features:create-wallet-selection:impl") +include(":features:create-wallet-start:api") +include(":features:create-wallet-start:impl") + include(":features:welcome:api") include(":features:welcome:impl") From 5b95c59a92ff1f5d56f5de3a2a1681b39703cacd Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Oct 2025 13:41:04 +0700 Subject: [PATCH 35/46] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 1 + .../core/ui/components/rows/BlockchainRow.kt | 19 +-- .../tangem/domain/markets/TokenMarketInfo.kt | 3 + .../add/impl/ChooseNetworkComponent.kt | 57 +++++++++ .../add/impl/ui/ChooseNetworkContent.kt | 110 ++++++++++++++++++ .../add/impl/ui/state/ChooseNetworkUM.kt | 9 ++ 6 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt create mode 100644 features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/ChooseNetworkContent.kt create mode 100644 features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/ChooseNetworkUM.kt diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 368cbca8bf..ce3e6d97ca 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -183,6 +183,7 @@ Add Add to portfolio Add token + Added Address All Allow diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/BlockchainRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/BlockchainRow.kt index d89d6710c1..4e74046e6c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/rows/BlockchainRow.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/BlockchainRow.kt @@ -27,15 +27,20 @@ private const val DISABLED_ICON_ALPHA = 0.4f * [Figma Component](https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=2737-2800&t=ewlXfWwbDnRhjw4B-4) * */ @Composable -fun BlockchainRow(model: BlockchainRowUM, modifier: Modifier = Modifier, action: @Composable BoxScope.() -> Unit) { +fun BlockchainRow( + model: BlockchainRowUM, + modifier: Modifier = Modifier, + itemPadding: PaddingValues = PaddingValues( + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing8, + ), + action: @Composable BoxScope.() -> Unit, +) { RowContentContainer( modifier = modifier .heightIn(min = TangemTheme.dimens.size52) - .padding( - top = TangemTheme.dimens.spacing8, - bottom = TangemTheme.dimens.spacing8, - start = TangemTheme.dimens.spacing8, - ), + .padding(itemPadding), icon = { RowIcon( resId = model.iconResId, @@ -58,7 +63,7 @@ fun BlockchainRow(model: BlockchainRowUM, modifier: Modifier = Modifier, action: } @Composable -private fun RowIcon( +fun RowIcon( @DrawableRes resId: Int, isColored: Boolean, showAccentBadge: Boolean, diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt index 97a9008aa1..5354432e4c 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt @@ -1,5 +1,6 @@ package com.tangem.domain.markets +import kotlinx.serialization.Serializable import org.joda.time.DateTime import java.math.BigDecimal @@ -18,6 +19,8 @@ data class TokenMarketInfo( val pricePerformance: PricePerformance?, val exchangesAmount: Int?, ) { + + @Serializable data class Network( val networkId: String, val exchangeable: Boolean, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt new file mode 100644 index 0000000000..3ab91ac88c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ChooseNetworkComponent.kt @@ -0,0 +1,57 @@ +package com.tangem.features.markets.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.portfolio.add.impl.ui.ChooseNetworkContent +import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM +import com.tangem.features.markets.portfolio.impl.model.BlockchainRowUMConverter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toPersistentList + +internal class ChooseNetworkComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val state by lazy { + val converter = BlockchainRowUMConverter( + alreadyAddedNetworks = params.alreadyAdded.mapTo(mutableSetOf()) { it.networkId }, + ) + val allAvailableNetworks = params.allAvailable.map { it to true } + ChooseNetworkUM( + networks = converter.convertList(allAvailableNetworks).toPersistentList(), + onNetworkClick = onNetworkClick@{ row -> + val network = params.allAvailable + .find { it.networkId == row.id } + ?: return@onNetworkClick + params.callbacks.onNetworkSelected(network) + }, + ) + } + + @Composable + override fun Content(modifier: Modifier) { + ChooseNetworkContent(state) + } + + data class Params( + val alreadyAdded: Set, + val allAvailable: List, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onNetworkSelected(network: TokenMarketInfo.Network) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): ChooseNetworkComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/ChooseNetworkContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/ChooseNetworkContent.kt new file mode 100644 index 0000000000..e2f26ae35b --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/ChooseNetworkContent.kt @@ -0,0 +1,110 @@ +package com.tangem.features.markets.portfolio.add.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.util.fastForEachIndexed +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.BlockchainRow +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +private const val DISABLED_ALPHA = 0.4f + +@Composable +internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.networks.fastForEachIndexed { index, model -> + key(model.id) { + BlockchainRow( + model = model, + itemPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing14, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }), + ) { + if (!model.isEnabled) { + Label( + modifier = Modifier.alpha(DISABLED_ALPHA), + state = LabelUM( + text = resourceReference(R.string.common_added), + style = LabelStyle.REGULAR, + ), + ) + } + } + } + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) { + TangemThemePreview { + ChooseNetworkContent( + state = content, + ) + } +} + +internal class ChooseNetworkContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = UUID.randomUUID().toString(), + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.img_eth_22, + isMainNetwork = false, + isSelected = true, + isEnabled = true, + ) + + override val values: Sequence + get() = sequenceOf( + ChooseNetworkUM( + onNetworkClick = {}, + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + ), + blockchainRow.copy( + iconResId = R.drawable.ic_bsc_16, + isEnabled = false, + ), + blockchainRow.copy(iconResId = R.drawable.img_polygon_22), + blockchainRow.copy(iconResId = R.drawable.img_optimism_22), + ), + ), + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/ChooseNetworkUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/ChooseNetworkUM.kt new file mode 100644 index 0000000000..8e27218757 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/add/impl/ui/state/ChooseNetworkUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.markets.portfolio.add.impl.ui.state + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import kotlinx.collections.immutable.ImmutableList + +data class ChooseNetworkUM( + val networks: ImmutableList, + val onNetworkClick: (BlockchainRowUM) -> Unit, +) \ No newline at end of file From 50806204abf0f95c7c0b103d0b887ab288e56b47 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 17:41:42 +0400 Subject: [PATCH 36/46] Updated on 2026-08-14 --- .../DefaultAccountsCRUDRepository.kt | 18 ++ .../repository/DefaultCurrenciesRepository.kt | 69 +---- .../repository/AccountsCRUDRepository.kt | 7 + domain/account/status/build.gradle.kts | 1 + .../status/di/AccountStatusUseCaseModule.kt | 41 +++ .../usecase/SaveCryptoCurrenciesUseCase.kt | 269 ++++++++++++++++++ .../tangem/domain/models/account/Account.kt | 8 +- .../tokens/repository/CurrenciesRepository.kt | 23 +- .../repository/MockCurrenciesRepository.kt | 13 +- 9 files changed, 357 insertions(+), 92 deletions(-) create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt 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 524e256767..5af36f3594 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 @@ -24,6 +24,7 @@ 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.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.replaceBy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -109,6 +110,23 @@ internal class DefaultAccountsCRUDRepository( walletAccountsSaver.pushAndStore(userWalletId = userWallet.walletId, response = accountsResponse) } + override suspend fun saveAccount(account: Account.CryptoPortfolio) { + val store = getAccountsResponseStore(userWalletId = account.userWalletId) + + val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = account.userWalletId) + val newAccountDTO = converter.convertBack(value = account) + + store.updateData { response -> + response ?: return@updateData response + + response.copy( + accounts = response.accounts.toMutableList().apply { + replaceBy(newAccountDTO) { it.id == newAccountDTO.id } + }, + ) + } + } + override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option = option { val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId) diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index ce7c5ddb13..80b1a4577d 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -74,77 +74,18 @@ internal class DefaultCurrenciesRepository( userTokensSaver.storeAndPush(userWalletId, response) } - override suspend fun saveNewCurrenciesList(userWalletId: UserWalletId, currencies: List) { + override suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List) { withContext(dispatchers.io) { val savedResponse = requireNotNull( value = getSavedUserTokensResponseSync(key = userWalletId), lazyMessage = { "Saved tokens empty. Can not perform add currencies action." }, ) - val newCurrencies = populateCurrenciesWithMissedCoins(currencies) - val updatedResponse = savedResponse.copy( - tokens = newCurrencies.map(userTokensResponseFactory::createResponseToken), - ) - userTokensSaver.storeAndPush( - userWalletId = userWalletId, - response = updatedResponse, + tokens = currencies.map(userTokensResponseFactory::createResponseToken), ) - fetchExpressAssetsByNetworkIds( - userWallet = userWalletsStore.getSyncStrict(key = userWalletId), - userTokens = updatedResponse, - ) - } - } - - override suspend fun addCurrencies( - userWalletId: UserWalletId, - currencies: List, - ): List = withContext(dispatchers.io) { - val savedCurrencies = requireNotNull( - value = getSavedUserTokensResponseSync(key = userWalletId), - lazyMessage = { "Saved tokens empty. Can not perform add currencies action" }, - ) - - val currenciesToAdd = filterAlreadyAddedCurrencies( - savedCurrencies = savedCurrencies.tokens, - currenciesToAdd = populateCurrenciesWithMissedCoins(currencies = currencies), - ) - - val updatedResponse = savedCurrencies.copy( - tokens = savedCurrencies.tokens + currenciesToAdd.map(userTokensResponseFactory::createResponseToken), - ) - - userTokensSaver.storeAndPush( - userWalletId = userWalletId, - response = updatedResponse, - ) - - fetchExpressAssetsByNetworkIds( - userWallet = userWalletsStore.getSyncStrict(key = userWalletId), - userTokens = updatedResponse, - ) - - currenciesToAdd - } - - override suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List) { - withContext(dispatchers.io) { - val savedResponse = requireNotNull( - value = getSavedUserTokensResponseSync(key = userWalletId), - lazyMessage = { "Saved tokens empty. Can not perform add currencies action." }, - ) - - val newCurrencies = populateCurrenciesWithMissedCoins(currencies) - - val updatedResponse = savedResponse.copy( - tokens = newCurrencies.map(userTokensResponseFactory::createResponseToken), - ) - userTokensSaver.store( - userWalletId = userWalletId, - response = updatedResponse, - ) + userTokensSaver.store(userWalletId = userWalletId, response = updatedResponse) fetchExpressAssetsByNetworkIds( userWallet = userWalletsStore.getSyncStrict(key = userWalletId), @@ -551,6 +492,10 @@ internal class DefaultCurrenciesRepository( } } + override fun createCoinCurrency(network: Network): CryptoCurrency.Coin { + return cryptoCurrencyFactory.createCoin(network = network) + } + override fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token { return cryptoCurrencyFactory.createToken( cryptoCurrency = cryptoCurrency, 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 e58992d600..5eb7a0433a 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 @@ -68,6 +68,13 @@ interface AccountsCRUDRepository { */ suspend fun saveAccounts(accountList: AccountList) + /** + * Save account + * + * @param account account to be saved + */ + suspend fun saveAccount(account: Account.CryptoPortfolio) + /** * Retrieves the total count of accounts associated with a specific user wallet including archived accounts * diff --git a/domain/account/status/build.gradle.kts b/domain/account/status/build.gradle.kts index 564baa96b8..8e2f490e75 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { api(projects.domain.networks) api(projects.domain.staking) api(projects.domain.tokens) + api(projects.domain.wallets) implementation(projects.libs.blockchainSdk) implementation(projects.libs.crypto) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt index f7cab3c413..79d373768b 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt @@ -1,11 +1,22 @@ package com.tangem.domain.account.status.di +import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import com.tangem.domain.networks.utils.NetworksCleaner +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.staking.StakingIdFactory +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.domain.staking.utils.StakingCleaner +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.derivations.DerivationsRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -37,4 +48,34 @@ internal object AccountStatusUseCaseModule { ): GetAccountCurrencyStatusUseCase { return GetAccountCurrencyStatusUseCase(singleAccountStatusListSupplier = singleAccountStatusListSupplier) } + + @Provides + @Singleton + fun provideSaveCryptoCurrenciesUseCase( + singleAccountListSupplier: SingleAccountListSupplier, + accountsCRUDRepository: AccountsCRUDRepository, + currenciesRepository: CurrenciesRepository, + derivationsRepository: DerivationsRepository, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteStatusFetcher: MultiQuoteStatusFetcher, + multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + stakingIdFactory: StakingIdFactory, + networksCleaner: NetworksCleaner, + stakingCleaner: StakingCleaner, + dispatchers: CoroutineDispatcherProvider, + ): SaveCryptoCurrenciesUseCase { + return SaveCryptoCurrenciesUseCase( + singleAccountListSupplier = singleAccountListSupplier, + accountsCRUDRepository = accountsCRUDRepository, + currenciesRepository = currenciesRepository, + derivationsRepository = derivationsRepository, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteStatusFetcher = multiQuoteStatusFetcher, + multiYieldBalanceFetcher = multiYieldBalanceFetcher, + stakingIdFactory = stakingIdFactory, + networksCleaner = networksCleaner, + stakingCleaner = stakingCleaner, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt new file mode 100644 index 0000000000..b89a3f03f5 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/SaveCryptoCurrenciesUseCase.kt @@ -0,0 +1,269 @@ +package com.tangem.domain.account.status.usecase + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.core.utils.eitherOn +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.networks.utils.NetworksCleaner +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.staking.StakingIdFactory +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.domain.staking.utils.StakingCleaner +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.derivations.DerivationsRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.* +import timber.log.Timber + +/** + * Use case for saving crypto currencies to a specific account. + * + * @property singleAccountListSupplier Supplier to get account details. + * @property currenciesRepository Repository for managing currencies. + * @property derivationsRepository Repository for deriving public keys. + * @property multiNetworkStatusFetcher Fetcher for updating network statuses. + * @property multiQuoteStatusFetcher Fetcher for updating quote statuses. + * @property multiYieldBalanceFetcher Fetcher for updating yield balances. + * @property stakingIdFactory Factory for creating staking IDs. + * @property networksCleaner Cleaner for removing obsolete network data. + * @property stakingCleaner Cleaner for removing obsolete staking data. + * @property dispatchers Coroutine dispatchers for managing threading. + * +[REDACTED_AUTHOR] + */ +@Suppress("LongParameterList") +class SaveCryptoCurrenciesUseCase( + private val singleAccountListSupplier: SingleAccountListSupplier, + private val accountsCRUDRepository: AccountsCRUDRepository, + private val currenciesRepository: CurrenciesRepository, + private val derivationsRepository: DerivationsRepository, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, + private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher, + private val stakingIdFactory: StakingIdFactory, + private val networksCleaner: NetworksCleaner, + private val stakingCleaner: StakingCleaner, + private val dispatchers: CoroutineDispatcherProvider, +) { + + suspend operator fun invoke( + accountId: AccountId, + add: List, + remove: List, + ): Either = eitherOn(dispatchers.default) { + if (add.isEmpty() && remove.isEmpty()) { + Timber.d("No currencies to add or remove, skipping") + return@eitherOn + } + + val userWalletId = accountId.userWalletId + withContext(NonCancellable) { + val account = getAccount(accountId = accountId) + + val modifiedCurrencyList = account.cryptoCurrencies.modify(add = add, remove = remove) + + saveAccount( + account = account.copy(cryptoCurrencies = modifiedCurrencyList.total.toSet()), + ) + + derivePublicKeys(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + + val jobs = refreshBalances(userWalletId = userWalletId, currencies = modifiedCurrencyList.added) + + clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed) + + jobs.joinAll() + } + } + + private suspend fun Raise.getAccount(accountId: AccountId): Account.CryptoPortfolio { + val accountList = singleAccountListSupplier.getSyncOrNull( + params = SingleAccountListProducer.Params(userWalletId = accountId.userWalletId), + ) ?: raise(IllegalStateException("No accounts for wallet ${accountId.userWalletId}")) + + return accountList.accounts.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio + ?: raise(IllegalStateException("No account with id $accountId")) + } + + private fun Set.modify( + add: List, + remove: List, + ): ModifiedCurrencyList { + val mutableCurrencies = this.toMutableList() + val added = mutableListOf() + val removed = mutableListOf() + + val existingCurrenciesById = mutableCurrencies.associateBy(::TempID) + + add.groupByNetwork { !existingCurrenciesById.containsKey(it) } + .forEach { (network, currenciesById) -> + val coinTempId = TempID(network) + + if (!existingCurrenciesById.containsKey(coinTempId)) { + val coin = currenciesById[coinTempId] + + if (coin != null) { + mutableCurrencies.add(coin) + added.add(coin) + + currenciesById.remove(coinTempId) + } else { + val createdCoin = currenciesRepository.createCoinCurrency(network) + mutableCurrencies.add(createdCoin) + added.add(createdCoin) + } + } + + mutableCurrencies.addAll(currenciesById.values) + added.addAll(currenciesById.values) + } + + remove.groupByNetwork(valuePredicate = existingCurrenciesById::containsKey) + .forEach { (network, currenciesById) -> + val coinTempId = TempID(network) + + if (currenciesById.containsKey(coinTempId)) { + val existingNetworkCurrenciesCount = mutableCurrencies.count { it.network == network } + + if (existingNetworkCurrenciesCount != currenciesById.size) { + return@forEach + } + } + + mutableCurrencies.removeAll(currenciesById.values) + removed.addAll(currenciesById.values) + } + + return ModifiedCurrencyList(added = added, removed = removed, total = mutableCurrencies) + } + + private suspend fun Raise.saveAccount(account: Account.CryptoPortfolio) { + catch( + block = { accountsCRUDRepository.saveAccount(account) }, + catch = ::raise, + ) + } + + private suspend fun Raise.derivePublicKeys( + userWalletId: UserWalletId, + currencies: List, + ) { + catch( + block = { derivationsRepository.derivePublicKeys(userWalletId = userWalletId, currencies = currencies) }, + catch = ::raise, + ) + } + + private fun List.groupByNetwork( + valuePredicate: (TempID) -> Boolean, + ): LinkedHashMap> { + val destination = LinkedHashMap>() + + for (currency in this) { + val key = currency.network + val mutableMap = destination.getOrPut(key) { mutableMapOf() } + + val id = TempID(currency) + + if (valuePredicate(id)) { + mutableMap.put(id, currency) + } + } + + return destination + } + + private suspend fun refreshBalances(userWalletId: UserWalletId, currencies: List): List { + if (currencies.isEmpty()) return emptyList() + + return coroutineScope { + listOf( + launch { refreshNetworks(userWalletId = userWalletId, currencies = currencies) }, + launch { refreshYieldBalances(userWalletId = userWalletId, currencies = currencies) }, + launch { refreshQuotes(currencies = currencies) }, + ) + } + } + + private suspend fun refreshNetworks(userWalletId: UserWalletId, currencies: List) { + multiNetworkStatusFetcher( + params = MultiNetworkStatusFetcher.Params( + userWalletId = userWalletId, + networks = currencies.mapTo(hashSetOf(), CryptoCurrency::network), + ), + ) + + currenciesRepository.syncTokens(userWalletId) + } + + private suspend fun refreshYieldBalances(userWalletId: UserWalletId, currencies: List) { + val stakingIds = currencies.mapNotNullTo(hashSetOf()) { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() + } + + multiYieldBalanceFetcher( + params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds), + ) + } + + private suspend fun refreshQuotes(currencies: List) { + multiQuoteStatusFetcher( + params = MultiQuoteStatusFetcher.Params( + currenciesIds = currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + appCurrencyId = null, + ), + ) + } + + private suspend fun clearMetadata(userWalletId: UserWalletId, currencies: List): List { + if (currencies.isEmpty()) return emptyList() + + return coroutineScope { + listOf( + launch { networksCleaner(userWalletId = userWalletId, currencies = currencies) }, + launch { clearStaking(userWalletId = userWalletId, currencies = currencies) }, + ) + } + } + + private suspend fun clearStaking(userWalletId: UserWalletId, currencies: List) { + val stakingIds = currencies.mapNotNullTo(hashSetOf()) { + stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull() + } + + stakingCleaner(userWalletId = userWalletId, stakingIds = stakingIds) + } + + private data class TempID( + val networkId: String, + val derivationPath: Network.DerivationPath, + val contractAddress: String?, + ) { + + constructor(network: Network) : this( + networkId = network.backendId, + derivationPath = network.derivationPath, + contractAddress = null, + ) + + constructor(currency: CryptoCurrency) : this( + networkId = currency.network.backendId, + derivationPath = currency.network.derivationPath, + contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, + ) + } + + private data class ModifiedCurrencyList( + val added: List, + val removed: List, + val total: List, + ) +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt index d3962fbd50..4b0f60f75a 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/Account.kt @@ -57,13 +57,17 @@ sealed interface Account { val networksCount: Int get() = cryptoCurrencies.map(CryptoCurrency::network).distinct().size - fun copy(accountName: AccountName = this.accountName, icon: CryptoPortfolioIcon = this.icon): CryptoPortfolio { + fun copy( + accountName: AccountName = this.accountName, + icon: CryptoPortfolioIcon = this.icon, + cryptoCurrencies: Set = this.cryptoCurrencies, + ): CryptoPortfolio { return CryptoPortfolio( accountId = this.accountId, accountName = accountName, icon = icon, derivationIndex = this.derivationIndex, - cryptoCurrencies = this.cryptoCurrencies, + cryptoCurrencies = cryptoCurrencies, ) } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt index 6dd77dfdc2..826fb686d5 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt @@ -40,26 +40,7 @@ interface CurrenciesRepository { * @param userWalletId The unique identifier of the user wallet. * @param currencies The list of cryptocurrencies to be saved. */ - suspend fun saveNewCurrenciesList(userWalletId: UserWalletId, currencies: List) - - /** - * Add currencies to a specific user wallet. - * - * @param userWalletId The unique identifier of the user wallet. - * @param currencies The currencies which must be added. - * @throws DataError.UserWalletError.WrongUserWallet If single-currency user wallet - * ID provided. - */ - suspend fun addCurrencies(userWalletId: UserWalletId, currencies: List): List - - /** - * Saves the given list of cryptocurrencies for a specific multi-currency user wallet. - * - * @param userWalletId The unique identifier of the user wallet. - * @param currencies The list of cryptocurrencies to be saved. - */ - @Deprecated("Tech debt") - suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List) + suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List) /** * Add currencies to a specific user wallet. @@ -256,6 +237,8 @@ interface CurrenciesRepository { */ suspend fun getFeePaidCurrency(userWalletId: UserWalletId, network: Network): FeePaidCurrency + fun createCoinCurrency(network: Network): CryptoCurrency.Coin + /** * Creates token [cryptoCurrency] based on current token and [network] it`s will be added */ diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt index 8a0f5fa136..c4459c749e 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt @@ -46,14 +46,7 @@ internal class MockCurrenciesRepository( isTokensSortedByBalanceAfterSortingApply = isSortedByBalance } - override suspend fun saveNewCurrenciesList(userWalletId: UserWalletId, currencies: List) = Unit - - override suspend fun addCurrencies( - userWalletId: UserWalletId, - currencies: List, - ): List = emptyList() - - override suspend fun saveNewCurrenciesListCache(userWalletId: UserWalletId, currencies: List) = Unit + override suspend fun saveCurrenciesLocal(userWalletId: UserWalletId, currencies: List) = Unit override suspend fun addCurrenciesCache( userWalletId: UserWalletId, @@ -152,6 +145,10 @@ internal class MockCurrenciesRepository( return FeePaidCurrency.Coin } + override fun createCoinCurrency(network: Network): CryptoCurrency.Coin { + error("not implemented") + } + override fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token { return cryptoCurrency } From 25eddc377132a83c9f850a5e31f26939509c95ec Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Oct 2025 16:08:12 +0500 Subject: [PATCH 37/46] Updated on 2026-08-14 --- .../features/home/impl/model/HomeModel.kt | 13 +-- .../com/tangem/features/home/impl/ui/Home.kt | 4 +- .../home/impl/ui/compose/StoriesScreenV2.kt | 21 +---- .../impl/ui/compose/views/HomeButtonsV2.kt | 90 ++----------------- .../features/home/impl/ui/state/HomeUM.kt | 3 +- 5 files changed, 18 insertions(+), 113 deletions(-) diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index f954397c48..1dd5728ec4 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -86,8 +86,7 @@ internal class HomeModel @Inject constructor( onScanClick = ::onScanClick, onShopClick = ::onShopClick, onSearchTokensClick = ::onSearchTokensClick, - onCreateNewWalletClick = ::onCreateNewWalletClick, - onAddExistingWalletClick = ::onAddExistingWalletClick, + onGetStartedClick = ::onGetStartedClick, ), ) @@ -145,12 +144,8 @@ internal class HomeModel @Inject constructor( router.push(AppRoute.ManageTokens(Source.STORIES)) } - private fun onCreateNewWalletClick() { - router.push(AppRoute.CreateWalletSelection) - } - - private fun onAddExistingWalletClick() { - router.push(AppRoute.AddExistingWallet) + private fun onGetStartedClick() { + router.push(AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.ColdWallet)) } private fun scanCard() { @@ -240,7 +235,7 @@ internal class HomeModel @Inject constructor( _uiState.update { it.copy(scanInProgress = isLoading) } } - fun handleScanError(error: TangemError) { + private fun handleScanError(error: TangemError) { when (error) { is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable() is TangemSdkError -> Timber.e(error, "Scan error occurred") diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt index 24025e1627..7277504058 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt @@ -17,9 +17,7 @@ internal fun Home(state: HomeUM, isV2StoriesEnabled: Boolean, modifier: Modifier StoriesScreenV2( modifier = modifier, state = state, - onCreateNewWalletButtonClick = state.onCreateNewWalletClick, - onAddExistingWalletButtonClick = state.onAddExistingWalletClick, - onScanButtonClick = state.onScanClick, + onGetStartedClick = state.onGetStartedClick, ) } else { StoriesScreen( diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt index 0554472199..3f790b2e79 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt @@ -29,13 +29,7 @@ import com.tangem.core.ui.R import com.tangem.features.home.impl.ui.state.HomeUM @Composable -internal fun StoriesScreenV2( - state: HomeUM, - onCreateNewWalletButtonClick: () -> Unit, - onAddExistingWalletButtonClick: () -> Unit, - onScanButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { +internal fun StoriesScreenV2(state: HomeUM, onGetStartedClick: () -> Unit, modifier: Modifier = Modifier) { var currentStory by remember { mutableStateOf(state.firstStory) } val currentStoryIndex by rememberUpdatedState(newValue = state.stepOf(currentStory)) @@ -64,9 +58,7 @@ internal fun StoriesScreenV2( isScanInProgress = state.scanInProgress, onGoToPreviousStory = goToPreviousStory, onGoToNextStory = goToNextStory, - onCreateNewWalletButtonClick = onCreateNewWalletButtonClick, - onAddExistingWalletButtonClick = onAddExistingWalletButtonClick, - onScanButtonClick = onScanButtonClick, + onGetStartedClick = onGetStartedClick, ), ) } @@ -176,10 +168,7 @@ private fun StoriesScreenContentV2(config: StoriesScreenContentV2Config, modifie ) { HomeButtonsV2( modifier = Modifier.fillMaxWidth(), - btnScanStateInProgress = config.isScanInProgress, - onScanButtonClick = config.onScanButtonClick, - onCreateNewWalletButtonClick = config.onCreateNewWalletButtonClick, - onAddExistingWalletButtonClick = config.onAddExistingWalletButtonClick, + onGetStartedClick = config.onGetStartedClick, ) } } @@ -192,9 +181,7 @@ private data class StoriesScreenContentV2Config( val isScanInProgress: Boolean, val onGoToPreviousStory: () -> Unit = {}, val onGoToNextStory: () -> Unit = {}, - val onCreateNewWalletButtonClick: () -> Unit = {}, - val onAddExistingWalletButtonClick: () -> Unit = {}, - val onScanButtonClick: () -> Unit = {}, + val onGetStartedClick: () -> Unit = {}, ) // region Preview diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt index c68825891d..cd2c7ae621 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt @@ -9,116 +9,42 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag 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 com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition 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.StoriesScreenTestTags import com.tangem.core.ui.R @Composable -internal fun HomeButtonsV2( - btnScanStateInProgress: Boolean, - onScanButtonClick: () -> Unit, - onCreateNewWalletButtonClick: () -> Unit, - onAddExistingWalletButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { +internal fun HomeButtonsV2(onGetStartedClick: () -> Unit, modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - CreateNewWalletButton( - modifier = Modifier - .fillMaxWidth() - .testTag(StoriesScreenTestTags.CREATE_NEW_WALLET_BUTTON), - onClick = onCreateNewWalletButtonClick, - ) - AddExistingWalletButton( - modifier = Modifier - .fillMaxWidth() - .testTag(StoriesScreenTestTags.ADD_EXISTING_WALLET_BUTTON), - onClick = onAddExistingWalletButtonClick, - ) - ScanCardButton( - modifier = Modifier - .fillMaxWidth() - .testTag(StoriesScreenTestTags.SCAN_BUTTON), - showProgress = btnScanStateInProgress, - onClick = onScanButtonClick, + StoriesButton( + modifier = modifier, + text = stringResourceSafe(id = R.string.common_get_started), + useDarkerColors = false, + onClick = onGetStartedClick, ) } } -@Composable -private fun CreateNewWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - StoriesButton( - modifier = modifier, - text = stringResourceSafe(id = R.string.home_button_create_new_wallet), - useDarkerColors = false, - onClick = onClick, - ) -} - -@Composable -private fun AddExistingWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - StoriesButton( - modifier = modifier, - text = stringResourceSafe(id = R.string.home_button_add_existing_wallet), - useDarkerColors = true, - onClick = onClick, - ) -} - -@Composable -private fun ScanCardButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - StoriesButton( - modifier = modifier, - text = stringResourceSafe(id = R.string.home_button_scan), - useDarkerColors = true, - icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24), - onClick = onClick, - showProgress = showProgress, - ) -} - // region Preview @Preview(showBackground = true, widthDp = 360) @Composable -private fun HomeButtonsV2Preview(@PreviewParameter(HomeButtonsV2ParameterProvider::class) state: HomeButtonsV2State) { +private fun HomeButtonsV2Preview() { TangemThemePreview { Box( modifier = Modifier.background(Color.Black), ) { HomeButtonsV2( - btnScanStateInProgress = state.btnScanStateInProgress, - onCreateNewWalletButtonClick = {}, - onAddExistingWalletButtonClick = {}, - onScanButtonClick = {}, + onGetStartedClick = {}, modifier = Modifier.padding(all = TangemTheme.dimens.spacing16), ) } } } - -private class HomeButtonsV2ParameterProvider : CollectionPreviewParameterProvider( - collection = listOf( - HomeButtonsV2State( - btnScanStateInProgress = false, - ), - HomeButtonsV2State( - btnScanStateInProgress = true, - ), - ), -) - -private data class HomeButtonsV2State( - val btnScanStateInProgress: Boolean, -) // endregion Preview \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt index d727df8990..e8f1e02f64 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt @@ -8,8 +8,7 @@ data class HomeUM( val onScanClick: () -> Unit, val onShopClick: () -> Unit, val onSearchTokensClick: () -> Unit, - val onCreateNewWalletClick: () -> Unit, - val onAddExistingWalletClick: () -> Unit, + val onGetStartedClick: () -> Unit, ) { val firstStory: Stories get() = stories[0] From 4ec75d6dcc27939fbdfe1db3000fd0dc98b86a57 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Oct 2025 17:06:23 +0300 Subject: [PATCH 38/46] Updated on 2026-08-14 --- .../com/tangem/scenarios/StakingScenarios.kt | 124 +++++++++++ .../com/tangem/screens/SendPageObject.kt | 36 ++- .../kotlin/com/tangem/tests/StakingTest.kt | 210 ++---------------- .../common/ui/amountScreen/ui/AmountBlock.kt | 8 +- .../ui/amountScreen/ui/AmountBlockV2.kt | 4 + .../amountScreen/ui/AmountFieldContainer.kt | 12 +- .../tangem/core/ui/test/SendScreenTestTags.kt | 8 +- 7 files changed, 170 insertions(+), 232 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/StakingScenarios.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/StakingScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/StakingScenarios.kt new file mode 100644 index 0000000000..f803796469 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/StakingScenarios.kt @@ -0,0 +1,124 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.screens.onSendScreen +import com.tangem.screens.onStakingConfirmScreen +import com.tangem.screens.onStakingDetailsScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.checkStakingDetailsScreen(withStaking: Boolean) { + step("Assert 'Title' is displayed") { + onStakingDetailsScreen { stakingTitle.assertIsDisplayed() } + } + step("Assert 'Annual percentage rate' is displayed") { + onStakingDetailsScreen { annualPercentageRate.assertIsDisplayed() } + } + step("Assert 'Available' block is displayed") { + onStakingDetailsScreen { availableBlock.assertIsDisplayed() } + } + step("Assert 'Unbonding Period' block is displayed") { + onStakingDetailsScreen { unbondingPeriodBlock.assertIsDisplayed() } + } + step("Assert 'Reward claiming' block is displayed") { + onStakingDetailsScreen { rewardClaimingBlock.assertIsDisplayed() } + } + step("Assert 'Reward schedule' block is displayed") { + onStakingDetailsScreen { rewardScheduleBlock.assertIsDisplayed() } + } + step("Assert 'ToS' text is displayed") { + onStakingDetailsScreen { toSText.assertIsDisplayed() } + } + if (withStaking) { + step("Assert 'Rewards block' is displayed") { + onStakingDetailsScreen { rewardsBlock.assertIsDisplayed() } + } + step("Assert 'Rewards block' title is displayed") { + onStakingDetailsScreen { rewardsBlockTitle.assertIsDisplayed() } + } + step("Assert 'Rewards block' text is displayed") { + onStakingDetailsScreen { rewardsBlockText.assertIsDisplayed() } + } + step("Assert 'Active staking block' is displayed") { + onStakingDetailsScreen { activeStakingBlock.assertIsDisplayed() } + } + step("Assert 'Your stakes' title is displayed") { + onStakingDetailsScreen { yourStakesTitle.assertIsDisplayed() } + } + step("Assert 'Stake more' button is displayed") { + onStakingDetailsScreen { stakeMoreButton.assertIsDisplayed() } + } + } else { + step("Assert banner image is displayed") { + onStakingDetailsScreen { bannerImage.assertIsDisplayed() } + } + step("Assert banner text is displayed") { + onStakingDetailsScreen { bannerText.assertIsDisplayed() } + } + step("Assert 'Stake' button is displayed") { + onStakingDetailsScreen { stakeButton.assertIsDisplayed() } + } + } + +} +fun BaseTestCase.checkStakingScreen(stakingAmount: String) { + step("Assert 'Staking' screen is displayed") { + onSendScreen { screenContainer.assertIsDisplayed() } + } + step("Assert top app bar 'Close' button is displayed") { + onSendScreen { closeButton.assertIsDisplayed() } + } + step("Assert 'Send' screen title is displayed") { + onSendScreen { title.assertIsDisplayed() } + } + step("Assert amount container title is displayed") { + onSendScreen { amountContainerTitle.assertIsDisplayed() } + } + step("Assert input text field is displayed") { + onSendScreen { amountInputTextField.assertIsDisplayed() } + } + step("Assert token name is displayed") { + onSendScreen { tokenName.assertIsDisplayed() } + } + step("Assert primary amount is displayed") { + onSendScreen { primaryAmount.assertIsDisplayed() } + } + step("Assert secondary amount is displayed") { + onSendScreen { secondaryAmount.assertIsDisplayed() } + } + step("Type '$stakingAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(stakingAmount) + } + } + step("Assert input text field has value: '$stakingAmount'") { + onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } + } + step("Assert 'Max' button is displayed") { + onSendScreen { maxButton.assertIsDisplayed() } + } + step("Assert 'Next' button is displayed") { + onSendScreen { nextButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkStakingConfirmScreen() { + step("Assert 'Staking confirm' screen title is displayed") { + onStakingConfirmScreen { title.assertIsDisplayed() } + } + step("Assert primary amount is displayed") { + onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } + } + step("Assert secondary amount is displayed") { + onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } + } + step("Assert 'Validator' block is displayed") { + onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } + } + step("Assert 'Network Fee' block is displayed") { + onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } + } + step("Assert 'Stake' button is displayed") { + onStakingConfirmScreen { stakeButton.assertIsDisplayed() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt index f988ad6de5..d12952956b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt @@ -10,7 +10,6 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString import com.tangem.features.send.v2.impl.R as SendR -import androidx.compose.ui.test.hasTestTag as withTestTag class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -19,6 +18,11 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(SendScreenTestTags.SCREEN_CONTAINER) } + val closeButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + val title: KNode = child { hasTestTag(TopAppBarTestTags.TITLE) useUnmergedTree = true @@ -29,13 +33,18 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } - val amountContainerText: KNode = child { - hasTestTag(SendScreenTestTags.AMOUNT_CONTAINER_TEXT) + val amountInputTextField: KNode = child { + hasTestTag(SendScreenTestTags.INPUT_TEXT_FIELD) useUnmergedTree = true } - val amountInputTextField: KNode = child { - hasTestTag(SendScreenTestTags.INPUT_TEXT_FIELD) + val tokenName: KNode = child { + hasTestTag(SendScreenTestTags.TOKEN_NAME) + useUnmergedTree = true + } + + val primaryAmount: KNode = child { + hasTestTag(SendScreenTestTags.PRIMARY_AMOUNT) useUnmergedTree = true } @@ -44,28 +53,11 @@ class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } - val currencyButton: KNode = child { - hasTestTag(SendScreenTestTags.CURRENCY_BUTTON) - hasAnyChild(withTestTag(SendScreenTestTags.CURRENCY_ICON)) - useUnmergedTree = true - } - - val fiatButton: KNode = child { - hasTestTag(SendScreenTestTags.CURRENCY_BUTTON) - hasAnyChild(withTestTag(SendScreenTestTags.FIAT_ICON)) - useUnmergedTree = true - } - val maxButton: KNode = child { hasTestTag(SendScreenTestTags.MAX_BUTTON) useUnmergedTree = true } - val previousButton: KNode = child { - hasTestTag(SendScreenTestTags.PREVIOUS_BUTTON) - useUnmergedTree = true - } - val nextButton: KNode = child { hasTestTag(BaseButtonTestTags.TEXT) hasText(getResourceString(SendR.string.common_next)) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt index 8bbb02ebab..29404214c2 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt @@ -7,9 +7,8 @@ import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.swipeVertical import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.* import com.tangem.screens.* -import com.tangem.scenarios.openMainScreen -import com.tangem.scenarios.synchronizeAddresses import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -117,116 +116,20 @@ class StakingTest : BaseTestCase() { step("Click on 'Staking block'") { onTokenDetailsScreen { stakingBlock.clickWithAssertion() } } - step("Assert 'Title' is displayed") { - onStakingDetailsScreen { stakingTitle.assertIsDisplayed() } - } - step("Assert 'Annual percentage rate' is displayed") { - onStakingDetailsScreen { annualPercentageRate.assertIsDisplayed() } - } - step("Assert 'Available' block is displayed") { - onStakingDetailsScreen { availableBlock.assertIsDisplayed() } - } - step("Assert 'Unbonding Period' block is displayed") { - onStakingDetailsScreen { unbondingPeriodBlock.assertIsDisplayed() } - } - step("Assert 'Reward claiming' block is displayed") { - onStakingDetailsScreen { rewardClaimingBlock.assertIsDisplayed() } - } - step("Assert 'Reward schedule' block is displayed") { - onStakingDetailsScreen { rewardScheduleBlock.assertIsDisplayed() } - } - step("Assert 'Rewards block' is displayed") { - onStakingDetailsScreen { rewardsBlock.assertIsDisplayed() } - } - step("Assert 'Rewards block' title is displayed") { - onStakingDetailsScreen { rewardsBlockTitle.assertIsDisplayed() } - } - step("Assert 'Rewards block' text is displayed") { - onStakingDetailsScreen { rewardsBlockText.assertIsDisplayed() } - } - step("Assert 'Active staking block' is displayed") { - onStakingDetailsScreen { activeStakingBlock.assertIsDisplayed() } - } - step("Assert 'Your stakes' title is displayed") { - onStakingDetailsScreen { yourStakesTitle.assertIsDisplayed() } - } - step("Assert 'ToS' text is displayed") { - onStakingDetailsScreen { toSText.assertIsDisplayed() } - } - step("Assert 'Stake more' button is displayed") { - onStakingDetailsScreen { stakeMoreButton.assertIsDisplayed() } + step("Check 'Staking details' screen") { + checkStakingDetailsScreen(withStaking = true) } step("Click 'Stake more' button") { onStakingDetailsScreen { stakeMoreButton.performClick() } } - step("Assert 'Send' screen is displayed") { - onSendScreen { screenContainer.assertIsDisplayed() } - } - step("Assert 'Send' screen title is displayed") { - onSendScreen { title.assertIsDisplayed() } - } - step("Assert amount container title is displayed") { - onSendScreen { amountContainerTitle.assertIsDisplayed() } - } - step("Assert amount container text is displayed") { - onSendScreen { amountContainerText.assertIsDisplayed() } - } - step("Assert input text field is displayed") { - onSendScreen { amountInputTextField.assertIsDisplayed() } - } - step("Assert secondary amount is displayed") { - onSendScreen { secondaryAmount.assertIsDisplayed() } - } - step("Type '$stakingAmount' in input text field") { - onSendScreen { - amountInputTextField.performClick() - amountInputTextField.performTextReplacement(stakingAmount) - } - } - step("Assert input text field has value: '$stakingAmount'") { - onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } - } - step("Assert currency button is displayed") { - onSendScreen { currencyButton.assertIsDisplayed() } - } - step("Assert fiat button is displayed") { - onSendScreen { fiatButton.assertIsDisplayed() } - } - step("Assert currency button is displayed") { - onSendScreen { currencyButton.assertIsDisplayed() } - } - step("Assert fiat button is displayed") { - onSendScreen { fiatButton.assertIsDisplayed() } - } - step("Assert 'Max' button is displayed") { - onSendScreen { maxButton.assertIsDisplayed() } - } - step("Assert previous button is displayed") { - onSendScreen { previousButton.assertIsDisplayed() } - } - step("Assert 'Next' button is displayed") { - onSendScreen { nextButton.assertIsDisplayed() } + step("Check 'Staking' screen") { + checkStakingScreen(stakingAmount) } step("Click on 'Next' button") { onSendScreen { nextButton.performClick() } } - step("Assert 'Send details' screen title is displayed") { - onStakingConfirmScreen { title.assertIsDisplayed() } - } - step("Assert primary amount is displayed") { - onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } - } - step("Assert secondary amount is displayed") { - onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } - } - step("Assert 'Validator' block is displayed") { - onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } - } - step("Assert 'Network Fee' block is displayed") { - onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } - } - step("Assert 'Stake' button is displayed") { - onStakingConfirmScreen { stakeButton.assertIsDisplayed() } + step("Check 'Staking confirm' screen") { + checkStakingConfirmScreen() } } } @@ -284,107 +187,20 @@ class StakingTest : BaseTestCase() { step("Click on 'Stake' button") { onTokenDetailsScreen { stakeButton.clickWithAssertion() } } - step("Assert 'Title' is displayed") { - onStakingDetailsScreen { stakingTitle.assertIsDisplayed() } - } - step("Assert banner image is displayed") { - onStakingDetailsScreen { bannerImage.assertIsDisplayed() } - } - step("Assert banner text is displayed") { - onStakingDetailsScreen { bannerText.assertIsDisplayed() } - } - step("Assert 'Annual percentage rate' is displayed") { - onStakingDetailsScreen { annualPercentageRate.assertIsDisplayed() } - } - step("Assert 'Available' block is displayed") { - onStakingDetailsScreen { availableBlock.assertIsDisplayed() } - } - step("Assert 'Unbonding Period' block is displayed") { - onStakingDetailsScreen { unbondingPeriodBlock.assertIsDisplayed() } - } - step("Assert 'Reward claiming' block is displayed") { - onStakingDetailsScreen { rewardClaimingBlock.assertIsDisplayed() } - } - step("Assert 'Reward schedule' block is displayed") { - onStakingDetailsScreen { rewardScheduleBlock.assertIsDisplayed() } - } - step("Assert 'ToS' text is displayed") { - onStakingDetailsScreen { toSText.assertIsDisplayed() } - } - step("Assert 'Stake' button is displayed") { - onStakingDetailsScreen { stakeButton.assertIsDisplayed() } + step("Check 'Staking details' screen") { + checkStakingDetailsScreen(withStaking = false) } step("Click 'Stake' button") { onStakingDetailsScreen { stakeButton.performClick() } } - step("Assert 'Send' screen is displayed") { - onSendScreen { screenContainer.assertIsDisplayed() } - } - step("Assert 'Send' screen title is displayed") { - onSendScreen { title.assertIsDisplayed() } - } - step("Assert amount container title is displayed") { - onSendScreen { amountContainerTitle.assertIsDisplayed() } - } - step("Assert amount container text is displayed") { - onSendScreen { amountContainerText.assertIsDisplayed() } - } - step("Assert input text field is displayed") { - onSendScreen { amountInputTextField.assertIsDisplayed() } - } - step("Assert secondary amount is displayed") { - onSendScreen { secondaryAmount.assertIsDisplayed() } - } - step("Type '$stakingAmount' in input text field") { - onSendScreen { - amountInputTextField.performClick() - amountInputTextField.performTextReplacement(stakingAmount) - } - } - step("Assert input text field has value: '$stakingAmount'") { - onSendScreen { amountInputTextField.assertTextContains(value = stakingAmount, substring = true) } - } - step("Assert currency button is displayed") { - onSendScreen { currencyButton.assertIsDisplayed() } - } - step("Assert fiat button is displayed") { - onSendScreen { fiatButton.assertIsDisplayed() } - } - step("Assert currency button is displayed") { - onSendScreen { currencyButton.assertIsDisplayed() } - } - step("Assert fiat button is displayed") { - onSendScreen { fiatButton.assertIsDisplayed() } - } - step("Assert 'Max' button is displayed") { - onSendScreen { maxButton.assertIsDisplayed() } - } - step("Assert previous button is displayed") { - onSendScreen { previousButton.assertIsDisplayed() } - } - step("Assert 'Next' button is displayed") { - onSendScreen { nextButton.assertIsDisplayed() } + step("Check 'Staking' screen") { + checkStakingScreen(stakingAmount) } step("Click on 'Next' button") { onSendScreen { nextButton.performClick() } } - step("Assert 'Send details' screen title is displayed") { - onStakingConfirmScreen { title.assertIsDisplayed() } - } - step("Assert primary amount is displayed") { - onStakingConfirmScreen { primaryAmount.assertIsDisplayed() } - } - step("Assert secondary amount is displayed") { - onStakingConfirmScreen { secondaryAmount.assertIsDisplayed() } - } - step("Assert 'Validator' block is displayed") { - onStakingConfirmScreen { validatorBlock.assertIsDisplayed() } - } - step("Assert 'Network Fee' block is displayed") { - onStakingConfirmScreen { networkFeeBlock.assertIsDisplayed() } - } - step("Assert 'Stake' button is displayed") { - onStakingConfirmScreen { stakeButton.assertIsDisplayed() } + step("Check 'Staking confirm' screen") { + checkStakingConfirmScreen() } } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt index 9ec184b76e..7cb45e5259 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -28,7 +27,6 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.BaseAmountBlockTestTags @Composable fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDisabled: Boolean, onClick: () -> Unit) { @@ -80,8 +78,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis maxLines = 1, modifier = Modifier .fillMaxWidth() - .padding(top = TangemTheme.dimens.spacing24) - .testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT), + .padding(top = TangemTheme.dimens.spacing24), ) Text( text = secondAmount, @@ -90,8 +87,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() - .padding(top = TangemTheme.dimens.spacing8) - .testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT), + .padding(top = TangemTheme.dimens.spacing8), ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt index 374ea3289c..68eca36591 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -28,6 +29,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.uncapped import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.BaseAmountBlockTestTags @Composable fun AmountBlockV2( @@ -133,6 +135,7 @@ private fun AmountBlockV2( style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, maxLines = 1, + modifier = Modifier.testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT), ) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -142,6 +145,7 @@ private fun AmountBlockV2( style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, maxLines = 1, + modifier = Modifier.testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT), ) extraContent() } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt index d5973e72b8..ec354c5ff4 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState @@ -26,6 +27,7 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.SendScreenTestTags private const val AMOUNT_FIELD_KEY = "amountFieldKey" @@ -60,6 +62,7 @@ internal fun LazyListScope.amountFieldV2( text = amountState.title.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, + modifier = Modifier.testTag(SendScreenTestTags.AMOUNT_CONTAINER_TITLE), ) } AmountFieldV2( @@ -117,7 +120,8 @@ private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modi indication = ripple(), onClick = onMaxAmountClick, ) - .padding(horizontal = 12.dp, vertical = 4.dp), + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag(SendScreenTestTags.MAX_BUTTON), ) } } @@ -139,6 +143,7 @@ private fun AmountInfoMain(amountUM: AmountState, modifier: Modifier = Modifier) style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, maxLines = 1, + modifier = Modifier.testTag(SendScreenTestTags.TOKEN_NAME), ) Row { EllipsisText( @@ -148,7 +153,9 @@ private fun AmountInfoMain(amountUM: AmountState, modifier: Modifier = Modifier) ellipsis = TextEllipsis.OffsetEnd( amountUM.amountTextField.cryptoAmount.currencySymbol.length, ), - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier + .weight(1f, fill = false) + .testTag(SendScreenTestTags.PRIMARY_AMOUNT), ) EllipsisText( text = amountUM.availableBalanceFiat.resolveReference(), @@ -157,6 +164,7 @@ private fun AmountInfoMain(amountUM: AmountState, modifier: Modifier = Modifier) ellipsis = TextEllipsis.OffsetEnd( amountUM.amountTextField.fiatAmount.currencySymbol.length, ), + modifier = Modifier.testTag(SendScreenTestTags.SECONDARY_AMOUNT), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt index bc025954ac..fe8785cc8c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/SendScreenTestTags.kt @@ -4,13 +4,11 @@ object SendScreenTestTags { const val SCREEN_CONTAINER = "SEND_SCREEN_CONTAINER" const val AMOUNT_CONTAINER_TITLE = "SEND_SCREEN_AMOUNT_CONTAINER_TITLE" - const val AMOUNT_CONTAINER_TEXT = "SEND_SCREEN_AMOUNT_CONTAINER_TEXT" const val INPUT_TEXT_FIELD = "SEND_SCREEN_INPUT_TEXT_FIELD" + const val TOKEN_NAME = "SEND_SCREEN_TOKEN_NAME" + const val PRIMARY_AMOUNT = "SEND_SCREEN_PRIMARY_AMOUNT" const val SECONDARY_AMOUNT = "SEND_SCREEN_SECONDARY_AMOUNT" - const val CURRENCY_BUTTON = "SEND_SCREEN_CURRENCY_BUTTON" - const val FIAT_ICON = "SEND_SCREEN_FIAT_ICON" - const val CURRENCY_ICON = "SEND_SCREEN_CURRENCY_ICON" - const val MAX_BUTTON = "END_SCREEN_MAX_BUTTON" + const val MAX_BUTTON = "SEND_SCREEN_MAX_BUTTON" const val PREVIOUS_BUTTON = "SEND_SCREEN_PREVIOUS_BUTTON" } \ No newline at end of file From 926b74d491191b200960362bffa9bcb3e119f159 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Oct 2025 18:59:02 +0400 Subject: [PATCH 39/46] Updated on 2026-08-14 --- .../features/tangempay/ui/TandemPayOnboardingScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt index 7240b154e5..8f2605df2e 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TandemPayOnboardingScreen.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -11,6 +12,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.WindowInsetsZero @Composable internal fun TandemPayOnboardingScreen( @@ -20,7 +22,7 @@ internal fun TandemPayOnboardingScreen( modifier: Modifier = Modifier, ) { Scaffold( - modifier = modifier, + modifier = modifier.systemBarsPadding(), topBar = { AppBarWithBackButton( modifier = Modifier.statusBarsPadding(), @@ -28,6 +30,7 @@ internal fun TandemPayOnboardingScreen( iconRes = R.drawable.ic_back_24, ) }, + contentWindowInsets = WindowInsetsZero, content = { paddingValues -> TangemPayOnboardingContent( modifier = Modifier From a9333c10a78052419ea2090d630612011cc4d312 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Oct 2025 15:53:45 +0400 Subject: [PATCH 40/46] Updated on 2026-08-14 --- .../GetAccountCurrencyStatusUseCase.kt | 55 +++- .../GetAccountCurrencyStatusUseCaseTest.kt | 300 +++++++++++++----- 2 files changed, 263 insertions(+), 92 deletions(-) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt index 1846999f3d..51abc977a6 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCase.kt @@ -16,6 +16,8 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.lib.crypto.derivation.AccountNodeRecognizer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull /** * Use case to retrieve the status of a specific cryptocurrency associated with an account. @@ -35,11 +37,8 @@ class GetAccountCurrencyStatusUseCase( * @param currency the cryptocurrency for which the status is to be retrieved. * @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None. */ - suspend operator fun invoke( - userWalletId: UserWalletId, - currency: CryptoCurrency, - ): Option { - return invoke(userWalletId = userWalletId, currencyId = currency.id, network = currency.network) + suspend fun invokeSync(userWalletId: UserWalletId, currency: CryptoCurrency): Option { + return invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = currency.network) } /** @@ -51,7 +50,7 @@ class GetAccountCurrencyStatusUseCase( * @param network the network associated with the cryptocurrency, can be null. * @return an [Option] containing [AccountCryptoCurrencyStatus] if found, otherwise None. */ - suspend operator fun invoke( + suspend fun invokeSync( userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, network: Network?, @@ -60,7 +59,48 @@ class GetAccountCurrencyStatusUseCase( params = SingleAccountStatusListProducer.Params(userWalletId), ) ?: return none() - return accountStatusList.getExpectedAccountStatuses(network) + return accountStatusList + .toAccountCryptoCurrencyStatus(currencyId, network) + .toOption() + } + + /** + * Retrieves the status of a specific cryptocurrency for a given user wallet as a [Flow]. + * + * @param userWalletId The ID of the user wallet. + * @param currency The cryptocurrency for which the status is to be retrieved. + * @return A [Flow] emitting [AccountCryptoCurrencyStatus] if found. + */ + operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Flow { + return invoke(userWalletId = userWalletId, currencyId = currency.id, network = currency.network) + } + + /** + * Retrieves the status of a specific cryptocurrency by its ID for a given user wallet and network as a [Flow]. + * + * @param userWalletId The ID of the user wallet. + * @param currencyId The ID of the cryptocurrency. + * @param network The network associated with the cryptocurrency, can be null. + * @return A [Flow] emitting [AccountCryptoCurrencyStatus] if found. + */ + operator fun invoke( + userWalletId: UserWalletId, + currencyId: CryptoCurrency.ID, + network: Network?, + ): Flow { + return singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId), + ) + .mapNotNull { accountStatusList -> + accountStatusList.toAccountCryptoCurrencyStatus(currencyId, network) + } + } + + private fun AccountStatusList.toAccountCryptoCurrencyStatus( + currencyId: CryptoCurrency.ID, + network: Network?, + ): AccountCryptoCurrencyStatus? { + return getExpectedAccountStatuses(network) .asSequence() .filterIsInstance() .mapNotNull { accountStatus -> @@ -70,7 +110,6 @@ class GetAccountCurrencyStatusUseCase( AccountCryptoCurrencyStatus(account = accountStatus.account, status = status) } .firstOrNull() - .toOption() } /** diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt index b8614cc704..7adf3a3264 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt @@ -1,8 +1,10 @@ package com.tangem.domain.account.status.usecase +import com.google.common.truth.Truth import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.utils.assertNone import com.tangem.common.test.utils.assertSome +import com.tangem.common.test.utils.getEmittedValues import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer @@ -18,8 +20,11 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWalletId import io.mockk.* +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -47,111 +52,238 @@ class GetAccountCurrencyStatusUseCaseTest { clearMocks(supplier) } - @Test - fun `invoke returns None when supplier returns null`() = runTest { - // Arrange - coEvery { supplier.getSyncOrNull(supplierParams) } returns null + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class InvokeSync { - // Act - val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + @Test + fun `invokeSync returns None when supplier returns null`() = runTest { + // Arrange + coEvery { supplier.getSyncOrNull(supplierParams) } returns null - // Assert - assertNone(actual) - coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + // Act + val actual = useCase.invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = null) + + // Assert + assertNone(actual) + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + + @Test + fun `invokeSync returns None when AccountList does not contain required currency id`() = runTest { + // Arrange + val accountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } + + coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + + // Act + val actual = useCase.invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = null) + + // Assert + assertNone(actual) + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + + @Test + fun `invokeSync returns Some if network is not null`() = runTest { + // Arrange + val mainAccountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) + + val account = mockk(relaxed = true) { + every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!! + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk()) + } + + coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + + // Act + val actual = useCase.invokeSync( + userWalletId = userWalletId, + currencyId = currency.id, + network = currency.network, + ) + + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + assertSome(actual, expected) + + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } + + @Test + fun `invokeSync returns Some if network is null`() = runTest { + // Arrange + val account = mockk(relaxed = true) { + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) + + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } + + coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + + // Act + val actual = useCase.invokeSync(userWalletId = userWalletId, currencyId = currency.id, network = null) + + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + assertSome(actual, expected) + coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + } } - @Test - fun `invoke returns None when AccountList does not contain required currency id`() = runTest { - // Arrange - val accountStatus = AccountStatus.CryptoPortfolio( - account = Account.CryptoPortfolio.createMainAccount(userWalletId), - tokenList = TokenList.Empty, - priceChangeLce = lceLoading(), - ) + @Suppress("UnusedFlow") + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Invoke { - val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns setOf(accountStatus) + @Test + fun `invoke returns empty flow when supplier returns empty flow`() = runTest { + // Arrange + coEvery { supplier(supplierParams) } returns emptyFlow() + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + .let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() + coVerifyOrder { supplier(supplierParams) } } - coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + @Test + fun `invoke returns empty flow when AccountList does not contain required currency id`() = runTest { + // Arrange + val accountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) - // Act - val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } - // Assert - assertNone(actual) - coVerifyOrder { supplier.getSyncOrNull(supplierParams) } - } + coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) - @Test - fun `invoke returns Some if network is not null`() = runTest { - // Arrange - val mainAccountStatus = AccountStatus.CryptoPortfolio( - account = Account.CryptoPortfolio.createMainAccount(userWalletId), - tokenList = TokenList.Empty, - priceChangeLce = lceLoading(), - ) + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + .let(::getEmittedValues) - val account = mockk(relaxed = true) { - every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!! - every { this@mockk.cryptoCurrencies } returns setOf(currency) - } - val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) - val accountStatus = AccountStatus.CryptoPortfolio( - account = account, - tokenList = TokenList.Ungrouped( - totalFiatBalance = TotalFiatBalance.Loading, - sortedBy = TokensSortType.NONE, - currencies = listOf(currencyStatus), - ), - priceChangeLce = lceLoading(), - ) - - val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk()) + // Assert + Truth.assertThat(actual).isEmpty() + coVerifyOrder { supplier(supplierParams) } } - coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + @Test + fun `invoke returns data if network is not null`() = runTest { + // Arrange + val mainAccountStatus = AccountStatus.CryptoPortfolio( + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + tokenList = TokenList.Empty, + priceChangeLce = lceLoading(), + ) - // Act - val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = currency.network) + val account = mockk(relaxed = true) { + every { this@mockk.derivationIndex } returns DerivationIndex(1).getOrNull()!! + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) - // Assert - val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) - assertSome(actual, expected) + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(mainAccountStatus, accountStatus, mockk()) + } - coVerifyOrder { supplier.getSyncOrNull(supplierParams) } - } + coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) - @Test - fun `invoke returns Some if network is null`() = runTest { - // Arrange - val account = mockk(relaxed = true) { - every { this@mockk.cryptoCurrencies } returns setOf(currency) - } - val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) - val accountStatus = AccountStatus.CryptoPortfolio( - account = account, - tokenList = TokenList.Ungrouped( - totalFiatBalance = TotalFiatBalance.Loading, - sortedBy = TokensSortType.NONE, - currencies = listOf(currencyStatus), - ), - priceChangeLce = lceLoading(), - ) + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + .let(::getEmittedValues) - val accountStatusList = mockk(relaxed = true) { - every { this@mockk.accountStatuses } returns setOf(accountStatus) + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + Truth.assertThat(actual).containsExactly(expected) + + coVerifyOrder { supplier(supplierParams) } } - coEvery { supplier.getSyncOrNull(supplierParams) } returns accountStatusList + @Test + fun `invoke returns data if network is null`() = runTest { + // Arrange + val account = mockk(relaxed = true) { + every { this@mockk.cryptoCurrencies } returns setOf(currency) + } + val currencyStatus = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + val accountStatus = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = listOf(currencyStatus), + ), + priceChangeLce = lceLoading(), + ) - // Act - val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + val accountStatusList = mockk(relaxed = true) { + every { this@mockk.accountStatuses } returns setOf(accountStatus) + } - // Assert - val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) - assertSome(actual, expected) - coVerifyOrder { supplier.getSyncOrNull(supplierParams) } + coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) + + // Act + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + .let(::getEmittedValues) + + // Assert + val expected = AccountCryptoCurrencyStatus(account = accountStatus.account, status = currencyStatus) + Truth.assertThat(actual).containsExactly(expected) + coVerifyOrder { supplier(supplierParams) } + } } } \ No newline at end of file From d6236a09b3586107dd1024ae41d28108b6206745 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Oct 2025 10:40:00 +0200 Subject: [PATCH 41/46] Updated on 2026-08-14 --- .../moonpay/MoonpayBlockchainMapping.kt | 1 + .../core/ui/extensions/BlockchainIcons.kt | 3 +++ .../main/res/drawable/ic_arbitrum_nova_22.xml | 9 +++++++++ .../main/res/drawable/img_arbitrum_nova_22.xml | 18 ++++++++++++++++++ .../data/common/network/NetworkFactory.kt | 1 + .../onramp/legacy/MercuryoBlockchainMapping.kt | 1 + .../domain/card/configs/Wallet2CardConfig.kt | 1 + .../card/configs/Wallet2CardConfigTest.kt | 1 + gradle/tangem_dependencies.toml | 2 +- .../tangem/blockchainsdk/utils/Blockchain.kt | 3 +++ 10 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 core/ui/src/main/res/drawable/ic_arbitrum_nova_22.xml create mode 100644 core/ui/src/main/res/drawable/img_arbitrum_nova_22.xml diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt index 06a188ca28..642cdea9ac 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt @@ -161,4 +161,5 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency? Hyperliquid, HyperliquidTestnet -> null Quai, QuaiTestnet -> null Linea, LineaTestnet -> null + ArbitrumNova -> null } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index d08bd2f20b..329a2d7744 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt @@ -97,6 +97,7 @@ fun getActiveIconRes(blockchainId: String): Int { "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 "quai", "quai/test" -> R.drawable.img_quai_22 "linea", "linea/test" -> R.drawable.img_linea_22 + "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 else -> R.drawable.ic_alert_24 } } @@ -192,6 +193,7 @@ fun getActiveIconResByCoinId(coinId: String): Int { "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 "quai", "quai/test" -> R.drawable.img_quai_22 "linea", "linea/test" -> R.drawable.img_linea_22 + "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 else -> R.drawable.ic_alert_24 } } @@ -290,6 +292,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "hyperliquid", "hyperliquid/test" -> R.drawable.ic_hyperliquid_22 "quai", "quai/test" -> R.drawable.ic_quai_22 "linea", "linea/test" -> R.drawable.ic_linea_22 + "arbitrum-nova" -> R.drawable.ic_arbitrum_nova_22 else -> R.drawable.ic_alert_24 } } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_arbitrum_nova_22.xml b/core/ui/src/main/res/drawable/ic_arbitrum_nova_22.xml new file mode 100644 index 0000000000..6607ba52a3 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_arbitrum_nova_22.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/img_arbitrum_nova_22.xml b/core/ui/src/main/res/drawable/img_arbitrum_nova_22.xml new file mode 100644 index 0000000000..2ef6dbac42 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_arbitrum_nova_22.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt index 07463e771f..94fb83724a 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt @@ -327,6 +327,7 @@ class NetworkFactory @Inject constructor( Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet, Blockchain.Quai, Blockchain.QuaiTestnet, Blockchain.Linea, Blockchain.LineaTestnet, + Blockchain.ArbitrumNova, -> Network.TransactionExtrasType.NONE // endregion } diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt index 2a67f6acfe..a535b536db 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt @@ -161,5 +161,6 @@ public val Blockchain.mercuryoNetwork: String? Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> null Blockchain.Quai, Blockchain.QuaiTestnet -> null Blockchain.Linea, Blockchain.LineaTestnet -> null + Blockchain.ArbitrumNova -> null } } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt index 1d3fa41ced..aa67bf21fe 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt @@ -214,6 +214,7 @@ data object Wallet2CardConfig : CardConfig { Blockchain.QuaiTestnet -> EllipticCurve.Secp256k1 Blockchain.Linea -> EllipticCurve.Secp256k1 Blockchain.LineaTestnet -> EllipticCurve.Secp256k1 + Blockchain.ArbitrumNova -> EllipticCurve.Secp256k1 } } } \ No newline at end of file diff --git a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt index 2dd73a1cb4..d211d4c8c9 100644 --- a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt +++ b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt @@ -170,6 +170,7 @@ class Wallet2CardConfigTest { Blockchain.QuaiTestnet to EllipticCurve.Secp256k1, Blockchain.Linea to EllipticCurve.Secp256k1, Blockchain.LineaTestnet to EllipticCurve.Secp256k1, + Blockchain.ArbitrumNova to EllipticCurve.Secp256k1, ) @Test diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 7a730369ed..458409a602 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1263" +tangemBlockchainSdk = "develop-1267" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-564" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt index 381452eefa..229c84f88e 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt @@ -171,6 +171,7 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? { "quai-network/test" -> Blockchain.QuaiTestnet "linea" -> Blockchain.Linea "linea/test" -> Blockchain.LineaTestnet + "arbitrum-nova" -> Blockchain.ArbitrumNova else -> null } } @@ -339,6 +340,7 @@ fun Blockchain.toNetworkId(): String { Blockchain.QuaiTestnet -> "quai-network/test" Blockchain.Linea -> "linea" Blockchain.LineaTestnet -> "linea/test" + Blockchain.ArbitrumNova -> "arbitrum-nova" } } @@ -445,6 +447,7 @@ fun Blockchain.toCoinId(): String { Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> "hyperliquid" Blockchain.Quai, Blockchain.QuaiTestnet -> "quai-network" Blockchain.Linea, Blockchain.LineaTestnet -> "linea" + Blockchain.ArbitrumNova -> "arbitrum-nova" } } From 4bb7fde0411a4ec15beeae2aa791f37123ceb4d2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Oct 2025 13:42:38 +0500 Subject: [PATCH 42/46] Updated on 2026-08-14 --- .../transformer/SelectFromTokenTransformer.kt | 2 +- .../transformer/SelectToTokenTransformer.kt | 2 +- .../swap/entity/utils/ExchangeCardUMExt.kt | 4 +-- .../swap/model/SwapSelectTokensModel.kt | 33 +++++++++++-------- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt index 036c81e855..3ca1f8776b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt @@ -18,7 +18,7 @@ import com.tangem.features.onramp.swap.entity.utils.toFilled internal class SelectFromTokenTransformer( private val selectedTokenItemState: TokenItemState, private val onRemoveClick: () -> Unit, - private val account: Account.CryptoPortfolio, + private val account: Account.CryptoPortfolio?, private val isAccountsMode: Boolean, ) : SwapSelectTokensUMTransformer { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt index f8798e8f75..8699cd9e56 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt @@ -17,7 +17,7 @@ import com.tangem.features.onramp.swap.entity.utils.toFilled internal class SelectToTokenTransformer( private val selectedTokenItemState: TokenItemState, private val isAccountsMode: Boolean, - private val account: Account.CryptoPortfolio, + private val account: Account.CryptoPortfolio?, ) : SwapSelectTokensUMTransformer { override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt index 04615a8b92..757945de5a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt @@ -31,13 +31,13 @@ internal fun createEmptyExchangeTo(): ExchangeCardUM.Empty { */ internal fun ExchangeCardUM.toFilled( selectedTokenItemState: TokenItemState, - account: Account.CryptoPortfolio, + account: Account.CryptoPortfolio?, isAccountsMode: Boolean, isFromCurrency: Boolean, removeButtonUM: ExchangeCardUM.RemoveButtonUM? = null, ): ExchangeCardUM.Filled { return ExchangeCardUM.Filled( - titleUM = if (isAccountsMode) { + titleUM = if (account != null && isAccountsMode) { ExchangeCardUM.TitleUM.Account( prefixText = if (isFromCurrency) { resourceReference(R.string.common_from) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt index e0e2d8e657..7b3b212edc 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt @@ -8,6 +8,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.models.account.Account @@ -35,6 +36,7 @@ internal class SwapSelectTokensModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, ) : Model() { val state: StateFlow = controller.state @@ -47,6 +49,7 @@ internal class SwapSelectTokensModel @Inject constructor( private val params = paramsContainer.require() private var isAccountsMode: Boolean = false + private var account: Account.CryptoPortfolio? = null init { controller.update { it.copy(onBackClick = ::onBackClick) } @@ -68,17 +71,19 @@ internal class SwapSelectTokensModel @Inject constructor( _fromCurrencyStatus.value = status - controller.update( - transformer = SelectFromTokenTransformer( - selectedTokenItemState = selectedTokenItemState, - onRemoveClick = ::onRemoveFromTokenClick, - isAccountsMode = isAccountsMode, - account = Account.CryptoPortfolio.createMainAccount( - userWalletId = params.userWalletId, - cryptoCurrencies = setOf(status.currency), - ), // todo account from from cryptocurrency - ), - ) + modelScope.launch { + controller.update( + transformer = SelectFromTokenTransformer( + selectedTokenItemState = selectedTokenItemState, + onRemoveClick = ::onRemoveFromTokenClick, + isAccountsMode = isAccountsMode, + account = getAccountCurrencyStatusUseCase.invoke( + userWalletId = params.userWalletId, + currency = status.currency, + ).getOrNull()?.account, + ), + ) + } } /** @@ -99,10 +104,10 @@ internal class SwapSelectTokensModel @Inject constructor( transformer = SelectToTokenTransformer( selectedTokenItemState = selectedTokenItemState, isAccountsMode = isAccountsMode, - account = Account.CryptoPortfolio.createMainAccount( + account = getAccountCurrencyStatusUseCase.invoke( userWalletId = params.userWalletId, - cryptoCurrencies = setOf(status.currency), - ), // todo account from from cryptocurrency + currency = status.currency, + ).getOrNull()?.account, ), ) From 4341673767f6fbfad7aeab44d2bab08b5e87d4fd Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Oct 2025 13:43:06 +0500 Subject: [PATCH 43/46] Updated on 2026-08-14 --- .../AccountPortfolioItemUMConverter.kt | 1 - .../common/ui/userwallet/UserWalletItem.kt | 17 +- .../converter/UserWalletItemUMConverter.kt | 15 -- .../ui/userwallet/state/UserWalletItemUM.kt | 4 +- .../core/ui/components/block/BlockItem.kt | 14 +- .../core/ui/components/block/model/BlockUM.kt | 16 +- .../main/res/drawable/ic_add_wallet_16.xml | 10 + .../main/res/drawable/ic_import_seed_16.xml | 9 + .../main/res/drawable/ic_mobile_wallet_16.xml | 14 ++ .../selector/ui/PortfolioSelectorContent.kt | 2 - .../CreateWalletSelectionModel.kt | 187 ++++------------ .../entity/CreateWalletSelectionUM.kt | 25 ++- .../ui/CreateWalletSelectionContent.kt | 208 ++++++++++-------- .../details/entity/UserWalletListUM.kt | 2 - .../details/model/UserWalletListModel.kt | 63 +----- .../details/ui/UserWalletListBlock.kt | 15 -- .../preview/PreviewWalletSettingsComponent.kt | 1 - .../model/WalletSettingsModel.kt | 7 +- .../walletsettings/utils/ItemsBuilder.kt | 12 +- .../wallet/utils/DefaultUserWalletsFetcher.kt | 5 + 20 files changed, 276 insertions(+), 351 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_add_wallet_16.xml create mode 100644 core/ui/src/main/res/drawable/ic_import_seed_16.xml create mode 100644 core/ui/src/main/res/drawable/ic_mobile_wallet_16.xml diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt index 276468c38c..0f29f40095 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountPortfolioItemUMConverter.kt @@ -33,7 +33,6 @@ class AccountPortfolioItemUMConverter( endIcon = endIcon, onClick = { onClick(value.accountId) }, imageState = getImageState(value), - label = null, ) } } 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 fedba7d453..44b2fc91e9 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 @@ -36,9 +36,6 @@ import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.TangemBlockCardColors -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.text.applyBladeBrush import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme @@ -76,8 +73,6 @@ fun UserWalletItem( balance = state.balance, ) - state.label?.let { Label(it) } - when (state.endIcon) { UserWalletItemUM.EndIcon.None -> Unit UserWalletItemUM.EndIcon.Arrow -> { @@ -94,6 +89,13 @@ fun UserWalletItem( contentDescription = null, ) } + UserWalletItemUM.EndIcon.Warning -> { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_alert_circle_24), + tint = TangemTheme.colors.icon.warning, + contentDescription = null, + ) + } } } } @@ -316,10 +318,7 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider { 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 ba418cea6b..98c0988741 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 @@ -2,7 +2,6 @@ package com.tangem.common.ui.userwallet.state import com.tangem.common.ui.account.CryptoPortfolioIconUM import com.tangem.core.ui.components.artwork.ArtworkUM -import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.wallet.UserWalletId import javax.annotation.concurrent.Immutable @@ -17,12 +16,13 @@ data class UserWalletItemUM( val isEnabled: Boolean, val endIcon: EndIcon = EndIcon.None, val onClick: () -> Unit, - val label: LabelUM? = null, ) { + enum class EndIcon { None, Arrow, Checkmark, + Warning, } sealed class Balance { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockItem.kt index 8aab6c9598..66cf54df35 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockItem.kt @@ -51,7 +51,19 @@ fun BlockItem(model: BlockUM, modifier: Modifier = Modifier) { overflow = TextOverflow.Ellipsis, ) - model.label?.let { Label(it) } + when (val endContent = model.endContent) { + is BlockUM.EndContent.None -> Unit + is BlockUM.EndContent.Icon -> Icon( + painter = painterResource(id = endContent.resId), + contentDescription = null, + tint = when (endContent.accentType) { + BlockUM.AccentType.NONE -> TangemTheme.colors.text.primary1 + BlockUM.AccentType.ACCENT -> TangemTheme.colors.text.accent + BlockUM.AccentType.WARNING -> TangemTheme.colors.text.warning + }, + ) + is BlockUM.EndContent.Label -> Label(endContent.label) + } } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/model/BlockUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/model/BlockUM.kt index c950b5e4e7..4988f72eff 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/model/BlockUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/model/BlockUM.kt @@ -3,15 +3,29 @@ package com.tangem.core.ui.components.block.model import androidx.annotation.DrawableRes import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference +import javax.annotation.concurrent.Immutable data class BlockUM( val text: TextReference, @DrawableRes val iconRes: Int, val onClick: () -> Unit, val accentType: AccentType = AccentType.NONE, - val label: LabelUM? = null, + val endContent: EndContent = EndContent.None, ) { + @Immutable + sealed interface EndContent { + data object None : EndContent + data class Label( + val label: LabelUM, + ) : EndContent + + data class Icon( + val resId: Int, + val accentType: AccentType = AccentType.NONE, + ) : EndContent + } + enum class AccentType { NONE, ACCENT, WARNING, } diff --git a/core/ui/src/main/res/drawable/ic_add_wallet_16.xml b/core/ui/src/main/res/drawable/ic_add_wallet_16.xml new file mode 100644 index 0000000000..48e62db467 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_add_wallet_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_import_seed_16.xml b/core/ui/src/main/res/drawable/ic_import_seed_16.xml new file mode 100644 index 0000000000..6e96d53e29 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_import_seed_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_mobile_wallet_16.xml b/core/ui/src/main/res/drawable/ic_mobile_wallet_16.xml new file mode 100644 index 0000000000..e37da72eac --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_mobile_wallet_16.xml @@ -0,0 +1,14 @@ + + + + diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt index 2bf56e09c3..a1bc34cd68 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt @@ -125,7 +125,6 @@ internal object PortfolioSelectorPreviewData { name = accountName, icon = AccountIconPreviewData.randomAccountIcon(), ), - label = null, ) private val walletItem: UserWalletItemUM @@ -137,7 +136,6 @@ internal object PortfolioSelectorPreviewData { isEnabled = true, onClick = { }, imageState = ImageState.MobileWallet, - label = null, ) private val lockedWalletItem: UserWalletItemUM diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt index 1225bd8144..20b64f77ca 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/CreateWalletSelectionModel.kt @@ -1,72 +1,80 @@ package com.tangem.features.createwalletselection -import com.tangem.common.core.TangemError -import com.tangem.common.core.TangemSdkError import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.Basic.SignedIn -import com.tangem.core.analytics.models.Basic.SignedIn.SignInType -import com.tangem.core.decompose.di.GlobalUiMessageSender 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.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.R +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.message.DialogMessage -import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.analytics.IntroductionProcess -import com.tangem.domain.card.analytics.ParamCardCurrencyConverter import com.tangem.domain.card.analytics.Shop -import com.tangem.domain.card.common.util.cardTypesResolver -import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.common.wallets.error.SaveWalletError -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase -import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM +import com.tangem.features.createwalletselection.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject -private const val HIDE_PROGRESS_DELAY = 400L - @Suppress("LongParameterList") @ModelScoped internal class CreateWalletSelectionModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, - private val scanCardProcessor: ScanCardProcessor, - private val cardSdkConfigRepository: CardSdkConfigRepository, - private val settingsRepository: SettingsRepository, private val analyticsEventHandler: AnalyticsEventHandler, - private val appRouter: AppRouter, - private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, - private val saveWalletUseCase: SaveWalletUseCase, private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, - private val userWalletsListRepository: UserWalletsListRepository, - @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { internal val uiState: StateFlow field = MutableStateFlow( CreateWalletSelectionUM( onBackClick = { router.pop() }, - onMobileWalletClick = ::onMobileWalletClick, - onHardwareWalletClick = ::onHardwareWalletClick, - onScanClick = ::onScanClick, + blocks = persistentListOf( + CreateWalletSelectionUM.Block( + title = resourceReference(R.string.wallet_create_hardware_title), + titleLabel = LabelUM( + text = resourceReference(R.string.common_recommended), + style = LabelStyle.ACCENT, + ), + description = resourceReference(R.string.wallet_add_hardware_description), + features = persistentListOf( + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_add_wallet_16, + title = resourceReference(R.string.wallet_add_hardware_info_create), + ), + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_import_seed_16, + title = resourceReference(R.string.wallet_add_import_seed_phrase), + ), + ), + onClick = ::onHardwareWalletClick, + ), + CreateWalletSelectionUM.Block( + title = resourceReference(R.string.wallet_create_mobile_title), + titleLabel = null, + description = resourceReference(R.string.wallet_add_mobile_description), + features = persistentListOf( + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_mobile_wallet_16, + title = resourceReference(R.string.hw_create_title), + ), + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_import_seed_16, + title = resourceReference(R.string.wallet_add_import_seed_phrase), + ), + ), + onClick = ::onMobileWalletClick, + ), + ), + onBuyClick = ::onBuyClick, ), ) @@ -86,6 +94,10 @@ internal class CreateWalletSelectionModel @Inject constructor( } private fun onHardwareWalletClick() { + // TODO [REDACTED_TASK_KEY] + } + + private fun onBuyClick() { analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards) analyticsEventHandler.send(Shop.ScreenOpened) modelScope.launch { @@ -93,113 +105,6 @@ internal class CreateWalletSelectionModel @Inject constructor( } } - private fun onScanClick() { - analyticsEventHandler.send(IntroductionProcess.ButtonScanCard) - scanCard() - } - - private fun scanCard() { - modelScope.launch { - setLoading(true) - - val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes() - cardSdkConfigRepository.setAccessCodeRequestPolicy( - isBiometricsRequestPolicy = shouldSaveAccessCodes, - ) - - val analyticsSource = AnalyticsParam.ScreensSources.Intro - - scanCardProcessor.scan( - analyticsSource = analyticsSource, - onProgressStateChange = { showProgress -> - if (!showProgress) { - delay(HIDE_PROGRESS_DELAY) - setLoading(false) - } else { - setLoading(true) - } - }, - onFailure = { error -> - handleScanError(error) - delay(HIDE_PROGRESS_DELAY) - setLoading(false) - }, - onSuccess = { scanResponse -> - proceedWithScanResponse(scanResponse) - }, - ) - } - } - - private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) { - val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build() - - if (userWallet == null) { - Timber.e("User wallet not created") - setLoading(false) - return - } - - saveWalletUseCase(userWallet = userWallet).fold( - ifLeft = { - delay(HIDE_PROGRESS_DELAY) - setLoading(false) - when (it) { - is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet") - is SaveWalletError.WalletAlreadySaved -> { - userWalletsListRepository.unlock( - userWalletId = userWallet.walletId, - unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), - ).onRight { - appRouter.replaceAll(AppRoute.Wallet) - } - } - } - }, - ifRight = { - setLoading(false) - sendSignedInCardAnalyticsEvent(scanResponse) - appRouter.replaceAll(AppRoute.Wallet) - }, - ) - } - - private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { - val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) - if (currency != null) { - analyticsEventHandler.send( - SignedIn( - currency = currency, - batch = scanResponse.card.batchId, - signInType = SignInType.Card, - walletsCount = userWalletsListRepository.userWalletsSync().size.toString(), - hasBackup = scanResponse.card.backupStatus?.isActive, - ), - ) - } - } - - private fun setLoading(isLoading: Boolean) { - uiState.update { it.copy(isScanInProgress = isLoading) } - } - - fun handleScanError(error: TangemError) { - when (error) { - is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable() - is TangemSdkError -> Timber.e(error, "Scan error occurred") - else -> Timber.e(error, "Error happened") - } - } - - private fun handleNfcFeatureUnavailable() { - uiMessageSender.send( - message = DialogMessage( - message = resourceReference(R.string.nfc_error_unavailable), - title = resourceReference(id = R.string.common_error), - ), - ) - } - companion object { private const val SHOW_ALREADY_HAVE_WALLET_DELAY = 3000L } diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt index ef14600b7a..1d76d78bac 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/entity/CreateWalletSelectionUM.kt @@ -1,11 +1,26 @@ package com.tangem.features.createwalletselection.entity +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + internal data class CreateWalletSelectionUM( val isScanInProgress: Boolean = false, - val hardwareWalletPrice: String = "$54.90", val showAlreadyHaveWallet: Boolean = false, + val blocks: ImmutableList, val onBackClick: () -> Unit, - val onMobileWalletClick: () -> Unit, - val onHardwareWalletClick: () -> Unit, - val onScanClick: () -> Unit, -) \ No newline at end of file + val onBuyClick: () -> Unit, +) { + data class Block( + val title: TextReference, + val titleLabel: LabelUM?, + val description: TextReference, + val features: ImmutableList, + val onClick: () -> Unit, + ) + + data class Feature( + val iconResId: Int, + val title: TextReference, + ) +} \ No newline at end of file diff --git a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt index 1df0b5eff7..1a953f008d 100644 --- a/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt +++ b/features/create-wallet-selection/impl/src/main/kotlin/com/tangem/features/createwalletselection/ui/CreateWalletSelectionContent.kt @@ -10,23 +10,25 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.buttons.common.TangemButton -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.buttons.common.TangemButtonSize -import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults -import com.tangem.core.ui.extensions.conditional +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.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference 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.createwalletselection.entity.CreateWalletSelectionUM import com.tangem.features.createwalletselection.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @@ -34,7 +36,7 @@ import com.tangem.features.createwalletselection.impl.R internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifier: Modifier = Modifier) { Column( modifier = modifier - .background(TangemTheme.colors.background.primary) + .background(TangemTheme.colors.background.secondary) .fillMaxSize() .systemBarsPadding(), ) { @@ -42,7 +44,7 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi modifier = Modifier .statusBarsPadding(), colors = TopAppBarDefaults.topAppBarColors( - containerColor = TangemTheme.colors.background.primary, + containerColor = TangemTheme.colors.background.secondary, ), navigationIcon = { IconButton(onClick = state.onBackClick) { @@ -58,7 +60,7 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi Text( modifier = Modifier .padding(16.dp), - text = stringResourceSafe(R.string.wallet_create_nav_info_title), + text = stringResourceSafe(R.string.wallet_add_support_title), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, maxLines = 1, @@ -78,60 +80,33 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi Text( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - text = stringResourceSafe(R.string.wallet_create_title), + .padding( + start = 16.dp, + end = 16.dp, + bottom = 24.dp, + ), + text = stringResourceSafe(R.string.wallet_add_common_title), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, ) - WalletBlock( - modifier = Modifier - .padding(top = 24.dp), - title = stringResourceSafe(R.string.wallet_create_mobile_title), - description = stringResourceSafe(R.string.wallet_create_mobile_description), - badge = { - Box( - modifier = Modifier - .background( - color = TangemTheme.colors.field.focused, - shape = TangemTheme.shapes.roundedCorners8, - ) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text( - text = stringResourceSafe(R.string.common_free), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.secondary, - ) - } - }, - onClick = state.onMobileWalletClick, - ) - WalletBlock( - title = stringResourceSafe(R.string.wallet_create_hardware_title), - description = stringResourceSafe(R.string.wallet_create_hardware_description), - badge = { - Box( - modifier = Modifier - .background( - color = TangemTheme.colors.text.accent.copy(alpha = 0.1f), - shape = TangemTheme.shapes.roundedCorners8, - ) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text( - text = stringResourceSafe(R.string.wallet_create_hardware_badge, state.hardwareWalletPrice), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.accent, - ) - } - }, - onClick = state.onHardwareWalletClick, - ) + state.blocks.forEach { block -> + WalletBlock( + modifier = Modifier + .padding(top = 8.dp), + title = block.title.resolveReference(), + description = block.description.resolveReference(), + features = block.features, + badge = block.titleLabel?.let { + { Label(it) } + }, + onClick = block.onClick, + ) + } } AnimatedVisibility(state.showAlreadyHaveWallet) { AlreadyHaveTangemWalletBlock( - onScanClick = state.onScanClick, + onBuyClick = state.onBuyClick, isScanInProgress = state.isScanInProgress, ) } @@ -143,8 +118,9 @@ private fun WalletBlock( title: String, description: String, onClick: () -> Unit, + features: ImmutableList, modifier: Modifier = Modifier, - badge: @Composable () -> Unit, + badge: @Composable (() -> Unit)? = null, ) { Column( modifier = modifier @@ -152,11 +128,14 @@ private fun WalletBlock( .padding(top = 8.dp) .clip(TangemTheme.shapes.roundedCornersXMedium) .background( - color = TangemTheme.colors.field.primary, + color = TangemTheme.colors.background.primary, shape = TangemTheme.shapes.roundedCornersXMedium, ) .clickable(onClick = onClick) - .padding(16.dp), + .padding( + horizontal = 16.dp, + vertical = 12.dp, + ), ) { Row { Text( @@ -167,7 +146,7 @@ private fun WalletBlock( style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, ) - badge() + badge?.invoke() } Text( modifier = Modifier @@ -176,24 +155,57 @@ private fun WalletBlock( style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, ) + if (features.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(top = 12.dp), + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + ) + features.forEach { + Feature( + feature = it, + modifier = Modifier + .padding(top = 12.dp), + ) + } + } + } +} + +@Composable +private fun Feature(feature: CreateWalletSelectionUM.Feature, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size16), + painter = painterResource(id = feature.iconResId), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier + .weight(1f, fill = false) + .padding(start = 6.dp), + text = feature.title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) } } @Composable private fun AlreadyHaveTangemWalletBlock( - onScanClick: () -> Unit, + onBuyClick: () -> Unit, isScanInProgress: Boolean, modifier: Modifier = Modifier, ) { - var buttonWidth by remember { mutableStateOf(0) } - val density = LocalDensity.current - Row( modifier = modifier .fillMaxWidth() .padding(16.dp) .background( - color = TangemTheme.colors.field.primary, + color = TangemTheme.colors.background.primary, shape = TangemTheme.shapes.roundedCornersXMedium, ) .padding( @@ -206,30 +218,16 @@ private fun AlreadyHaveTangemWalletBlock( modifier = Modifier .weight(1f) .padding(end = 16.dp), - text = stringResourceSafe(R.string.wallet_create_scan_question), + text = stringResourceSafe(R.string.wallet_add_hardware_purchase), style = TangemTheme.typography.button, color = TangemTheme.colors.text.primary1, ) - TangemButton( - modifier = Modifier - .conditional(buttonWidth > 0) { - width(with(density) { buttonWidth.toDp() }) - } - .onGloballyPositioned { coordinates -> - if (buttonWidth == 0) { - buttonWidth = coordinates.size.width - } - }, - text = stringResourceSafe(R.string.wallet_create_scan_title), - onClick = onScanClick, - icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24), + SecondaryButton( + text = stringResourceSafe(R.string.wallet_import_buy_title), + onClick = onBuyClick, size = TangemButtonSize.RoundedAction, showProgress = isScanInProgress, - colors = TangemButtonsDefaults.secondaryButtonColors, - textStyle = TangemTheme.typography.subtitle1, - enabled = true, - animateContentChange = true, ) } } @@ -241,11 +239,45 @@ private fun PreviewCreateWalletContent() { TangemThemePreview { CreateWalletSelectionContent( state = CreateWalletSelectionUM( - showAlreadyHaveWallet = true, - onBackClick = {}, - onMobileWalletClick = {}, - onHardwareWalletClick = {}, - onScanClick = {}, + onBackClick = { }, + blocks = persistentListOf( + CreateWalletSelectionUM.Block( + title = resourceReference(R.string.wallet_create_hardware_title), + titleLabel = LabelUM( + text = resourceReference(R.string.common_recommended), + style = LabelStyle.ACCENT, + ), + description = resourceReference(R.string.wallet_add_hardware_description), + features = persistentListOf( + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_add_wallet_16, + title = resourceReference(R.string.wallet_add_hardware_info_create), + ), + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_import_seed_16, + title = resourceReference(R.string.wallet_add_import_seed_phrase), + ), + ), + onClick = { }, + ), + CreateWalletSelectionUM.Block( + title = resourceReference(R.string.wallet_create_mobile_title), + titleLabel = null, + description = resourceReference(R.string.wallet_add_mobile_description), + features = persistentListOf( + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_mobile_wallet_16, + title = resourceReference(R.string.hw_create_title), + ), + CreateWalletSelectionUM.Feature( + iconResId = R.drawable.ic_import_seed_16, + title = resourceReference(R.string.wallet_add_import_seed_phrase), + ), + ), + onClick = { }, + ), + ), + onBuyClick = { }, ), ) } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/UserWalletListUM.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/UserWalletListUM.kt index f217f837ce..a8ef5eb141 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/UserWalletListUM.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/UserWalletListUM.kt @@ -2,7 +2,6 @@ package com.tangem.features.details.entity import androidx.compose.runtime.Immutable import com.tangem.common.ui.userwallet.state.UserWalletItemUM -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -12,5 +11,4 @@ internal data class UserWalletListUM( val isWalletSavingInProgress: Boolean, val addNewWalletText: TextReference, val onAddNewWalletClick: () -> Unit, - val addWalletBottomSheet: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty, ) \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index 9ced337ddf..a096f5231d 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -6,14 +6,8 @@ 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.decompose.ui.UiMessageSender -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.R.* -import com.tangem.core.ui.components.bottomsheets.BottomSheetOption -import com.tangem.core.ui.components.bottomsheets.OptionsBottomSheetContent -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase import com.tangem.features.details.entity.UserWalletListUM import com.tangem.features.details.impl.R @@ -27,7 +21,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import javax.inject.Inject @Suppress("LongParameterList") @@ -38,8 +31,6 @@ internal class UserWalletListModel @Inject constructor( private val router: Router, private val messageSender: UiMessageSender, override val dispatchers: CoroutineDispatcherProvider, - private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, - private val urlOpener: UrlOpener, private val userWalletSaver: UserWalletSaver, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { @@ -58,7 +49,6 @@ internal class UserWalletListModel @Inject constructor( isWalletSavingInProgress = false, addNewWalletText = TextReference.EMPTY, onAddNewWalletClick = ::onAddNewWalletClick, - addWalletBottomSheet = TangemBottomSheetConfig.Empty, ), ) @@ -90,62 +80,11 @@ internal class UserWalletListModel @Inject constructor( private fun onAddNewWalletClick() { if (hotWalletFeatureToggles.isHotWalletEnabled) { - state.update { currentState -> - currentState.copy( - addWalletBottomSheet = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = ::dismissAddWalletBottomSheet, - content = createAddWalletBottomSheetContent(), - ), - ) - } + router.push(AppRoute.CreateWalletSelection) } else { withProgress(isWalletSavingInProgress) { userWalletSaver.scanAndSaveUserWallet(modelScope) } } } - - private fun dismissAddWalletBottomSheet() { - state.update { currentState -> - currentState.copy( - addWalletBottomSheet = currentState.addWalletBottomSheet.copy(isShown = false), - ) - } - } - - private fun createAddWalletBottomSheetContent(): OptionsBottomSheetContent { - return OptionsBottomSheetContent( - options = persistentListOf( - BottomSheetOption( - key = ADD_WALLET_KEY_CREATE, - label = resourceReference(string.home_button_create_new_wallet), - ), - BottomSheetOption( - key = ADD_WALLET_KEY_ADD, - label = resourceReference(string.home_button_add_existing_wallet), - ), - BottomSheetOption( - key = ADD_WALLET_KEY_BUY, - label = resourceReference(string.details_buy_wallet), - ), - ), - onOptionClick = { optionKey -> - dismissAddWalletBottomSheet() - when (optionKey) { - ADD_WALLET_KEY_CREATE -> router.push(AppRoute.CreateWalletSelection) - ADD_WALLET_KEY_ADD -> router.push(AppRoute.AddExistingWallet) - ADD_WALLET_KEY_BUY -> modelScope.launch { - generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) } - } - } - }, - ) - } - - companion object { - private const val ADD_WALLET_KEY_CREATE = "create" - private const val ADD_WALLET_KEY_ADD = "add" - private const val ADD_WALLET_KEY_BUY = "buy" - } } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt index bea3f8c35d..ec27900868 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt @@ -15,13 +15,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.common.ui.userwallet.UserWalletItem -import com.tangem.core.ui.R.* import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.bottomsheets.OptionsBottomSheet -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.details.component.UserWalletListComponent @@ -48,8 +44,6 @@ internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = M onClick = state.onAddNewWalletClick, ) } - - AddWalletBottomSheet(state.addWalletBottomSheet) } @Composable @@ -100,15 +94,6 @@ private fun AddWalletButton( } } -@Composable -private fun AddWalletBottomSheet(config: TangemBottomSheetConfig) { - OptionsBottomSheet( - config = config, - title = resourceReference(string.auth_info_add_wallet_title), - containerColor = TangemTheme.colors.background.tertiary, - ) -} - // region Preview @Composable @Preview(showBackground = true, widthDp = 360) 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 d359b824c4..53d1a92331 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 @@ -39,7 +39,6 @@ internal class PreviewWalletSettingsComponent : WalletSettingsComponent { name = accountName, icon = AccountIconPreviewData.randomAccountIcon(), ), - label = null, ) private val previewState = WalletSettingsUM( 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 d0674d5ed0..5453245f07 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 @@ -197,7 +197,12 @@ internal class WalletSettingsModel @Inject constructor( onCheckedNFTChange = ::onCheckedNFTChange, forgetWallet = { val message = DialogMessage( - message = resourceReference(R.string.user_wallet_list_delete_prompt), + message = resourceReference( + id = when (userWallet) { + is UserWallet.Cold -> R.string.user_wallet_list_delete_prompt + is UserWallet.Hot -> R.string.user_wallet_list_delete_hw_prompt + }, + ), firstActionBuilder = { EventMessageAction( title = resourceReference(R.string.common_delete), 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 e3caaf89d5..8375ca44c6 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 @@ -191,12 +191,14 @@ internal class ItemsBuilder @Inject constructor( text = resourceReference(R.string.common_backup), iconRes = R.drawable.ic_more_cards_24, onClick = { router.push(AppRoute.WalletBackup(userWalletId)) }, - label = if (hasBackup) { - null + endContent = if (hasBackup) { + BlockUM.EndContent.None } else { - LabelUM( - text = resourceReference(R.string.hw_backup_no_backup), - style = LabelStyle.WARNING, + BlockUM.EndContent.Label( + label = LabelUM( + text = resourceReference(R.string.hw_backup_no_backup), + style = LabelStyle.WARNING, + ), ) }, ).let(::add) 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 1b4e9c358f..65c3ffbbe7 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 @@ -130,6 +130,11 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor( balance = balance, isBalanceHidden = balanceHidingSettings.isBalanceHidden, artwork = artworks[userWallet.walletId], + endIcon = if (isAuthMode.not() && userWallet is UserWallet.Hot && !userWallet.backedUp) { + UserWalletItemUM.EndIcon.Warning + } else { + UserWalletItemUM.EndIcon.None + }, isAuthMode = isAuthMode, ) .convert(userWallet) From 03b3a7d0a29cbb6326e8cb1d5e668a196f22987e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Oct 2025 12:54:25 +0400 Subject: [PATCH 44/46] Updated on 2026-08-14 --- .../tap/di/domain/AccountDomainModule.kt | 6 +- .../api/tangemTech/TangemTechApi.kt | 2 +- .../fetcher/DefaultWalletAccountsFetcher.kt | 28 ++++++---- .../FetchWalletAccountsErrorHandler.kt | 23 +++++--- .../DefaultAccountsCRUDRepository.kt | 12 ++-- .../DefaultWalletAccountsFetcherTest.kt | 52 +++-------------- .../FetchWalletAccountsErrorHandlerTest.kt | 38 ++++--------- .../DefaultAccountsCRUDRepositoryTest.kt | 50 ++++++----------- .../common/account/WalletAccountsSaver.kt | 8 +-- .../usecase/RecoverCryptoPortfolioUseCase.kt | 56 +++++++++---------- .../RecoverCryptoPortfolioUseCaseTest.kt | 34 ++++++----- .../archived/ArchivedAccountListModel.kt | 40 +++++++++++-- 12 files changed, 168 insertions(+), 181 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt index 34f9a1101c..7c946ee340 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt @@ -49,8 +49,12 @@ internal object AccountDomainModule { @Singleton fun provideRecoverCryptoPortfolioUseCase( accountsCRUDRepository: AccountsCRUDRepository, + mainAccountTokensMigration: MainAccountTokensMigration, ): RecoverCryptoPortfolioUseCase { - return RecoverCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository) + return RecoverCryptoPortfolioUseCase( + crudRepository = accountsCRUDRepository, + mainAccountTokensMigration = mainAccountTokensMigration, + ) } @Provides 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 3b08465384..e838002aa8 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 @@ -156,7 +156,7 @@ interface TangemTechApi { @Path("walletId") walletId: String, @Header("If-Match") eTag: String, @Body body: SaveWalletAccountsResponse, - ): ApiResponse + ): ApiResponse @GET("/v1/wallets/{walletId}/accounts/archived") suspend fun getWalletArchivedAccounts( diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt index 5740e30ccb..200e133dbd 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt @@ -63,23 +63,24 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( } } - override suspend fun pushAndStore(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { - push(userWalletId = userWalletId, accounts = response.accounts) - store(userWalletId = userWalletId, response = response) - } - override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { val store = getAccountsResponseStore(userWalletId = userWalletId) store.updateData { response } } - override suspend fun push(userWalletId: UserWalletId, accounts: List) { - push(userWalletId = userWalletId, body = SaveWalletAccountsResponse(accounts = accounts)) + override suspend fun push( + userWalletId: UserWalletId, + accounts: List, + ): GetWalletAccountsResponse? { + return push(userWalletId = userWalletId, body = SaveWalletAccountsResponse(accounts = accounts)) } - override suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse) { - safeApiCall( + override suspend fun push( + userWalletId: UserWalletId, + body: SaveWalletAccountsResponse, + ): GetWalletAccountsResponse? { + return safeApiCall( call = { var eTag = getETag(userWalletId) @@ -105,6 +106,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) { throw error } + + null }, ) } @@ -152,7 +155,12 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( ), ) - pushAndStore(userWalletId, response) + userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) + val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts) + + if (syncedResponse != null) { + store(userWalletId = userWalletId, response = syncedResponse) + } } private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 6f284edf5b..0d9ea8c585 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -47,8 +47,8 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( error: ApiResponseError, userWalletId: UserWalletId, savedAccountsResponse: GetWalletAccountsResponse?, - pushWalletAccounts: suspend (userWalletId: UserWalletId, accounts: List) -> Unit, - storeWalletAccounts: suspend (userWalletId: UserWalletId, response: GetWalletAccountsResponse) -> Unit, + pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse?, + storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit, ): GetWalletAccountsResponse? { val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED) if (isResponseUpToDate) { @@ -56,17 +56,17 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( return savedAccountsResponse } - val response = savedAccountsResponse ?: defaultWalletAccountsResponseFactory.create( - userWalletId = userWalletId, - userTokensResponse = getFromLegacyStore(userWalletId), - ) - + var response = savedAccountsResponse ?: createDefaultResponse(userWalletId) val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse() val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) if (isNotFoundError) { - pushWalletAccounts(userWalletId, accountDTOs) userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse) + val updatedResponse = pushWalletAccounts(userWalletId, accountDTOs) + + if (updatedResponse != null) { + response = updatedResponse + } } storeWalletAccounts(userWalletId, response) @@ -74,6 +74,13 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( return response } + private suspend fun createDefaultResponse(userWalletId: UserWalletId): GetWalletAccountsResponse { + return defaultWalletAccountsResponseFactory.create( + userWalletId = userWalletId, + userTokensResponse = getFromLegacyStore(userWalletId), + ) + } + private suspend fun getFromLegacyStore(userWalletId: UserWalletId): UserTokensResponse? { return userTokensResponseStore.getSyncOrNull(userWalletId) ?.let { 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 5af36f3594..6dc1771d27 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 @@ -102,12 +102,16 @@ internal class DefaultAccountsCRUDRepository( } override suspend fun saveAccounts(accountList: AccountList) { - val userWallet = userWalletsStore.getSyncStrict(accountList.userWalletId) + val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = accountList.userWalletId) - val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) - val accountsResponse = converter.convert(value = accountList) + val accountDTOs = converter.convertListBack( + input = accountList.accounts.filterIsInstance(), + ) - walletAccountsSaver.pushAndStore(userWalletId = userWallet.walletId, response = accountsResponse) + val syncedResponse = walletAccountsSaver.push(userWalletId = accountList.userWalletId, accounts = accountDTOs) + if (syncedResponse != null) { + walletAccountsSaver.store(userWalletId = accountList.userWalletId, response = syncedResponse) + } } override suspend fun saveAccount(account: Account.CryptoPortfolio) { diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt index 4494f5af61..55be8a64db 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcherTest.kt @@ -117,7 +117,7 @@ class DefaultWalletAccountsFetcherTest { eTag = eTag, body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts), ) - } returns ApiResponse.Success(data = Unit) + } returns ApiResponse.Success(data = updatedAccountsResponse) // Act fetcher.fetch(userWalletId) @@ -273,26 +273,26 @@ class DefaultWalletAccountsFetcherTest { @Test fun `push should call saveWalletAccounts with correct params`() = runTest { // Arrange - val accounts = listOf(createWalletAccountDTO(userWalletId = userWalletId, tokens = null)) - val response = SaveWalletAccountsResponse(accounts) + val getResponse = createGetWalletAccountsResponse(userWalletId, tokens = null) + val saveResponse = SaveWalletAccountsResponse(getResponse.accounts) coEvery { tangemTechApi.saveWalletAccounts( walletId = userWalletId.stringValue, eTag = eTag, - body = response, + body = saveResponse, ) - } returns ApiResponse.Success(data = Unit) + } returns ApiResponse.Success(data = getResponse) // Act - fetcher.push(userWalletId, response) + fetcher.push(userWalletId, saveResponse) // Assert coVerify { tangemTechApi.saveWalletAccounts( walletId = userWalletId.stringValue, eTag = eTag, - body = response, + body = saveResponse, ) } } @@ -316,7 +316,7 @@ class DefaultWalletAccountsFetcherTest { eTag = eTag, body = response, ) - } returns saveApiResponse as ApiResponse + } returns saveApiResponse as ApiResponse // Act val actual = runCatching { fetcher.push(userWalletId, response) }.exceptionOrNull()!! @@ -326,42 +326,6 @@ class DefaultWalletAccountsFetcherTest { } } - @Nested - @TestInstance(TestInstance.Lifecycle.PER_CLASS) - inner class PushAndStore { - - @Test - fun `pushAndStore should call push and store with correct params`() = runTest { - // Arrange - val accounts = listOf(createWalletAccountDTO(userWalletId = userWalletId, tokens = null)) - val response = createGetWalletAccountsResponse(userWalletId).copy(accounts = accounts) - - coEvery { - tangemTechApi.saveWalletAccounts( - walletId = userWalletId.stringValue, - eTag = eTag, - body = SaveWalletAccountsResponse(accounts = response.accounts), - ) - } returns ApiResponse.Success(data = Unit) - - coEvery { accountsResponseStore.updateData(any()) } returns mockk() - - // Act - fetcher.pushAndStore(userWalletId, response) - - // Assert - coVerifyOrder { - tangemTechApi.saveWalletAccounts( - walletId = userWalletId.stringValue, - eTag = eTag, - body = SaveWalletAccountsResponse(accounts = response.accounts), - ) - accountsResponseStoreFactory.create(userWalletId) - accountsResponseStore.updateData(any()) - } - } - } - private fun createToken( networkId: String = "ethereum", derivationPath: String = "m/44'/60'/0'/0/0", diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index e8a3341536..6500556de8 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -1,5 +1,7 @@ package com.tangem.data.account.fetcher +import com.tangem.data.account.converter.createGetWalletAccountsResponse +import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.account.utils.toUserTokensResponse import com.tangem.data.common.currency.UserTokensSaver @@ -35,6 +37,10 @@ class FetchWalletAccountsErrorHandlerTest { defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, ) + private val pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse = + mockk(relaxed = true) + private val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + @BeforeEach fun setupEach() { clearMocks( @@ -53,9 +59,6 @@ class FetchWalletAccountsErrorHandlerTest { errorBody = null, ) - val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk() - val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk() - // Act handler.handle( error = error, @@ -84,29 +87,11 @@ class FetchWalletAccountsErrorHandlerTest { errorBody = null, ) - val accountDTO = WalletAccountDTO( - id = "nibh", - name = "Michael Dotson", - derivationIndex = 7135, - icon = "consectetuer", - iconColor = "ferri", - tokens = listOf(), - totalTokens = 7738, - totalNetworks = 3348, - ) + val accountDTO = createWalletAccountDTO(userWalletId) - val savedAccountsResponse = GetWalletAccountsResponse( - wallet = GetWalletAccountsResponse.Wallet( - group = UserTokensResponse.GroupType.NONE, - sort = UserTokensResponse.SortType.MANUAL, - totalAccounts = 1, - ), - accounts = listOf(accountDTO), - unassignedTokens = emptyList(), - ) + val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId) - val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) - val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse // Act handler.handle( @@ -119,8 +104,8 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify { - pushWalletAccounts(userWalletId, listOf(accountDTO)) userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + pushWalletAccounts(userWalletId, listOf(accountDTO)) storeWalletAccounts(userWalletId, savedAccountsResponse) } @@ -163,9 +148,6 @@ class FetchWalletAccountsErrorHandlerTest { defaultWalletAccountsResponseFactory.create(userWalletId, userTokensResponse) } returns savedAccountsResponse - val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) - val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) - // Act handler.handle( error = error, 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 index b830678839..9507b208c1 100644 --- 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 @@ -25,7 +25,6 @@ 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.* @@ -37,6 +36,7 @@ import kotlin.time.Duration.Companion.minutes /** [REDACTED_AUTHOR] */ +@Suppress("UnusedFlow") @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultAccountsCRUDRepositoryTest { @@ -580,24 +580,18 @@ class DefaultAccountsCRUDRepositoryTest { @Test fun `saveAccounts should call API and update store`() = runTest { // Arrange - val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId - } - val accountList = AccountList.empty(userWalletId = userWalletId) + val accounts = accountList.accounts.filterIsInstance() - val accountsResponse = mockk() + val accountsResponse = createGetWalletAccountsResponse(userWalletId) accountsResponseStoreFlow.value = accountsResponse - val converter = mockk { - every { this@mockk.convert(accountList) } returns accountsResponse + val converter = mockk { + every { this@mockk.convertListBack(accounts) } returns accountsResponse.accounts } - every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet - - every { - convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) - } returns converter + every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns converter + coEvery { walletAccountsSaver.push(userWalletId, accountsResponse.accounts) } returns accountsResponse // Act repository.saveAccounts(accountList) @@ -606,37 +600,30 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse) coVerifyOrder { - convertersContainer.getWalletAccountsResponseCF.create(userWallet) - converter.convert(accountList) - walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) + convertersContainer.createCryptoPortfolioConverter(userWalletId) + converter.convertListBack(accounts) + walletAccountsSaver.push(userWalletId, accountsResponse.accounts) } } @Test fun `saveAccounts if API request is failed`() = runTest { // Arrange - val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId - } - val accountList = AccountList.empty(userWalletId = userWalletId) + val accounts = accountList.accounts.filterIsInstance() - val accountsResponse = mockk() + val accountsResponse = createGetWalletAccountsResponse(userWalletId) accountsResponseStoreFlow.value = accountsResponse - val converter = mockk { - every { this@mockk.convert(accountList) } returns accountsResponse + val converter = mockk { + every { this@mockk.convertListBack(accounts) } returns accountsResponse.accounts } - every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet - - every { - convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet) - } returns converter + every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns converter val exception = Exception("Test error") - coEvery { walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) } throws exception + coEvery { walletAccountsSaver.push(userWalletId, accountsResponse.accounts) } throws exception // Act val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!! @@ -646,9 +633,8 @@ class DefaultAccountsCRUDRepositoryTest { Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message) coVerifyOrder { - convertersContainer.getWalletAccountsResponseCF.create(userWallet) - converter.convert(accountList) - walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) + convertersContainer.createCryptoPortfolioConverter(userWalletId) + converter.convertListBack(accounts) } } } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt index f4b684613b..de6c29ccc4 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/account/WalletAccountsSaver.kt @@ -12,18 +12,14 @@ import com.tangem.domain.models.wallet.UserWalletId */ interface WalletAccountsSaver { - /** Push and store wallet accounts [response] by [userWalletId] */ - @Throws - suspend fun pushAndStore(userWalletId: UserWalletId, response: GetWalletAccountsResponse) - /** Store wallet accounts [response] by [userWalletId] */ suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) /** Push wallet accounts [body] by [userWalletId] */ @Throws - suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse) + suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse): GetWalletAccountsResponse? /** Push wallet accounts [accounts] by [userWalletId] */ @Throws - suspend fun push(userWalletId: UserWalletId, accounts: List) + suspend fun push(userWalletId: UserWalletId, accounts: List): GetWalletAccountsResponse? } \ No newline at end of file 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 1bff615daf..8e52598bb2 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 @@ -5,9 +5,11 @@ import arrow.core.getOrElse import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either +import arrow.core.raise.ensure 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.account.tokens.MainAccountTokensMigration import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId @@ -16,11 +18,13 @@ import com.tangem.domain.models.wallet.UserWalletId * Use case for recovering a crypto portfolio account from archived accounts * * @property crudRepository repository for performing CRUD operations on accounts + * @property mainAccountTokensMigration handles the migration of tokens from the main account to the recovered account * [REDACTED_AUTHOR] */ class RecoverCryptoPortfolioUseCase( private val crudRepository: AccountsCRUDRepository, + private val mainAccountTokensMigration: MainAccountTokensMigration, ) { /** @@ -30,15 +34,25 @@ class RecoverCryptoPortfolioUseCase( */ suspend operator fun invoke(accountId: AccountId): Either = either { val accountList = getAccountList(userWalletId = accountId.userWalletId) + + ensure(accountList.canAddMoreAccounts) { + raise(Error.AccountListRequirementsNotMet(cause = AccountList.Error.ExceedsMaxAccountsCount)) + } + val archivedAccount = getArchivedAccount(accountId = accountId) val recoveredAccount = archivedAccount.recover() val updatedAccountList = (accountList + recoveredAccount) - .getOrElse { raise(Error.CriticalTechError.AccountListRequirementsNotMet(cause = it)) } + .getOrElse { raise(Error.AccountListRequirementsNotMet(cause = it)) } saveAccounts(updatedAccountList) + mainAccountTokensMigration.migrate( + userWalletId = accountId.userWalletId, + derivationIndex = recoveredAccount.derivationIndex, + ) + recoveredAccount } @@ -47,7 +61,9 @@ class RecoverCryptoPortfolioUseCase( block = { crudRepository.getAccountListSync(userWalletId = userWalletId) }, catch = { raise(Error.DataOperationFailed(cause = it)) }, ) - .getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) } + .getOrElse { + raise(Error.DataOperationFailed(message = "Account list not found for wallet $userWalletId")) + } } private suspend fun Raise.getArchivedAccount(accountId: AccountId): ArchivedAccount { @@ -56,7 +72,7 @@ class RecoverCryptoPortfolioUseCase( catch = { raise(Error.DataOperationFailed(cause = it)) }, ) .getOrElse { - raise(Error.CriticalTechError.AccountNotFound(accountId = accountId)) + raise(Error.DataOperationFailed(message = "Account not found: $accountId")) } } @@ -66,7 +82,6 @@ class RecoverCryptoPortfolioUseCase( accountName = this.name, icon = this.icon, derivationIndex = this.derivationIndex, - // TODO: [REDACTED_JIRA] cryptoCurrencies = emptySet(), ) } @@ -87,37 +102,18 @@ class RecoverCryptoPortfolioUseCase( get() = this::class.simpleName ?: "RecoverCryptoPortfolioUseCase.Error" /** - * Critical technical errors that can occur during the recovery operation + * Error indicating that the account list requirements were not met. + * + * @property cause the underlying cause of the error */ - sealed interface CriticalTechError : Error { - - /** - - * - * @property userWalletId the unique identifier of the user wallet - */ - data class AccountsNotCreated(val userWalletId: UserWalletId) : CriticalTechError { - override fun toString(): String = "$tag: Accounts for $userWalletId are not created" - } - - /** Error indicating that the account with [accountId] was not found */ - data class AccountNotFound(val accountId: AccountId) : CriticalTechError { - override fun toString(): String = "$tag: Account with ID $accountId not found" - } - - /** - * Error indicating that the account list requirements were not met. - * - * @property cause the underlying cause of the error - */ - data class AccountListRequirementsNotMet(val cause: AccountList.Error) : Error { - override fun toString(): String = "$tag: Account list requirements not met: $cause" - } + data class AccountListRequirementsNotMet(val cause: AccountList.Error) : Error { + override fun toString(): String = "$tag: Account list requirements not met: $cause" } /** Error indicating that a data operation failed */ data class DataOperationFailed(val cause: Throwable) : Error { - override fun toString(): String = "$tag: Data operation failed: ${cause.message ?: "Unknown error"}" + + constructor(message: String) : this(cause = IllegalStateException(message)) } } } \ No newline at end of file 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 f5ede48207..8a16fd034c 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 @@ -8,6 +8,7 @@ import com.google.common.truth.Truth 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.account.tokens.MainAccountTokensMigration import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase.Error import com.tangem.domain.account.utils.createAccount import com.tangem.domain.models.account.AccountId @@ -26,7 +27,11 @@ import org.junit.jupiter.api.TestInstance class RecoverCryptoPortfolioUseCaseTest { private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true) - private val useCase = RecoverCryptoPortfolioUseCase(crudRepository) + private val mainAccountTokensMigration: MainAccountTokensMigration = mockk() + private val useCase = RecoverCryptoPortfolioUseCase( + crudRepository = crudRepository, + mainAccountTokensMigration = mainAccountTokensMigration, + ) @BeforeEach fun resetMocks() { @@ -51,6 +56,7 @@ class RecoverCryptoPortfolioUseCaseTest { coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption() coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns archivedAccount.toOption() + coEvery { mainAccountTokensMigration.migrate(userWalletId, account.derivationIndex) } returns Unit.right() // Act val actual = useCase(account.accountId) @@ -59,7 +65,7 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = account.right() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { + coVerifySequence { crudRepository.getAccountListSync(userWalletId) crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) @@ -77,13 +83,14 @@ class RecoverCryptoPortfolioUseCaseTest { coEvery { crudRepository.getAccountListSync(userWalletId) } returns None // Act - val actual = useCase(accountId) + val actual = useCase(accountId).leftOrNull() as Error.DataOperationFailed // Assert - val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left() - Truth.assertThat(actual).isEqualTo(expected) + val expected = IllegalStateException("Account list not found for wallet $userWalletId") + Truth.assertThat(actual.cause).isInstanceOf(expected::class.java) + Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message) - coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } + coVerifySequence { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) @@ -108,7 +115,7 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { crudRepository.getAccountListSync(userWalletId) } + coVerifySequence { crudRepository.getAccountListSync(userWalletId) } coVerify(inverse = true) { crudRepository.getArchivedAccountSync(any()) crudRepository.saveAccounts(any()) @@ -132,7 +139,7 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { + coVerifySequence { crudRepository.getAccountListSync(userWalletId) crudRepository.getArchivedAccountSync(account.accountId) } @@ -149,13 +156,14 @@ class RecoverCryptoPortfolioUseCaseTest { coEvery { crudRepository.getArchivedAccountSync(account.accountId) } returns None // Act - val actual = useCase(account.accountId) + val actual = useCase(account.accountId).leftOrNull() as Error.DataOperationFailed // Assert - val expected = Error.CriticalTechError.AccountNotFound(account.accountId).left() - Truth.assertThat(actual).isEqualTo(expected) + val expected = IllegalStateException("Account not found: ${account.accountId}") + Truth.assertThat(actual.cause).isInstanceOf(expected::class.java) + Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message) - coVerifyOrder { + coVerifySequence { crudRepository.getAccountListSync(userWalletId) crudRepository.getArchivedAccountSync(account.accountId) } @@ -190,7 +198,7 @@ class RecoverCryptoPortfolioUseCaseTest { val expected = Error.DataOperationFailed(exception).left() Truth.assertThat(actual).isEqualTo(expected) - coVerifyOrder { + coVerifySequence { crudRepository.getAccountListSync(userWalletId) crudRepository.getArchivedAccountSync(account.accountId) crudRepository.saveAccounts(updatedAccountList) 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 f1c4734092..76c6acb6da 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 @@ -1,6 +1,8 @@ package com.tangem.features.account.archived import com.tangem.common.ui.account.toUM +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router @@ -11,6 +13,8 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.ToastMessage +import com.tangem.core.ui.utils.showErrorDialog +import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.ArchivedAccount import com.tangem.domain.account.usecase.ArchivedAccountList import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase @@ -20,6 +24,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.features.account.ArchivedAccountListComponent import com.tangem.features.account.archived.entity.AccountArchivedUM import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder +import com.tangem.features.account.createedit.error.AccountFeatureError import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -37,6 +42,7 @@ internal class ArchivedAccountListModel @Inject constructor( private val recoverCryptoPortfolioUseCase: RecoverCryptoPortfolioUseCase, private val getArchivedAccountsUseCase: GetArchivedAccountsUseCase, private val umBuilder: AccountArchivedUMBuilder, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, ) : Model() { private val params = paramsContainer.require() @@ -103,11 +109,37 @@ internal class ArchivedAccountListModel @Inject constructor( ) } - private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch { + private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch(dispatchers.default) { recoverCryptoPortfolioUseCase(accountId) - .onLeft { Timber.e(it.toString()) } - .onRight { showSuccessRecoverMessage() } - router.pop() + .onLeft(::handleRecoverError) + .onRight { + showSuccessRecoverMessage() + router.pop() + } + } + + private fun handleRecoverError(error: RecoverCryptoPortfolioUseCase.Error) { + if (error is RecoverCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet && + error.cause is AccountList.Error.ExceedsMaxAccountsCount + ) { + // TODO("account") show alert that max accounts count reached + // https://www.figma.com/design/09KKG4ZVuFDZhj8WLv5rGJ/%F0%9F%9A%A7-App-experience?node-id=24765-180563&t=vk6TCy4MkYol1cPb-4 + return + } + + val featureError = AccountFeatureError.ArchivedAccountList.FailedToRecoverAccount(cause = error) + logError(error = featureError) + messageSender.showErrorDialog(universalError = featureError, onDismiss = router::pop) + } + + private fun logError(error: AccountFeatureError, params: Map = mapOf()) { + val exception = IllegalStateException(error.toString()) + + Timber.e(exception) + + analyticsExceptionHandler.sendException( + event = ExceptionAnalyticsEvent(exception = exception, params = params), + ) } private fun showSuccessRecoverMessage() { From 03b0f2b2ce6ae8f57a0a07ddc9fac032f1c1e7c6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Oct 2025 12:46:38 +0300 Subject: [PATCH 45/46] Updated on 2026-08-14 --- .../features/onramp/swap/model/SwapSelectTokensModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt index 7b3b212edc..5d12338467 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt @@ -77,7 +77,7 @@ internal class SwapSelectTokensModel @Inject constructor( selectedTokenItemState = selectedTokenItemState, onRemoveClick = ::onRemoveFromTokenClick, isAccountsMode = isAccountsMode, - account = getAccountCurrencyStatusUseCase.invoke( + account = getAccountCurrencyStatusUseCase.invokeSync( userWalletId = params.userWalletId, currency = status.currency, ).getOrNull()?.account, @@ -104,7 +104,7 @@ internal class SwapSelectTokensModel @Inject constructor( transformer = SelectToTokenTransformer( selectedTokenItemState = selectedTokenItemState, isAccountsMode = isAccountsMode, - account = getAccountCurrencyStatusUseCase.invoke( + account = getAccountCurrencyStatusUseCase.invokeSync( userWalletId = params.userWalletId, currency = status.currency, ).getOrNull()?.account, From 9b5ad39c51686e1c16cfba42b0d45b035d17808b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Oct 2025 14:59:13 +0500 Subject: [PATCH 46/46] Updated on 2026-08-14 --- .../utils/TangemPayTxHistoryItemConverter.kt | 2 +- .../TangemPayTxHistoryItemStatusConverter.kt | 16 +++++++++++++ .../visa/model/TangemPayTxHistoryItem.kt | 10 +++++++- .../PreviewTangemPayTxHistoryComponent.kt | 24 +++++++++++++++++++ .../TangemPayTxHistoryItemsConverter.kt | 19 +++++++++++---- 5 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt index defb6559b9..e8bead6d27 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt @@ -30,7 +30,7 @@ internal object TangemPayTxHistoryItemConverter : merchantName = spend.merchantName, enrichedMerchantCategory = spend.enrichedMerchantCategory, merchantCategory = spend.merchantCategory, - status = spend.status, + status = TangemPayTxHistoryItemStatusConverter.convert(spend.status), enrichedMerchantIconUrl = spend.enrichedMerchantIcon, ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt new file mode 100644 index 0000000000..775b3843a9 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt @@ -0,0 +1,16 @@ +package com.tangem.data.visa.utils + +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.utils.converter.Converter + +internal object TangemPayTxHistoryItemStatusConverter : Converter { + override fun convert(value: String): TangemPayTxHistoryItem.Status { + return when (value.uppercase()) { + "PENDING" -> TangemPayTxHistoryItem.Status.PENDING + "RESERVED" -> TangemPayTxHistoryItem.Status.RESERVED + "COMPLETED" -> TangemPayTxHistoryItem.Status.COMPLETED + "DECLINED" -> TangemPayTxHistoryItem.Status.DECLINED + else -> TangemPayTxHistoryItem.Status.UNKNOWN + } + } +} \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt index 65d960d01c..6793255df3 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayTxHistoryItem.kt @@ -19,7 +19,7 @@ sealed class TangemPayTxHistoryItem { val merchantName: String, val enrichedMerchantCategory: String?, val merchantCategory: String, - val status: String, + val status: Status, val enrichedMerchantIconUrl: String?, ) : TangemPayTxHistoryItem() @@ -36,4 +36,12 @@ sealed class TangemPayTxHistoryItem { override val amount: BigDecimal, override val currency: Currency, ) : TangemPayTxHistoryItem() + + enum class Status { + PENDING, + RESERVED, + COMPLETED, + DECLINED, + UNKNOWN, + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt index eae34d3741..49dccbf15e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/PreviewTangemPayTxHistoryComponent.kt @@ -40,6 +40,30 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor iconUrl = null, ), ), + TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( + transaction = TangemPayTransactionState.Content.Payment( + id = "signiferumque", + amount = "-126.20 USD", + amountColor = { TangemTheme.colors.text.primary1 }, + time = "12:04", + onClick = {}, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + isIncome = false, + ), + ), + TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( + transaction = TangemPayTransactionState.Content.Payment( + id = "signiferumque", + amount = "+126.20 USD", + amountColor = { TangemTheme.colors.text.accent }, + time = "12:04", + onClick = {}, + title = stringReference("Wallmart"), + subtitle = stringReference("Supermarket"), + isIncome = true, + ), + ), TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction( transaction = TangemPayTransactionState.Content.Spend( id = "signiferumque", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt index 9184fac026..efd45df7b3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayTxHistoryItemsConverter.kt @@ -8,6 +8,7 @@ import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.features.tangempay.entity.TangemPayTransactionState import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions +import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.isPositive import org.joda.time.DateTimeZone @@ -25,14 +26,23 @@ internal class TangemPayTxHistoryItemsConverter( private fun convertSpend(spend: TangemPayTxHistoryItem.Spend): TangemPayTransactionState.Content.Spend { val localDate = spend.date.withZone(DateTimeZone.getDefault()) - val amount = spend.amount.format { + val amountPrefix = when (spend.status) { + TangemPayTxHistoryItem.Status.DECLINED -> "" + else -> StringsSigns.MINUS + } + val amount = amountPrefix + spend.amount.format { fiat(fiatCurrencyCode = spend.currency.currencyCode, fiatCurrencySymbol = spend.currency.symbol) } return TangemPayTransactionState.Content.Spend( id = spend.id, onClick = { txHistoryUiActions.onTransactionClick(spend) }, amount = amount, - amountColor = { TangemTheme.colors.text.primary1 }, + amountColor = { + when (spend.status) { + TangemPayTxHistoryItem.Status.DECLINED -> TangemTheme.colors.text.warning + else -> TangemTheme.colors.text.primary1 + } + }, title = stringReference(spend.enrichedMerchantName ?: spend.merchantName), subtitle = stringReference(spend.enrichedMerchantCategory ?: spend.merchantCategory), time = DateTimeFormatters.formatDate(localDate, DateTimeFormatters.timeFormatter), @@ -41,7 +51,8 @@ internal class TangemPayTxHistoryItemsConverter( } private fun convertPayment(payment: TangemPayTxHistoryItem.Payment): TangemPayTransactionState.Content.Payment { - val amount = payment.amount.format { + val amountPrefix = if (payment.amount.isPositive()) StringsSigns.PLUS else StringsSigns.MINUS + val amount = amountPrefix + payment.amount.format { fiat(fiatCurrencyCode = payment.currency.currencyCode, fiatCurrencySymbol = payment.currency.symbol) } val title = if (payment.amount.isPositive()) "Deposit" else "Withdrawal" @@ -64,7 +75,7 @@ internal class TangemPayTxHistoryItemsConverter( } private fun convertFee(fee: TangemPayTxHistoryItem.Fee): TangemPayTransactionState.Content.Fee { - val amount = fee.amount.format { + val amount = StringsSigns.MINUS + fee.amount.format { fiat(fiatCurrencyCode = fee.currency.currencyCode, fiatCurrencySymbol = fee.currency.symbol) } return TangemPayTransactionState.Content.Fee(