Updated on 2026-08-14

This commit is contained in:
Tangem 2024-05-16 15:01:13 +03:00
commit 81bd9875e0
87 changed files with 1128 additions and 225 deletions

View file

@ -8,6 +8,8 @@ import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase
import com.tangem.tap.domain.TangemSdkManager
import com.tangem.tap.domain.card.DefaultDeleteSavedAccessCodesUseCase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -69,4 +71,10 @@ internal object CardDomainModule {
): GetExtendedPublicKeyForCurrencyUseCase { ): GetExtendedPublicKeyForCurrencyUseCase {
return GetExtendedPublicKeyForCurrencyUseCase(derivationsRepository) return GetExtendedPublicKeyForCurrencyUseCase(derivationsRepository)
} }
@Provides
@ViewModelScoped
fun provideDeleteSavedAccessCodesUseCase(tangemSdkManager: TangemSdkManager): DeleteSavedAccessCodesUseCase {
return DefaultDeleteSavedAccessCodesUseCase(tangemSdkManager)
}
} }

View file

@ -7,7 +7,9 @@ import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.domain.settings.* import com.tangem.domain.settings.*
import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.domain.settings.repositories.PromoSettingsRepository
import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.tap.domain.TangemSdkManager
import com.tangem.domain.settings.repositories.SwapPromoRepository import com.tangem.domain.settings.repositories.SwapPromoRepository
import com.tangem.tap.domain.sdk.TangemSdkManager import com.tangem.tap.domain.sdk.TangemSdkManager
import com.tangem.tap.domain.settings.DefaultLegacySettingsRepository import com.tangem.tap.domain.settings.DefaultLegacySettingsRepository
@ -108,17 +110,25 @@ internal object SettingsDomainModule {
@Provides @Provides
@ViewModelScoped @ViewModelScoped
fun provideShouldShowSwapPromoWalletUseCase( fun provideShouldShowSwapPromoWalletUseCase(
swapPromoRepository: SwapPromoRepository, promoSettingsRepository: PromoSettingsRepository,
): ShouldShowSwapPromoWalletUseCase { ): ShouldShowSwapPromoWalletUseCase {
return ShouldShowSwapPromoWalletUseCase(swapPromoRepository) return ShouldShowSwapPromoWalletUseCase(promoSettingsRepository)
}
@Provides
@ViewModelScoped
fun provideShouldShowTravalaPromoWalletUseCase(
promoSettingsRepository: PromoSettingsRepository,
): ShouldShowTravalaPromoWalletUseCase {
return ShouldShowTravalaPromoWalletUseCase(promoSettingsRepository)
} }
@Provides @Provides
@ViewModelScoped @ViewModelScoped
fun provideShouldShowSwapPromoTokenUseCase( fun provideShouldShowSwapPromoTokenUseCase(
swapPromoRepository: SwapPromoRepository, promoSettingsRepository: PromoSettingsRepository,
): ShouldShowSwapPromoTokenUseCase { ): ShouldShowSwapPromoTokenUseCase {
return ShouldShowSwapPromoTokenUseCase(swapPromoRepository) return ShouldShowSwapPromoTokenUseCase(promoSettingsRepository)
} }
@Provides @Provides

View file

@ -346,4 +346,12 @@ internal object TokensDomainModule {
): RunPolkadotAccountHealthCheckUseCase { ): RunPolkadotAccountHealthCheckUseCase {
return RunPolkadotAccountHealthCheckUseCase(repository) return RunPolkadotAccountHealthCheckUseCase(repository)
} }
@Provides
@ViewModelScoped
fun provideGetNetworkStatusesUseCase(networksRepository: NetworksRepository): GetNetworkAddressesUseCase {
return GetNetworkAddressesUseCase(
networksRepository = networksRepository,
)
}
} }

View file

@ -0,0 +1,22 @@
package com.tangem.tap.domain.card
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.domain.card.DeleteSavedAccessCodesUseCase
import com.tangem.tap.domain.TangemSdkManager
internal class DefaultDeleteSavedAccessCodesUseCase(
private val tangemSdkManager: TangemSdkManager,
) : DeleteSavedAccessCodesUseCase {
override suspend fun invoke(cardId: String): Either<Throwable, Unit> {
tangemSdkManager.deleteSavedUserCodes(setOf(cardId))
.doOnFailure { return it.left() }
.doOnSuccess { return Unit.right() }
return Unit.right()
}
}

View file

