Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-22 18:17:15 +03:00
commit 9b53fd4f0c
909 changed files with 14900 additions and 6085 deletions

View file

@ -220,6 +220,8 @@ fun SecondaryButtonIconEnd(
modifier: Modifier = Modifier,
showProgress: Boolean = false,
enabled: Boolean = true,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -230,6 +232,8 @@ fun SecondaryButtonIconEnd(
enabled = enabled,
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
@ -244,6 +248,8 @@ fun SecondaryButtonIconStart(
modifier: Modifier = Modifier,
showProgress: Boolean = false,
enabled: Boolean = true,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -254,6 +260,8 @@ fun SecondaryButtonIconStart(
enabled = enabled,
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
// endregion SecondaryButton

View file

@ -37,44 +37,46 @@ fun DialogFullScreen(
decorFitsSystemWindows = false,
),
content = {
val activityWindow = getActivityWindow()
val dialogWindow = getDialogWindow()
val parentView = LocalView.current.parent as View
SideEffect {
if (activityWindow != null && dialogWindow != null) {
val attributes = WindowManager.LayoutParams().apply {
copyFrom(activityWindow.attributes)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
} else {
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
type = dialogWindow.attributes.type
}
dialogWindow.attributes = attributes
parentView.layoutParams =
FrameLayout.LayoutParams(
activityWindow.decorView.width,
activityWindow.decorView.height,
)
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
val systemUiController = rememberSystemUiController(getActivityWindow())
val dialogSystemUiController = rememberSystemUiController(getDialogWindow())
ProvideSystemBarsIconsController {
val activityWindow = getActivityWindow()
val dialogWindow = getDialogWindow()
val parentView = LocalView.current.parent as View
SideEffect {
systemUiController.setSystemBarsColor(color = Color.Transparent)
dialogSystemUiController.setSystemBarsColor(color = Color.Transparent)
if (activityWindow != null && dialogWindow != null) {
val attributes = WindowManager.LayoutParams().apply {
copyFrom(activityWindow.attributes)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
} else {
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
type = dialogWindow.attributes.type
}
dialogWindow.attributes = attributes
parentView.layoutParams =
FrameLayout.LayoutParams(
activityWindow.decorView.width,
activityWindow.decorView.height,
)
}
}
}
SystemBarsIconsDisposable(darkIcons = LocalIsInDarkTheme.current.not())
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
val systemUiController = rememberSystemUiController(getActivityWindow())
val dialogSystemUiController = rememberSystemUiController(getDialogWindow())
Surface(modifier = Modifier.fillMaxSize(), color = Color.Transparent) {
content()
SideEffect {
systemUiController.setSystemBarsColor(color = Color.Transparent)
dialogSystemUiController.setSystemBarsColor(color = Color.Transparent)
}
}
SystemBarsIconsDisposable(darkIcons = LocalIsInDarkTheme.current.not())
Surface(modifier = Modifier.fillMaxSize(), color = Color.Transparent) {
content()
}
}
},
)

View file

@ -308,13 +308,14 @@ private fun DialogContent(type: DialogType, modifier: Modifier = Modifier) {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DialogButtons(
confirmButton: DialogButtonUM,
dismissButton: DialogButtonUM?,
modifier: Modifier = Modifier,
) {
Row(
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(
space = TangemTheme.dimens.spacing4,

View file

@ -2,17 +2,13 @@ package com.tangem.core.ui.components.bottomsheets.message
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -25,6 +21,7 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTi
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.icons.HighlightedIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@ -144,20 +141,11 @@ private fun BottomSheetIcon(icon: MessageBottomSheetUMV2.Icon, modifier: Modifie
MessageBottomSheetUMV2.Icon.BackgroundType.Warning -> TangemTheme.colors.icon.warning
}
Box(
modifier = modifier
.size(TangemTheme.dimens.size56)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.1F)),
contentAlignment = Alignment.Center,
content = {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size32),
painter = painterResource(icon.res),
contentDescription = null,
tint = tint,
)
},
HighlightedIcon(
modifier = modifier,
icon = icon.res,
iconTint = tint,
backgroundColor = backgroundColor,
)
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.components.bottomsheets.state
enum class BottomSheetState {
EXPANDED,
COLLAPSED,
}

View file

@ -39,8 +39,8 @@ fun PinTextField(
pinTextColor: PinTextColor,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
val focusRequester = remember { FocusRequester() }
val textFieldValue = remember(value) {
TextFieldValue(value, selection = TextRange(value.length))
}

View file

@ -0,0 +1,39 @@
package com.tangem.core.ui.components.icons
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.res.TangemTheme
@Composable
fun HighlightedIcon(
@DrawableRes icon: Int,
iconTint: Color,
modifier: Modifier = Modifier,
backgroundColor: Color = iconTint,
) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size56)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.1F)),
contentAlignment = Alignment.Center,
content = {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size32),
painter = painterResource(icon),
contentDescription = null,
tint = iconTint,
)
},
)
}

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
@ -18,10 +19,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelSize
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
@ -37,6 +44,7 @@ import com.tangem.core.ui.res.TangemThemePreview
*
* @see <a href="https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=4480-1459&t=2QTpi1G7FeTexTFS-4">Figma</a>
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun Label(state: LabelUM, modifier: Modifier = Modifier) {
val backgroundColor by animateColorAsState(
@ -63,12 +71,28 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
},
)
AnimatedContent(targetState = state.text) { text ->
val horizontalArrangementSize = remember {
when (state.size) {
LabelSize.REGULAR -> 4.dp
LabelSize.BIG -> 8.dp
}
}
val paddings = remember {
when (state.size) {
LabelSize.REGULAR -> PaddingValues(horizontal = 8.dp, vertical = 4.dp)
LabelSize.BIG -> PaddingValues(horizontal = 16.dp, vertical = 8.dp)
}
}
AnimatedContent(
modifier = modifier,
targetState = state.text,
) { text ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
.padding(horizontal = 4.dp)
horizontalArrangement = Arrangement.spacedBy(horizontalArrangementSize),
modifier = Modifier
.clip(TangemTheme.shapes.roundedCorners8)
.background(color = backgroundColor)
.then(
@ -82,8 +106,34 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
Modifier
},
)
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(paddings),
) {
state.leadingContent.let { leadingContentUM ->
when (leadingContentUM) {
is LabelLeadingContentUM.Token -> {
SubcomposeAsyncImage(
modifier = Modifier.size(16.dp),
model = ImageRequest.Builder(context = LocalContext.current)
.data(leadingContentUM.iconUrl)
.crossfade(enable = true)
.allowHardware(enable = false)
.build(),
loading = { CircleShimmer() },
error = {
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.background.tertiary,
shape = CircleShape,
),
)
},
contentDescription = null,
)
}
LabelLeadingContentUM.None -> Unit
}
}
Text(
modifier = Modifier.weight(1.0f, fill = false),
text = text.resolveReference(),
@ -109,6 +159,8 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
}
}
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -118,47 +170,93 @@ private fun LabelPreview() {
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp),
) {
Label(
state = LabelUM(
text = TextReference.Str("Regular Label"),
style = LabelStyle.REGULAR,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
),
)
Label(
state = LabelUM(
text = TextReference.Str(
"Regular long long long long long long long long long long long long Label",
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Label(
state = LabelUM(
text = TextReference.Str("Regular Label"),
style = LabelStyle.REGULAR,
),
style = LabelStyle.REGULAR,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
icon = R.drawable.ic_information_24,
),
)
)
Label(
state = LabelUM(
leadingContent = LabelLeadingContentUM.Token(
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/euro-coin.png",
),
text = TextReference.Str("Regular Label"),
style = LabelStyle.REGULAR,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
),
)
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Label(
state = LabelUM(
text = TextReference.Str(
"Regular long long long long long long long long long long long long Label",
),
style = LabelStyle.REGULAR,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
icon = R.drawable.ic_information_24,
),
)
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Label(
state = LabelUM(
text = TextReference.Str("Regular Label"),
style = LabelStyle.REGULAR,
size = LabelSize.BIG,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
size = LabelSize.BIG,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
size = LabelSize.BIG,
),
)
}
}
}
}

