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

@ -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,
)
}
}