@ -8,15 +8,20 @@ internal class DelegatedKeystoreManager(
private val keystoreManagerProvider: Provider<KeystoreManager>, private val keystoreManagerProvider: Provider<KeystoreManager>,
) : KeystoreManager { ) : KeystoreManager {
override suspend fun get(masterKeyConfig: KeystoreManager.MasterKeyConfig, keyAlias: String): SecretKey? { override suspend fun get(
return keystoreManagerProvider().get(masterKeyConfig, keyAlias) masterKeyConfig: KeystoreManager.MasterKeyConfig,
keyAlias: String,
forceAuthentication: Boolean,
): SecretKey? {
return keystoreManagerProvider().get(masterKeyConfig, keyAlias, forceAuthentication)
} }
override suspend fun get( override suspend fun get(
masterKeyConfig: KeystoreManager.MasterKeyConfig, masterKeyConfig: KeystoreManager.MasterKeyConfig,
keyAliases: Set<String>, keyAliases: Set<String>,
forceAuthentication: Boolean,
): Map<String, SecretKey> { ): Map<String, SecretKey> {
return keystoreManagerProvider().get(masterKeyConfig, keyAliases) return keystoreManagerProvider().get(masterKeyConfig, keyAliases, forceAuthentication)
} }
override suspend fun store(masterKeyConfig: KeystoreManager.MasterKeyConfig, keyAlias: String, key: SecretKey) { override suspend fun store(masterKeyConfig: KeystoreManager.MasterKeyConfig, keyAlias: String, key: SecretKey) {

View file

@ -14,6 +14,7 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.ListenToFlipsUseCase import com.tangem.domain.balancehiding.ListenToFlipsUseCase
import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase
import com.tangem.domain.settings.DeleteDeprecatedLogsUseCase import com.tangem.domain.settings.DeleteDeprecatedLogsUseCase
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.legacy.UserWalletsListManager
@ -39,6 +40,7 @@ internal class MainViewModel @Inject constructor(
private val blockchainSDKFactory: BlockchainSDKFactory, private val blockchainSDKFactory: BlockchainSDKFactory,
private val userWalletsListManager: UserWalletsListManager, private val userWalletsListManager: UserWalletsListManager,
private val walletManagersFacade: WalletManagersFacade, private val walletManagersFacade: WalletManagersFacade,
private val sendFeatureToggles: SendFeatureToggles,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
) : ViewModel(), MainIntents { ) : ViewModel(), MainIntents {
@ -61,6 +63,7 @@ internal class MainViewModel @Inject constructor(
viewModelScope.launch(dispatchers.main) { incrementAppLaunchCounterUseCase() } viewModelScope.launch(dispatchers.main) { incrementAppLaunchCounterUseCase() }
updateAppCurrencies() updateAppCurrencies()
updateSendFeatureToggle()
observeFlips() observeFlips()
displayBalancesHidingStatusToast() displayBalancesHidingStatusToast()
displayHiddenBalancesModalNotification() displayHiddenBalancesModalNotification()
@ -108,6 +111,12 @@ internal class MainViewModel @Inject constructor(
} }
} }
private fun updateSendFeatureToggle() {
viewModelScope.launch(dispatchers.main) {
sendFeatureToggles.fetchNewSendEnabled()
}
}
private fun observeFlips() { private fun observeFlips() {
listenToFlipsUseCase().launchIn(viewModelScope) listenToFlipsUseCase().launchIn(viewModelScope)
} }

View file

@ -101,6 +101,7 @@ sealed class AnalyticsParam {
data object Min : FeeType("Min") data object Min : FeeType("Min")
data object Normal : FeeType("Normal") data object Normal : FeeType("Normal")
data object Max : FeeType("Max") data object Max : FeeType("Max")
data object Custom : FeeType("Custom")
companion object { companion object {
fun fromString(feeType: String): FeeType { fun fromString(feeType: String): FeeType {

View file

@ -13,6 +13,7 @@ data class PromotionInfoResponse(
data class BannerState( data class BannerState(
@Json(name = "timeline") val timeline: Timeline, @Json(name = "timeline") val timeline: Timeline,
@Json(name = "status") val status: String, @Json(name = "status") val status: String,
@Json(name = "link") val link: String?,
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)

View file

@ -126,4 +126,7 @@ interface TangemTechApi {
@Header("card_id") cardId: String, @Header("card_id") cardId: String,
@Path("account_id") accountId: Int, @Path("account_id") accountId: Int,
): ApiResponse<UserTokensAccountResponse> ): ApiResponse<UserTokensAccountResponse>
@GET("features")
suspend fun getFeatures(): ApiResponse<FeaturesResponse>
} }

View file

@ -0,0 +1,7 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
data class FeaturesResponse(
@Json(name = "send") val isNewSendEnabled: Boolean,
)

View file

@ -61,6 +61,10 @@ object PreferencesKeys {
booleanPreferencesKey(name = "isTokenSwapPromoChangellyShown") booleanPreferencesKey(name = "isTokenSwapPromoChangellyShown")
} }
val IS_WALLET_TRAVALA_PROMO_SHOWN_KEY by lazy {
booleanPreferencesKey(name = "isWalletTravalaPromoShown")
}
val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") }
val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") } val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") }

View file

@ -9,7 +9,7 @@
}, },
{ {
"name": "REDESIGNED_SEND_SCREEN_ENABLED", "name": "REDESIGNED_SEND_SCREEN_ENABLED",
"version": "5.9.1" "version": "5.10.0"
}, },
{ {
"name": "LOCAL_USER_LOGS_ENABLED", "name": "LOCAL_USER_LOGS_ENABLED",

View file

@ -46,4 +46,5 @@ dependencies {
implementation(deps.zxing.qrCore) implementation(deps.zxing.qrCore)
implementation(deps.jodatime) implementation(deps.jodatime)
implementation(deps.timber) implementation(deps.timber)
implementation(deps.markdown)
} }

View file

@ -95,10 +95,14 @@ fun AmountTextField(
SimpleTextField( SimpleTextField(
value = value, value = value,
onValueChange = { newText -> onValueChange = { newText ->
if (decimalFormat.isValidSymbols(newText)) { onValueChange(
val trimmed = decimalFormat.getValidatedNumberWithFixedDecimals(newText, decimals) prepareEnter(
onValueChange(trimmed) oldValue = value,
} newValue = newText,
decimalFormat = decimalFormat,
decimals = decimals,
),
)
}, },
textStyle = textStyle.copy( textStyle = textStyle.copy(
fontSize = fontSize, fontSize = fontSize,
@ -117,8 +121,37 @@ fun AmountTextField(
} }
} }
private fun prepareEnter(oldValue: String, newValue: String, decimalFormat: DecimalFormat, decimals: Int): String {
val decimalSymbol = decimalFormat.decimalFormatSymbols.decimalSeparator
return if (decimalFormat.isValidSymbols(newValue)) {
val parsedValue = newValue.parseBigDecimalOrNull()?.toPlainString()
?: if (newValue.isBlank()) "" else oldValue
val replacedWithSymbol = if (parsedValue.findLast { it != decimalSymbol } != null) {
when {
parsedValue.findLast { it == COMMA_SEPARATOR } != null -> {
parsedValue.replace(COMMA_SEPARATOR, decimalSymbol)
}
parsedValue.findLast { it == POINT_SEPARATOR } != null -> {
parsedValue.replace(POINT_SEPARATOR, decimalSymbol)
}
else -> parsedValue
}
} else {
parsedValue
}
val joinedSymbol = if (newValue.endsWith(COMMA_SEPARATOR) || newValue.endsWith(POINT_SEPARATOR)) {
replacedWithSymbol.plus(decimalSymbol)
} else {
replacedWithSymbol
}
decimalFormat.getValidatedNumberWithFixedDecimals(joinedSymbol, decimals)
} else {
oldValue
}
}
private fun DecimalFormat.isValidSymbols(text: String): Boolean { private fun DecimalFormat.isValidSymbols(text: String): Boolean {
return checkDecimalSeparatorDuplicate(text) && checkGroupingSeparator(text) return checkDecimalSeparatorDuplicate(text)
} }
// region preview // region preview

View file

@ -38,6 +38,7 @@ fun SimpleTextField(
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
color: Color = TangemTheme.colors.text.primary1, color: Color = TangemTheme.colors.text.primary1,
textStyle: TextStyle = TangemTheme.typography.body2.copy(color = color), textStyle: TextStyle = TangemTheme.typography.body2.copy(color = color),
placeholderColor: Color = TangemTheme.colors.text.disabled,
readOnly: Boolean = false, readOnly: Boolean = false,
isValuePasted: Boolean = false, isValuePasted: Boolean = false,
onValuePastedTriggerDismiss: () -> Unit = {}, onValuePastedTriggerDismiss: () -> Unit = {},
@ -108,6 +109,7 @@ fun SimpleTextField(
value = value, value = value,
textStyle = textStyle, textStyle = textStyle,
textValue = textValue, textValue = textValue,
color = placeholderColor,
) )
}, },
modifier = modifier modifier = modifier
@ -122,6 +124,7 @@ private fun SimpleTextPlaceholder(
value: String, value: String,
textStyle: TextStyle, textStyle: TextStyle,
textValue: @Composable () -> Unit, textValue: @Composable () -> Unit,
color: Color = TangemTheme.colors.text.disabled,
) { ) {
Box { Box {
if (value.isBlank() && placeholder != null) { if (value.isBlank() && placeholder != null) {
@ -132,7 +135,7 @@ private fun SimpleTextPlaceholder(
Text( Text(
text = it.resolveReference(), text = it.resolveReference(),
style = textStyle, style = textStyle,
color = TangemTheme.colors.text.disabled, color = color,
) )
} }
} }

View file

@ -51,12 +51,14 @@ fun Notification(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
containerColor: Color? = null, containerColor: Color? = null,
iconTint: Color? = null, iconTint: Color? = null,
isEnabled: Boolean = true,
) { ) {
BaseContainer( BaseContainer(
buttonsState = config.buttonsState, buttonsState = config.buttonsState,
onClick = config.onClick, onClick = config.onClick,
modifier = modifier, modifier = modifier,
containerColor = containerColor, containerColor = containerColor,
isEnabled = isEnabled,
) { ) {
Column( Column(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing12), modifier = Modifier.padding(all = TangemTheme.dimens.spacing12),
@ -67,15 +69,16 @@ fun Notification(
iconTint = iconTint, iconTint = iconTint,
title = config.title, title = config.title,
subtitle = config.subtitle, subtitle = config.subtitle,
isClickableComponent = config.onClick != null, isClickableComponent = isEnabled && config.onClick != null,
) )
Buttons(state = config.buttonsState) Buttons(state = config.buttonsState, isEnabled = isEnabled)
} }
CloseableIconButton( CloseableIconButton(
onClick = config.onCloseClick, onClick = config.onCloseClick,
modifier = Modifier.align(alignment = Alignment.TopEnd), modifier = Modifier.align(alignment = Alignment.TopEnd),
isEnabled = isEnabled,
) )
} }
} }
@ -85,6 +88,7 @@ private fun BaseContainer(
buttonsState: NotificationConfig.ButtonsState?, buttonsState: NotificationConfig.ButtonsState?,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isEnabled: Boolean = true,
containerColor: Color? = null, containerColor: Color? = null,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
@ -101,7 +105,7 @@ private fun BaseContainer(
modifier = modifier modifier = modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size62) .defaultMinSize(minHeight = TangemTheme.dimens.size62)
.fillMaxWidth(), .fillMaxWidth(),
enabled = onClick != null, enabled = onClick != null && isEnabled,
shape = TangemTheme.shapes.roundedCornersXMedium, shape = TangemTheme.shapes.roundedCornersXMedium,
color = containerColor ?: tempContainerColor, color = containerColor ?: tempContainerColor,
) { ) {
@ -179,27 +183,31 @@ private fun TextsBlock(title: TextReference, subtitle: TextReference) {
} }
@Composable @Composable
private fun Buttons(state: NotificationButtonsState?) { private fun Buttons(state: NotificationButtonsState?, isEnabled: Boolean = true) {
when (state) { when (state) {
is NotificationButtonsState.SecondaryButtonConfig -> SingleSecondaryButton(config = state) is NotificationButtonsState.SecondaryButtonConfig -> SingleSecondaryButton(
is NotificationButtonsState.PrimaryButtonConfig -> SinglePrimaryButton(config = state) config = state,
is NotificationButtonsState.PairButtonsConfig -> PairButtons(config = state) isEnabled = isEnabled,
)
is NotificationButtonsState.PrimaryButtonConfig -> SinglePrimaryButton(config = state, isEnabled = isEnabled)
is NotificationButtonsState.PairButtonsConfig -> PairButtons(config = state, isEnabled = isEnabled)
null -> Unit null -> Unit
} }
} }
@Composable @Composable
private fun SingleSecondaryButton(config: NotificationButtonsState.SecondaryButtonConfig) { private fun SingleSecondaryButton(config: NotificationButtonsState.SecondaryButtonConfig, isEnabled: Boolean = true) {
SecondaryButton( SecondaryButton(
text = config.text.resolveReference(), text = config.text.resolveReference(),
onClick = config.onClick, onClick = config.onClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
size = TangemButtonSize.WideAction, size = TangemButtonSize.WideAction,
enabled = isEnabled,
) )
} }
@Composable @Composable
private fun SinglePrimaryButton(config: NotificationButtonsState.PrimaryButtonConfig) { private fun SinglePrimaryButton(config: NotificationButtonsState.PrimaryButtonConfig, isEnabled: Boolean = true) {
if (config.iconResId != null) { if (config.iconResId != null) {
PrimaryButtonIconEnd( PrimaryButtonIconEnd(
text = config.text.resolveReference(), text = config.text.resolveReference(),
@ -207,6 +215,7 @@ private fun SinglePrimaryButton(config: NotificationButtonsState.PrimaryButtonCo
onClick = config.onClick, onClick = config.onClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
size = TangemButtonSize.WideAction, size = TangemButtonSize.WideAction,
enabled = isEnabled,
) )
} else { } else {
PrimaryButton( PrimaryButton(
@ -214,18 +223,20 @@ private fun SinglePrimaryButton(config: NotificationButtonsState.PrimaryButtonCo
onClick = config.onClick, onClick = config.onClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
size = TangemButtonSize.WideAction, size = TangemButtonSize.WideAction,
enabled = isEnabled,
) )
} }
} }
@Composable @Composable
private fun PairButtons(config: NotificationButtonsState.PairButtonsConfig) { private fun PairButtons(config: NotificationButtonsState.PairButtonsConfig, isEnabled: Boolean = true) {
Row(horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8)) { Row(horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8)) {
SecondaryButton( SecondaryButton(
text = config.secondaryText.resolveReference(), text = config.secondaryText.resolveReference(),
onClick = config.onSecondaryClick, onClick = config.onSecondaryClick,
modifier = Modifier.weight(weight = 1f), modifier = Modifier.weight(weight = 1f),
size = TangemButtonSize.WideAction, size = TangemButtonSize.WideAction,
enabled = isEnabled,
) )
PrimaryButton( PrimaryButton(
@ -233,12 +244,13 @@ private fun PairButtons(config: NotificationButtonsState.PairButtonsConfig) {
onClick = config.onPrimaryClick, onClick = config.onPrimaryClick,
modifier = Modifier.weight(weight = 1f), modifier = Modifier.weight(weight = 1f),
size = TangemButtonSize.WideAction, size = TangemButtonSize.WideAction,
enabled = isEnabled,
) )
} }
} }
@Composable @Composable
private fun CloseableIconButton(onClick: (() -> Unit)?, modifier: Modifier = Modifier) { private fun CloseableIconButton(onClick: (() -> Unit)?, modifier: Modifier = Modifier, isEnabled: Boolean = true) {
AnimatedVisibility(visible = onClick != null, modifier = modifier) { AnimatedVisibility(visible = onClick != null, modifier = modifier) {
onClick ?: return@AnimatedVisibility onClick ?: return@AnimatedVisibility
@ -255,6 +267,7 @@ private fun CloseableIconButton(onClick: (() -> Unit)?, modifier: Modifier = Mod
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current, indication = LocalIndication.current,
role = Role.Button, role = Role.Button,
enabled = isEnabled,
onClick = onClick, onClick = onClick,
), ),
) { ) {

View file

@ -49,7 +49,11 @@ fun NotificationWithBackground(config: NotificationConfig, modifier: Modifier =
modifier = modifier modifier = modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size62) .defaultMinSize(minHeight = TangemTheme.dimens.size62)
.fillMaxWidth() .fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium), .clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable(
enabled = config.onClick != null,
onClick = config.onClick ?: {},
),
) { ) {
val (iconRef, titleRef, subtitleRef, closeIconRef, buttonRef, backgroundRef) = createRefs() val (iconRef, titleRef, subtitleRef, closeIconRef, buttonRef, backgroundRef) = createRefs()

View file

@ -0,0 +1,215 @@
package com.tangem.core.ui.components.notifications
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.ScaleFactor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonColors
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemColorPalette.White
import com.tangem.core.ui.res.TangemTheme
/**
* Travala notification with image background
* @see <a href="https://www.figma.com/file/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?type=design&node-id=11690-12057&mode=design&t=eFnsA9sNytcQIoQ4-4">Travala Promo</a>
*/
@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries")
@Composable
fun TravalaNotificationWithBackground(config: NotificationConfig, modifier: Modifier = Modifier) {
val button = config.buttonsState as? NotificationConfig.ButtonsState.SecondaryButtonConfig
Box(
modifier = modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size62)
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(Color.Black)
.clickable(
enabled = config.onClick != null,
onClick = config.onClick ?: {},
),
propagateMinConstraints = true,
contentAlignment = Alignment.TopStart,
) {
val density = LocalDensity.current
Image(
painter = painterResource(R.drawable.img_travala_banner_promo_background),
contentDescription = null,
contentScale = TravalaBackgroundScale(density),
alignment = Alignment.TopStart,
modifier = Modifier
.matchParentSize()
.wrapContentSize(unbounded = true, align = Alignment.TopStart)
.align(Alignment.TopStart),
)
Image(
painter = painterResource(R.drawable.img_travala_banner_promo_background_2),
contentDescription = null,
contentScale = TravalaBackgroundScale(density),
alignment = Alignment.TopStart,
modifier = Modifier
.matchParentSize()
.wrapContentSize(unbounded = true, align = Alignment.TopEnd)
.align(Alignment.TopEnd),
)
Column {
Row {
Box(modifier = Modifier.size(87.dp))
Column(
Modifier
.weight(1f)
.padding(top = TangemTheme.dimens.spacing12),
) {
Text(
text = config.title.resolveReference(),
style = TangemTheme.typography.button.copy(
lineBreak = LineBreak.Heading,
),
color = TangemTheme.colors.text.constantWhite,
)
SpacerH8()
Text(
text = formatSubtitle(config.subtitle.resolveReference()),
style = TangemTheme.typography.caption2.copy(
lineBreak = LineBreak.Heading,
),
color = TangemTheme.colors.text.constantWhite,
)
}
Icon(
painter = painterResource(id = R.drawable.ic_close_24),
contentDescription = null,
tint = TangemTheme.colors.text.constantWhite,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
start = TangemTheme.dimens.spacing2,
)
.size(TangemTheme.dimens.size16)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
) {
config.onCloseClick?.invoke()
},
)
}
TangemButton(
text = button?.text?.resolveReference().orEmpty(),
icon = TangemButtonIconPosition.None,
onClick = button?.onClick ?: {},
colors = TangemButtonColors(
backgroundColor = White.copy(alpha = 0.3f),
contentColor = White,
disabledBackgroundColor = TangemTheme.colors.button.disabled,
disabledContentColor = TangemTheme.colors.text.disabled,
),
enabled = true,
showProgress = false,
modifier = Modifier
.padding(TangemTheme.dimens.spacing12)
.fillMaxWidth(),
)
}
}
}
private const val TRAVALA_BACKGROUND_SRC_IMG_SCALE = 4
private class TravalaBackgroundScale(
val density: Density,
) : ContentScale {
override fun computeScaleFactor(srcSize: Size, dstSize: Size): ScaleFactor {
with(density) {
val originalWidth = (srcSize.width / TRAVALA_BACKGROUND_SRC_IMG_SCALE).dp.toPx()
val widthScale = originalWidth / srcSize.width
val originalHeight = (srcSize.height / TRAVALA_BACKGROUND_SRC_IMG_SCALE).dp.toPx()
val heightScale = originalHeight / srcSize.height
return ScaleFactor(widthScale, heightScale)
}
}
}
@Composable
private fun formatSubtitle(subtitle: String): AnnotatedString {
val pattern = Regex("\\*\\*(.*?)\\*\\*")
var startIndex = 0
val annotatedString = buildAnnotatedString {
pattern.findAll(subtitle).forEach { matchResult ->
val index = matchResult.range.first
val matchedValue = matchResult.groups[1]?.value ?: ""
// appends unformatted part
append(subtitle.substring(startIndex, index))
// applies style on ^^-wrapped parts
withStyle(SpanStyle(fontWeight = TangemTheme.typography.caption1.fontWeight)) {
append(matchedValue)
}
// goes to next part
startIndex = matchResult.range.last + 1
}
// appends remaining ending if exists
append(subtitle.substring(startIndex))
}
return annotatedString
}
//region preview
@Preview
@Composable
private fun TravalaNotificationWithBackgroundPreview() {
TangemTheme {
TravalaNotificationWithBackground(
config = NotificationConfig(
title = resourceReference(
id = R.string.main_travala_promotion_title,
),
subtitle = resourceReference(
id = R.string.main_travala_promotion_description,
formatArgs = wrappedList("May 13", "June 12"),
),
iconResId = R.drawable.img_swap_promo,
backgroundResId = R.drawable.img_travala_banner_promo_background,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(id = R.string.token_swap_promotion_button),
onClick = {},
),
),
)
}
}
//endregion

View file

@ -0,0 +1,56 @@
package com.tangem.core.ui.extensions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.getTextInNode
import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor
import org.intellij.markdown.parser.MarkdownParser
/** Markdown parser */
@Composable
fun rememberMarkdownParser() = remember {
MarkdownParser(CommonMarkFlavourDescriptor())
}
/**
* Styling markdown tree recursively
*
* @param markdownText original text
* @param node current processed node
*/
@Composable
fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode): AnnotatedString.Builder {
when (node.type) {
MarkdownElementTypes.MARKDOWN_FILE, MarkdownElementTypes.PARAGRAPH -> {
node.children.forEach { childNode ->
appendMarkdown(
markdownText = markdownText,
node = childNode,
)
}
}
MarkdownElementTypes.STRONG -> {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
node.children
.drop(2)
.dropLast(2)
.forEach { childNode ->
appendMarkdown(
markdownText = markdownText,
node = childNode,
)
}
}
}
else -> {
append(node.getTextInNode(markdownText).toString())
}
}
return this
}

View file

@ -8,6 +8,9 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import org.intellij.markdown.MarkdownElementTypes
/** /**
* Utility class for creating text as [String] or [StringRes]. * Utility class for creating text as [String] or [StringRes].
@ -160,6 +163,29 @@ fun TextReference.resolveReference(resources: Resources): String {
} }
} }
/** Resolve [TextReference] as [AnnotatedString] */
@Composable
fun TextReference.resolveAnnotatedReference(): AnnotatedString {
return when (this) {
is TextReference.Res -> {
val args = formatArgs
.map { if (it is TextReference) it.resolveReference() else it }
.toTypedArray()
formatAnnotated(stringResource(id = id, *args))
}
is TextReference.PluralRes -> formatAnnotated(
pluralStringResource(id, count, *formatArgs.toTypedArray()),
)
is TextReference.Str -> formatAnnotated(value)
is TextReference.Combined -> buildAnnotatedString {
refs.forEach {
append(formatAnnotated(it.resolveReference()))
}
}
}
}
/** Concatenate [this] reference with [ref] */ /** Concatenate [this] reference with [ref] */
operator fun TextReference.plus(ref: TextReference): TextReference { operator fun TextReference.plus(ref: TextReference): TextReference {
return when (this) { return when (this) {
@ -169,4 +195,14 @@ operator fun TextReference.plus(ref: TextReference): TextReference {
is TextReference.Str, is TextReference.Str,
-> TextReference.Combined(refs = wrappedList(this, ref)) -> TextReference.Combined(refs = wrappedList(this, ref))
} }
}
@Composable
private fun formatAnnotated(rawString: String): AnnotatedString {
val markdownDescriptor = rememberMarkdownParser()
val parsedTree = markdownDescriptor.parse(MarkdownElementTypes.MARKDOWN_FILE, rawString, true)
return buildAnnotatedString {
appendMarkdown(markdownText = rawString, node = parsedTree)
}
} }

View file

@ -1,5 +1,6 @@
package com.tangem.core.ui.utils package com.tangem.core.ui.utils
import android.text.format.DateFormat
import org.joda.time.DateTime import org.joda.time.DateTime
import org.joda.time.format.DateTimeFormat import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatter import org.joda.time.format.DateTimeFormatter
@ -55,6 +56,16 @@ object DateTimeFormatters {
.withLocale(Locale.getDefault()) .withLocale(Locale.getDefault())
} }
/**
* In API version < 24, there may be some problems with getting the best date and time format pattern.
*/
val dateMMMMd: DateTimeFormatter by lazy {
DateTimeFormatterBuilder()
.appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMM d"))
.toFormatter()
.withLocale(Locale.getDefault())
}
val dateTimeFormatter: DateTimeFormatter by lazy { val dateTimeFormatter: DateTimeFormatter by lazy {
DateTimeFormat.forPattern("dd.MM.yyyy HH:mm") DateTimeFormat.forPattern("dd.MM.yyyy HH:mm")
} }

View file

@ -10,9 +10,9 @@ import java.text.DecimalFormatSymbols
import java.util.Locale import java.util.Locale
private const val TEXT_CHUNK_THOUSAND = 3 private const val TEXT_CHUNK_THOUSAND = 3
private const val POINT_SEPARATOR = '.'
private const val COMMA_SEPARATOR = ','
private const val SCIENTIFIC_NOTATION = 'e' private const val SCIENTIFIC_NOTATION = 'e'
const val POINT_SEPARATOR = '.'
const val COMMA_SEPARATOR = ','
const val DECIMAL_SEPARATOR_LIMIT = 1 const val DECIMAL_SEPARATOR_LIMIT = 1
@Composable @Composable

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

View file

@ -23,7 +23,17 @@ internal class DefaultPromoRepository(
}.getOrNull() }.getOrNull()
} }
override suspend fun getTravalaPromoBanner(): PromoBanner? {
return runCatching(dispatchers.io) {
promoResponseConverter.convert(
tangemApi.getPromotionInfo(TRAVALA)
.getOrThrow(),
)
}.getOrNull()
}
private companion object { private companion object {
private const val CHANGELLY_NAME = "changelly" private const val CHANGELLY_NAME = "changelly"
private const val TRAVALA = "travala"
} }
} }

View file

@ -13,6 +13,7 @@ class PromoResponseConverter : Converter<PromotionInfoResponse, PromoBanner?> {
name = value.name, name = value.name,
bannerState = PromoBanner.BannerState( bannerState = PromoBanner.BannerState(
status = bannerState.status, status = bannerState.status,
link = bannerState.link,
timeline = PromoBanner.Timeline( timeline = PromoBanner.Timeline(
start = DateTime.parse(bannerState.timeline.start), start = DateTime.parse(bannerState.timeline.start),
end = DateTime.parse(bannerState.timeline.end), end = DateTime.parse(bannerState.timeline.end),

View file

@ -3,36 +3,48 @@ package com.tangem.data.settings
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_TRAVALA_PROMO_SHOWN_KEY
import com.tangem.datasource.local.preferences.utils.get import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.store import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.settings.repositories.SwapPromoRepository import com.tangem.domain.settings.repositories.PromoSettingsRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** /**
* Repository for showing swap promo notification. * Repository for showing swap promo notification.
*/ */
class DefaultSwapPromoRepository( class DefaultPromoSettingsRepository(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
) : SwapPromoRepository { ) : PromoSettingsRepository {
override fun isReadyToShowWalletPromo(): Flow<Boolean> { override fun isReadyToShowWalletSwapPromo(): Flow<Boolean> {
return appPreferencesStore.get(IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY, true) return appPreferencesStore.get(IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY, true)
} }
override fun isReadyToShowTokenPromo(): Flow<Boolean> { override fun isReadyToShowTokenSwapPromo(): Flow<Boolean> {
return appPreferencesStore.get(IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY, true) return appPreferencesStore.get(IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY, true)
} }
override suspend fun setNeverToShowWalletPromo() { override suspend fun setNeverToShowWalletSwapPromo() {
appPreferencesStore.store( appPreferencesStore.store(
key = IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY, key = IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY,
value = false, value = false,
) )
} }
override suspend fun setNeverToShowTokenPromo() { override suspend fun setNeverToShowTokenSwapPromo() {
appPreferencesStore.store( appPreferencesStore.store(
key = IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY, key = IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY,
value = false, value = false,
) )
} }
override fun isReadyToShowWalletTravalaPromo(): Flow<Boolean> {
return appPreferencesStore.get(IS_WALLET_TRAVALA_PROMO_SHOWN_KEY, true)
}
override suspend fun setNeverToShowWalletTravalaPromo() {
appPreferencesStore.store(
key = IS_WALLET_TRAVALA_PROMO_SHOWN_KEY,
value = false,
)
}
} }

View file

@ -2,10 +2,14 @@ package com.tangem.data.settings.di
import com.tangem.data.settings.DefaultAppRatingRepository import com.tangem.data.settings.DefaultAppRatingRepository
import com.tangem.data.settings.DefaultSettingsRepository import com.tangem.data.settings.DefaultSettingsRepository
import com.tangem.data.settings.DefaultPromoSettingsRepository
import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.data.settings.DefaultSwapPromoRepository import com.tangem.data.settings.DefaultSwapPromoRepository
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.repositories.PromoSettingsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.domain.settings.repositories.SwapPromoRepository import com.tangem.domain.settings.repositories.SwapPromoRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -31,7 +35,7 @@ internal object SettingsDataModule {
@Provides @Provides
@Singleton @Singleton
fun provideSwapPromoRepository(appPreferencesStore: AppPreferencesStore): SwapPromoRepository { fun providePromoSettingsSettingsRepository(appPreferencesStore: AppPreferencesStore): PromoSettingsRepository {
return DefaultSwapPromoRepository(appPreferencesStore = appPreferencesStore) return DefaultPromoSettingsRepository(appPreferencesStore = appPreferencesStore)
} }
} }

View file

@ -0,0 +1,8 @@
package com.tangem.domain.card
import arrow.core.Either
interface DeleteSavedAccessCodesUseCase {
suspend operator fun invoke(cardId: String): Either<Throwable, Unit>
}

View file

@ -30,6 +30,12 @@ interface CardTypesResolver {
fun isSatoshiFriendsWallet(): Boolean fun isSatoshiFriendsWallet(): Boolean
fun isBitcoinPizzaDayWallet(): Boolean
fun isVeChainWallet(): Boolean
fun isNewWorldEliteWallet(): Boolean
fun isWhiteWallet(): Boolean fun isWhiteWallet(): Boolean
fun isWallet2(): Boolean fun isWallet2(): Boolean

View file

@ -48,6 +48,12 @@ internal class TangemCardTypesResolver(
override fun isSatoshiFriendsWallet(): Boolean = card.batchId == SATOSHI_WALLET_BATCH_ID override fun isSatoshiFriendsWallet(): Boolean = card.batchId == SATOSHI_WALLET_BATCH_ID
override fun isBitcoinPizzaDayWallet(): Boolean = card.batchId == BITCOIN_PIZZA_DAY_WALLET_BATCH_ID
override fun isVeChainWallet(): Boolean = card.batchId == VECHAIN_WALLET_BATCH_ID
override fun isNewWorldEliteWallet(): Boolean = card.batchId == NEW_WORLD_ELITE_WALLET_BATCH_ID
override fun isWhiteWallet(): Boolean { override fun isWhiteWallet(): Boolean {
return walletData == null && card.firmwareVersion <= FirmwareVersion.HDWalletAvailable return walletData == null && card.firmwareVersion <= FirmwareVersion.HDWalletAvailable
} }
@ -154,5 +160,8 @@ internal class TangemCardTypesResolver(
const val WHITE_WALLET2_BATCH_ID = "AF15" const val WHITE_WALLET2_BATCH_ID = "AF15"
const val TRILLIANT_WALLET_BATCH_ID = "AF16" const val TRILLIANT_WALLET_BATCH_ID = "AF16"
const val AVRORA_WALLET_BATCH_ID = "AF18" const val AVRORA_WALLET_BATCH_ID = "AF18"
const val BITCOIN_PIZZA_DAY_WALLET_BATCH_ID = "AF33"
const val VECHAIN_WALLET_BATCH_ID = "AF29"
const val NEW_WORLD_ELITE_WALLET_BATCH_ID = "AF26"
} }
} }

View file

@ -1,11 +1,11 @@
package com.tangem.domain.settings package com.tangem.domain.settings
import com.tangem.domain.settings.repositories.SwapPromoRepository import com.tangem.domain.settings.repositories.PromoSettingsRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class ShouldShowSwapPromoTokenUseCase(private val swapPromoRepository: SwapPromoRepository) { class ShouldShowSwapPromoTokenUseCase(private val promoSettingsRepository: PromoSettingsRepository) {
operator fun invoke(): Flow<Boolean> = swapPromoRepository.isReadyToShowTokenPromo() operator fun invoke(): Flow<Boolean> = promoSettingsRepository.isReadyToShowTokenSwapPromo()
suspend fun neverToShow() = swapPromoRepository.setNeverToShowTokenPromo() suspend fun neverToShow() = promoSettingsRepository.setNeverToShowTokenSwapPromo()
} }

View file

@ -1,11 +1,11 @@
package com.tangem.domain.settings package com.tangem.domain.settings
import com.tangem.domain.settings.repositories.SwapPromoRepository import com.tangem.domain.settings.repositories.PromoSettingsRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class ShouldShowSwapPromoWalletUseCase(private val swapPromoRepository: SwapPromoRepository) { class ShouldShowSwapPromoWalletUseCase(private val promoSettingsRepository: PromoSettingsRepository) {
operator fun invoke(): Flow<Boolean> = swapPromoRepository.isReadyToShowWalletPromo() operator fun invoke(): Flow<Boolean> = promoSettingsRepository.isReadyToShowWalletSwapPromo()
suspend fun neverToShow() = swapPromoRepository.setNeverToShowWalletPromo() suspend fun neverToShow() = promoSettingsRepository.setNeverToShowWalletSwapPromo()
} }

View file

@ -0,0 +1,11 @@
package com.tangem.domain.settings
import com.tangem.domain.settings.repositories.PromoSettingsRepository
import kotlinx.coroutines.flow.Flow
class ShouldShowTravalaPromoWalletUseCase(private val promoSettingsRepository: PromoSettingsRepository) {
operator fun invoke(): Flow<Boolean> = promoSettingsRepository.isReadyToShowWalletTravalaPromo()
suspend fun neverToShow() = promoSettingsRepository.setNeverToShowWalletTravalaPromo()
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.settings.repositories
import kotlinx.coroutines.flow.Flow
interface PromoSettingsRepository {
fun isReadyToShowWalletSwapPromo(): Flow<Boolean>
fun isReadyToShowTokenSwapPromo(): Flow<Boolean>
suspend fun setNeverToShowWalletSwapPromo()
suspend fun setNeverToShowTokenSwapPromo()
fun isReadyToShowWalletTravalaPromo(): Flow<Boolean>
suspend fun setNeverToShowWalletTravalaPromo()
}

View file

@ -1,13 +0,0 @@
package com.tangem.domain.settings.repositories
import kotlinx.coroutines.flow.Flow
interface SwapPromoRepository {
fun isReadyToShowWalletPromo(): Flow<Boolean>
fun isReadyToShowTokenPromo(): Flow<Boolean>
suspend fun setNeverToShowWalletPromo()
suspend fun setNeverToShowTokenPromo()
}

View file

@ -26,6 +26,7 @@ dependencies {
/** Project - Other */ /** Project - Other */
implementation(projects.core.utils) implementation(projects.core.utils)
implementation(projects.libs.crypto)
/** Android - Other */ /** Android - Other */
implementation(deps.androidx.paging.runtime) implementation(deps.androidx.paging.runtime)

View file

@ -12,6 +12,7 @@ data class PromoBanner(
data class BannerState( data class BannerState(
val timeline: Timeline, val timeline: Timeline,
val status: String, val status: String,
val link: String?,
) )
data class Timeline( data class Timeline(

View file

@ -45,4 +45,6 @@ sealed class CryptoCurrencyWarning {
val startDateTime: DateTime, val startDateTime: DateTime,
val endDateTime: DateTime, val endDateTime: DateTime,
) : CryptoCurrencyWarning() ) : CryptoCurrencyWarning()
data object BeaconChainShutdown : CryptoCurrencyWarning()
} }

View file

@ -12,6 +12,7 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching import com.tangem.utils.coroutines.runCatching
import com.tangem.utils.isNullOrZero import com.tangem.utils.isNullOrZero
@ -74,6 +75,7 @@ class GetCurrencyWarningsUseCase(
*coinRelatedWarnings.toTypedArray(), *coinRelatedWarnings.toTypedArray(),
getNetworkUnavailableWarning(currencyStatus), getNetworkUnavailableWarning(currencyStatus),
getNetworkNoAccountWarning(currencyStatus), getNetworkNoAccountWarning(currencyStatus),
getBeaconChainShutdownWarning(currency.network.id),
) )
}.flowOn(dispatchers.io) }.flowOn(dispatchers.io)
} }
@ -261,6 +263,10 @@ class GetCurrencyWarningsUseCase(
} }
} }
private fun getBeaconChainShutdownWarning(networkId: Network.ID): CryptoCurrencyWarning.BeaconChainShutdown? {
return if (BlockchainUtils.isBeaconChain(networkId.value)) CryptoCurrencyWarning.BeaconChainShutdown else null
}
private fun BigDecimal?.isZero(): Boolean { private fun BigDecimal?.isZero(): Boolean {
return this?.signum() == 0 return this?.signum() == 0
} }

