Updated on 2026-08-14

This commit is contained in:
Tangem 2024-05-17 10:06:12 +03:00
commit 968d9cfac8
88 changed files with 2530 additions and 1664 deletions

View file

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

View file

@ -7,8 +7,8 @@ import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.domain.settings.*
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.SwapPromoRepository
import com.tangem.tap.domain.sdk.TangemSdkManager
import com.tangem.tap.domain.settings.DefaultLegacySettingsRepository
import dagger.Module
@ -108,17 +108,25 @@ internal object SettingsDomainModule {
@Provides
@ViewModelScoped
fun provideShouldShowSwapPromoWalletUseCase(
swapPromoRepository: SwapPromoRepository,
promoSettingsRepository: PromoSettingsRepository,
): ShouldShowSwapPromoWalletUseCase {
return ShouldShowSwapPromoWalletUseCase(swapPromoRepository)
return ShouldShowSwapPromoWalletUseCase(promoSettingsRepository)
}
@Provides
@ViewModelScoped
fun provideShouldShowTravalaPromoWalletUseCase(
promoSettingsRepository: PromoSettingsRepository,
): ShouldShowTravalaPromoWalletUseCase {
return ShouldShowTravalaPromoWalletUseCase(promoSettingsRepository)
}
@Provides
@ViewModelScoped
fun provideShouldShowSwapPromoTokenUseCase(
swapPromoRepository: SwapPromoRepository,
promoSettingsRepository: PromoSettingsRepository,
): ShouldShowSwapPromoTokenUseCase {
return ShouldShowSwapPromoTokenUseCase(swapPromoRepository)
return ShouldShowSwapPromoTokenUseCase(promoSettingsRepository)
}
@Provides

View file

@ -346,4 +346,12 @@ internal object TokensDomainModule {
): RunPolkadotAccountHealthCheckUseCase {
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.sdk.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

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import retrofit2.http.*
*
[REDACTED_AUTHOR]
*/
@Suppress("TooManyFunctions")
interface TangemTechApi {
@GET("coins")
@ -126,4 +127,7 @@ interface TangemTechApi {
@Header("card_id") cardId: String,
@Path("account_id") accountId: Int,
): 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")
}
val IS_WALLET_TRAVALA_PROMO_SHOWN_KEY by lazy {
booleanPreferencesKey(name = "isWalletTravalaPromoShown")
}
val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") }
val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") }

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -95,10 +95,14 @@ fun AmountTextField(
SimpleTextField(
value = value,
onValueChange = { newText ->
if (decimalFormat.isValidSymbols(newText)) {
val trimmed = decimalFormat.getValidatedNumberWithFixedDecimals(newText, decimals)
onValueChange(trimmed)
}
onValueChange(
prepareEnter(
oldValue = value,
newValue = newText,
decimalFormat = decimalFormat,
decimals = decimals,
),
)
},
textStyle = textStyle.copy(
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 {
return checkDecimalSeparatorDuplicate(text) && checkGroupingSeparator(text)
return checkDecimalSeparatorDuplicate(text)
}
// region preview

View file

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

View file

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

View file

@ -49,7 +49,11 @@ fun NotificationWithBackground(config: NotificationConfig, modifier: Modifier =
modifier = modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size62)
.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()

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.ui.res.pluralStringResource
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].
@ -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] */
operator fun TextReference.plus(ref: TextReference): TextReference {
return when (this) {
@ -169,4 +195,14 @@ operator fun TextReference.plus(ref: TextReference): TextReference {
is TextReference.Str,
-> 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
import android.text.format.DateFormat
import org.joda.time.DateTime
import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatter
@ -55,6 +56,16 @@ object DateTimeFormatters {
.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 {
DateTimeFormat.forPattern("dd.MM.yyyy HH:mm")
}

View file

@ -10,9 +10,9 @@ import java.text.DecimalFormatSymbols
import java.util.Locale
private const val TEXT_CHUNK_THOUSAND = 3
private const val POINT_SEPARATOR = '.'
private const val COMMA_SEPARATOR = ','
private const val SCIENTIFIC_NOTATION = 'e'
const val POINT_SEPARATOR = '.'
const val COMMA_SEPARATOR = ','
const val DECIMAL_SEPARATOR_LIMIT = 1
@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()
}
override suspend fun getTravalaPromoBanner(): PromoBanner? {
return runCatching(dispatchers.io) {
promoResponseConverter.convert(
tangemApi.getPromotionInfo(TRAVALA)
.getOrThrow(),
)
}.getOrNull()
}
private companion object {
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,
bannerState = PromoBanner.BannerState(
status = bannerState.status,
link = bannerState.link,
timeline = PromoBanner.Timeline(
start = DateTime.parse(bannerState.timeline.start),
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.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_TRAVALA_PROMO_SHOWN_KEY
import com.tangem.datasource.local.preferences.utils.get
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
/**
* Repository for showing swap promo notification.
*/
class DefaultSwapPromoRepository(
class DefaultPromoSettingsRepository(
private val appPreferencesStore: AppPreferencesStore,
) : SwapPromoRepository {
override fun isReadyToShowWalletPromo(): Flow<Boolean> {
) : PromoSettingsRepository {
override fun isReadyToShowWalletSwapPromo(): Flow<Boolean> {
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)
}
override suspend fun setNeverToShowWalletPromo() {
override suspend fun setNeverToShowWalletSwapPromo() {
appPreferencesStore.store(
key = IS_WALLET_SWAP_PROMO_CHANGELLY_SHOW_KEY,
value = false,
)
}
override suspend fun setNeverToShowTokenPromo() {
override suspend fun setNeverToShowTokenSwapPromo() {
appPreferencesStore.store(
key = IS_TOKEN_SWAP_PROMO_CHANGELLY_SHOW_KEY,
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,11 +2,11 @@ package com.tangem.data.settings.di
import com.tangem.data.settings.DefaultAppRatingRepository
import com.tangem.data.settings.DefaultSettingsRepository
import com.tangem.data.settings.DefaultSwapPromoRepository
import com.tangem.data.settings.DefaultPromoSettingsRepository
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.repositories.SwapPromoRepository
import com.tangem.domain.settings.repositories.PromoSettingsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,7 +31,7 @@ internal object SettingsDataModule {
@Provides
@Singleton
fun provideSwapPromoRepository(appPreferencesStore: AppPreferencesStore): SwapPromoRepository {
return DefaultSwapPromoRepository(appPreferencesStore = appPreferencesStore)
fun providePromoSettingsSettingsRepository(appPreferencesStore: AppPreferencesStore): PromoSettingsRepository {
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 isBitcoinPizzaDayWallet(): Boolean
fun isVeChainWallet(): Boolean
fun isNewWorldEliteWallet(): Boolean
fun isWhiteWallet(): 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 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 {
return walletData == null && card.firmwareVersion <= FirmwareVersion.HDWalletAvailable
}
@ -154,5 +160,8 @@ internal class TangemCardTypesResolver(
const val WHITE_WALLET2_BATCH_ID = "AF15"
const val TRILLIANT_WALLET_BATCH_ID = "AF16"
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
import com.tangem.domain.settings.repositories.SwapPromoRepository
import com.tangem.domain.settings.repositories.PromoSettingsRepository
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
import com.tangem.domain.settings.repositories.SwapPromoRepository
import com.tangem.domain.settings.repositories.PromoSettingsRepository
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 */
implementation(projects.core.utils)
implementation(projects.libs.crypto)
/** Android - Other */
implementation(deps.androidx.paging.runtime)

View file

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

View file

@ -45,4 +45,6 @@ sealed class CryptoCurrencyWarning {
val startDateTime: DateTime,
val endDateTime: DateTime,
) : 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.feature.swap.domain.api.SwapRepository
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.runCatching
import com.tangem.utils.isNullOrZero
@ -74,6 +75,7 @@ class GetCurrencyWarningsUseCase(
*coinRelatedWarnings.toTypedArray(),
getNetworkUnavailableWarning(currencyStatus),
getNetworkNoAccountWarning(currencyStatus),
getBeaconChainShutdownWarning(currency.network.id),
)
}.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 {
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 {
suspend fun getChangellyPromoBanner(): PromoBanner?
suspend fun getTravalaPromoBanner(): PromoBanner?
}

View file

@ -3,6 +3,7 @@ package com.tangem.domain.wallets.usecase
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
/**
* Use case for getting list of user wallets
@ -15,4 +16,7 @@ class GetWalletsUseCase(private val userWalletsListManager: UserWalletsListManag
@Throws(IllegalArgumentException::class)
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 */
val isRedesignedSendEnabled: Boolean
/** Updates remote toggle */
suspend fun fetchNewSendEnabled()
}

View file

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

View file

@ -1,8 +1,10 @@
package com.tangem.features.send.impl.di
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.impl.featuretoggles.DefaultSendFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -18,7 +20,15 @@ internal object SendFeatureTogglesModule {
@Provides
@Singleton
fun provideSendFeatureToggles(featureTogglesManager: FeatureTogglesManager): SendFeatureToggles {
return DefaultSendFeatureToggles(featureTogglesManager = featureTogglesManager)
fun provideSendFeatureToggles(
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
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.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
*
* @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(
private val featureTogglesManager: FeatureTogglesManager,
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) : SendFeatureToggles {
private val remoteSendEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true)
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
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.FEE_TYPE
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.TYPE
@ -66,9 +68,9 @@ internal sealed class SendAnalyticEvents(
data object FeeScreenOpened : SendAnalyticEvents(event = "Fee 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",
params = mapOf("Fee Type" to feeType.name),
params = mapOf("Fee Type" to feeType.value),
)
/** Custom fee selected */
@ -97,7 +99,16 @@ internal sealed class SendAnalyticEvents(
// region Transaction Result
/** 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 */
data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share")
@ -145,12 +156,4 @@ internal enum class EnterAddressSource {
internal enum class SelectedCurrencyType(val value: String) {
Token("Token"),
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.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.SelectedFeeType
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.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.fee.FeeSelectorState
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
internal class SendScreenAnalyticSender(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyProvider: Provider<CryptoCurrency>,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
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) {
val type = when (feeSelectorState.fees) {
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
}
}
val type = getSendTransactionFeeType(feeSelectorState)
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
import androidx.compose.runtime.Immutable
import com.tangem.domain.wallets.models.UserWalletId
/**
* Available wallet to send
*
* @property name wallet name
* @property userWalletId wallet id
* @property address blockchain address
*/
@Immutable
data class AvailableWallet(
val name: String,
val userWalletId: UserWalletId,
val address: String,
)

View file

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

View file

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

View file

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

View file

@ -47,7 +47,7 @@ internal class BitcoinCustomFeeConverter(
keyboardType = KeyboardType.Number,
),
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(
rate = feeCurrency?.fiatRate,
value = feeValue,

View file

@ -43,7 +43,7 @@ internal class EthereumCustomFeeConverter(
keyboardType = KeyboardType.Number,
),
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(
rate = feeCurrency?.fiatRate,
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.converter.Converter
import com.tangem.utils.isNullOrZero
import java.math.RoundingMode
internal class SendAmountFieldMaxAmountConverter(
private val stateRouterProvider: Provider<StateRouter>,
@ -33,7 +34,7 @@ internal class SendAmountFieldMaxAmountConverter(
val isDoneActionEnabled = !decimalCryptoValue.isNullOrZero()
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(
isEditState = isEditState,
amountState = amountState.copy(

View file

@ -26,12 +26,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.TangemButtonsDefaults
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.wrappedList
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.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.utils.getFiatFormatted
@Composable
internal fun SendNavigationButtons(
@ -172,21 +175,27 @@ private fun SendingText(
}
if (feeFiat != null && sendingFiat != null) {
val sendingValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = sendingFiat,
fiatCurrencyCode = feeState.appCurrency.code,
fiatCurrencySymbol = feeState.appCurrency.symbol,
val sendingValue = getFiatFormatted(
value = sendingFiat,
currencySymbol = feeState.appCurrency.symbol,
currencyCode = feeState.appCurrency.code,
)
val feeValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeFiat,
fiatCurrencyCode = feeState.appCurrency.code,
fiatCurrencySymbol = feeState.appCurrency.symbol,
val feeValue = getFiatFormatted(
value = feeState.fee?.amount?.value,
currencySymbol = 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 = stringResource(id = R.string.send_summary_transaction_description, sendingValue, feeValue),
text = textResource.resolveAnnotatedReference(),
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing12),

View file

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

View file

@ -15,6 +15,7 @@ internal fun LazyListScope.notifications(
notifications: ImmutableList<SendNotification>,
modifier: Modifier = Modifier,
hasPaddingAbove: Boolean = false,
isClickDisabled: Boolean = false,
) {
itemsIndexed(
items = notifications,
@ -44,6 +45,7 @@ internal fun LazyListScope.notifications(
-> null
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.res.TangemTheme
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.fee.FeeSelectorState
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? {
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) {
FeeType.Slow -> choosableFees?.minimum?.amount
FeeType.Market -> fees.normal.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 {
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
}
val placeholderColor = if (isReadOnly) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.disabled
FooterContainer(modifier, footer) {
Box(
modifier = Modifier
@ -59,6 +60,7 @@ internal fun TextFieldWithPaste(
SimpleTextField(
value = value,
placeholder = placeholder,
placeholderColor = placeholderColor,
onValueChange = onValueChange,
readOnly = isReadOnly,
modifier = Modifier

View file

@ -26,7 +26,7 @@ import com.tangem.features.send.impl.presentation.state.previewdata.AmountStateP
@Composable
internal fun AmountBlock(
amountState: SendStates.AmountState,
isSuccess: Boolean,
isClickDisabled: Boolean,
isEditingDisabled: Boolean,
onClick: () -> Unit,
) {
@ -55,7 +55,7 @@ internal fun AmountBlock(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor)
.clickable(enabled = !isSuccess && !isEditingDisabled, onClick = onClick)
.clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing16,
@ -92,7 +92,7 @@ private fun AmountBlockPreview(@PreviewParameter(AmountBlockPreviewProvider::cla
TangemThemePreview {
AmountBlock(
amountState = value,
isSuccess = false,
isClickDisabled = false,
isEditingDisabled = false,
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
@Composable
internal fun FeeBlock(feeState: SendStates.FeeState, isSuccess: Boolean, onClick: () -> Unit) {
internal fun FeeBlock(feeState: SendStates.FeeState, isClickDisabled: Boolean, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(enabled = !isSuccess, onClick = onClick)
.clickable(enabled = !isClickDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing12),
) {
Text(
@ -118,7 +118,7 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va
TangemThemePreview {
FeeBlock(
feeState = value,
isSuccess = true,
isClickDisabled = true,
onClick = {},
)
}

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import java.math.BigDecimal
import java.math.RoundingMode
private const val FIAT_DECIMALS = 2
private const val CRYPTO_FEE_DECIMALS = 6
private const val FEE_MINIMUM_VALUE = 0.01
internal fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): TextReference? {
@ -21,7 +22,7 @@ internal fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): Tex
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amount.value,
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 {
if (value == null || rate == null) return EMPTY_BALANCE_SIGN
val feeValue = value.multiply(rate)
val scaled = feeValue.setScale(FIAT_DECIMALS, RoundingMode.UP) ?: BigDecimal.ZERO
val formattedValue = if (scaled < BigDecimal(FEE_MINIMUM_VALUE)) {
return getFiatFormatted(feeValue, appCurrency.code, appCurrency.symbol)
}
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 {
append(BigDecimalFormatter.CAN_BE_LOWER_SIGN)
append(
BigDecimalFormatter.formatFiatAmount(
fiatAmount = BigDecimal(FEE_MINIMUM_VALUE),
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = currencySymbol,
),
)
}
} else {
BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeValue,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
fiatAmount = value,
fiatCurrencyCode = currencyCode,
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.model.CryptoCurrency
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.utils.convertToAmount
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.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.math.BigDecimal
import java.util.Locale
@ -76,8 +76,8 @@ internal class SendViewModel @Inject constructor(
private val getWalletsUseCase: GetWalletsUseCase,
private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val getCryptoCurrencyStatusesSyncUseCase: GetCryptoCurrencyStatusesSyncUseCase,
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
@ -92,6 +92,7 @@ internal class SendViewModel @Inject constructor(
private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
validateTransactionUseCase: ValidateTransactionUseCase,
currencyChecksRepository: CurrencyChecksRepository,
isFeeApproximateUseCase: IsFeeApproximateUseCase,
@ -179,6 +180,7 @@ internal class SendViewModel @Inject constructor(
stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState },
analyticsEventHandler = analyticsEventHandler,
cryptoCurrencyProvider = Provider { cryptoCurrency },
)
}
@ -187,6 +189,7 @@ internal class SendViewModel @Inject constructor(
private set
private var userWallet: UserWallet by Delegates.notNull()
private var userWallets: List<AvailableWallet> = emptyList()
private var isAmountSubtractAvailable: Boolean = false
private var isTapHelpPreviewEnabled: Boolean = false
private var coinCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
@ -204,7 +207,6 @@ internal class SendViewModel @Inject constructor(
private var sendIdleTimer = 0L
init {
subscribeOnQRScannerResult()
subscribeOnCurrencyStatusUpdates()
subscribeOnBalanceHidden()
getTapHelpPreviewAvailability()
@ -371,7 +373,7 @@ internal class SendViewModel @Inject constructor(
cryptoCurrencyStatus = currencyStatus
coinCryptoCurrencyStatus = coinCurrencyStatus
feeCryptoCurrencyStatus = feeCurrencyStatus
subscribeOnQRScannerResult()
when {
uiState.sendState?.isSuccess == true -> {
stateRouter.showSend()
@ -399,57 +401,48 @@ internal class SendViewModel @Inject constructor(
}
private fun getUserWallets() {
getWalletsUseCase()
.conflate()
.distinctUntilChanged()
.onEach { userWallets ->
coroutineScope {
runCatching {
userWallets
.filterNot { it.walletId == userWalletId || it.isLocked }
.map { wallet ->
async(dispatchers.io) { wallet.toAvailableWallet() }
}.awaitAll()
}.onSuccess { result ->
uiState = stateFactory.onLoadedWalletsList(wallets = result)
}.onFailure {
uiState = stateFactory.onLoadedWalletsList(wallets = emptyList())
viewModelScope.launch(dispatchers.main) {
runCatching {
getWalletsUseCase.invokeSync()
?.toAvailableWallets()
.orEmpty()
}.onSuccess { result ->
combine(*result.toTypedArray()) { it }
.onEach {
userWallets = it.filterNotNull().toList()
uiState = stateFactory.onLoadedWalletsList(wallets = userWallets)
}
}
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
}
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,
)
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
}.onFailure {
uiState = stateFactory.onLoadedWalletsList(wallets = emptyList())
}
}
}
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() {
val txHistoryList = getFixedTxHistoryItemsUseCase.getSync(
userWalletId = userWalletId,
@ -636,7 +629,7 @@ internal class SendViewModel @Inject constructor(
}.saveIn(memoValidationJobHolder)
}
private suspend fun validateAddress(value: String): Boolean {
private suspend fun validateAddress(value: String): Boolean = runCatching {
val isValidAddress = validateWalletAddressUseCase(
userWalletId = userWalletId,
network = cryptoCurrency.network,
@ -646,7 +639,7 @@ internal class SendViewModel @Inject constructor(
?.any { it.value == value } ?: true
onEnteredValidAddress(isValidAddress, isAddressInWallet)
return isValidAddress
}
}.getOrElse { false }
private suspend fun checkIfXrpAddressValue(value: String): Boolean {
return BlockchainUtils.decodeRippleXAddress(value, cryptoCurrency.network.id.value)?.let { decodedAddress ->
@ -874,11 +867,25 @@ internal class SendViewModel @Inject constructor(
uiState = stateFactory.getSendingStateUpdate(isSending = false)
updateTransactionStatus(txData)
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) {
val txUrl = getExplorerTransactionUrlUseCase(
userWalletId = userWalletId,

View file

@ -216,6 +216,7 @@ internal class StateBuilder(
val warnings = getWarningsForSuccessState(
quoteModel = quoteModel,
fromToken = fromToken,
selectedFeeType = selectedFeeType,
)
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
@ -314,12 +315,13 @@ internal class StateBuilder(
private fun getWarningsForSuccessState(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
selectedFeeType: FeeType,
): List<SwapWarning> {
val warnings = mutableListOf<SwapWarning>()
maybeAddDomainWarnings(quoteModel, warnings)
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
maybeAddInsufficientFundsWarning(quoteModel, warnings)
maybeAddTransactionInProgressWarning(quoteModel, warnings)
@ -460,18 +462,37 @@ internal class StateBuilder(
private fun maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
selectedFeeType: FeeType,
) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included ->
is IncludeFeeInAmount.Included -> {
val fee = selectFeeByType(selectedFeeType, quoteModel.txFee) ?: return
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
createNetworkFeeCoverageNotificationConfig(
quoteModel.fromTokenInfo.tokenAmount.getFormattedCryptoAmount(
quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency,
),
fee.feeFiatFormatted,
),
),
)
}
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(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
@ -547,12 +568,12 @@ internal class StateBuilder(
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = mutableListOf<SwapWarning>()
warnings.add(getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency))
if (includeFeeInAmount is IncludeFeeInAmount.Included) {
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
),
if (includeFeeInAmount is IncludeFeeInAmount.Included && uiStateHolder.fee is FeeItemState.Content) {
val feeCoverageNotification = createNetworkFeeCoverageNotificationConfig(
fromToken.tokenAmount.getFormattedCryptoAmount(fromToken.cryptoCurrencyStatus.currency),
uiStateHolder.fee.amountFiatFormatted,
)
warnings.add(SwapWarning.GeneralWarning(feeCoverageNotification))
}
val providerState = getProviderStateForError(
swapProvider = swapProvider,
@ -1402,10 +1423,16 @@ internal class StateBuilder(
)
}
private fun createNetworkFeeCoverageNotificationConfig(): NotificationConfig {
private fun createNetworkFeeCoverageNotificationConfig(
cryptoAmount: String,
fiatAmount: String,
): NotificationConfig {
return NotificationConfig(
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,
)
}

View file

@ -41,6 +41,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.TopUpWithoutReserve,
is TokenDetailsNotification.RentInfo,
is TokenDetailsNotification.SwapPromo,
is TokenDetailsNotification.NetworkShutdown,
-> 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),
subtitle = resourceReference(
id = R.string.no_account_generic,
@ -167,4 +171,9 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
title = resourceReference(id = R.string.warning_no_account_title),
subtitle = resourceReference(id = R.string.no_account_send_to_create),
)
data class NetworkShutdown(private val title: TextReference, private val subtitle: TextReference) : Warning(
title = title,
subtitle = subtitle,
)
}

View file

@ -3,12 +3,14 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrency
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.components.TokenDetailsNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification.*
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.extensions.removeBy
import kotlinx.collections.immutable.ImmutableList
@ -80,6 +82,10 @@ internal class TokenDetailsNotificationConverter(
onSwapClick = clickIntents::onSwapPromoClick,
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")
}
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.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.state.model.WalletNotification
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.RateApp -> MainScreen.HowDoYouLikeTangem
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.Informational.NoAccount,
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.promo.PromoBanner
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.error.TokenListError
import com.tangem.domain.tokens.model.CryptoCurrency
@ -34,7 +34,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val getTokenListUseCase: GetTokenListUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase,
private val shouldShowTravalaPromoWalletUseCase: ShouldShowTravalaPromoWalletUseCase,
private val promoRepository: PromoRepository,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator,
@ -45,18 +45,18 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotification>> {
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
val promoFlow = flow { emit(promoRepository.getChangellyPromoBanner()) }
val travalaPromoFlow = flow { emit(promoRepository.getTravalaPromoBanner()) }
return combine(
flow = getTokenListUseCase.launch(userWallet.walletId).conflate(),
flow2 = isReadyToShowRateAppUseCase().conflate(),
flow3 = isNeedToBackupUseCase(userWallet.walletId).conflate(),
flow4 = shouldShowSwapPromoWalletUseCase().conflate(),
flow5 = promoFlow,
) { maybeTokenList, isReadyToShowRating, isNeedToBackup, shouldShowPromo, promoBanner ->
flow4 = shouldShowTravalaPromoWalletUseCase().conflate(),
flow5 = travalaPromoFlow.conflate(),
) { maybeTokenList, isReadyToShowRating, isNeedToBackup, shouldShowTravalaPromo, promoBanner ->
readyForRateAppNotification = true
buildList {
addSwapPromoNotification(shouldShowPromo, promoBanner, clickIntents)
addTravalaPromoNotification(shouldShowTravalaPromo, promoBanner, clickIntents)
addCriticalNotifications(userWallet)
@ -69,16 +69,18 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
}
}
private fun MutableList<WalletNotification>.addSwapPromoNotification(
private fun MutableList<WalletNotification>.addTravalaPromoNotification(
shouldShowPromo: Boolean,
promoBanner: PromoBanner?,
clickIntents: WalletClickIntents,
) {
promoBanner ?: return
val promoNotification = WalletNotification.SwapPromo(
val promoNotification = WalletNotification.TravalaPromo(
startDateTime = promoBanner.bannerState.timeline.start,
endDateTime = promoBanner.bannerState.timeline.end,
onCloseClick = clickIntents::onCloseSwapPromoClick,
bannerLink = promoBanner.bannerState.link,
onBookNowButtonClick = clickIntents::onTravalaPromoClick,
onCloseClick = clickIntents::onCloseTravalaPromoClick,
)
addIf(
element = promoNotification,

View file

@ -34,6 +34,9 @@ internal object WalletImageResolver {
cardTypesResolver.isJrWallet() -> userWallet.resolveJrWallet()
cardTypesResolver.isGrimWallet() -> userWallet.resolveGrimWallet()
cardTypesResolver.isSatoshiFriendsWallet() -> userWallet.resolveSatoshiWallet()
cardTypesResolver.isBitcoinPizzaDayWallet() -> userWallet.resolveBitcoinPizzaDayWallet()
cardTypesResolver.isVeChainWallet() -> userWallet.resolveVeChainWallet()
cardTypesResolver.isNewWorldEliteWallet() -> userWallet.resolveNewWorldEliteWallet()
cardTypesResolver.isWallet2() -> userWallet.resolveWallet2()
cardTypesResolver.isShibaWallet() -> userWallet.resolveShibaWallet()
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? {
return resolveWalletWithBackups { count ->
when (count) {

View file

@ -2,10 +2,8 @@ package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.feature.wallet.impl.R
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(
val startDateTime: DateTime,
val endDateTime: DateTime,

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.Notification
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.feature.wallet.presentation.wallet.state.model.WalletNotification
import kotlinx.collections.immutable.ImmutableList
@ -25,23 +26,33 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
key = { it::class.java },
contentType = { it::class.java },
itemContent = {
if (it is WalletNotification.SwapPromo) {
NotificationWithBackground(
config = it.config,
modifier = modifier.animateItemPlacement(),
)
} 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
},
)
// TODO develop promo banner general component
when (it) {
is WalletNotification.SwapPromo -> {
NotificationWithBackground(
config = it.config,
modifier = modifier.animateItemPlacement(),
)
}
is WalletNotification.TravalaPromo -> {
TravalaNotificationWithBackground(
config = it.config,
modifier = modifier.animateItemPlacement(),
)
}
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
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.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.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
@ -32,9 +36,13 @@ internal class WalletCardClickIntentsImplementor @Inject constructor(
private val stateHolder: WalletStateController,
private val walletEventSender: WalletEventSender,
private val walletScreenContentLoader: WalletScreenContentLoader,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val updateWalletUseCase: UpdateWalletUseCase,
private val deleteWalletUseCase: DeleteWalletUseCase,
private val deleteSavedAccessCodesUseCase: DeleteSavedAccessCodesUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val reduxStateHolder: ReduxStateHolder,
private val dispatchers: CoroutineDispatcherProvider,
) : BaseWalletClickIntents(), WalletCardClickIntents {
@ -72,7 +80,18 @@ internal class WalletCardClickIntentsImplementor @Inject constructor(
override fun onDeleteAfterConfirmationClick(userWalletId: UserWalletId) {
viewModelScope.launch(dispatchers.main) {
walletScreenContentLoader.cancel(userWalletId)
val deletedUserWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return@launch
deleteSavedAccessCodesUseCase(cardId = deletedUserWallet.cardId)
.onLeft { Timber.e(it.toString()) }
deleteWalletUseCase(userWalletId)
.onRight {
getSelectedWalletSyncUseCase().getOrNull()?.let {
reduxStateHolder.onUserWalletSelected(it)
}
}
.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.RemindToRateAppLaterUseCase
import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase
import com.tangem.domain.settings.ShouldShowTravalaPromoWalletUseCase
import com.tangem.domain.tokens.FetchTokenListUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
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.UnlockWalletsUseCase
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.MainScreen
import com.tangem.feature.wallet.presentation.wallet.domain.ScanCardToUnlockWalletClickHandler
@ -56,6 +58,10 @@ internal interface WalletWarningsClickIntents {
fun onCloseRateAppWarningClick()
fun onCloseSwapPromoClick()
fun onTravalaPromoClick(link: String?)
fun onCloseTravalaPromoClick()
}
@Suppress("LongParameterList")
@ -75,6 +81,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
private val reduxStateHolder: ReduxStateHolder,
private val dispatchers: CoroutineDispatcherProvider,
private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase,
private val shouldShowTravalaPromoWalletUseCase: ShouldShowTravalaPromoWalletUseCase,
) : BaseWalletClickIntents(), WalletWarningsClickIntents {
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? {
val userWalletId = stateHolder.getSelectedWalletId()
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,10 +83,11 @@ web3j = "4.10.1"
leakcanary = "2.13"
decompose = "2.2.2"
room = "2.6.1"
markdown = "0.7.2"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "develop-628"
tangemBlockchainSdk = "develop-633"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-354"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
@ -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-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" }
# endregion Other

View file

@ -49,4 +49,10 @@ object BlockchainUtils {
val blockchain = Blockchain.fromId(networkId)
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
}
}