Updated on 2026-08-14

This commit is contained in:
Tangem 2024-10-04 15:46:58 +04:00
parent 5b60d94e60
commit 01e6e50bcb
14 changed files with 131 additions and 82 deletions

View file

@ -287,6 +287,7 @@
<string name="feedback_subject_support">フィードバック</string>
<string name="feedback_subject_support_tangem">Tangemへのフィードバック</string>
<string name="feedback_subject_tx_failed">取引を送信できません</string>
<string name="feedback_token_description_error">コインの説明エラー</string>
<string name="give_permission_current_transaction">取引</string>
<string name="give_permission_fee_footer">ネットワークは、あなたがトークンのスワップを承認していることを確認するために、トークン承認手数料を請求します。</string>
<string name="give_permission_policy_type_footer">選択したトークンの承認制限を指定します</string>

View file

@ -71,7 +71,7 @@ fun Notification(
title = config.title,
subtitle = config.subtitle,
subtitleColor = subtitleColor,
isClickableComponent = isEnabled && config.onClick != null,
showArrowIcon = isEnabled && config.showArrowIcon,
)
}
}
@ -130,7 +130,7 @@ private fun MainContent(
title: TextReference?,
subtitle: TextReference,
subtitleColor: Color,
isClickableComponent: Boolean,
showArrowIcon: Boolean,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
@ -145,7 +145,7 @@ private fun MainContent(
TextsBlock(title = title, subtitle = subtitle, subtitleColor = subtitleColor)
if (isClickableComponent) {
if (showArrowIcon) {
SpacerWMax()
Icon(

View file

@ -24,6 +24,7 @@ data class NotificationConfig(
val buttonsState: ButtonsState? = null,
val onClick: (() -> Unit)? = null,
val onCloseClick: (() -> Unit)? = null,
val showArrowIcon: Boolean = onClick != null,
) {
sealed class ButtonsState {

View file

@ -37,8 +37,7 @@ internal class FeedbackDataBuilder {
if (tokens.isNotEmpty()) {
builder.breakLine()
tokens.forEach { token ->
builder.appendKeyValue("Token ID", token.id ?: "[custom token]")
builder.appendKeyValue("Name", token.name)
addTokenShortInfo(id = token.id, name = token.name)
builder.appendKeyValue("Contract address", token.contractAddress)
builder.appendKeyValue("Decimals", token.decimals)
@ -100,6 +99,11 @@ internal class FeedbackDataBuilder {
builder.appendKeyValue("Transaction ID", txId)
}
fun addTokenShortInfo(id: String?, name: String) {
builder.appendKeyValue("Token ID", id ?: "[custom token]")
builder.appendKeyValue("Name", name)
}
fun addDelimiter(): StringBuilder = builder.appendDelimiter()
fun build(): String = builder.trimEnd().toString()

View file

@ -54,11 +54,19 @@ class SendFeedbackEmailUseCase(
}
private fun StringBuilder.appendDisclaimerIfNeeded(type: FeedbackEmailType): StringBuilder {
return if (type is FeedbackEmailType.ScanningProblem) {
this
} else {
append(resources.getString(R.string.feedback_data_collection_message))
skipLine()
return when (type) {
is FeedbackEmailType.ScanningProblem,
is FeedbackEmailType.CurrencyDescriptionError,
-> this
is FeedbackEmailType.DirectUserRequest,
is FeedbackEmailType.RateCanBeBetter,
is FeedbackEmailType.StakingProblem,
is FeedbackEmailType.SwapProblem,
is FeedbackEmailType.TransactionSendingProblem,
-> {
append(resources.getString(R.string.feedback_data_collection_message))
skipLine()
}
}
}
}

View file

@ -36,4 +36,14 @@ sealed interface FeedbackEmailType {
val providerName: String,
val txId: String,
) : FeedbackEmailType
/**
* Error in currency description
*
* @property currencyId currency id
* @property currencyName currency name
*/
data class CurrencyDescriptionError(val currencyId: String, val currencyName: String) : FeedbackEmailType {
override val cardInfo: CardInfo? = null
}
}

View file

@ -23,13 +23,9 @@ internal class EmailMessageBodyResolver(
is FeedbackEmailType.RateCanBeBetter -> addCardAndPhoneInfo(type.cardInfo)
is FeedbackEmailType.ScanningProblem -> addScanningProblemBody()
is FeedbackEmailType.TransactionSendingProblem -> addTransactionSendingProblemBody(type.cardInfo)
is FeedbackEmailType.StakingProblem -> addStakingProblemBody(
type.cardInfo,
type.validatorName,
type.transactionTypes,
type.unsignedTransactions,
)
is FeedbackEmailType.SwapProblem -> addSwapProblemBody(type.cardInfo, type.providerName, type.txId)
is FeedbackEmailType.StakingProblem -> addStakingProblemBody(type)
is FeedbackEmailType.SwapProblem -> addSwapProblemBody(type)
is FeedbackEmailType.CurrencyDescriptionError -> addTokenInfo(type)
}
return build()
@ -79,16 +75,11 @@ internal class EmailMessageBodyResolver(
addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo())
}
private suspend fun FeedbackDataBuilder.addStakingProblemBody(
cardInfo: CardInfo,
validatorName: String?,
transactionTypes: List<String>,
unsignedTransactions: List<String?>,
) {
addCardInfo(cardInfo)
private suspend fun FeedbackDataBuilder.addStakingProblemBody(type: FeedbackEmailType.StakingProblem) {
addCardInfo(type.cardInfo)
addDelimiter()
val userWalletId = requireNotNull(cardInfo.userWalletId) { "UserWalletId must be not null" }
val userWalletId = requireNotNull(type.cardInfo.userWalletId) { "UserWalletId must be not null" }
val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId)
val blockchainInfo = blockchainError?.let {
feedbackRepository.getBlockchainInfo(
@ -103,21 +94,21 @@ internal class EmailMessageBodyResolver(
addDelimiter()
}
addStakingInfo(validatorName, transactionTypes, unsignedTransactions)
addStakingInfo(
validatorName = type.validatorName,
transactionTypes = type.transactionTypes,
unsignedTransactions = type.unsignedTransactions,
)
addDelimiter()
addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo())
}
private suspend fun FeedbackDataBuilder.addSwapProblemBody(
cardInfo: CardInfo,
providerName: String,
txId: String,
) {
addCardInfo(cardInfo)
private suspend fun FeedbackDataBuilder.addSwapProblemBody(type: FeedbackEmailType.SwapProblem) {
addCardInfo(type.cardInfo)
addDelimiter()
val userWalletId = requireNotNull(cardInfo.userWalletId) { "UserWalletId must be not null" }
val userWalletId = requireNotNull(type.cardInfo.userWalletId) { "UserWalletId must be not null" }
val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId)
val blockchainInfo = blockchainError?.let {
feedbackRepository.getBlockchainInfo(
@ -132,7 +123,7 @@ internal class EmailMessageBodyResolver(
addDelimiter()
}
addSwapInfo(providerName, txId)
addSwapInfo(providerName = type.providerName, txId = type.txId)
addDelimiter()
addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo())
@ -143,4 +134,8 @@ internal class EmailMessageBodyResolver(
addDelimiter()
addPhoneInfo(phoneInfo = feedbackRepository.getPhoneInfo())
}
private fun FeedbackDataBuilder.addTokenInfo(type: FeedbackEmailType.CurrencyDescriptionError) {
addTokenShortInfo(id = type.currencyId, name = type.currencyName)
}
}

View file

@ -16,7 +16,9 @@ internal class EmailMessageTitleResolver(private val resources: Resources) {
/** Resolve email message title by [type] */
fun resolve(type: FeedbackEmailType): String {
return when (type) {
is FeedbackEmailType.DirectUserRequest -> R.string.feedback_preface_support
is FeedbackEmailType.DirectUserRequest,
is FeedbackEmailType.CurrencyDescriptionError,
-> R.string.feedback_preface_support
is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative
is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed
is FeedbackEmailType.TransactionSendingProblem,

View file

@ -29,6 +29,7 @@ internal class EmailSubjectResolver(private val resources: Resources) {
is FeedbackEmailType.StakingProblem,
is FeedbackEmailType.SwapProblem,
-> R.string.feedback_subject_tx_failed
is FeedbackEmailType.CurrencyDescriptionError -> R.string.feedback_token_description_error
}
.let(resources::getString)
}

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.feedback)
implementation(projects.domain.manageTokens)
implementation(projects.domain.markets)
implementation(projects.domain.staking.models)

View file

@ -20,6 +20,8 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.markets.*
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent
@ -57,6 +59,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase,
private val getTokenExchangesUseCase: GetTokenExchangesUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
@ -109,6 +112,16 @@ internal class MarketsTokenDetailsModel @Inject constructor(
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked())
},
onGeneratedAINotificationClick = {
modelScope.launch {
sendFeedbackEmailUseCase(
type = FeedbackEmailType.CurrencyDescriptionError(
currencyId = params.token.id,
currencyName = params.token.name,
),
)
}
},
)
private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) {
@ -268,52 +281,56 @@ internal class MarketsTokenDetailsModel @Inject constructor(
)
}
val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval)
chart.onRight {
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
y = it.priceY.toImmutableList(),
x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted()
updateLook {
chart
.onRight { updateTokenChart(it) }
.onLeft {
state.update {
it.copy(
xAxisFormatter = xAxisFormatter,
type = state.value.priceChangeType.toChartType(),
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.ERROR,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Error) {
MarketsTokenDetailsUM.Body.Nothing
} else {
it.body
},
)
}
}
state.update {
it.copy(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.DATA,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) {
MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked)
} else {
it.body
},
)
}
}.onLeft {
state.update {
it.copy(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.ERROR,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Error) {
MarketsTokenDetailsUM.Body.Nothing
} else {
it.body
},
)
}
}
}.saveIn(loadChartJobHolder)
}
private suspend fun updateTokenChart(tokenChart: TokenChart) {
val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval)
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
y = tokenChart.priceY.toImmutableList(),
x = tokenChart.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted()
updateLook {
it.copy(
xAxisFormatter = xAxisFormatter,
type = state.value.priceChangeType.toChartType(),
)
}
}
state.update {
it.copy(
chartState = it.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.DATA,
),
body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) {
MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked)
} else {
it.body
},
)
}
}
private fun loadInfo() {
state.update {
it.copy(

View file

@ -12,7 +12,8 @@ import com.tangem.utils.converter.Converter
@Stable
internal class DescriptionConverter(
val onReadModeClicked: (InfoBottomSheetContent) -> Unit,
private val onReadModeClicked: (InfoBottomSheetContent) -> Unit,
private val onGeneratedAINotificationClick: () -> Unit,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.Description?> {
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? {
@ -32,7 +33,9 @@ internal class DescriptionConverter(
),
),
body = stringReference(value.fullDescription ?: ""),
showGeneratedAINotification = true,
generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM(
onClick = onGeneratedAINotificationClick,
),
),
)
},

View file

@ -27,14 +27,14 @@ internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
config = config,
addBottomInsets = false,
title = { TangemBottomSheetTitle(title = it.title) },
content = {
content = { content ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
MarkdownText(
markdown = it.body.resolveReference(),
markdown = content.body.resolveReference(),
disableLinkMovementMethod = true,
linkifyMask = 0,
syntaxHighlightColor = TangemTheme.colors.text.secondary,
@ -43,8 +43,9 @@ internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
),
)
if (it.showGeneratedAINotification) {
if (content.generatedAINotificationUM != null) {
AdditionalInfoNotification(
onClick = content.generatedAINotificationUM.onClick,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
@ -58,11 +59,13 @@ internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
}
@Composable
private fun AdditionalInfoNotification(modifier: Modifier = Modifier) {
private fun AdditionalInfoNotification(onClick: () -> Unit, modifier: Modifier = Modifier) {
Notification(
config = NotificationConfig(
subtitle = TextReference.Res(id = R.string.information_generated_with_ai),
iconResId = R.drawable.ic_magic_28,
onClick = onClick,
showArrowIcon = false,
),
modifier = modifier,
subtitleColor = TangemTheme.colors.text.primary1,

View file

@ -6,5 +6,8 @@ import com.tangem.core.ui.extensions.TextReference
internal data class InfoBottomSheetContent(
val title: TextReference,
val body: TextReference,
val showGeneratedAINotification: Boolean = false,
) : TangemBottomSheetConfigContent
val generatedAINotificationUM: GeneratedAINotificationUM? = null,
) : TangemBottomSheetConfigContent {
data class GeneratedAINotificationUM(val onClick: () -> Unit)
}