View file

@ -0,0 +1,24 @@
package com.tangem.domain.tokens
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetNetworkAddressesUseCase(
internal val networksRepository: NetworksRepository,
) {
operator fun invoke(userWalletId: UserWalletId, network: Network): Flow<String> =
networksRepository.getNetworkStatusesUpdates(userWalletId, setOf(network))
.map { networkStatuses ->
when (val networkStatus = networkStatuses.singleOrNull { it.network.id == network.id }?.value) {
is NetworkStatus.NoAccount -> networkStatus.address.defaultAddress.value
is NetworkStatus.Unreachable -> networkStatus.address?.defaultAddress?.value.orEmpty()
is NetworkStatus.Verified -> networkStatus.address.defaultAddress.value
else -> ""
}
}
}

View file

@ -5,4 +5,6 @@ import com.tangem.domain.promo.PromoBanner
interface PromoRepository { interface PromoRepository {
suspend fun getChangellyPromoBanner(): PromoBanner? suspend fun getChangellyPromoBanner(): PromoBanner?
suspend fun getTravalaPromoBanner(): PromoBanner?
} }

View file

@ -3,7 +3,7 @@ package com.tangem.domain.wallets.usecase
import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
/** /**
* Use case for getting list of user wallets * Use case for getting list of user wallets
* *
@ -15,4 +15,7 @@ class GetWalletsUseCase(private val userWalletsListManager: UserWalletsListManag
@Throws(IllegalArgumentException::class) @Throws(IllegalArgumentException::class)
operator fun invoke(): Flow<List<UserWallet>> = userWalletsListManager.userWallets operator fun invoke(): Flow<List<UserWallet>> = userWalletsListManager.userWallets
@Throws(IllegalArgumentException::class)
suspend fun invokeSync(): List<UserWallet>? = userWalletsListManager.userWallets.firstOrNull()
} }

View file

@ -7,4 +7,7 @@ interface SendFeatureToggles {
/** Availability of redesigned send screen */ /** Availability of redesigned send screen */
val isRedesignedSendEnabled: Boolean val isRedesignedSendEnabled: Boolean
/** Updates remote toggle */
suspend fun fetchNewSendEnabled()
} }

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.core.navigation) implementation(projects.core.navigation)
implementation(projects.core.analytics) implementation(projects.core.analytics)
implementation(projects.core.analytics.models) implementation(projects.core.analytics.models)
implementation(projects.core.datasource)
/** Common */ /** Common */
implementation(projects.common) implementation(projects.common)

View file

@ -1,8 +1,10 @@
package com.tangem.features.send.impl.di package com.tangem.features.send.impl.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.features.send.impl.featuretoggles.DefaultSendFeatureToggles import com.tangem.features.send.impl.featuretoggles.DefaultSendFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -18,7 +20,15 @@ internal object SendFeatureTogglesModule {
@Provides @Provides
@Singleton @Singleton
fun provideSendFeatureToggles(featureTogglesManager: FeatureTogglesManager): SendFeatureToggles { fun provideSendFeatureToggles(
return DefaultSendFeatureToggles(featureTogglesManager = featureTogglesManager) featureTogglesManager: FeatureTogglesManager,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): SendFeatureToggles {
return DefaultSendFeatureToggles(
featureTogglesManager = featureTogglesManager,
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
)
} }
} }

View file

@ -1,16 +1,41 @@
package com.tangem.features.send.impl.featuretoggles package com.tangem.features.send.impl.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import timber.log.Timber
/** /**
* Default implementation of Send feature toggles * Default implementation of Send feature toggles
* *
* @property featureTogglesManager manager for getting information about the availability of feature toggles * @property featureTogglesManager manager for getting information about the availability of feature toggles
* @property tangemTechApi api to get remote feature toggle for send
* @property dispatchers coroutine dispatchers
*/ */
internal class DefaultSendFeatureToggles( internal class DefaultSendFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager, private val featureTogglesManager: FeatureTogglesManager,
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) : SendFeatureToggles { ) : SendFeatureToggles {
private val remoteSendEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true)
override val isRedesignedSendEnabled: Boolean override val isRedesignedSendEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_SEND_SCREEN_ENABLED") get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_SEND_SCREEN_ENABLED") &&
remoteSendEnabled.value
override suspend fun fetchNewSendEnabled() {
runCatching(dispatchers.io) {
tangemTechApi.getFeatures().getOrThrow()
}.onSuccess { response ->
remoteSendEnabled.update { response.isNewSendEnabled }
}.onFailure {
Timber.e(it.localizedMessage, "Unable to fetch new send toggle")
}
}
} }

View file

@ -1,7 +1,9 @@
package com.tangem.features.send.impl.presentation.analytics package com.tangem.features.send.impl.presentation.analytics
import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
@ -66,9 +68,9 @@ internal sealed class SendAnalyticEvents(
data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened") data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened")
/** Selected fee (send after next screen opened) */ /** Selected fee (send after next screen opened) */
data class SelectedFee(val feeType: SelectedFeeType) : SendAnalyticEvents( data class SelectedFee(val feeType: AnalyticsParam.FeeType) : SendAnalyticEvents(
event = "Fee Selected", event = "Fee Selected",
params = mapOf("Fee Type" to feeType.name), params = mapOf("Fee Type" to feeType.value),
) )
/** Custom fee selected */ /** Custom fee selected */
@ -97,7 +99,16 @@ internal sealed class SendAnalyticEvents(
// region Transaction Result // region Transaction Result
/** Transaction send screen opened */ /** Transaction send screen opened */
data object TransactionScreenOpened : SendAnalyticEvents(event = "Transaction Sent Screen Opened") data class TransactionScreenOpened(
val token: String,
val feeType: AnalyticsParam.FeeType,
) : SendAnalyticEvents(
event = "Transaction Sent Screen Opened",
params = mapOf(
TOKEN to token,
FEE_TYPE to feeType.value,
),
)
/** Share button clicked */ /** Share button clicked */
data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share") data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share")
@ -145,12 +156,4 @@ internal enum class EnterAddressSource {
internal enum class SelectedCurrencyType(val value: String) { internal enum class SelectedCurrencyType(val value: String) {
Token("Token"), Token("Token"),
AppCurrency("App Currency"), AppCurrency("App Currency"),
}
internal enum class SelectedFeeType {
Min,
Max,
Fixed,
Normal,
Custom,
} }

View file

@ -2,8 +2,10 @@ package com.tangem.features.send.impl.presentation.analytics.utils
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.send.impl.presentation.analytics.SelectedCurrencyType import com.tangem.features.send.impl.presentation.analytics.SelectedCurrencyType
import com.tangem.features.send.impl.presentation.analytics.SelectedFeeType
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.analytics.SendScreenSource import com.tangem.features.send.impl.presentation.analytics.SendScreenSource
import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiState
@ -11,11 +13,13 @@ import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.utils.Provider import com.tangem.utils.Provider
internal class SendScreenAnalyticSender( internal class SendScreenAnalyticSender(
private val stateRouterProvider: Provider<StateRouter>, private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>, private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyProvider: Provider<CryptoCurrency>,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
) { ) {
fun send(prevScreen: SendUiStateType, state: SendUiState) { fun send(prevScreen: SendUiStateType, state: SendUiState) {
@ -73,16 +77,57 @@ internal class SendScreenAnalyticSender(
) )
} }
fun sendTransaction() {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val cryptoCurrency = cryptoCurrencyProvider()
val feeState = state.getFeeState(isEditState) ?: return
val recipientState = state.getRecipientState(isEditState) ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val feeType = getSendTransactionFeeType(feeSelectorState)
analyticsEventHandler.send(
SendAnalyticEvents.TransactionScreenOpened(
token = cryptoCurrency.symbol,
feeType = feeType,
),
)
analyticsEventHandler.send(
Basic.TransactionSent(
sentFrom = AnalyticsParam.TxSentFrom.Send(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
feeType = feeType,
),
memoType = getSendTransactionMemoType(recipientState.memoTextField),
),
)
}
private fun sendSelectedFeeAnalytics(feeSelectorState: FeeSelectorState.Content) { private fun sendSelectedFeeAnalytics(feeSelectorState: FeeSelectorState.Content) {
val type = when (feeSelectorState.fees) { val type = getSendTransactionFeeType(feeSelectorState)
is TransactionFee.Single -> SelectedFeeType.Fixed
is TransactionFee.Choosable -> when (feeSelectorState.selectedFee) {
FeeType.Slow -> SelectedFeeType.Min
FeeType.Market -> SelectedFeeType.Normal
FeeType.Fast -> SelectedFeeType.Max
FeeType.Custom -> SelectedFeeType.Custom
}
}
analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(type)) analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(type))
} }
private fun getSendTransactionFeeType(feeSelectorState: FeeSelectorState.Content): AnalyticsParam.FeeType =
when (feeSelectorState.fees) {
is TransactionFee.Single -> AnalyticsParam.FeeType.Fixed
is TransactionFee.Choosable -> when (feeSelectorState.selectedFee) {
FeeType.Slow -> AnalyticsParam.FeeType.Min
FeeType.Market -> AnalyticsParam.FeeType.Normal
FeeType.Fast -> AnalyticsParam.FeeType.Max
FeeType.Custom -> AnalyticsParam.FeeType.Custom
}
}
private fun getSendTransactionMemoType(
recipientMemo: SendTextField.RecipientMemo?,
): Basic.TransactionSent.MemoType {
val memo = recipientMemo?.value
return when {
memo?.isBlank() == true -> Basic.TransactionSent.MemoType.Empty
memo?.isNotBlank() == true -> Basic.TransactionSent.MemoType.Full
else -> Basic.TransactionSent.MemoType.Null
}
}
} }

