Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-29 17:59:40 +04:00
parent 08608fa610
commit 8511623421
19 changed files with 633 additions and 122 deletions

View file

@ -20,11 +20,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.constraintlayout.compose.*
import com.tangem.common.ui.R
import com.tangem.common.ui.expressStatus.state.buildExpressStatusSubtitle
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.isNullOrEmpty
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
@ -43,6 +45,7 @@ internal fun ExpressStatusItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
toAmount: TextReference = TextReference.EMPTY,
subtitle: TextReference = TextReference.EMPTY,
@DrawableRes infoIconRes: Int? = null,
infoIconTint: Color? = null,
) {
@ -55,7 +58,8 @@ internal fun ExpressStatusItem(
.padding(TangemTheme.dimens.spacing12)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM),
) {
val (titleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) = createRefs()
val (titleRef, subtitleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) =
createRefs()
val padding6 = TangemTheme.dimens.spacing6
Text(
@ -66,9 +70,25 @@ internal fun ExpressStatusItem(
.constrainAs(titleRef) {
start.linkTo(parent.start)
top.linkTo(parent.top)
end.linkTo(infoIconRef.start, padding6, padding6)
width = Dimension.preferredWrapContent
horizontalBias = 0f
}
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE),
)
Text(
text = subtitle.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.constrainAs(subtitleRef) {
start.linkTo(parent.start)
top.linkTo(titleRef.bottom)
end.linkTo(infoIconRef.start, padding6, padding6)
width = Dimension.preferredWrapContent
horizontalBias = 0f
visibility = if (subtitle.isNullOrEmpty()) Visibility.Gone else Visibility.Visible
},
)
CurrencyIcon(
state = fromTokenIconState,
shouldDisplayNetwork = false,
@ -76,7 +96,7 @@ internal fun ExpressStatusItem(
.size(TangemTheme.dimens.size20)
.constrainAs(fromIconRef) {
start.linkTo(parent.start)
top.linkTo(titleRef.bottom, padding6)
top.linkTo(subtitleRef.bottom, padding6)
bottom.linkTo(parent.bottom)
}
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON),
@ -89,7 +109,7 @@ internal fun ExpressStatusItem(
modifier = Modifier
.constrainAs(fromRef) {
start.linkTo(fromIconRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
top.linkTo(subtitleRef.bottom, padding6)
end.linkTo(swapIconRef.start)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints.atMostWrapContent
@ -104,7 +124,7 @@ internal fun ExpressStatusItem(
.size(TangemTheme.dimens.size12)
.constrainAs(swapIconRef) {
start.linkTo(fromRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
top.linkTo(subtitleRef.bottom, padding6)
end.linkTo(toIconRef.start)
bottom.linkTo(parent.bottom)
}
@ -117,7 +137,7 @@ internal fun ExpressStatusItem(
.size(TangemTheme.dimens.size20)
.constrainAs(toIconRef) {
start.linkTo(swapIconRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
top.linkTo(subtitleRef.bottom, padding6)
end.linkTo(toRef.start)
bottom.linkTo(parent.bottom)
}
@ -131,7 +151,7 @@ internal fun ExpressStatusItem(
modifier = Modifier
.constrainAs(toRef) {
start.linkTo(toIconRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
top.linkTo(subtitleRef.bottom, padding6)
end.linkTo(infoIconRef.start, padding6, padding6)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints.atLeastWrapContent
@ -180,13 +200,17 @@ private fun ExpressStatusItemPreview(
) {
TangemThemePreview {
ExpressStatusItem(
title = stringReference("ChangeNow"),
title = stringReference("Exchange by ChangeHero"),
fromTokenIconState = CurrencyIconState.Loading,
toTokenIconState = CurrencyIconState.Loading,
fromAmount = stringReference(amount),
fromSymbol = "USDT",
toAmount = stringReference(amount),
toSymbol = "USDT",
subtitle = buildExpressStatusSubtitle(
activeStatus = stringReference("Confirming"),
date = stringReference("59 min ago"),
),
onClick = {},
infoIconRes = null,
infoIconTint = null,

View file

@ -14,10 +14,10 @@ fun LazyListScope.expressTransactionsItems(
) {
items(
count = expressTxs.size,
key = { expressTxs[it].info.txId },
contentType = { expressTxs[it]::class.java },
) {
val itemInfo = expressTxs[it].info
key = { index -> expressTxs[index].info.txId },
contentType = { index -> expressTxs[index]::class.java },
) { index ->
val itemInfo = expressTxs[index].info
val (iconRes, tint) = when (itemInfo.iconState) {
ExpressTransactionStateIconUM.Warning -> {
R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention
@ -36,6 +36,7 @@ fun LazyListScope.expressTransactionsItems(
fromSymbol = itemInfo.fromAmountSymbol,
toAmount = itemInfo.toAmount,
toSymbol = itemInfo.toAmountSymbol,
subtitle = itemInfo.subtitle,
onClick = itemInfo.onClick,
infoIconRes = iconRes,
infoIconTint = tint,

View file

@ -0,0 +1,38 @@
package com.tangem.common.ui.expressStatus
import com.tangem.common.ui.R
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.onramp.model.OnrampStatus
fun OnrampStatus.Status.toActiveStatusText(currencyName: String): TextReference = when (this) {
OnrampStatus.Status.Created,
OnrampStatus.Status.WaitingForPayment,
-> resourceReference(R.string.express_exchange_status_receiving_active)
OnrampStatus.Status.PaymentProcessing -> resourceReference(R.string.express_exchange_status_confirming_active)
OnrampStatus.Status.Verifying -> resourceReference(R.string.express_exchange_status_verifying)
OnrampStatus.Status.Paid -> resourceReference(R.string.express_status_buying_active, wrappedList(currencyName))
OnrampStatus.Status.Sending -> resourceReference(
R.string.express_exchange_status_sending_active,
wrappedList(currencyName),
)
OnrampStatus.Status.Finished -> resourceReference(R.string.express_status_bought, wrappedList(currencyName))
OnrampStatus.Status.RefundInProgress -> resourceReference(R.string.express_exchange_status_refunding)
OnrampStatus.Status.Refunded -> resourceReference(R.string.express_exchange_status_refunded)
OnrampStatus.Status.Paused -> resourceReference(R.string.express_exchange_status_paused)
OnrampStatus.Status.Expired,
OnrampStatus.Status.Failed,
-> resourceReference(R.string.express_exchange_status_failed)
}
fun OnrampStatus.Status.toIconState(): ExpressTransactionStateIconUM = when (this) {
OnrampStatus.Status.Verifying,
OnrampStatus.Status.RefundInProgress,
-> ExpressTransactionStateIconUM.Warning
OnrampStatus.Status.Refunded,
OnrampStatus.Status.Failed,
-> ExpressTransactionStateIconUM.Error
else -> ExpressTransactionStateIconUM.None
}

View file

@ -0,0 +1,52 @@
package com.tangem.common.ui.expressStatus.state
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.isNullOrEmpty
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
/**
* Builds express status subtitle by combining [activeStatus] with formatted [date].
*
* Separator rules:
* - Relative date (MinutesAgo / HoursAgo PluralRes) " ~ ".
* - Absolute date (Today / FullDate) " "; Today's leading Res is decapitalized when a status
* prefix is present.
* - Either side empty the other is returned as is (no modifications).
*/
fun buildExpressStatusSubtitle(activeStatus: TextReference, date: TextReference): TextReference {
val hasStatus = !activeStatus.isNullOrEmpty()
val hasDate = !date.isNullOrEmpty()
return when {
!hasStatus && !hasDate -> TextReference.EMPTY
hasStatus && !hasDate -> activeStatus
!hasStatus && hasDate -> date
else -> combineWithStatus(activeStatus, date)
}
}
private fun combineWithStatus(status: TextReference, date: TextReference): TextReference {
val shouldUseTilde = date.isRelativeTimeAgo()
val separator = if (shouldUseTilde) stringReference(value = " ~ ") else stringReference(value = " ")
val datePart = if (shouldUseTilde) date else date.decapitalizeToday()
return TextReference.Combined(refs = wrappedList(status, separator, datePart))
}
private fun TextReference.isRelativeTimeAgo(): Boolean {
return this is TextReference.PluralRes &&
(id == R.plurals.common_minutes_time_ago || id == R.plurals.common_hours_time_ago)
}
private fun TextReference.decapitalizeToday(): TextReference {
if (this !is TextReference.Combined) return this
val patched = refs.data.map { ref ->
if (ref is TextReference.Res && ref.id == R.string.common_today) {
ref.copy(shouldDecapitalize = true)
} else {
ref
}
}
return TextReference.Combined(refs = WrappedList(data = patched))
}

View file

@ -28,6 +28,8 @@ data class ExpressTransactionStateInfoUM(
val txExternalUrl: String?,
val timestamp: Long,
val timestampFormatted: TextReference,
val timestampAgoFormatted: TextReference,
val activeStatus: TextReference,
val onGoToProviderClick: (String) -> Unit,
val onClick: () -> Unit,
val onDisposeExpressStatus: () -> Unit,
@ -36,12 +38,14 @@ data class ExpressTransactionStateInfoUM(
val toFiatAmount: TextReference?,
val toAmountSymbol: String,
val toCurrencyIcon: CurrencyIconState,
val fromAmount: TextReference,
val fromFiatAmount: TextReference?,
val fromAmountSymbol: String,
val fromCurrencyIcon: CurrencyIconState,
)
) {
val subtitle: TextReference
get() = buildExpressStatusSubtitle(activeStatus = activeStatus, date = timestampAgoFormatted)
}
enum class ExpressTransactionStateIconUM {
Warning,

View file

@ -0,0 +1,117 @@
package com.tangem.common.ui.expressStatus.state
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.utils.StringsSigns
import org.junit.jupiter.api.Test
internal class ExpressStatusSubtitleBuilderTest {
private val status = stringReference("Confirming")
private val minutesAgo = TextReference.PluralRes(
id = R.plurals.common_minutes_time_ago,
count = 30,
formatArgs = wrappedList(30),
)
private val hoursAgo = TextReference.PluralRes(
id = R.plurals.common_hours_time_ago,
count = 3,
formatArgs = wrappedList(3),
)
private val today = TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str("03:00"),
),
),
)
private val fullDate = TextReference.Str("14 Oct 2025")
@Test
fun `GIVEN empty activeStatus AND empty date WHEN buildExpressStatusSubtitle THEN return EMPTY`() {
val result = buildExpressStatusSubtitle(
activeStatus = TextReference.EMPTY,
date = TextReference.EMPTY,
)
assertThat(result).isEqualTo(TextReference.EMPTY)
}
@Test
fun `GIVEN non-empty activeStatus AND empty date WHEN buildExpressStatusSubtitle THEN return activeStatus`() {
val result = buildExpressStatusSubtitle(activeStatus = status, date = TextReference.EMPTY)
assertThat(result).isEqualTo(status)
}
@Test
fun `GIVEN empty activeStatus AND MinutesAgo date WHEN buildExpressStatusSubtitle THEN return date as is`() {
val result = buildExpressStatusSubtitle(activeStatus = TextReference.EMPTY, date = minutesAgo)
assertThat(result).isEqualTo(minutesAgo)
}
@Test
fun `GIVEN status AND MinutesAgo date WHEN buildExpressStatusSubtitle THEN return Combined with tilde separator`() {
val result = buildExpressStatusSubtitle(activeStatus = status, date = minutesAgo)
assertThat(result).isEqualTo(
TextReference.Combined(refs = wrappedList(status, stringReference(" ~ "), minutesAgo)),
)
}
@Test
fun `GIVEN status AND HoursAgo date WHEN buildExpressStatusSubtitle THEN return Combined with tilde separator`() {
val result = buildExpressStatusSubtitle(activeStatus = status, date = hoursAgo)
assertThat(result).isEqualTo(
TextReference.Combined(refs = wrappedList(status, stringReference(" ~ "), hoursAgo)),
)
}
@Test
fun `GIVEN status AND Today date WHEN buildExpressStatusSubtitle THEN return Combined with space AND decapitalized today`() {
val result = buildExpressStatusSubtitle(activeStatus = status, date = today)
val expectedToday = TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(id = R.string.common_today, shouldDecapitalize = true),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str("03:00"),
),
),
)
assertThat(result).isEqualTo(
TextReference.Combined(refs = wrappedList(status, stringReference(" "), expectedToday)),
)
}
@Test
fun `GIVEN status AND FullDate date WHEN buildExpressStatusSubtitle THEN return Combined with space separator`() {
val result = buildExpressStatusSubtitle(activeStatus = status, date = fullDate)
assertThat(result).isEqualTo(
TextReference.Combined(refs = wrappedList(status, stringReference(" "), fullDate)),
)
}
@Test
fun `GIVEN empty activeStatus AND Today date WHEN buildExpressStatusSubtitle THEN return Today as is without decapitalize`() {
val result = buildExpressStatusSubtitle(activeStatus = TextReference.EMPTY, date = today)
assertThat(result).isEqualTo(today)
}
}

View file

@ -66,6 +66,10 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
android {
namespace = "com.tangem.core.ui"
@ -149,4 +153,5 @@ dependencies {
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testRuntimeOnly(deps.test.junit5.vintage.engine)
}

View file

@ -5,6 +5,7 @@ import com.tangem.utils.extensions.isToday
import com.tangem.utils.extensions.isYesterday
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.LocalDate
import org.joda.time.format.DateTimeFormatter
/**
@ -46,16 +47,26 @@ fun Long.formatAsDateTime(formatter: DateTimeFormatter): String {
* @param now The current date to compare against.
* @return A [FormattedDate] subclass.
*/
@Suppress("MagicNumber")
fun getFormattedDate(createdAt: String, now: DateTime): FormattedDate {
val pastDateUtc = try {
DateTime.parse(createdAt)
} catch (_: Exception) {
return FormattedDate.FullDate(createdAt)
}
return getFormattedDate(pastDateUtc = pastDateUtc, now = now)
}
/**
* Compares the given past date to [now] and returns a [FormattedDate] describing the difference.
*
* @param pastDateUtc Past date in UTC.
* @param now The current date to compare against.
*/
@Suppress("MagicNumber")
fun getFormattedDate(pastDateUtc: DateTime, now: DateTime): FormattedDate {
val pastDateLocal = pastDateUtc.withZone(DateTimeZone.getDefault())
val isToday = pastDateLocal.isToday()
val nowLocal = now.withZone(DateTimeZone.getDefault())
val isToday = LocalDate(pastDateLocal) == LocalDate(nowLocal)
val diffInMillis = now.millis - pastDateUtc.millis
val diffInMinutes = diffInMillis / (1000 * 60)

View file

@ -0,0 +1,55 @@
package com.tangem.core.ui.utils
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.utils.StringsSigns
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
/**
* Maps an ISO 8601 date string into a [TextReference] with a human-friendly "time ago" label.
*/
fun mapFormattedDate(createdAt: String, now: DateTime = DateTime.now()): TextReference {
val formattedDate = runCatching {
getFormattedDate(createdAt = createdAt, now = now)
}.getOrElse { FormattedDate.FullDate(createdAt) }
return formattedDate.toTextReference()
}
/**
* Maps an epoch millisecond [timestamp] into a [TextReference] with a human-friendly "time ago" label.
*/
fun mapFormattedDate(timestamp: Long, now: DateTime = DateTime.now()): TextReference {
val formattedDate = runCatching {
getFormattedDate(pastDateUtc = DateTime(timestamp, DateTimeZone.UTC), now = now)
}.getOrElse { FormattedDate.FullDate(timestamp.toString()) }
return formattedDate.toTextReference()
}
private fun FormattedDate.toTextReference(): TextReference = when (this) {
is FormattedDate.FullDate -> TextReference.Str(value = date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(
id = R.plurals.common_hours_time_ago,
count = hours,
formatArgs = wrappedList(hours),
)
is FormattedDate.MinutesAgo -> TextReference.PluralRes(
id = R.plurals.common_minutes_time_ago,
count = minutes,
formatArgs = wrappedList(minutes),
)
is FormattedDate.Today -> TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str(time),
),
),
)
}

View file

@ -1,6 +1,12 @@
package com.tangem.core.ui.utils
import android.text.format.DateFormat
import com.google.common.truth.Truth
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@ -11,6 +17,17 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DateTimeFormattersTest {
@BeforeEach
fun setUp() {
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
}
@AfterEach
fun tearDown() {
unmockkStatic(DateFormat::class)
}
@Test
fun `converts LLLL to MMMM - full standalone month pattern that crashes on Chinese locale`() {
// Arrange

View file

@ -1,8 +1,15 @@
package com.tangem.core.ui.utils
import android.text.format.DateFormat
import com.google.common.truth.Truth
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.DateTimeFormatterBuilder
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -19,11 +26,23 @@ class DateUtilsTest {
fun setUp() {
defaultTimeZone = DateTimeZone.getDefault()
DateTimeZone.setDefault(DateTimeZone.forID("Europe/Moscow"))
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
mockkObject(DateTimeFormatters)
every { DateTimeFormatters.timeFormatter } returns DateTimeFormatterBuilder()
.appendHourOfDay(2)
.appendLiteral(':')
.appendMinuteOfHour(2)
.toFormatter()
}
@AfterEach
fun tearDown() {
DateTimeZone.setDefault(defaultTimeZone)
unmockkObject(DateTimeFormatters)
unmockkStatic(DateFormat::class)
}
@Test

View file

@ -0,0 +1,234 @@
package com.tangem.core.ui.utils
import android.text.format.DateFormat
import com.google.common.truth.Truth
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.utils.StringsSigns
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.DateTimeFormatterBuilder
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FormattedDateMapperTest {
private lateinit var defaultTimeZone: DateTimeZone
private val now = createDateTime(day = 14, hour = 12)
@BeforeEach
fun setUp() {
defaultTimeZone = DateTimeZone.getDefault()
DateTimeZone.setDefault(DateTimeZone.forID("Europe/Moscow"))
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
mockkObject(DateTimeFormatters)
every { DateTimeFormatters.timeFormatter } returns DateTimeFormatterBuilder()
.appendHourOfDay(2)
.appendLiteral(':')
.appendMinuteOfHour(2)
.toFormatter()
}
@AfterEach
fun tearDown() {
DateTimeZone.setDefault(defaultTimeZone)
unmockkObject(DateTimeFormatters)
unmockkStatic(DateFormat::class)
}
// region String overload
@Test
fun `GIVEN iso string less than a minute ago WHEN mapFormattedDate THEN return minutes PluralRes with count 1`() {
val createdAt = now.minusSeconds(30).toString()
val result = mapFormattedDate(createdAt = createdAt, now = now)
Truth.assertThat(result).isEqualTo(
TextReference.PluralRes(
id = R.plurals.common_minutes_time_ago,
count = 1,
formatArgs = wrappedList(1),
),
)
}
@Test
fun `GIVEN iso string 30 minutes ago WHEN mapFormattedDate THEN return minutes PluralRes with count 30`() {
val createdAt = now.minusMinutes(30).toString()
val result = mapFormattedDate(createdAt = createdAt, now = now)
Truth.assertThat(result).isEqualTo(
TextReference.PluralRes(
id = R.plurals.common_minutes_time_ago,
count = 30,
formatArgs = wrappedList(30),
),
)
}
@Test
fun `GIVEN iso string 3 hours ago today WHEN mapFormattedDate THEN return hours PluralRes with count 3`() {
val createdAt = now.minusHours(3).toString()
val result = mapFormattedDate(createdAt = createdAt, now = now)
Truth.assertThat(result).isEqualTo(
TextReference.PluralRes(
id = R.plurals.common_hours_time_ago,
count = 3,
formatArgs = wrappedList(3),
),
)
}
@Test
fun `GIVEN iso string today but past 12 hours WHEN mapFormattedDate THEN return Combined with today and local time`() {
val pastDate = createDateTime(day = 14, hour = 0)
val createdAt = pastDate.toString()
val nowInTest = createDateTime(day = 14, hour = 12)
val result = mapFormattedDate(createdAt = createdAt, now = nowInTest)
Truth.assertThat(result).isEqualTo(
TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str("03:00"),
),
),
),
)
}
@Test
fun `GIVEN iso string from a previous day WHEN mapFormattedDate THEN return Str FullDate`() {
val pastDate = createDateTime(day = 10, hour = 9)
val createdAt = pastDate.toString()
val result = mapFormattedDate(createdAt = createdAt, now = now)
Truth.assertThat(result).isInstanceOf(TextReference.Str::class.java)
}
@Test
fun `GIVEN malformed iso string WHEN mapFormattedDate THEN return Str with original value`() {
val malformed = "2025/10/14T12:00:00.000Z"
val result = mapFormattedDate(createdAt = malformed, now = now)
Truth.assertThat(result).isEqualTo(TextReference.Str(value = malformed))
}
// endregion
// region Long overload
@Test
fun `GIVEN timestamp less than a minute ago WHEN mapFormattedDate THEN return minutes PluralRes with count 1`() {
val timestamp = now.minusSeconds(30).millis
val result = mapFormattedDate(timestamp = timestamp, now = now)
Truth.assertThat(result).isEqualTo(
TextReference.PluralRes(
id = R.plurals.common_minutes_time_ago,
count = 1,
formatArgs = wrappedList(1),
),
)
}
@Test
fun `GIVEN timestamp 45 minutes ago WHEN mapFormattedDate THEN return minutes PluralRes with count 45`() {
val timestamp = now.minusMinutes(45).millis
val result = mapFormattedDate(timestamp = timestamp, now = now)
Truth.assertThat(result).isEqualTo(
TextReference.PluralRes(
id = R.plurals.common_minutes_time_ago,
count = 45,
formatArgs = wrappedList(45),
),
)
}
@Test
fun `GIVEN timestamp 5 hours ago today WHEN mapFormattedDate THEN return hours PluralRes with count 5`() {
val timestamp = now.minusHours(5).millis
val result = mapFormattedDate(timestamp = timestamp, now = now)
Truth.assertThat(result).isEqualTo(
TextReference.PluralRes(
id = R.plurals.common_hours_time_ago,
count = 5,
formatArgs = wrappedList(5),
),
)
}
@Test
fun `GIVEN timestamp today but past 12 hours WHEN mapFormattedDate THEN return Combined with today and local time`() {
val pastDate = createDateTime(day = 14, hour = 0)
val nowInTest = createDateTime(day = 14, hour = 12)
val result = mapFormattedDate(timestamp = pastDate.millis, now = nowInTest)
Truth.assertThat(result).isEqualTo(
TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str("03:00"),
),
),
),
)
}
@Test
fun `GIVEN timestamp from a previous day WHEN mapFormattedDate THEN return Str FullDate`() {
val pastDate = createDateTime(day = 10, hour = 9)
val result = mapFormattedDate(timestamp = pastDate.millis, now = now)
Truth.assertThat(result).isInstanceOf(TextReference.Str::class.java)
}
// endregion
private fun createDateTime(day: Int, hour: Int): DateTime {
return DateTime(
/* year = */ 2025,
/* monthOfYear = */ 10,
/* dayOfMonth = */ day,
/* hourOfDay = */ hour,
/* minuteOfHour = */ 0,
/* secondOfMinute = */ 0,
/* millisOfSecond = */ 0,
/* zone = */ DateTimeZone.UTC,
)
}
}

View file

@ -7,7 +7,7 @@ import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.news.ShortArticle
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.features.feed.ui.utils.mapFormattedDate
import com.tangem.core.ui.utils.mapFormattedDate
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet

View file

@ -4,23 +4,17 @@ import androidx.compose.runtime.Stable
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.FormattedDate
import com.tangem.core.ui.utils.getFormattedDate
import com.tangem.core.ui.utils.mapFormattedDate
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.news.DetailedArticle
import com.tangem.domain.models.news.RelatedArticle
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.Media
import com.tangem.features.feed.ui.news.details.state.RelatedArticleUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import org.joda.time.DateTime
@Stable
internal class NewsDetailsConverter(
@ -74,34 +68,4 @@ internal class NewsDetailsConverter(
)
}.toImmutableList()
}
private fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
return when (formattedDate) {
is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(
id = R.plurals.news_published_hours_ago,
count = formattedDate.hours,
formatArgs = wrappedList(formattedDate.hours),
)
is FormattedDate.MinutesAgo -> TextReference.PluralRes(
id = R.plurals.news_published_minutes_ago,
count = formattedDate.minutes,
formatArgs = wrappedList(formattedDate.minutes),
)
is FormattedDate.Today -> TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str(formattedDate.time),
),
),
)
}
}
}

View file

@ -1,43 +0,0 @@
package com.tangem.features.feed.ui.utils
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.FormattedDate
import com.tangem.core.ui.utils.getFormattedDate
import com.tangem.features.feed.impl.R
import com.tangem.utils.StringsSigns
import org.joda.time.DateTime
internal fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = runCatching {
getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
}.getOrElse { FormattedDate.FullDate("") }
return when (formattedDate) {
is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(
id = R.plurals.news_published_hours_ago,
count = formattedDate.hours,
formatArgs = wrappedList(formattedDate.hours),
)
is FormattedDate.MinutesAgo -> TextReference.PluralRes(
id = R.plurals.news_published_minutes_ago,
count = formattedDate.minutes,
formatArgs = wrappedList(formattedDate.minutes),
)
is FormattedDate.Today -> TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str(formattedDate.time),
),
),
)
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.tangem.common.ui.expressStatus.state.*
import com.tangem.common.ui.expressStatus.toActiveStatusText
import com.tangem.common.ui.expressStatus.toIconState
import com.tangem.common.ui.notifications.ExpressNotificationsUM
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -12,6 +14,7 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.mapFormattedDate
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.appcurrency.model.AppCurrency
@ -55,6 +58,8 @@ internal class TokenDetailsOnrampTransactionStateConverter(
value.timestamp.toTimeFormat(),
),
),
timestampAgoFormatted = mapFormattedDate(value.timestamp),
activeStatus = value.status.toActiveStatusText(cryptoCurrency.name),
toAmount = stringReference(
value.toAmount.format { crypto(cryptoCurrency) },
),
@ -82,7 +87,7 @@ internal class TokenDetailsOnrampTransactionStateConverter(
url = value.fromCurrency.image,
fallbackResId = R.drawable.ic_currency_24,
),
iconState = getIconState(value.status),
iconState = value.status.toIconState(),
onGoToProviderClick = { url ->
analyticsEventHandler.send(TokenOnrampAnalyticsEvent.GoToProvider())
clickIntents.onGoToProviderClick(url)
@ -124,18 +129,6 @@ internal class TokenDetailsOnrampTransactionStateConverter(
null
}
private fun getIconState(status: OnrampStatus.Status): ExpressTransactionStateIconUM {
return when (status) {
OnrampStatus.Status.RefundInProgress,
OnrampStatus.Status.Verifying,
-> ExpressTransactionStateIconUM.Warning
OnrampStatus.Status.Refunded,
OnrampStatus.Status.Failed,
-> ExpressTransactionStateIconUM.Error
else -> ExpressTransactionStateIconUM.None
}
}
private fun convertStatuses(status: OnrampStatus.Status, externalTxUrl: String?): ExpressStatusUM {
val statuses = with(status) {
persistentListOf(

View file

@ -13,6 +13,7 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.mapFormattedDate
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.appcurrency.model.AppCurrency
@ -143,6 +144,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
info = tx.info.copy(
txExternalId = statusModel.txExternalId,
txExternalUrl = statusModel.txExternalUrl,
activeStatus = getActiveStatusText(statusModel.status),
),
)
}
@ -164,6 +166,8 @@ internal class TokenDetailsSwapTransactionsStateConverter(
timestampFormatted = stringReference(
"${timestamp.toDateFormatWithTodayYesterday()}, ${timestamp.toTimeFormat()}",
),
timestampAgoFormatted = mapFormattedDate(timestamp),
activeStatus = getActiveStatusText(transaction.status?.status),
toAmount = getCryptoAmount(transaction.toCryptoAmount, toCryptoCurrency),
toFiatAmount = getFiatAmount(toFiatAmount),
toCurrencyIcon = iconStateConverter.convert(toCryptoCurrency),
@ -251,6 +255,26 @@ internal class TokenDetailsSwapTransactionsStateConverter(
}
}
private fun getActiveStatusText(status: ExchangeStatus?): TextReference = when (status) {
ExchangeStatus.New,
ExchangeStatus.Waiting,
-> resourceReference(R.string.express_exchange_status_receiving_active)
ExchangeStatus.WaitingTxHash -> resourceReference(R.string.express_exchange_status_waiting_tx_hash)
ExchangeStatus.Confirming -> resourceReference(R.string.express_exchange_status_confirming_active)
ExchangeStatus.Verifying -> resourceReference(R.string.express_exchange_status_verifying)
ExchangeStatus.Exchanging -> resourceReference(R.string.express_exchange_status_exchanging_active)
ExchangeStatus.Sending -> resourceReference(R.string.express_exchange_status_sending_active)
ExchangeStatus.Finished -> resourceReference(R.string.express_exchange_status_sent)
ExchangeStatus.Refunded -> resourceReference(R.string.express_exchange_status_refunded)
ExchangeStatus.Paused -> resourceReference(R.string.express_exchange_status_paused)
ExchangeStatus.Cancelled -> resourceReference(R.string.express_exchange_status_canceled)
ExchangeStatus.Failed,
ExchangeStatus.TxFailed,
ExchangeStatus.Unknown,
-> resourceReference(R.string.express_exchange_status_failed)
null -> TextReference.EMPTY
}
private fun getIconState(status: ExchangeStatus?): ExpressTransactionStateIconUM {
return when (status) {
ExchangeStatus.Verifying -> ExpressTransactionStateIconUM.Warning

View file

@ -42,6 +42,8 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider<ExpressSt
txExternalUrl = "https://example.com/tx/78910",
timestamp = System.currentTimeMillis(),
timestampFormatted = TextReference.Str("Just now"),
timestampAgoFormatted = TextReference.Str("1m ago"),
activeStatus = TextReference.Str("Confirming"),
onGoToProviderClick = {},
onClick = {},
onDisposeExpressStatus = {},

View file

@ -1,6 +1,8 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter
import com.tangem.common.ui.expressStatus.state.*
import com.tangem.common.ui.expressStatus.toActiveStatusText
import com.tangem.common.ui.expressStatus.toIconState
import com.tangem.common.ui.notifications.ExpressNotificationsUM
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -12,6 +14,7 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.mapFormattedDate
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.appcurrency.model.AppCurrency
@ -36,6 +39,7 @@ internal class SingleWalletOnrampTransactionConverter(
private val currency = cryptoCurrencyStatus.currency
private val status = cryptoCurrencyStatus.value
@Suppress("LongMethod")
override fun convert(value: OnrampTransaction): ExpressTransactionStateUM.OnrampUM {
return ExpressTransactionStateUM.OnrampUM(
info = ExpressTransactionStateInfoUM(
@ -56,6 +60,8 @@ internal class SingleWalletOnrampTransactionConverter(
value.timestamp.toTimeFormat(),
),
),
timestampAgoFormatted = mapFormattedDate(value.timestamp),
activeStatus = value.status.toActiveStatusText(currency.name),
toAmount = stringReference(value.toAmount.format { crypto(currency) }),
toFiatAmount = stringReference(
status.fiatRate?.multiply(value.toAmount).format {
@ -81,7 +87,7 @@ internal class SingleWalletOnrampTransactionConverter(
url = value.fromCurrency.image,
fallbackResId = R.drawable.ic_currency_24,
),
iconState = getIconState(value.status),
iconState = value.status.toIconState(),
onGoToProviderClick = { url ->
analyticsEventHandler.send(TokenOnrampAnalyticsEvent.GoToProvider())
clickIntents.onGoToProviderClick(url)
@ -134,18 +140,6 @@ internal class SingleWalletOnrampTransactionConverter(
null
}
private fun getIconState(status: OnrampStatus.Status): ExpressTransactionStateIconUM {
return when (status) {
OnrampStatus.Status.Verifying,
OnrampStatus.Status.RefundInProgress,
-> ExpressTransactionStateIconUM.Warning
OnrampStatus.Status.Refunded,
OnrampStatus.Status.Failed,
-> ExpressTransactionStateIconUM.Error
else -> ExpressTransactionStateIconUM.None
}
}
private fun convertStatuses(status: OnrampStatus.Status, externalTxUrl: String?): ExpressStatusUM {
val statuses = with(status) {
persistentListOf(