View file

@ -1,16 +1,29 @@
package com.tangem.core.ui.components.label.entity
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
data class LabelUM(
val text: TextReference,
val style: LabelStyle,
val style: LabelStyle = LabelStyle.REGULAR,
val size: LabelSize = LabelSize.REGULAR,
val leadingContent: LabelLeadingContentUM = LabelLeadingContentUM.None,
@DrawableRes val icon: Int? = null,
val onIconClick: (() -> Unit)? = null,
val onClick: (() -> Unit)? = null,
)
@Immutable
sealed class LabelLeadingContentUM {
data object None : LabelLeadingContentUM()
data class Token(val iconUrl: String) : LabelLeadingContentUM()
}
enum class LabelStyle {
REGULAR, ACCENT, WARNING,
}
enum class LabelSize {
REGULAR, BIG,
}

View file

@ -0,0 +1,130 @@
package com.tangem.core.ui.components.pager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* Horizontal pager indicator
*
* @param pagerState state of pager
* @param indicatorCount counter of visible indicator items
*/
@Composable
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier, indicatorCount: Int = 5) {
val listState = rememberLazyListState()
val indicatorColor = TangemTheme.colors.control.key
val overlayColor = TangemTheme.colors.overlay.secondary
val indicatorSize = 8.dp
val spacing = 4.dp
val totalWidth: Dp = indicatorSize * indicatorCount + spacing * (indicatorCount - 1)
val widthInPx = LocalDensity.current.run { indicatorSize.toPx() }
val currentItem by remember {
derivedStateOf {
pagerState.currentPage
}
}
val itemCount = pagerState.pageCount
LaunchedEffect(key1 = currentItem) {
val viewportSize = listState.layoutInfo.viewportSize
listState.animateScrollToItem(
currentItem,
(widthInPx / 2 - viewportSize.width / 2).toInt(),
)
}
Box(
modifier = modifier
.height(32.dp)
.background(
color = overlayColor,
shape = CircleShape,
)
.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
LazyRow(
modifier = Modifier
.width(totalWidth),
state = listState,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
userScrollEnabled = false,
) {
indicatorItems(
itemCount = itemCount,
currentItem = currentItem,
indicatorShape = CircleShape,
activeColor = indicatorColor,
inActiveColor = indicatorColor.copy(alpha = 0.5f),
indicatorSize = indicatorSize,
)
}
}
}
@Suppress("LongParameterList")
private fun LazyListScope.indicatorItems(
itemCount: Int,
currentItem: Int,
indicatorShape: Shape,
activeColor: Color,
inActiveColor: Color,
indicatorSize: Dp,
) {
items(itemCount) { index ->
val isSelected = index == currentItem
Box(
modifier = Modifier
.clip(indicatorShape)
.size(indicatorSize)
.background(
if (isSelected) activeColor else inActiveColor,
indicatorShape,
),
)
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicatorPreviewFirstPage() {
TangemThemePreview {
Box(
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.padding(),
contentAlignment = Alignment.Center,
) {
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { 10 },
)
PagerIndicator(pagerState = pagerState)
}
}
}

View file

@ -246,7 +246,8 @@ data class EventMessageAction(
*
* @param onClick The action to perform when the button is clicked. By default, it dismisses the message.
* */
fun cancelAction(onClick: () -> Unit = onDismissRequest) = EventMessageAction(
fun cancelAction(isWarning: Boolean = false, onClick: () -> Unit = onDismissRequest) = EventMessageAction(
isWarning = isWarning,
title = resourceReference(id = R.string.common_cancel),
onClick = onClick,
)

View file

@ -39,6 +39,30 @@ object Dialogs {
)
}
/**
* Hot wallet creation not supported dialog
*
* @param leastSupportedVersion least supported OS version name ex. "Android 10"
* @param onDismiss lambda be invoked when dialog is dismissed
*/
fun hotWalletCreationNotSupportedDialog(leastSupportedVersion: String, onDismiss: () -> Unit = {}): DialogMessage {
return DialogMessage(
title = resourceReference(
id = R.string.mobile_wallet_requires_min_os_warning_title,
formatArgs = wrappedList(leastSupportedVersion),
),
message = resourceReference(
id = R.string.mobile_wallet_requires_min_os_warning_body,
formatArgs = wrappedList(leastSupportedVersion),
),
firstAction = EventMessageAction(
title = resourceReference(R.string.common_got_it),
onClick = {},
),
onDismissRequest = onDismiss,
)
}
/**
* Universal error dialog
*/

View file

@ -3,4 +3,5 @@ package com.tangem.core.ui.test
object DetailsScreenTestTags {
const val SCREEN_CONTAINER = "DETAILS_SCREEN_CONTAINER"
const val SCREEN_ITEM = "DETAILS_SCREEN_ITEM"
const val VERSION_NAME = "DETAILS_SCREEN_VERSION_NAME"
}

View file

@ -5,6 +5,7 @@ object SendScreenTestTags {
const val AMOUNT_CONTAINER_TITLE = "SEND_SCREEN_AMOUNT_CONTAINER_TITLE"
const val INPUT_TEXT_FIELD = "SEND_SCREEN_INPUT_TEXT_FIELD"
const val AMOUNT_ERROR_TEXT = "SEND_SCREEN_AMOUNT_ERROR_TEXT"
const val EQUIVALENT_INPUT_AMOUNT = "SEND_SCREEN_EQUIVALENT_INPUT_AMOUNT"
const val EXCHANGE_ICON = "SEND_SCREEN_EXCHANGE_ICON"
const val TOKEN_NAME = "SEND_SCREEN_TOKEN_NAME"

View file

@ -1,7 +1,12 @@
package com.tangem.core.ui.utils
import android.text.format.DateFormat
import com.tangem.core.ui.utils.DateTimeFormatters.dateDDMMYYYY
import com.tangem.core.ui.utils.DateTimeFormatters.dateMMMdd
import com.tangem.core.ui.utils.DateTimeFormatters.dateTimeFormatter
import com.tangem.core.ui.utils.DateTimeFormatters.dateYYYY
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatter
import org.joda.time.format.DateTimeFormatterBuilder
@ -80,6 +85,13 @@ object DateTimeFormatters {
getBestFormatterBySkeleton("yyyy")
}
/**
* Example: "June 31"
*/
val dateDMMM: DateTimeFormatter by lazy {
getBestFormatterBySkeleton("d MMMM")
}
/**
* Example: "31.06.2020 12:00", "06/31/2020 12:00", "06/31/2020 12:00 PM"
*/
@ -87,6 +99,23 @@ object DateTimeFormatters {
getBestFormatterBySkeleton("dd.MM.yyyy HH:mm")
}
/**
* Local full date formatter (e.g., "dd MMMM, HH:mm")
*/
val localFullDate: DateTimeFormatter by lazy {
DateTimeFormatterBuilder()
.appendDayOfMonth(2)
.appendLiteral(' ')
.appendMonthOfYearText()
.appendLiteral(", ")
.appendHourOfDay(2)
.appendLiteral(':')
.appendMinuteOfHour(2)
.toFormatter()
.withLocale(Locale.getDefault())
.withZone(DateTimeZone.getDefault())
}
fun formatDate(date: DateTime, formatter: DateTimeFormatter = dateFormatter): String {
return formatter.print(date)
}

View file

@ -37,4 +37,51 @@ fun Long.toTimeFormat(formatter: DateTimeFormatter = DateTimeFormatters.timeForm
*/
fun Long.formatAsDateTime(formatter: DateTimeFormatter): String {
return DateTimeFormatters.formatDate(date = DateTime(this, DateTimeZone.getDefault()), formatter = formatter)
}
/**
* Parses an ISO 8601 date string and compares it to the current UTC time.
*
* @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)
}
val pastDateLocal = pastDateUtc.withZone(DateTimeZone.getDefault())
val isToday = pastDateLocal.isToday()
val diffInMillis = now.millis - pastDateUtc.millis
val diffInMinutes = diffInMillis / (1000 * 60)
val diffInHours = diffInMillis / (1000 * 60 * 60)
return when {
diffInMinutes < 1 -> FormattedDate.MinutesAgo(1)
diffInMinutes < 60 -> FormattedDate.MinutesAgo(diffInMinutes.toInt())
diffInHours < 12 && isToday -> FormattedDate.HoursAgo(diffInHours.toInt())
isToday -> {
val timeString = DateTimeFormatters.timeFormatter.print(pastDateLocal)
FormattedDate.Today(timeString)
}
else -> {
val dateString = DateTimeFormatters.localFullDate.print(pastDateLocal)
FormattedDate.FullDate(dateString)
}
}
}
/**
* Representing different formatted date representations.
*/
sealed class FormattedDate {
data class MinutesAgo(val minutes: Int) : FormattedDate()
data class HoursAgo(val hours: Int) : FormattedDate()
data class Today(val time: String) : FormattedDate()
data class FullDate(val date: String) : FormattedDate()
}

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="40" android:viewportWidth="40" android:width="24dp">
<path android:fillColor="#000000" android:pathData="M15.849,26.896C16.954,26.896 17.849,27.728 17.85,28.753V33.615C17.849,34.641 16.954,35.473 15.849,35.473C14.745,35.473 13.85,34.641 13.849,33.615V28.753C13.85,27.727 14.745,26.896 15.849,26.896Z"/>
<path android:fillColor="#000000" android:pathData="M32.454,20.958C33.558,20.958 34.454,21.819 34.454,22.879V33.552C34.454,34.612 33.558,35.473 32.454,35.473C31.35,35.473 30.454,34.612 30.454,33.552V22.879C30.454,21.818 31.349,20.958 32.454,20.958Z"/>
<path android:fillColor="#000000" android:pathData="M7.547,29.467C8.651,29.467 9.547,30.362 9.547,31.467V33.472C9.547,34.576 8.651,35.472 7.547,35.472C6.442,35.472 5.547,34.576 5.547,33.472V31.467C5.547,30.362 6.442,29.467 7.547,29.467Z"/>
<path android:fillColor="#000000" android:pathData="M24.151,23.976C25.256,23.976 26.151,24.755 26.151,25.716V33.732C26.151,34.693 25.256,35.472 24.151,35.472C23.047,35.472 22.151,34.693 22.151,33.732V25.716C22.152,24.755 23.047,23.976 24.151,23.976Z"/>
<path android:fillColor="#000000" android:pathData="M29.46,4.062C29.927,4.036 30.392,4.174 30.77,4.457C31.203,4.781 31.486,5.266 31.556,5.801L32.671,14.396C32.813,15.491 32.04,16.495 30.944,16.637C29.849,16.779 28.846,16.006 28.704,14.911L28.228,11.251C26.87,12.973 25.156,14.988 23.389,16.644C19.165,20.601 14.718,22.22 9.224,21.976C8.12,21.927 7.265,20.992 7.314,19.889C7.363,18.786 8.298,17.931 9.401,17.98C13.802,18.175 17.202,16.958 20.653,13.725C22.214,12.263 23.781,10.43 25.071,8.796L20.395,9.536C19.305,9.709 18.28,8.965 18.107,7.874C17.935,6.784 18.679,5.759 19.769,5.586L29.259,4.083L29.46,4.062Z"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M2.667,8C2.667,5.055 5.055,2.667 8,2.667C10.946,2.667 13.333,5.055 13.333,8C13.333,10.946 10.946,13.333 8,13.333C5.055,13.333 2.667,10.946 2.667,8ZM1.333,8C1.333,11.682 4.318,14.667 8,14.667C11.682,14.667 14.667,11.682 14.667,8C14.667,4.318 11.682,1.334 8,1.334C4.318,1.334 1.333,4.318 1.333,8Z"
android:fillColor="#919191"/>
<path
android:pathData="M5.032,11.297C4.984,11.287 4.94,11.263 4.906,11.229C4.871,11.194 4.847,11.15 4.837,11.102C4.827,11.054 4.832,11.005 4.85,10.959L6.315,7.297C6.416,7.045 6.567,6.816 6.758,6.625C6.95,6.433 7.178,6.282 7.429,6.182L11.092,4.717C11.137,4.699 11.188,4.695 11.235,4.704C11.283,4.714 11.328,4.738 11.362,4.772C11.396,4.807 11.42,4.851 11.43,4.899C11.44,4.947 11.435,4.997 11.417,5.042L9.952,8.704C9.851,8.956 9.7,9.185 9.509,9.376C9.317,9.568 9.089,9.718 8.838,9.819L5.175,11.284C5.13,11.302 5.08,11.306 5.032,11.297ZM8.28,8.736C8.425,8.707 8.559,8.636 8.664,8.531C8.768,8.426 8.84,8.292 8.869,8.147C8.898,8.001 8.884,7.85 8.827,7.713C8.77,7.576 8.673,7.46 8.55,7.377C8.427,7.295 8.282,7.251 8.134,7.251C7.935,7.251 7.744,7.33 7.603,7.47C7.463,7.611 7.384,7.802 7.384,8.001C7.384,8.149 7.427,8.294 7.51,8.417C7.592,8.54 7.709,8.637 7.846,8.694C7.983,8.75 8.134,8.765 8.28,8.736Z"
android:fillColor="#919191"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<group>
<clip-path
android:pathData="M0,0h20v20h-20z"/>
<path
android:pathData="M13.75,1.75C16.73,1.75 19.083,4.093 19.084,7.083C19.084,8.9 18.257,10.495 16.961,12.081C15.673,13.657 13.843,15.315 11.713,17.247L10.505,18.347L10,18.806L9.495,18.347L8.288,17.247C6.157,15.315 4.327,13.657 3.039,12.081C1.742,10.495 0.917,8.9 0.917,7.083C0.917,4.093 3.27,1.75 6.25,1.75C7.646,1.75 8.986,2.29 10,3.174C11.013,2.29 12.353,1.75 13.75,1.75Z"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#1E1E1E"/>
</group>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M1.873,2.461C2.205,2.461 2.473,2.73 2.473,3.061V8.464C2.473,9.9 3.638,11.064 5.073,11.064H11.855V10.146C11.855,9.831 12.203,9.639 12.469,9.809L14.858,11.326C15.105,11.483 15.105,11.844 14.858,12.002L12.469,13.519C12.203,13.688 11.855,13.497 11.855,13.181V12.264H5.073C2.975,12.264 1.273,10.562 1.273,8.464V3.061C1.273,2.73 1.542,2.461 1.873,2.461Z"
android:fillColor="#0099FF"/>
<path
android:pathData="M9.473,6.413C9.805,6.413 10.073,6.682 10.073,7.013C10.073,7.345 9.805,7.613 9.473,7.613H5.673C5.342,7.613 5.074,7.345 5.073,7.013C5.073,6.682 5.342,6.413 5.673,6.413H9.473Z"
android:fillColor="#0099FF"/>
<path
android:pathData="M13.273,2.696C13.605,2.696 13.873,2.964 13.873,3.296C13.873,3.627 13.605,3.896 13.273,3.896H5.673C5.342,3.896 5.074,3.627 5.073,3.296C5.073,2.964 5.342,2.696 5.673,2.696H13.273Z"
android:fillColor="#0099FF"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M18.327,4.365C20.007,4.365 20.847,4.365 21.489,4.692C22.053,4.98 22.512,5.439 22.8,6.003C23.127,6.645 23.127,7.485 23.127,9.165V14.836C23.127,16.516 23.127,17.356 22.8,17.998C22.512,18.562 22.053,19.021 21.489,19.309C20.847,19.636 20.007,19.636 18.327,19.636H5.675C3.995,19.636 3.154,19.636 2.513,19.309C1.948,19.021 1.49,18.562 1.202,17.998C0.875,17.356 0.875,16.516 0.875,14.836V9.165C0.875,7.485 0.875,6.645 1.202,6.003C1.49,5.439 1.948,4.98 2.513,4.692C3.154,4.365 3.995,4.365 5.675,4.365H18.327ZM14.403,9.195C13.006,9.195 11.988,9.949 11.988,10.995C11.988,11.79 12.708,12.208 13.217,12.46C13.767,12.711 13.979,12.878 13.936,13.129C13.936,13.505 13.513,13.673 13.09,13.673C12.581,13.673 12.073,13.547 11.607,13.338L11.354,14.51C11.862,14.719 12.412,14.803 12.921,14.803C14.488,14.844 15.462,14.092 15.462,12.962C15.462,11.539 13.471,11.455 13.471,10.828C13.513,10.535 13.767,10.367 14.064,10.367C14.53,10.325 15.038,10.409 15.462,10.618L15.716,9.447C15.292,9.28 14.826,9.195 14.403,9.195ZM2.881,9.321V9.489C3.389,9.573 3.855,9.74 4.278,9.949C4.448,10.033 4.563,10.21 4.617,10.41L5.761,14.762H7.285L9.572,9.321H8.09L6.565,13.004L5.973,9.865C5.93,9.573 5.676,9.321 5.337,9.321H2.881ZM10.164,9.321L8.979,14.762H10.419L11.604,9.321H10.164ZM18.13,9.321C17.876,9.321 17.622,9.489 17.537,9.74L15.42,14.762H16.902L17.198,13.967H19.02L19.188,14.762H20.502L19.358,9.321H18.13ZM18.76,12.838H17.574L18.337,10.787L18.76,12.838Z"
android:fillColor="#656565"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,155 @@
package com.tangem.core.ui.utils
import com.google.common.truth.Truth
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
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 DateUtilsTest {
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"))
}
@AfterEach
fun tearDown() {
DateTimeZone.setDefault(defaultTimeZone)
}
@Test
fun `should return MinutesAgo when difference is less than 1 minute`() {
val pastDate = now.minusSeconds(30)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
@Test
fun `should return MinutesAgo when difference is 1 minute`() {
val pastDate = now.minusMinutes(1)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
@Test
fun `should return MinutesAgo when difference is 30 minutes`() {
val pastDate = now.minusMinutes(30)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(30)
}
@Test
fun `should return MinutesAgo when difference is 59 minutes`() {
val pastDate = now.minusMinutes(59).minusSeconds(59)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(59)
}
@Test
fun `should return HoursAgo when difference is exactly 1 hour`() {
val pastDate = now.minusHours(1)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.HoursAgo::class.java)
Truth.assertThat((result as FormattedDate.HoursAgo).hours).isEqualTo(1)
}
@Test
fun `should return HoursAgo when difference is 11 hours`() {
val pastDate = now.minusHours(11)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.HoursAgo::class.java)
Truth.assertThat((result as FormattedDate.HoursAgo).hours).isEqualTo(11)
}
@Test
fun `should return Today when difference is exactly 12 hours`() {
val pastDate = createDateTime(day = 14, hour = 0)
val createdAt = pastDate.toString()
val nowInTest = createDateTime(day = 14, hour = 12)
val result = getFormattedDate(createdAt, nowInTest)
Truth.assertThat(result).isInstanceOf(FormattedDate.Today::class.java)
Truth.assertThat((result as FormattedDate.Today).time).isEqualTo("03:00")
}
@Test
fun `should return FullDate when difference is 18 hours and past date is another day`() {
val pastDate = createDateTime(day = 13, hour = 18)
val createdAt = pastDate.toString()
val nowInTest = createDateTime(day = 14, hour = 12)
val result = getFormattedDate(createdAt, nowInTest)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
}
@Test
fun `should return FullDate when difference is exactly 24 hours`() {
val pastDate = now.minusDays(1)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
}
@Test
fun `should return FullDate when difference is 2 days`() {
val pastDate = now.minusDays(2)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
}
@Test
fun `should return FullDate with original string when format has wrong date separator`() {
val wrongFormat = "2025/10/14T12:00:00.000Z"
val result = getFormattedDate(wrongFormat, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
Truth.assertThat((result as FormattedDate.FullDate).date).isEqualTo(wrongFormat)
}
@Test
fun `should handle future dates correctly`() {
val futureDate = now.plusHours(1)
val createdAt = futureDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
@Test
fun `should handle edge case of exactly 0 milliseconds difference`() {
val createdAt = now.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
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,
)
}
}