View file

@ -1,15 +1,18 @@
package com.tangem.features.send.impl.presentation.domain package com.tangem.features.send.impl.presentation.domain
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.domain.wallets.models.UserWalletId
/** /**
* Available wallet to send * Available wallet to send
* *
* @property name wallet name * @property name wallet name
* @property userWalletId wallet id
* @property address blockchain address * @property address blockchain address
*/ */
@Immutable @Immutable
data class AvailableWallet( data class AvailableWallet(
val name: String, val name: String,
val userWalletId: UserWalletId,
val address: String, val address: String,
) )

View file

@ -54,7 +54,7 @@ internal sealed class SendNotification(val config: NotificationConfig) {
wrappedList(cryptoCurrency, utxoLimit, amountLimit), wrappedList(cryptoCurrency, utxoLimit, amountLimit),
), ),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig( buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_to, wrappedList(amountLimit)), text = resourceReference(R.string.send_notification_leave_button, wrappedList(amountLimit)),
onClick = onConfirmClick, onClick = onConfirmClick,
), ),
) )
@ -98,7 +98,7 @@ internal sealed class SendNotification(val config: NotificationConfig) {
title = resourceReference(R.string.send_notification_existential_deposit_title), title = resourceReference(R.string.send_notification_existential_deposit_title),
subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)), subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig( buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_existential_deposit_button, wrappedList(deposit)), text = resourceReference(R.string.send_notification_leave_button, wrappedList(deposit)),
onClick = onConfirmClick, onClick = onConfirmClick,
), ),
) )
@ -119,12 +119,13 @@ internal sealed class SendNotification(val config: NotificationConfig) {
), ),
) { ) {
data class HighFeeError( data class HighFeeError(
val currencyName: String,
val amount: String, val amount: String,
val onConfirmClick: () -> Unit, val onConfirmClick: () -> Unit,
val onCloseClick: () -> Unit, val onCloseClick: () -> Unit,
) : Warning( ) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title), title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)), subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(currencyName, amount)),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig( buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)), text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)),
onClick = onConfirmClick, onClick = onConfirmClick,
@ -156,7 +157,7 @@ internal sealed class SendNotification(val config: NotificationConfig) {
data class FeeCoverageNotification(val cryptoAmount: String, val fiatAmount: String) : Warning( data class FeeCoverageNotification(val cryptoAmount: String, val fiatAmount: String) : Warning(
title = resourceReference(R.string.send_network_fee_warning_title), title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference( subtitle = resourceReference(
R.string.send_network_fee_warning_content, R.string.common_network_fee_warning_content,
wrappedList(cryptoAmount, fiatAmount), wrappedList(cryptoAmount, fiatAmount),
), ),
) )

View file

@ -72,11 +72,12 @@ internal class SendNotificationFactory(
amountValue = amountValue, amountValue = amountValue,
feeValue = feeValue, feeValue = feeValue,
) )
val sendingAmount = calculateSubtractedAmount( val sendingAmount = checkAndCalculateSubtractedAmount(
isFeeCoverage = isFeeCoverage, isAmountSubtractAvailable = isFeeCoverage,
cryptoCurrencyStatus = cryptoCurrencyStatusProvider(), cryptoCurrencyStatus = cryptoCurrencyStatusProvider(),
amountValue = amountValue, amountValue = amountValue,
feeValue = feeValue, feeValue = feeValue,
reduceAmountBy = sendState.reduceAmountBy,
) )
buildList { buildList {
// errors // errors
@ -204,7 +205,7 @@ internal class SendNotificationFactory(
val spendingAmount = if (cryptoCurrency is CryptoCurrency.Token) { val spendingAmount = if (cryptoCurrency is CryptoCurrency.Token) {
feeAmount feeAmount
} else { } else {
feeAmount + receivedAmount receivedAmount
} }
val currencyDeposit = currencyChecksRepository.getExistentialDeposit( val currencyDeposit = currencyChecksRepository.getExistentialDeposit(
userWalletId, userWalletId,
@ -270,6 +271,7 @@ internal class SendNotificationFactory(
if (!ignoreAmountReduce && isTotalBalance && isTezos) { if (!ignoreAmountReduce && isTotalBalance && isTezos) {
add( add(
SendNotification.Warning.HighFeeError( SendNotification.Warning.HighFeeError(
currencyName = cryptoCurrencyStatus.currency.name,
amount = threshold.toPlainString(), amount = threshold.toPlainString(),
onConfirmClick = { onConfirmClick = {
clickIntents.onAmountReduceClick( clickIntents.onAmountReduceClick(

View file

@ -54,7 +54,7 @@ internal fun checkFeeCoverage(
/** /**
* Calculates subtracted amount * Calculates subtracted amount
*/ */
internal fun calculateSubtractedAmount( private fun calculateSubtractedAmount(
isFeeCoverage: Boolean, isFeeCoverage: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus, cryptoCurrencyStatus: CryptoCurrencyStatus,
amountValue: BigDecimal, amountValue: BigDecimal,

View file

@ -47,7 +47,7 @@ internal class BitcoinCustomFeeConverter(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
), ),
title = resourceReference(R.string.send_max_fee), title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer), footer = resourceReference(R.string.send_bitcoin_custom_fee_footer),
label = getFiatReference( label = getFiatReference(
rate = feeCurrency?.fiatRate, rate = feeCurrency?.fiatRate,
value = feeValue, value = feeValue,

View file

@ -43,7 +43,7 @@ internal class EthereumCustomFeeConverter(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
), ),
title = resourceReference(R.string.send_max_fee), title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer), footer = resourceReference(R.string.send_evm_custom_fee_footer),
label = getFiatReference( label = getFiatReference(
rate = feeCurrency?.fiatRate, rate = feeCurrency?.fiatRate,
value = feeValue, value = feeValue,

View file

@ -10,6 +10,7 @@ import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero import com.tangem.utils.isNullOrZero
import java.math.RoundingMode
internal class SendAmountFieldMaxAmountConverter( internal class SendAmountFieldMaxAmountConverter(
private val stateRouterProvider: Provider<StateRouter>, private val stateRouterProvider: Provider<StateRouter>,
@ -33,7 +34,7 @@ internal class SendAmountFieldMaxAmountConverter(
val isDoneActionEnabled = !decimalCryptoValue.isNullOrZero() val isDoneActionEnabled = !decimalCryptoValue.isNullOrZero()
val cryptoValue = decimalCryptoValue?.parseBigDecimal(cryptoDecimals).orEmpty() val cryptoValue = decimalCryptoValue?.parseBigDecimal(cryptoDecimals).orEmpty()
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals).orEmpty() val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals, roundingMode = RoundingMode.HALF_UP).orEmpty()
return state.copyWrapped( return state.copyWrapped(
isEditState = isEditState, isEditState = isEditState,
amountState = amountState.copy( amountState = amountState.copy(

View file

@ -4,7 +4,10 @@ import androidx.compose.animation.*
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -26,12 +29,15 @@ import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.shareText import com.tangem.core.ui.extensions.shareText
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen
import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.utils.getFiatFormatted
@Composable @Composable
internal fun SendNavigationButtons( internal fun SendNavigationButtons(
@ -172,21 +178,27 @@ private fun SendingText(
} }
if (feeFiat != null && sendingFiat != null) { if (feeFiat != null && sendingFiat != null) {
val sendingValue = BigDecimalFormatter.formatFiatAmount( val sendingValue = getFiatFormatted(
fiatAmount = sendingFiat, value = sendingFiat,
fiatCurrencyCode = feeState.appCurrency.code, currencySymbol = feeState.appCurrency.symbol,
fiatCurrencySymbol = feeState.appCurrency.symbol, currencyCode = feeState.appCurrency.code,
) )
val feeValue = BigDecimalFormatter.formatFiatAmount( val feeValue = getFiatFormatted(
fiatAmount = feeFiat, value = feeState.fee?.amount?.value,
fiatCurrencyCode = feeState.appCurrency.code, currencySymbol = feeState.appCurrency.symbol,
fiatCurrencySymbol = feeState.appCurrency.symbol, currencyCode = feeState.appCurrency.code,
) )
val textResource = remember(sendingValue, feeValue) {
resourceReference(
id = R.string.send_summary_transaction_description,
formatArgs = wrappedList(sendingValue, feeValue),
)
}
Text( Text(
text = stringResource(id = R.string.send_summary_transaction_description, sendingValue, feeValue), text = textResource.resolveAnnotatedReference(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = TangemTheme.typography.caption1, style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary, color = TangemTheme.colors.text.primary1,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(TangemTheme.dimens.spacing12), .padding(TangemTheme.dimens.spacing12),

View file

@ -33,7 +33,10 @@ import kotlinx.coroutines.flow.withIndex
@Composable @Composable
internal fun SendScreen(uiState: SendUiState, currentState: SendUiCurrentScreen) { internal fun SendScreen(uiState: SendUiState, currentState: SendUiCurrentScreen) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
BackHandler(onBack = uiState.clickIntents::onBackClick) val onBackClick = uiState.clickIntents::onBackClick.takeIf {
uiState.sendState?.isSending != true
} ?: {}
BackHandler(onBack = onBackClick)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View file

@ -15,6 +15,7 @@ internal fun LazyListScope.notifications(
notifications: ImmutableList<SendNotification>, notifications: ImmutableList<SendNotification>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hasPaddingAbove: Boolean = false, hasPaddingAbove: Boolean = false,
isClickDisabled: Boolean = false,
) { ) {
itemsIndexed( itemsIndexed(
items = notifications, items = notifications,
@ -44,6 +45,7 @@ internal fun LazyListScope.notifications(
-> null -> null
is SendNotification.Error -> TangemTheme.colors.icon.warning is SendNotification.Error -> TangemTheme.colors.icon.warning
}, },
isEnabled = !isClickDisabled,
) )
}, },
) )

View file

@ -16,6 +16,7 @@ import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.rows.SelectorRowItem import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.features.send.impl.presentation.state.SendStates import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType import com.tangem.features.send.impl.presentation.state.fee.FeeType
@ -114,11 +115,14 @@ private fun FeeError(feeSelectorState: FeeSelectorState) {
private fun FeeSelectorState.Content.getAmount(feeType: FeeType): Amount? { private fun FeeSelectorState.Content.getAmount(feeType: FeeType): Amount? {
val choosableFees = fees as? TransactionFee.Choosable val choosableFees = fees as? TransactionFee.Choosable
val decimals = fees.normal.amount.decimals
val customValue = this.customValues.firstOrNull()?.value?.parseToBigDecimal(decimals)
val customAmount = fees.normal.amount.copy(value = customValue)
return when (feeType) { return when (feeType) {
FeeType.Slow -> choosableFees?.minimum?.amount FeeType.Slow -> choosableFees?.minimum?.amount
FeeType.Market -> fees.normal.amount FeeType.Market -> fees.normal.amount
FeeType.Fast -> choosableFees?.priority?.amount FeeType.Fast -> choosableFees?.priority?.amount
FeeType.Custom -> null FeeType.Custom -> customAmount
} }
} }

View file

@ -33,9 +33,10 @@ internal fun TextFieldWithPaste(
) { ) {
val (title, color) = when { val (title, color) = when {
isError && error != null -> error to TangemTheme.colors.text.warning isError && error != null -> error to TangemTheme.colors.text.warning
isReadOnly -> label to TangemTheme.colors.text.disabled isReadOnly -> label to TangemTheme.colors.text.tertiary
else -> label to TangemTheme.colors.text.secondary else -> label to TangemTheme.colors.text.secondary
} }
val placeholderColor = if (isReadOnly) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.disabled
FooterContainer(modifier, footer) { FooterContainer(modifier, footer) {
Box( Box(
modifier = Modifier modifier = Modifier
@ -59,6 +60,7 @@ internal fun TextFieldWithPaste(
SimpleTextField( SimpleTextField(
value = value, value = value,
placeholder = placeholder, placeholder = placeholder,
placeholderColor = placeholderColor,
onValueChange = onValueChange, onValueChange = onValueChange,
readOnly = isReadOnly, readOnly = isReadOnly,
modifier = Modifier modifier = Modifier

View file

@ -26,7 +26,7 @@ import com.tangem.features.send.impl.presentation.state.previewdata.AmountStateP
@Composable @Composable
internal fun AmountBlock( internal fun AmountBlock(
amountState: SendStates.AmountState, amountState: SendStates.AmountState,
isSuccess: Boolean, isClickDisabled: Boolean,
isEditingDisabled: Boolean, isEditingDisabled: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
@ -55,7 +55,7 @@ internal fun AmountBlock(
modifier = Modifier modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium) .clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor) .background(backgroundColor)
.clickable(enabled = !isSuccess && !isEditingDisabled, onClick = onClick) .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding( .padding(
vertical = TangemTheme.dimens.spacing14, vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing16, horizontal = TangemTheme.dimens.spacing16,
@ -92,7 +92,7 @@ private fun AmountBlockPreview(@PreviewParameter(AmountBlockPreviewProvider::cla
TangemThemePreview { TangemThemePreview {
AmountBlock( AmountBlock(
amountState = value, amountState = value,
isSuccess = false, isClickDisabled = false,
isEditingDisabled = false, isEditingDisabled = false,
onClick = {}, onClick = {},
) )

View file

@ -28,13 +28,13 @@ import com.tangem.features.send.impl.presentation.utils.getCryptoReference
import com.tangem.features.send.impl.presentation.utils.getFiatReference import com.tangem.features.send.impl.presentation.utils.getFiatReference
@Composable @Composable
internal fun FeeBlock(feeState: SendStates.FeeState, isSuccess: Boolean, onClick: () -> Unit) { internal fun FeeBlock(feeState: SendStates.FeeState, isClickDisabled: Boolean, onClick: () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium) .clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action) .background(TangemTheme.colors.background.action)
.clickable(enabled = !isSuccess, onClick = onClick) .clickable(enabled = !isClickDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing12), .padding(TangemTheme.dimens.spacing12),
) { ) {
Text( Text(
@ -118,7 +118,7 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va
TangemThemePreview { TangemThemePreview {
FeeBlock( FeeBlock(
feeState = value, feeState = value,
isSuccess = true, isClickDisabled = true,
onClick = {}, onClick = {},
) )
} }

View file

@ -25,7 +25,7 @@ import com.tangem.features.send.impl.presentation.state.previewdata.RecipientSta
@Composable @Composable
internal fun RecipientBlock( internal fun RecipientBlock(
recipientState: SendStates.RecipientState, recipientState: SendStates.RecipientState,
isSuccess: Boolean, isClickDisabled: Boolean,
isEditingDisabled: Boolean, isEditingDisabled: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
@ -40,7 +40,7 @@ internal fun RecipientBlock(
.fillMaxWidth() .fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium) .clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor) .background(backgroundColor)
.clickable(enabled = !isSuccess && !isEditingDisabled, onClick = onClick) .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing12), .padding(TangemTheme.dimens.spacing12),
) { ) {
AddressBlock(recipientState.addressTextField) AddressBlock(recipientState.addressTextField)
@ -107,7 +107,7 @@ private fun RecipientBlockPreview(
TangemThemePreview { TangemThemePreview {
RecipientBlock( RecipientBlock(
recipientState = value, recipientState = value,
isSuccess = true, isClickDisabled = true,
isEditingDisabled = false, isEditingDisabled = false,
onClick = {}, onClick = {},
) )

View file

@ -35,12 +35,13 @@ private const val TAP_HELP_ANIMATION_DELAY = 500L
@Composable @Composable
internal fun SendContent(uiState: SendUiState) { internal fun SendContent(uiState: SendUiState) {
val sendState = uiState.sendState ?: return val sendState = uiState.sendState ?: return
val isClickDisabled = sendState.isSending || sendState.isSuccess
LazyColumn( LazyColumn(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) { ) {
blocks(uiState) blocks(uiState)
tapHelp(isDisplay = sendState.showTapHelp) tapHelp(isDisplay = sendState.showTapHelp)
notifications(sendState.notifications) notifications(notifications = sendState.notifications, isClickDisabled = isClickDisabled)
} }
} }
@ -50,6 +51,7 @@ private fun LazyListScope.blocks(uiState: SendUiState) {
val feeState = uiState.feeState ?: return val feeState = uiState.feeState ?: return
val sendState = uiState.sendState ?: return val sendState = uiState.sendState ?: return
val isSuccess = sendState.isSuccess val isSuccess = sendState.isSuccess
val isClickDisabled = sendState.isSending || isSuccess
val timestamp = sendState.transactionDate val timestamp = sendState.transactionDate
item(key = BLOCKS_KEY) { item(key = BLOCKS_KEY) {
@ -65,19 +67,19 @@ private fun LazyListScope.blocks(uiState: SendUiState) {
} }
RecipientBlock( RecipientBlock(
recipientState = recipientState, recipientState = recipientState,
isSuccess = isSuccess, isClickDisabled = isClickDisabled,
isEditingDisabled = uiState.isEditingDisabled, isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showRecipient, onClick = uiState.clickIntents::showRecipient,
) )
AmountBlock( AmountBlock(
amountState = amountState, amountState = amountState,
isSuccess = isSuccess, isClickDisabled = isClickDisabled,
isEditingDisabled = uiState.isEditingDisabled, isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showAmount, onClick = uiState.clickIntents::showAmount,
) )
FeeBlock( FeeBlock(
feeState = feeState, feeState = feeState,
isSuccess = isSuccess, isClickDisabled = isClickDisabled,
onClick = uiState.clickIntents::showFee, onClick = uiState.clickIntents::showFee,
) )
} }

View file

@ -11,6 +11,7 @@ import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
private const val FIAT_DECIMALS = 2 private const val FIAT_DECIMALS = 2
private const val CRYPTO_FEE_DECIMALS = 6
private const val FEE_MINIMUM_VALUE = 0.01 private const val FEE_MINIMUM_VALUE = 0.01
internal fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): TextReference? { internal fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): TextReference? {
@ -21,7 +22,7 @@ internal fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): Tex
BigDecimalFormatter.formatCryptoAmount( BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amount.value, cryptoAmount = amount.value,
cryptoCurrency = amount.currencySymbol, cryptoCurrency = amount.currencySymbol,
decimals = amount.decimals, decimals = amount.decimals.coerceAtMost(CRYPTO_FEE_DECIMALS),
), ),
), ),
) )
@ -36,24 +37,27 @@ internal fun getFiatReference(value: BigDecimal?, rate: BigDecimal?, appCurrency
internal fun getFiatString(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurrency): String { internal fun getFiatString(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurrency): String {
if (value == null || rate == null) return EMPTY_BALANCE_SIGN if (value == null || rate == null) return EMPTY_BALANCE_SIGN
val feeValue = value.multiply(rate) val feeValue = value.multiply(rate)
val scaled = feeValue.setScale(FIAT_DECIMALS, RoundingMode.UP) ?: BigDecimal.ZERO return getFiatFormatted(feeValue, appCurrency.code, appCurrency.symbol)
val formattedValue = if (scaled < BigDecimal(FEE_MINIMUM_VALUE)) { }
internal fun getFiatFormatted(value: BigDecimal?, currencyCode: String, currencySymbol: String): String {
val scaled = value?.setScale(FIAT_DECIMALS, RoundingMode.UP) ?: BigDecimal.ZERO
return if (scaled < BigDecimal(FEE_MINIMUM_VALUE)) {
buildString { buildString {
append(BigDecimalFormatter.CAN_BE_LOWER_SIGN) append(BigDecimalFormatter.CAN_BE_LOWER_SIGN)
append( append(
BigDecimalFormatter.formatFiatAmount( BigDecimalFormatter.formatFiatAmount(
fiatAmount = BigDecimal(FEE_MINIMUM_VALUE), fiatAmount = BigDecimal(FEE_MINIMUM_VALUE),
fiatCurrencyCode = appCurrency.code, fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = appCurrency.symbol, fiatCurrencySymbol = currencySymbol,
), ),
) )
} }
} else { } else {
BigDecimalFormatter.formatFiatAmount( BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeValue, fiatAmount = value,
fiatCurrencyCode = appCurrency.code, fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = appCurrency.symbol, fiatCurrencySymbol = currencySymbol,
) )
} }
return formattedValue
} }

View file

@ -26,7 +26,6 @@ import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.utils.convertToAmount import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
@ -56,8 +55,9 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.* import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
@ -76,8 +76,8 @@ internal class SendViewModel @Inject constructor(
private val getWalletsUseCase: GetWalletsUseCase, private val getWalletsUseCase: GetWalletsUseCase,
private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase, private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val getCryptoCurrencyStatusesSyncUseCase: GetCryptoCurrencyStatusesSyncUseCase, private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase, private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val getFeeUseCase: GetFeeUseCase, private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase, private val sendTransactionUseCase: SendTransactionUseCase,
@ -92,6 +92,7 @@ internal class SendViewModel @Inject constructor(
private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
validateTransactionUseCase: ValidateTransactionUseCase, validateTransactionUseCase: ValidateTransactionUseCase,
currencyChecksRepository: CurrencyChecksRepository, currencyChecksRepository: CurrencyChecksRepository,
isFeeApproximateUseCase: IsFeeApproximateUseCase, isFeeApproximateUseCase: IsFeeApproximateUseCase,
@ -179,6 +180,7 @@ internal class SendViewModel @Inject constructor(
stateRouterProvider = Provider { stateRouter }, stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState }, currentStateProvider = Provider { uiState },
analyticsEventHandler = analyticsEventHandler, analyticsEventHandler = analyticsEventHandler,
cryptoCurrencyProvider = Provider { cryptoCurrency },
) )
} }
@ -187,6 +189,7 @@ internal class SendViewModel @Inject constructor(
private set private set
private var userWallet: UserWallet by Delegates.notNull() private var userWallet: UserWallet by Delegates.notNull()
private var userWallets: List<AvailableWallet> = emptyList()
private var isAmountSubtractAvailable: Boolean = false private var isAmountSubtractAvailable: Boolean = false
private var isTapHelpPreviewEnabled: Boolean = false private var isTapHelpPreviewEnabled: Boolean = false
private var coinCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() private var coinCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
@ -399,57 +402,48 @@ internal class SendViewModel @Inject constructor(
} }
private fun getUserWallets() { private fun getUserWallets() {
getWalletsUseCase() viewModelScope.launch(dispatchers.main) {
.conflate() runCatching {
.distinctUntilChanged() getWalletsUseCase.invokeSync()
.onEach { userWallets -> ?.toAvailableWallets()
coroutineScope { .orEmpty()
runCatching { }.onSuccess { result ->
userWallets combine(*result.toTypedArray()) { it }
.filterNot { it.walletId == userWalletId || it.isLocked } .onEach {
.map { wallet -> userWallets = it.filterNotNull().toList()
async(dispatchers.io) { wallet.toAvailableWallet() } uiState = stateFactory.onLoadedWalletsList(wallets = userWallets)
}.awaitAll()
}.onSuccess { result ->
uiState = stateFactory.onLoadedWalletsList(wallets = result)
}.onFailure {
uiState = stateFactory.onLoadedWalletsList(wallets = emptyList())
} }
} .flowOn(dispatchers.main)
} .launchIn(viewModelScope)
.flowOn(dispatchers.main) }.onFailure {
.launchIn(viewModelScope) uiState = stateFactory.onLoadedWalletsList(wallets = emptyList())
}
private suspend fun UserWallet.toAvailableWallet(): AvailableWallet? {
return if (!isMultiCurrency) {
val status = getCryptoCurrencyStatusSyncUseCase(walletId).getOrNull()
val address = status?.value?.networkAddress.takeIf {
status?.currency?.network?.id == cryptoCurrency.network.id &&
status.currency.network.derivationPath !is Network.DerivationPath.Custom
}
address?.let {
AvailableWallet(
name = name,
address = it.defaultAddress.value,
)
}
} else {
val statuses = getCryptoCurrencyStatusesSyncUseCase(walletId).getOrNull()
val walletCurrency = statuses?.firstOrNull {
it.currency.network.id == cryptoCurrency.network.id &&
it.currency.network.derivationPath !is Network.DerivationPath.Custom
}
val address = walletCurrency?.value?.networkAddress
address?.let {
AvailableWallet(
name = name,
address = it.defaultAddress.value,
)
} }
} }
} }
private suspend fun List<UserWallet>.toAvailableWallets(): List<Flow<AvailableWallet?>> =
filterNot { it.walletId == userWalletId || it.isLocked }
.mapNotNull { wallet ->
val status = if (!wallet.isMultiCurrency) {
getCryptoCurrencyUseCase(wallet.walletId).getOrNull()?.let {
if (it.network.id == cryptoCurrency.network.id) {
getNetworkAddressesUseCase(wallet.walletId, it.network)
} else {
null
}
}
} else {
getNetworkAddressesUseCase(wallet.walletId, cryptoCurrency.network)
}
status?.map { address ->
AvailableWallet(
name = wallet.name,
address = address,
userWalletId = wallet.walletId,
)
}
}
private suspend fun getTxHistory() { private suspend fun getTxHistory() {
val txHistoryList = getFixedTxHistoryItemsUseCase.getSync( val txHistoryList = getFixedTxHistoryItemsUseCase.getSync(
userWalletId = userWalletId, userWalletId = userWalletId,
@ -874,11 +868,25 @@ internal class SendViewModel @Inject constructor(
uiState = stateFactory.getSendingStateUpdate(isSending = false) uiState = stateFactory.getSendingStateUpdate(isSending = false)
updateTransactionStatus(txData) updateTransactionStatus(txData)
scheduleBalanceUpdate() scheduleBalanceUpdate()
analyticsEventHandler.send(SendAnalyticEvents.TransactionScreenOpened) addTokenToWalletIfNeeded()
sendScreenAnalyticSender.sendTransaction()
}, },
) )
} }
private fun addTokenToWalletIfNeeded() {
if (cryptoCurrency !is CryptoCurrency.Token) return
val recipientState = uiState.getRecipientState(stateRouter.isEditState) ?: return
val destinationAddress = recipientState.addressTextField.value
val maybeUserWallet = userWallets.firstOrNull { it.address == destinationAddress } ?: return
viewModelScope.launch(dispatchers.io) {
addCryptoCurrenciesUseCase(userWalletId = maybeUserWallet.userWalletId, currency = cryptoCurrency)
}
}
private suspend fun updateTransactionStatus(txData: TransactionData) { private suspend fun updateTransactionStatus(txData: TransactionData) {
val txUrl = getExplorerTransactionUrlUseCase( val txUrl = getExplorerTransactionUrlUseCase(
userWalletId = userWalletId, userWalletId = userWalletId,

View file

@ -216,6 +216,7 @@ internal class StateBuilder(
val warnings = getWarningsForSuccessState( val warnings = getWarningsForSuccessState(
quoteModel = quoteModel, quoteModel = quoteModel,
fromToken = fromToken, fromToken = fromToken,
selectedFeeType = selectedFeeType,
) )
val feeState = createFeeState(quoteModel.txFee, selectedFeeType) val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
@ -314,12 +315,14 @@ internal class StateBuilder(
private fun getWarningsForSuccessState( private fun getWarningsForSuccessState(
quoteModel: SwapState.QuotesLoadedState, quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency, fromToken: CryptoCurrency,
ignoreAmountReduce: Boolean,
selectedFeeType: FeeType,
): List<SwapWarning> { ): List<SwapWarning> {
val warnings = mutableListOf<SwapWarning>() val warnings = mutableListOf<SwapWarning>()
maybeAddDomainWarnings(quoteModel, warnings) maybeAddDomainWarnings(quoteModel, warnings)
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings) maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken) maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings) maybeAddNetworkFeeCoverageWarning(quoteModel, warnings, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings) maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
maybeAddInsufficientFundsWarning(quoteModel, warnings) maybeAddInsufficientFundsWarning(quoteModel, warnings)
maybeAddTransactionInProgressWarning(quoteModel, warnings) maybeAddTransactionInProgressWarning(quoteModel, warnings)
@ -460,18 +463,37 @@ internal class StateBuilder(
private fun maybeAddNetworkFeeCoverageWarning( private fun maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState, quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>, warnings: MutableList<SwapWarning>,
selectedFeeType: FeeType,
) { ) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) { when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included -> is IncludeFeeInAmount.Included -> {
val fee = selectFeeByType(selectedFeeType, quoteModel.txFee) ?: return
warnings.add( warnings.add(
SwapWarning.GeneralWarning( SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(), createNetworkFeeCoverageNotificationConfig(
quoteModel.fromTokenInfo.tokenAmount.getFormattedCryptoAmount(
quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency,
),
fee.feeFiatFormatted,
),
), ),
) )
}
else -> Unit else -> Unit
} }
} }
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? {
return when (txFeeState) {
TxFeeState.Empty -> null
is TxFeeState.SingleFeeState -> txFeeState.fee
is TxFeeState.MultipleFeeState -> when (feeType) {
FeeType.NORMAL -> txFeeState.normalFee
FeeType.PRIORITY -> txFeeState.priorityFee
}
}
}
private fun maybeAddUnableCoverFeeWarning( private fun maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState, quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency, fromToken: CryptoCurrency,
@ -547,12 +569,12 @@ internal class StateBuilder(
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = mutableListOf<SwapWarning>() val warnings = mutableListOf<SwapWarning>()
warnings.add(getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency)) warnings.add(getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency))
if (includeFeeInAmount is IncludeFeeInAmount.Included) { if (includeFeeInAmount is IncludeFeeInAmount.Included && uiStateHolder.fee is FeeItemState.Content) {
warnings.add( val feeCoverageNotification = createNetworkFeeCoverageNotificationConfig(
SwapWarning.GeneralWarning( fromToken.tokenAmount.getFormattedCryptoAmount(fromToken.cryptoCurrencyStatus.currency),
createNetworkFeeCoverageNotificationConfig(), uiStateHolder.fee.amountFiatFormatted,
),
) )
warnings.add(SwapWarning.GeneralWarning(feeCoverageNotification))
} }
val providerState = getProviderStateForError( val providerState = getProviderStateForError(
swapProvider = swapProvider, swapProvider = swapProvider,
@ -1402,10 +1424,16 @@ internal class StateBuilder(
) )
} }
private fun createNetworkFeeCoverageNotificationConfig(): NotificationConfig { private fun createNetworkFeeCoverageNotificationConfig(
cryptoAmount: String,
fiatAmount: String,
): NotificationConfig {
return NotificationConfig( return NotificationConfig(
title = resourceReference(R.string.send_network_fee_warning_title), title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(R.string.swapping_network_fee_warning_content), subtitle = resourceReference(
R.string.common_network_fee_warning_content,
wrappedList(cryptoAmount, fiatAmount),
),
iconResId = R.drawable.img_attention_20, iconResId = R.drawable.img_attention_20,
) )
} }

View file

@ -41,6 +41,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.TopUpWithoutReserve, is TokenDetailsNotification.TopUpWithoutReserve,
is TokenDetailsNotification.RentInfo, is TokenDetailsNotification.RentInfo,
is TokenDetailsNotification.SwapPromo, is TokenDetailsNotification.SwapPromo,
is TokenDetailsNotification.NetworkShutdown,
-> null -> null
} }
} }

View file

@ -155,7 +155,11 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
), ),
) )
class NetworksNoAccount(val network: String, val symbol: String, val amount: String) : Informational( data class NetworksNoAccount(
private val network: String,
private val symbol: String,
private val amount: String,
) : Informational(
title = resourceReference(R.string.warning_no_account_title), title = resourceReference(R.string.warning_no_account_title),
subtitle = resourceReference( subtitle = resourceReference(
id = R.string.no_account_generic, id = R.string.no_account_generic,
@ -167,4 +171,17 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
title = resourceReference(id = R.string.warning_no_account_title), title = resourceReference(id = R.string.warning_no_account_title),
subtitle = resourceReference(id = R.string.no_account_send_to_create), subtitle = resourceReference(id = R.string.no_account_send_to_create),
) )
class HasPendingTransactions(val coinSymbol: String) : Informational(
title = resourceReference(R.string.warning_send_blocked_pending_transactions_title),
subtitle = resourceReference(
id = R.string.warning_send_blocked_pending_transactions_message,
formatArgs = wrappedList(coinSymbol),
),
)
data class NetworkShutdown(private val title: TextReference, private val subtitle: TextReference) : Warning(
title = title,
subtitle = subtitle,
)
} }

View file

@ -3,12 +3,15 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification.*
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.removeBy import com.tangem.utils.extensions.removeBy
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -80,6 +83,10 @@ internal class TokenDetailsNotificationConverter(
onSwapClick = clickIntents::onSwapPromoClick, onSwapClick = clickIntents::onSwapPromoClick,
onCloseClick = clickIntents::onSwapPromoDismiss, onCloseClick = clickIntents::onSwapPromoDismiss,
) )
is CryptoCurrencyWarning.BeaconChainShutdown -> NetworkShutdown(
title = resourceReference(R.string.warning_beacon_chain_retirement_title),
subtitle = resourceReference(R.string.warning_beacon_chain_retirement_content),
)
} }
} }

View file

@ -121,4 +121,38 @@ sealed class WalletScreenAnalyticsEvent {
data object DeleteWalletTapped : MainScreen(event = "Button - Delete Wallet Tapped") data object DeleteWalletTapped : MainScreen(event = "Button - Delete Wallet Tapped")
} }
sealed class Promotion(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Promotion", event = event, params = params) {
class NoticePromotionBanner(
source: AnalyticsParam.ScreensSources,
programName: String,
) : Promotion(
event = "Notice - Promotion Banner",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to programName,
),
)
class PromotionBannerClicked(
source: AnalyticsParam.ScreensSources,
programName: String,
action: BannerAction,
) : Promotion(
event = "Promo Banner Clicked",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to programName,
"Action" to action.action,
),
) {
sealed class BannerAction(val action: String) {
data object Clicked : BannerAction(action = "Clicked")
data object Closed : BannerAction(action = "Closed")
}
}
}
} }

View file

@ -2,6 +2,8 @@ package com.tangem.feature.wallet.presentation.wallet.analytics.utils
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
@ -44,6 +46,10 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.Informational.MissingAddresses -> MainScreen.MissingAddresses is WalletNotification.Informational.MissingAddresses -> MainScreen.MissingAddresses
is WalletNotification.RateApp -> MainScreen.HowDoYouLikeTangem is WalletNotification.RateApp -> MainScreen.HowDoYouLikeTangem
is WalletNotification.Critical.BackupError -> MainScreen.BackupError is WalletNotification.Critical.BackupError -> MainScreen.BackupError
is WalletNotification.TravalaPromo -> WalletScreenAnalyticsEvent.Promotion.NoticePromotionBanner(
source = AnalyticsParam.ScreensSources.Main,
programName = "Travala",
)
is WalletNotification.UnlockWallets -> null // See [SelectedWalletAnalyticsSender] is WalletNotification.UnlockWallets -> null // See [SelectedWalletAnalyticsSender]
is WalletNotification.Informational.NoAccount, is WalletNotification.Informational.NoAccount,
is WalletNotification.Warning.LowSignatures, is WalletNotification.Warning.LowSignatures,

View file

@ -6,7 +6,7 @@ import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.promo.PromoBanner import com.tangem.domain.promo.PromoBanner
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase import com.tangem.domain.settings.ShouldShowTravalaPromoWalletUseCase
import com.tangem.domain.tokens.GetTokenListUseCase import com.tangem.domain.tokens.GetTokenListUseCase
import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
@ -34,7 +34,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val getTokenListUseCase: GetTokenListUseCase, private val getTokenListUseCase: GetTokenListUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase, private val shouldShowTravalaPromoWalletUseCase: ShouldShowTravalaPromoWalletUseCase,
private val promoRepository: PromoRepository, private val promoRepository: PromoRepository,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase, private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator, private val backupValidator: BackupValidator,
@ -45,18 +45,18 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotification>> { fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotification>> {
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
val promoFlow = flow { emit(promoRepository.getChangellyPromoBanner()) } val travalaPromoFlow = flow { emit(promoRepository.getTravalaPromoBanner()) }
return combine( return combine(
flow = getTokenListUseCase.launch(userWallet.walletId).conflate(), flow = getTokenListUseCase.launch(userWallet.walletId).conflate(),
flow2 = isReadyToShowRateAppUseCase().conflate(), flow2 = isReadyToShowRateAppUseCase().conflate(),
flow3 = isNeedToBackupUseCase(userWallet.walletId).conflate(), flow3 = isNeedToBackupUseCase(userWallet.walletId).conflate(),
flow4 = shouldShowSwapPromoWalletUseCase().conflate(), flow4 = shouldShowTravalaPromoWalletUseCase().conflate(),
flow5 = promoFlow, flow5 = travalaPromoFlow.conflate(),
) { maybeTokenList, isReadyToShowRating, isNeedToBackup, shouldShowPromo, promoBanner -> ) { maybeTokenList, isReadyToShowRating, isNeedToBackup, shouldShowTravalaPromo, promoBanner ->
readyForRateAppNotification = true readyForRateAppNotification = true
buildList { buildList {
addSwapPromoNotification(shouldShowPromo, promoBanner, clickIntents) addTravalaPromoNotification(shouldShowTravalaPromo, promoBanner, clickIntents)
addCriticalNotifications(userWallet) addCriticalNotifications(userWallet)
@ -69,16 +69,18 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
} }
} }
private fun MutableList<WalletNotification>.addSwapPromoNotification( private fun MutableList<WalletNotification>.addTravalaPromoNotification(
shouldShowPromo: Boolean, shouldShowPromo: Boolean,
promoBanner: PromoBanner?, promoBanner: PromoBanner?,
clickIntents: WalletClickIntents, clickIntents: WalletClickIntents,
) { ) {
promoBanner ?: return promoBanner ?: return
val promoNotification = WalletNotification.SwapPromo( val promoNotification = WalletNotification.TravalaPromo(
startDateTime = promoBanner.bannerState.timeline.start, startDateTime = promoBanner.bannerState.timeline.start,
endDateTime = promoBanner.bannerState.timeline.end, endDateTime = promoBanner.bannerState.timeline.end,
onCloseClick = clickIntents::onCloseSwapPromoClick, bannerLink = promoBanner.bannerState.link,
onBookNowButtonClick = clickIntents::onTravalaPromoClick,
onCloseClick = clickIntents::onCloseTravalaPromoClick,
) )
addIf( addIf(
element = promoNotification, element = promoNotification,

View file

@ -34,6 +34,9 @@ internal object WalletImageResolver {
cardTypesResolver.isJrWallet() -> userWallet.resolveJrWallet() cardTypesResolver.isJrWallet() -> userWallet.resolveJrWallet()
cardTypesResolver.isGrimWallet() -> userWallet.resolveGrimWallet() cardTypesResolver.isGrimWallet() -> userWallet.resolveGrimWallet()
cardTypesResolver.isSatoshiFriendsWallet() -> userWallet.resolveSatoshiWallet() cardTypesResolver.isSatoshiFriendsWallet() -> userWallet.resolveSatoshiWallet()
cardTypesResolver.isBitcoinPizzaDayWallet() -> userWallet.resolveBitcoinPizzaDayWallet()
cardTypesResolver.isVeChainWallet() -> userWallet.resolveVeChainWallet()
cardTypesResolver.isNewWorldEliteWallet() -> userWallet.resolveNewWorldEliteWallet()
cardTypesResolver.isWallet2() -> userWallet.resolveWallet2() cardTypesResolver.isWallet2() -> userWallet.resolveWallet2()
cardTypesResolver.isShibaWallet() -> userWallet.resolveShibaWallet() cardTypesResolver.isShibaWallet() -> userWallet.resolveShibaWallet()
cardTypesResolver.isTangemWallet() -> userWallet.resolveWallet1() cardTypesResolver.isTangemWallet() -> userWallet.resolveWallet1()
@ -130,6 +133,27 @@ internal object WalletImageResolver {
) )
} }
private fun UserWallet.resolveBitcoinPizzaDayWallet(): Int? {
return resolveWallet2(
oneBackupResId = R.drawable.ill_pizza_day_card2_120_106,
twoBackupResId = R.drawable.ill_pizza_day_card3_120_106,
)
}
private fun UserWallet.resolveVeChainWallet(): Int? {
return resolveWallet2(
oneBackupResId = R.drawable.ill_vechain_card2_120_106,
twoBackupResId = R.drawable.ill_vechain_card3_120_106,
)
}
private fun UserWallet.resolveNewWorldEliteWallet(): Int? {
return resolveWallet2(
oneBackupResId = R.drawable.ill_nwe_card2_120_106,
twoBackupResId = R.drawable.ill_nwe_card3_120_106,
)
}
private fun UserWallet.resolveWallet1(): Int? { private fun UserWallet.resolveWallet1(): Int? {
return resolveWalletWithBackups { count -> return resolveWalletWithBackups { count ->
when (count) { when (count) {

View file

@ -2,10 +2,8 @@ package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.impl.R
import org.joda.time.DateTime import org.joda.time.DateTime
@ -173,6 +171,34 @@ sealed class WalletNotification(val config: NotificationConfig) {
), ),
) )
data class TravalaPromo(
val startDateTime: DateTime,
val endDateTime: DateTime,
val bannerLink: String?,
val onBookNowButtonClick: (String?) -> Unit,
val onCloseClick: () -> Unit,
) : WalletNotification(
config = NotificationConfig(
title = resourceReference(id = R.string.main_travala_promotion_title),
subtitle = resourceReference(
id = R.string.main_travala_promotion_description,
wrappedList(
DateTimeFormatters.formatDate(startDateTime, DateTimeFormatters.dateMMMMd),
DateTimeFormatters.formatDate(endDateTime, DateTimeFormatters.dateMMMMd),
),
),
// Stub. Travala has its own Composable implementation with correct img
iconResId = R.drawable.ic_star_24,
// Stub. Travala has its own Composable implementation with correct img
backgroundResId = R.drawable.ic_star_24,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
onClick = { onBookNowButtonClick(bannerLink) },
text = resourceReference(R.string.main_travala_promotion_button),
),
onCloseClick = onCloseClick,
),
)
data class SwapPromo( data class SwapPromo(
val startDateTime: DateTime, val startDateTime: DateTime,
val endDateTime: DateTime, val endDateTime: DateTime,

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationWithBackground import com.tangem.core.ui.components.notifications.NotificationWithBackground
import com.tangem.core.ui.components.notifications.TravalaNotificationWithBackground
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -25,23 +26,33 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
key = { it::class.java }, key = { it::class.java },
contentType = { it::class.java }, contentType = { it::class.java },
itemContent = { itemContent = {
if (it is WalletNotification.SwapPromo) { // TODO develop promo banner general component
NotificationWithBackground( when (it) {
config = it.config, is WalletNotification.SwapPromo -> {
modifier = modifier.animateItemPlacement(), NotificationWithBackground(
) config = it.config,
} else { modifier = modifier.animateItemPlacement(),
Notification( )
config = it.config, }
modifier = modifier.animateItemPlacement(), is WalletNotification.TravalaPromo -> {
iconTint = when (it) { TravalaNotificationWithBackground(
is WalletNotification.Critical -> TangemTheme.colors.icon.warning config = it.config,
is WalletNotification.Informational -> TangemTheme.colors.icon.accent modifier = modifier.animateItemPlacement(),
is WalletNotification.RateApp -> TangemTheme.colors.icon.attention )
is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 }
else -> null else -> {
}, Notification(
) config = it.config,
modifier = modifier.animateItemPlacement(),
iconTint = when (it) {
is WalletNotification.Critical -> TangemTheme.colors.icon.warning
is WalletNotification.Informational -> TangemTheme.colors.icon.accent
is WalletNotification.RateApp -> TangemTheme.colors.icon.attention
is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1
else -> null
},
)
}
} }
}, },
) )

View file

@ -1,8 +1,12 @@
package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.card.DeleteSavedAccessCodesUseCase
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.UpdateWalletUseCase import com.tangem.domain.wallets.usecase.UpdateWalletUseCase
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
@ -32,9 +36,13 @@ internal class WalletCardClickIntentsImplementor @Inject constructor(
private val stateHolder: WalletStateController, private val stateHolder: WalletStateController,
private val walletEventSender: WalletEventSender, private val walletEventSender: WalletEventSender,
private val walletScreenContentLoader: WalletScreenContentLoader, private val walletScreenContentLoader: WalletScreenContentLoader,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val updateWalletUseCase: UpdateWalletUseCase, private val updateWalletUseCase: UpdateWalletUseCase,
private val deleteWalletUseCase: DeleteWalletUseCase, private val deleteWalletUseCase: DeleteWalletUseCase,
private val deleteSavedAccessCodesUseCase: DeleteSavedAccessCodesUseCase,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val reduxStateHolder: ReduxStateHolder,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : BaseWalletClickIntents(), WalletCardClickIntents { ) : BaseWalletClickIntents(), WalletCardClickIntents {
@ -72,7 +80,18 @@ internal class WalletCardClickIntentsImplementor @Inject constructor(
override fun onDeleteAfterConfirmationClick(userWalletId: UserWalletId) { override fun onDeleteAfterConfirmationClick(userWalletId: UserWalletId) {
viewModelScope.launch(dispatchers.main) { viewModelScope.launch(dispatchers.main) {
walletScreenContentLoader.cancel(userWalletId) walletScreenContentLoader.cancel(userWalletId)
val deletedUserWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return@launch
deleteSavedAccessCodesUseCase(cardId = deletedUserWallet.cardId)
.onLeft { Timber.e(it.toString()) }
deleteWalletUseCase(userWalletId) deleteWalletUseCase(userWalletId)
.onRight {
getSelectedWalletSyncUseCase().getOrNull()?.let {
reduxStateHolder.onUserWalletSelected(it)
}
}
.onLeft { Timber.e(it.toString()) } .onLeft { Timber.e(it.toString()) }
} }
} }

View file

@ -11,6 +11,7 @@ import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.settings.NeverToSuggestRateAppUseCase import com.tangem.domain.settings.NeverToSuggestRateAppUseCase
import com.tangem.domain.settings.RemindToRateAppLaterUseCase import com.tangem.domain.settings.RemindToRateAppLaterUseCase
import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase
import com.tangem.domain.settings.ShouldShowTravalaPromoWalletUseCase
import com.tangem.domain.tokens.FetchTokenListUseCase import com.tangem.domain.tokens.FetchTokenListUseCase
import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType
@ -19,6 +20,7 @@ import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.UnlockWalletsUseCase import com.tangem.domain.wallets.usecase.UnlockWalletsUseCase
import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.domain.ScanCardToUnlockWalletClickHandler import com.tangem.feature.wallet.presentation.wallet.domain.ScanCardToUnlockWalletClickHandler
@ -56,6 +58,10 @@ internal interface WalletWarningsClickIntents {
fun onCloseRateAppWarningClick() fun onCloseRateAppWarningClick()
fun onCloseSwapPromoClick() fun onCloseSwapPromoClick()
fun onTravalaPromoClick(link: String?)
fun onCloseTravalaPromoClick()
} }
@Suppress("LongParameterList") @Suppress("LongParameterList")
@ -75,6 +81,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
private val reduxStateHolder: ReduxStateHolder, private val reduxStateHolder: ReduxStateHolder,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase, private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase,
private val shouldShowTravalaPromoWalletUseCase: ShouldShowTravalaPromoWalletUseCase,
) : BaseWalletClickIntents(), WalletWarningsClickIntents { ) : BaseWalletClickIntents(), WalletWarningsClickIntents {
override fun onAddBackupCardClick() { override fun onAddBackupCardClick() {
@ -212,6 +219,34 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
} }
} }
override fun onTravalaPromoClick(link: String?) {
analyticsEventHandler.send(
WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked(
source = AnalyticsParam.ScreensSources.Main,
programName = "Travala",
action = WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked.BannerAction.Clicked,
),
)
link?.let {
viewModelScope.launch(dispatchers.main) {
router.openUrl(link)
}
}
}
override fun onCloseTravalaPromoClick() {
analyticsEventHandler.send(
WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked(
source = AnalyticsParam.ScreensSources.Main,
programName = "Travala",
action = WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked.BannerAction.Closed,
),
)
viewModelScope.launch(dispatchers.main) {
shouldShowTravalaPromoWalletUseCase.neverToShow()
}
}
private suspend fun getSelectedUserWallet(): UserWallet? { private suspend fun getSelectedUserWallet(): UserWallet? {
val userWalletId = stateHolder.getSelectedWalletId() val userWalletId = stateHolder.getSelectedWalletId()
return getUserWalletUseCase(userWalletId).getOrElse { return getUserWalletUseCase(userWalletId).getOrElse {

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -83,6 +83,7 @@ web3j = "4.10.1"
leakcanary = "2.13" leakcanary = "2.13"
decompose = "2.2.2" decompose = "2.2.2"
room = "2.6.1" room = "2.6.1"
markdown = "0.7.2"
# endregion Other libraries # endregion Other libraries
# region Tangem # region Tangem
@ -258,4 +259,5 @@ decompose-ext-compose = { module = "com.arkivanov.decompose:extensions-compose-j
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" }
# endregion Other # endregion Other

View file

@ -49,4 +49,10 @@ object BlockchainUtils {
val blockchain = Blockchain.fromId(networkId) val blockchain = Blockchain.fromId(networkId)
return blockchain == Blockchain.Cardano return blockchain == Blockchain.Cardano
} }
/** If current [networkId] is BeaconChain */
fun isBeaconChain(networkId: String): Boolean {
val blockchain = Blockchain.fromId(networkId)
return blockchain == Blockchain.Binance || blockchain == Blockchain.BinanceTestnet
}
} }