Updated on 2026-08-14
This commit is contained in:
parent
5b60d94e60
commit
01e6e50bcb
14 changed files with 131 additions and 82 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue