From d661eda82a67f2cca72860e950fbb0762f0f1e07 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Dec 2025 16:09:50 +0500 Subject: [PATCH] Updated on 2026-08-14 --- ...downExtension.kt => AnnotatedStringExt.kt} | 19 ++ .../core/ui/extensions/ColorReference.kt | 3 +- .../core/ui/extensions/SpanStyleReference.kt | 19 ++ .../core/ui/extensions/TextReference.kt | 241 +++++++++++++++++- 4 files changed, 271 insertions(+), 11 deletions(-) rename core/ui/src/main/java/com/tangem/core/ui/extensions/{MarkdownExtension.kt => AnnotatedStringExt.kt} (86%) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt similarity index 86% rename from core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt rename to core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt index f1e49e308f..e824de1e5d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/MarkdownExtension.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt @@ -56,12 +56,31 @@ fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode): return this } +/** + * Appends a single space character to the [AnnotatedString.Builder]. + */ fun AnnotatedString.Builder.appendSpace() = append(" ") +/** + * Appends text with the specified [Color] to the [AnnotatedString.Builder]. + * + * @param text The text to append. + * @param color The [Color] to apply to the appended text. + */ fun AnnotatedString.Builder.appendColored(text: String, color: Color) = withStyle(SpanStyle(color = color)) { append(text) } +/** + * Appends text with the specified [SpanStyle] to the [AnnotatedString.Builder]. + * + * @param text The text to append. + * @param spanStyle The [SpanStyle] to apply to the appended text. + */ +fun AnnotatedString.Builder.appendStyled(text: String, spanStyle: SpanStyle) = withStyle(spanStyle) { + append(text) +} + /** * Appends text from a template string to the AnnotatedString.Builder, replacing a placeholder (default "%s") * with custom styled content provided by a lambda. The lambda allows you to insert styled or complex content diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt index 8add413e59..604a8a16e1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/ColorReference.kt @@ -7,10 +7,11 @@ import androidx.compose.ui.graphics.Color /** * Utility class for keeping themed color reference from app theme. * - * It necessary to use [Immutable] annotation for runtime stability. + * It is necessary to use [Immutable] annotation for runtime stability. * * @property value color provider from theme */ +@Deprecated("Use TextReference with applied SpanStyleReference for colored text.") @Immutable data class ColorReference(val value: @Composable () -> Color) diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt new file mode 100644 index 0000000000..bd4ab76903 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/SpanStyleReference.kt @@ -0,0 +1,19 @@ +package com.tangem.core.ui.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.SpanStyle + +/** + * Utility functional interface for keeping themed [SpanStyle] reference from app theme. + * It is necessary to use [Stable] annotation for runtime stability. + */ +@Stable +@FunctionalInterface +fun interface SpanStyleReference { + + @ReadOnlyComposable + @Composable + operator fun invoke(): SpanStyle +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt index 9141b2e002..9997470e8c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt @@ -1,16 +1,33 @@ package com.tangem.core.ui.extensions +import android.content.res.Configuration import android.content.res.Resources import androidx.annotation.PluralsRes import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString.Builder +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp import com.tangem.core.res.getPluralStringSafe import com.tangem.core.res.getStringSafe +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.utils.StringsSigns.THREE_STARS import org.intellij.markdown.MarkdownElementTypes import kotlin.contracts.ExperimentalContracts @@ -69,6 +86,35 @@ sealed interface TextReference { */ data class Combined(val refs: WrappedList) : TextReference + /** + * Styled string value + * + * @property value string value + * @property spanStyleReference text style reference + * @property onClick optional click action + */ + data class StyledStr( + val value: String, + val spanStyleReference: SpanStyleReference, + val onClick: (() -> Unit)? = null, + ) : TextReference + + /** + * Styled string resource + * + * @property id resource id + * @property formatArgs arguments. Impossible to use [kotlinx.collections.immutable.ImmutableList] because + * [Any] is unstable. + * @property spanStyleReference text style reference + * @property onClick optional click action + */ + data class StyledRes( + @StringRes val id: Int, + val formatArgs: WrappedList = WrappedList(emptyList()), + val spanStyleReference: SpanStyleReference, + val onClick: (() -> Unit)? = null, + ) : TextReference + companion object { /** Empty string as [TextReference] */ @@ -139,6 +185,42 @@ fun pluralReference( return TextReference.PluralRes(id, count, formatArgs) } +/** + * Creates a [TextReference] using a plain string value with optional span style and click action. + * + * @param value The plain string value. + * @param spanStyleReference A [SpanStyleReference] representing the text style to be applied. + * @param onClick An optional lambda function to be invoked when the text is clicked. + * @return A [TextReference] representing the styled string with click action. + */ +fun styledStringReference(value: String, spanStyleReference: SpanStyleReference, onClick: (() -> Unit)? = null) = + TextReference.StyledStr( + value = value, + onClick = onClick, + spanStyleReference = spanStyleReference, + ) + +/** + * Creates a [TextReference] using a string resource ID with optional format arguments, span style, and click action. + * + * @param id The resource ID of the string. + * @param formatArgs A list of format arguments to be applied to the string resource. + * @param spanStyleReference A [SpanStyleReference] representing the text style to be applied. + * @param onClick An optional lambda function to be invoked when the text is clicked. + * @return A [TextReference] representing the styled string with click action. + */ +fun styledResourceReference( + @StringRes id: Int, + formatArgs: WrappedList = WrappedList(emptyList()), + spanStyleReference: SpanStyleReference, + onClick: (() -> Unit)? = null, +) = TextReference.StyledRes( + id = id, + formatArgs = formatArgs, + spanStyleReference = spanStyleReference, + onClick = onClick, +) + /** * Combines multiple [TextReference] instances into a single [TextReference]. * @@ -165,9 +247,7 @@ fun combinedReference(vararg refs: TextReference): TextReference { fun TextReference.resolveReference(): String { return when (this) { is TextReference.Res -> { - val args = formatArgs - .map { if (it is TextReference) it.resolveReference() else it } - .toTypedArray() + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() val resolvedReference = stringResourceSafe(id = id, *args) @@ -187,6 +267,12 @@ fun TextReference.resolveReference(): String { } } } + is TextReference.StyledRes -> { + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() + + stringResourceSafe(id = id, *args) + } + is TextReference.StyledStr -> value } } @@ -194,9 +280,7 @@ fun TextReference.resolveReference(): String { fun TextReference.resolveReference(resources: Resources): String { return when (this) { is TextReference.Res -> { - val args = formatArgs - .map { if (it is TextReference) it.resolveReference(resources) else it } - .toTypedArray() + val args = formatArgs.map { if (it is TextReference) it.resolveReference(resources) else it }.toTypedArray() resources.getStringSafe(id, *args) } @@ -210,6 +294,12 @@ fun TextReference.resolveReference(resources: Resources): String { } } } + is TextReference.StyledRes -> { + val args = formatArgs.map { if (it is TextReference) it.resolveReference(resources) else it }.toTypedArray() + + resources.getStringSafe(id, *args) + } + is TextReference.StyledStr -> value } } @@ -218,9 +308,7 @@ fun TextReference.resolveReference(resources: Resources): String { fun TextReference.resolveAnnotatedReference(): AnnotatedString { return when (this) { is TextReference.Res -> { - val args = formatArgs - .map { if (it is TextReference) it.resolveReference() else it } - .toTypedArray() + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() formatAnnotated(stringResourceSafe(id = id, *args)) } @@ -234,6 +322,21 @@ fun TextReference.resolveAnnotatedReference(): AnnotatedString { append(it.resolveAnnotatedReference()) } } + is TextReference.StyledRes -> { + val args = formatArgs.map { if (it is TextReference) it.resolveReference() else it }.toTypedArray() + val text = stringResourceSafe(id = id, *args) + + createStyledText( + text = text, + spanStyleReference = spanStyleReference, + onClick = onClick, + ) + } + is TextReference.StyledStr -> createStyledText( + text = value, + spanStyleReference = spanStyleReference, + onClick = onClick, + ) } } @@ -245,6 +348,8 @@ operator fun TextReference.plus(ref: TextReference): TextReference { is TextReference.Res, is TextReference.Str, is TextReference.Annotated, + is TextReference.StyledRes, + is TextReference.StyledStr, -> TextReference.Combined(refs = wrappedList(this, ref)) } } @@ -280,4 +385,120 @@ private fun formatAnnotated(rawString: String): AnnotatedString { */ fun TextReference.orMaskWithStars(maskWithStars: Boolean): TextReference { return if (maskWithStars) stringReference(THREE_STARS) else this -} \ No newline at end of file +} + +@ReadOnlyComposable +@Composable +private fun createStyledText( + text: String, + spanStyleReference: SpanStyleReference, + onClick: (() -> Unit)?, +): AnnotatedString = buildAnnotatedString { + if (onClick != null) { + withLink( + link = LinkAnnotation.Clickable( + tag = text, + linkInteractionListener = { onClick() }, + ), + block = { + appendStyled( + text = text, + spanStyle = spanStyleReference(), + ) + }, + ) + } else { + appendStyled( + text = text, + spanStyle = spanStyleReference(), + ) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TextReference_Preview(@PreviewParameter(TextReferencePreviewProvider::class) params: TextReference) { + TangemThemePreview { + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors.background.primary) + .padding(4.dp), + ) { + Text( + text = params.resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = styledResourceReference( + id = R.string.common_read_more, + spanStyleReference = { + TangemTheme.typography.body1.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + onClick = { + uriHandler.openUri("https://tangem.com") + }, + ).resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = stringReference("To be masked").orMaskWithStars(true).resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + } +} + +private class TextReferencePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + stringReference("Simple string"), + resourceReference(R.string.common_tangem), + pluralReference( + id = R.plurals.common_days, + count = 5, + formatArgs = wrappedList(5), + ), + styledStringReference( + value = "Styled string", + spanStyleReference = { + TangemTheme.typography.subtitle2.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + ), + styledResourceReference( + id = R.string.common_tangem, + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + ), + combinedReference( + stringReference("Simple string"), + resourceReference(R.string.common_tangem), + pluralReference( + id = R.plurals.common_days, + count = 5, + formatArgs = wrappedList(5), + ), + styledStringReference( + value = "Styled string", + spanStyleReference = { + TangemTheme.typography.subtitle2.copy(TangemTheme.colors.text.accent).toSpanStyle() + }, + ), + styledResourceReference( + id = R.string.common_tangem, + spanStyleReference = { + TangemTheme.typography.caption1.copy(TangemTheme.colors.text.warning).toSpanStyle() + }, + ), + ), + ) +} +// endregion \ No newline at end of file