From b14caba3cf6aa40d436f37cfc8de508258492d25 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jan 2024 15:39:56 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../ui/components/fields/AmountTextField.kt | 154 ++++++++++++++++++ .../ui/components/fields/SimpleTextField.kt | 99 ++++++++--- .../AmountVisualTransformation.kt | 53 ++++-- .../inputrow/InputRowEnterAmount.kt | 104 ++++++++++++ .../inputrow/InputRowEnterInfoAmount.kt | 94 +++++++++++ .../core/ui/utils/DecimalFormatterExt.kt | 153 +++++++++++++++++ .../core/ui/utils/InputNumberFormatter.kt | 1 + .../com/tangem/domain/tokens/model/Amount.kt | 30 ++++ .../amount/SendAmountCurrencyConverter.kt | 32 ++++ 9 files changed, 677 insertions(+), 43 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt create mode 100644 domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Amount.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountCurrencyConverter.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt new file mode 100644 index 0000000000..d5263bfded --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt @@ -0,0 +1,154 @@ +package com.tangem.core.ui.components.fields + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.TopCenter +import androidx.compose.ui.Alignment.Companion.TopStart +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.* +import java.text.DecimalFormat + +@Composable +fun AmountTextField( + value: String, + decimals: Int, + onValueChange: (String) -> Unit, + textStyle: TextStyle, + modifier: Modifier = Modifier, + symbol: String? = null, + color: Color = TangemTheme.colors.text.primary1, + placeholderAlignment: Alignment = TopStart, + showPlaceholder: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), +) { + val decimalFormat = rememberDecimalFormat() + + val placeholderTextAlign = if (placeholderAlignment == TopCenter) { + TextAlign.Center + } else { + TextAlign.Start + } + SimpleTextField( + value = value, + onValueChange = { newText -> + if (decimalFormat.isValidSymbols(newText)) { + val trimmed = decimalFormat.getValidatedNumberWithFixedDecimals(newText, decimals) + onValueChange(trimmed) + } + }, + modifier = modifier + .background(TangemTheme.colors.background.action), + textStyle = textStyle, + color = color, + keyboardOptions = keyboardOptions, + singleLine = true, + visualTransformation = AmountVisualTransformation(decimals, symbol, decimalFormat), + decorationBox = { innerTextField -> + Box { + if (value.isBlank() && showPlaceholder) { + val placeholder = if (symbol != null) { + decimalFormat.defaultFormat().plus(" $symbol") + } else { + decimalFormat.defaultFormat() + } + Text( + text = placeholder, + style = textStyle, + color = TangemTheme.colors.text.disabled, + textAlign = placeholderTextAlign, + modifier = Modifier + .align(placeholderAlignment), + ) + } + innerTextField() + } + }, + ) +} + +private fun DecimalFormat.isValidSymbols(text: String): Boolean { + return checkDecimalSeparatorDuplicate(text) && checkGroupingSeparator(text) +} + +// region preview +@Preview(locale = "en", showBackground = true, name = "English") +@Preview(locale = "ru", showBackground = true, name = "Russian") +@Composable +private fun AmountTextFieldPreview( + @PreviewParameter(AmountTextFieldPreviewProvider::class) amount: AmountTextFieldPreviewData, +) { + var text by remember { mutableStateOf(amount.value.orEmpty()) } + TangemTheme { + AmountTextField( + value = text, + decimals = amount.decimals, + symbol = amount.symbol, + placeholderAlignment = amount.placeholderAlignment, + showPlaceholder = amount.showPlaceholder, + onValueChange = { text = it }, + textStyle = TangemTheme.typography.h2.copy( + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +private class AmountTextFieldPreviewProvider : PreviewParameterProvider { + override val values = sequenceOf( + AmountTextFieldPreviewData( + symbol = "USD", + value = "1000000,123123", + decimals = 3, + placeholderAlignment = TopStart, + showPlaceholder = true, + ), + AmountTextFieldPreviewData( + symbol = null, + value = "1000000.123123", + decimals = 6, + placeholderAlignment = TopStart, + showPlaceholder = false, + ), + AmountTextFieldPreviewData( + symbol = "$", + value = null, + decimals = 2, + showPlaceholder = true, + placeholderAlignment = TopCenter, + ), + AmountTextFieldPreviewData( + symbol = null, + value = null, + decimals = 2, + showPlaceholder = true, + placeholderAlignment = TopStart, + ), + ) +} + +private data class AmountTextFieldPreviewData( + val symbol: String? = "$", + val value: String? = null, + val decimals: Int = 2, + val showPlaceholder: Boolean, + val placeholderAlignment: Alignment, +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt index 7e0e1643c6..1e29006979 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt @@ -3,14 +3,18 @@ package com.tangem.core.ui.components.fields import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference @@ -19,6 +23,7 @@ import com.tangem.core.ui.res.TangemTheme /** * Simple text field with placeholder */ +@Suppress("ReusedModifierInstance") @Composable fun SimpleTextField( value: String, @@ -29,32 +34,72 @@ fun SimpleTextField( visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, color: Color = TangemTheme.colors.text.primary1, + textStyle: TextStyle = TangemTheme.typography.body2.copy(color = color), readOnly: Boolean = false, + decorationBox: (@Composable (innerTextField: @Composable () -> Unit) -> Unit)? = null, ) { - val focusRequester = remember { FocusRequester() } - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = TangemTheme.typography.body2.copy(color = color), - cursorBrush = SolidColor(TangemTheme.colors.text.primary1), - singleLine = singleLine, - readOnly = readOnly, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - decorationBox = { textValue -> - Box { - if (value.isBlank() && placeholder != null) { - Text( - text = placeholder.resolveReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.disabled, - modifier = Modifier, - ) - } - textValue() - } - }, - modifier = modifier - .focusRequester(focusRequester), + var textFieldValueState by remember { + mutableStateOf( + TextFieldValue( + text = value, + selection = when { + value.isEmpty() -> TextRange.Zero + else -> TextRange(value.length, value.length) + }, + ), + ) + } + val focusRequester = remember { FocusRequester.Default } + val customTextSelectionColors = TextSelectionColors( + handleColor = TangemTheme.colors.text.secondary, + backgroundColor = TangemTheme.colors.text.secondary.copy(alpha = 0.4f), ) + + val textFieldValue = textFieldValueState.copy(text = value) + + SideEffect { + if (textFieldValue.selection != textFieldValueState.selection || + textFieldValue.composition != textFieldValueState.composition + ) { + textFieldValueState = textFieldValue + } + } + + var lastTextValue by remember(value) { mutableStateOf(value) } + + CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) { + BasicTextField( + value = textFieldValue, + onValueChange = { newTextFieldValueState -> + textFieldValueState = newTextFieldValueState + + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text + lastTextValue = newTextFieldValueState.text + + if (stringChangedSinceLastInvocation) { + onValueChange(newTextFieldValueState.text) + } + }, + textStyle = textStyle.copy(color = color), + cursorBrush = SolidColor(TangemTheme.colors.text.primary1), + singleLine = singleLine, + readOnly = readOnly, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + decorationBox = decorationBox ?: { textValue -> + Box { + if (value.isBlank() && placeholder != null) { + Text( + text = placeholder.resolveReference(), + style = textStyle, + color = TangemTheme.colors.text.disabled, + ) + } + textValue() + } + }, + modifier = modifier + .focusRequester(focusRequester), + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt index b3f27e218e..278c631728 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt @@ -5,28 +5,49 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation +import com.tangem.core.ui.utils.formatWithThousands +import java.text.DecimalFormat class AmountVisualTransformation( - private val symbol: String, + private val decimals: Int, + private val symbol: String? = null, + private val decimalFormat: DecimalFormat = DecimalFormat(), ) : VisualTransformation { - override fun filter(text: AnnotatedString): TransformedText { - return TransformedText( - buildAnnotatedString { - append(text) - if (text.isNotBlank()) { - append(" ") - append(symbol) - } - }, - object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - return text.length - } - override fun transformedToOriginal(offset: Int): Int { - return text.length + override fun filter(text: AnnotatedString): TransformedText { + val formattedText = decimalFormat.formatWithThousands( + text.text, + decimals, + ) + val groupingSymbol = decimalFormat.decimalFormatSymbols.groupingSeparator + return TransformedText( + text = buildAnnotatedString { + append(formattedText) + if (formattedText.isNotEmpty() && symbol != null) { + append(" $symbol") } }, + offsetMapping = OffsetMappingImpl(text.text, formattedText, groupingSymbol), ) } + + private class OffsetMappingImpl( + private val text: String, + private val formattedText: String, + private val gropingSymbol: Char, + ) : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + var noneDigitCount = 0 + var i = 0 + while (i < offset + noneDigitCount) { + if (formattedText.getOrNull(i++) == gropingSymbol) noneDigitCount++ + } + return (offset + noneDigitCount).coerceIn(0, formattedText.length) + } + + override fun transformedToOriginal(offset: Int): Int { + val noneDigitCount = formattedText.take(offset).count { it == gropingSymbol } + return (offset - noneDigitCount).coerceIn(0, text.length) + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt new file mode 100644 index 0000000000..d25b9e7bc4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt @@ -0,0 +1,104 @@ +package com.tangem.core.ui.components.inputrow + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import com.tangem.core.ui.components.fields.AmountTextField +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +/** + * Input row for entering amount. Manages correct amount format and validation + * + * @param title title reference + * @param text primary text reference + * @param onValueChange text change callback + * @param modifier modifier + * @param titleColor title color + * @param textColor text color + * @param keyboardOptions keyboard options for field + * @param iconRes action icon + * @param iconTint action icon tint + * @param onIconClick click on action icon + * @param showDivider show divider + * + * @see [InputRowDefault] for read only version + * @see InputRowEnter + */ +@Composable +fun InputRowEnterAmount( + title: TextReference, + text: String, + decimals: Int, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + symbol: String? = null, + titleColor: Color = TangemTheme.colors.text.secondary, + textColor: Color = TangemTheme.colors.text.primary1, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + iconRes: Int? = null, + iconTint: Color = TangemTheme.colors.icon.informative, + onIconClick: (() -> Unit)? = null, + showDivider: Boolean = false, +) { + DividerContainer( + modifier = modifier, + showDivider = showDivider, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(TangemTheme.dimens.spacing12), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.caption2, + color = titleColor, + ) + AmountTextField( + value = text, + decimals = decimals, + symbol = symbol, + onValueChange = onValueChange, + color = textColor, + textStyle = TangemTheme.typography.body2, + keyboardOptions = keyboardOptions, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing8), + ) + } + iconRes?.let { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = iconTint, + modifier = Modifier + .padding( + top = TangemTheme.dimens.spacing10, + bottom = TangemTheme.dimens.spacing10, + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + ) { onIconClick?.invoke() }, + ) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt new file mode 100644 index 0000000000..273e0ab9ab --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt @@ -0,0 +1,94 @@ +package com.tangem.core.ui.components.inputrow + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.tangem.core.ui.components.fields.AmountTextField +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +/** + * `Input Row Enter Info` for entering amount. Manages correct amount format and validation + + * @param title title reference + * @param text primary text reference + * @param onValueChange text change callback + * @param modifier modifier + * @param titleColor title color + * @param textColor text color + * @param isSingleLine text + * @param visualTransformation applied transformation to text + * @param keyboardOptions keyboard options for field + * @param showDivider show divider + * + * @see [InputRowEnterInfo] + * @see Input Row Enter + * @see Input Row Enter Info + */ +@Composable +fun InputRowEnterInfoAmount( + title: TextReference, + text: String, + decimals: Int, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + symbol: String? = null, + info: TextReference? = null, + titleColor: Color = TangemTheme.colors.text.secondary, + textColor: Color = TangemTheme.colors.text.primary1, + infoColor: Color = TangemTheme.colors.text.tertiary, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + showDivider: Boolean = false, +) { + DividerContainer( + modifier = modifier, + showDivider = showDivider, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(TangemTheme.dimens.spacing12), + ) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.caption2, + color = titleColor, + ) + Row { + AmountTextField( + value = text, + decimals = decimals, + symbol = symbol, + onValueChange = onValueChange, + color = textColor, + textStyle = TangemTheme.typography.body2, + keyboardOptions = keyboardOptions, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing8) + .weight(1f), + ) + info?.let { + Text( + text = it.resolveReference(), + style = TangemTheme.typography.body2, + color = infoColor, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing8) + .align(Alignment.Bottom), + ) + } + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt new file mode 100644 index 0000000000..8cc2cf7bda --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt @@ -0,0 +1,153 @@ +package com.tangem.core.ui.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale + +private const val TEXT_CHUNK_THOUSAND = 3 +private const val POINT_SEPARATOR = '.' + +@Composable +fun rememberDecimalFormat(): DecimalFormat { + val locale = LocalConfiguration.current.locale + val decimalSymbols = remember { DecimalFormatSymbols.getInstance(locale) } + + return remember { + DecimalFormat().apply { + decimalFormatSymbols = decimalSymbols + isParseBigDecimal = true + } + } +} + +/** + * Formats input [String] for InputField, to remove wrong symbols, letters etc + * Use [decimals] for cut this number symbols after floating point + * + * Example (with 8 decimals): + * input string - ab123.46377372ab53 + * result string 123.46377372 + */ +fun DecimalFormat.getValidatedNumberWithFixedDecimals(text: String, decimals: Int): String { + val thousandsSeparator = decimalFormatSymbols.groupingSeparator + val decimalSeparator = decimalFormatSymbols.decimalSeparator + + val lastChar = text.lastOrNull() + val trimmedText = if (text.isNotEmpty() && (lastChar == thousandsSeparator || lastChar == POINT_SEPARATOR)) { + text.dropLast(1) + decimalSeparator + } else { + text + } + + if (trimmedText.startsWith("0") && trimmedText.length > 1 && trimmedText[1] != decimalSeparator) { + return "0" + } + + val filteredChars = trimmedText.replace(thousandsSeparator.toString(), "").filterIndexed { index, c -> + val isOneOrZeroPoint = + c == decimalSeparator && index != 0 && trimmedText.count { it == decimalSeparator } <= 1 + val isIndexPointIndex = + c == decimalSeparator && index != 0 && trimmedText.indexOf(decimalSeparator) == index + c.isDigit() || isIndexPointIndex || isOneOrZeroPoint + } + // If dot is present, take first digits before decimal and first decimals digits after decimal + return if (filteredChars.count { it == decimalSeparator } == 1) { + val beforeDecimal = filteredChars.substringBefore(decimalSeparator) + val afterDecimal = filteredChars.substringAfter(decimalSeparator) + beforeDecimal + decimalSeparator + afterDecimal.take(decimals) + } + // If there is no dot, just take all digits + else { + filteredChars + } +} + +/** + * Formats input [text] with grouping and decimal separators. + * Takes into account [decimals] number of digits after floating point. + */ +fun DecimalFormat.formatWithThousands(text: String, decimals: Int): String { + val thousandsSeparator = decimalFormatSymbols.groupingSeparator + val decimalSeparator = decimalFormatSymbols.decimalSeparator + val localizedText = text.replace("[,.]".toRegex(), decimalSeparator.toString()) + return if (localizedText.count { it == decimalSeparator } == 1) { + val beforeDecimal = localizedText.substringBefore(decimalSeparator) + .reversed() + .chunked(TEXT_CHUNK_THOUSAND) + .joinToString(thousandsSeparator.toString()) + .reversed() + val afterDecimal = localizedText.substringAfter(decimalSeparator) + beforeDecimal + decimalSeparator + afterDecimal.take(decimals) + } + // If there is no dot, just take all digits + else { + localizedText.reversed() + .chunked(TEXT_CHUNK_THOUSAND) + .joinToString(thousandsSeparator.toString()) + .reversed() + } +} + +fun DecimalFormat.defaultFormat(): String { + return "0${decimalFormatSymbols.decimalSeparator}00" +} + +/** + * Checks if text input contains extra decimal separators. + * If so, it will return false, otherwise true. + * + * Note: number can contain only one decimal separator. + */ +fun DecimalFormat.checkDecimalSeparatorDuplicate(text: String): Boolean { + val regex = "[${decimalFormatSymbols.decimalSeparator}]".toRegex() + val decimalSeparatorCount = regex.findAll(text).count() + return decimalSeparatorCount <= 1 // only one decimal separator +} + +/** + * Checks if text input contains grouping separators. + * If so, it will return false, otherwise true. + * + * Note: grouping separators are used only for VisualTransformations. + */ +fun DecimalFormat.checkGroupingSeparator(text: String): Boolean { + val regex = "[${decimalFormatSymbols.groupingSeparator}]".toRegex() + val decimalSeparatorCount = regex.findAll(text).count() + return decimalSeparatorCount == 0 // no grouping separator +} + +fun String.parseToBigDecimal(decimals: Int): BigDecimal { + val decimalFormat = DecimalFormat().apply { + decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()) + isParseBigDecimal = true + maximumFractionDigits = decimals + minimumFractionDigits = decimals + } + return try { + decimalFormat.parse(this) as? BigDecimal ?: BigDecimal.ZERO + } catch (e: Exception) { + BigDecimal.ZERO + } +} + +fun BigDecimal.parseBigDecimal(decimals: Int, roundingMode: RoundingMode = RoundingMode.DOWN): String { + val decimalFormat = DecimalFormat().apply { + decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()) + isParseBigDecimal = true + isGroupingUsed = false + maximumFractionDigits = decimals + minimumFractionDigits = 0 + this.roundingMode = roundingMode + } + + return try { + decimalFormat.format(this) + } catch (e: Exception) { + "" + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/InputNumberFormatter.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/InputNumberFormatter.kt index 7ed7c4a7c4..7873012994 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/InputNumberFormatter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/InputNumberFormatter.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui.utils import java.text.DecimalFormat +@Deprecated("Deprecated due to unnecessary abstraction. Use methods from DecimalFormatterExt") class InputNumberFormatter( numberFormat: DecimalFormat, ) { diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Amount.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Amount.kt new file mode 100644 index 0000000000..eb777dbcca --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Amount.kt @@ -0,0 +1,30 @@ +package com.tangem.domain.tokens.model + +import java.math.BigDecimal + +data class Amount( + val currencySymbol: String, + val value: BigDecimal? = null, + val decimals: Int, + val type: AmountType = AmountType.CoinType, +) + +sealed class AmountType { + object CoinType : AmountType() + object ReserveType : AmountType() + data class TokenType(val token: CryptoCurrency.Token) : AmountType() + data class FiatType(val code: String) : AmountType() +} + +/** Converts `BigDecimal` [cryptoCurrency] to [Amount] */ +fun BigDecimal.convertToAmount(cryptoCurrency: CryptoCurrency) = Amount( + currencySymbol = cryptoCurrency.symbol, + value = this, + decimals = cryptoCurrency.decimals, + type = when (cryptoCurrency) { + is CryptoCurrency.Coin -> AmountType.CoinType + is CryptoCurrency.Token -> AmountType.TokenType( + token = cryptoCurrency, + ) + }, +) \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountCurrencyConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountCurrencyConverter.kt new file mode 100644 index 0000000000..0f95e9fad7 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountCurrencyConverter.kt @@ -0,0 +1,32 @@ +package com.tangem.features.send.impl.presentation.state.amount + +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.impl.presentation.state.SendUiState +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import com.tangem.utils.isNullOrZero + +internal class SendAmountCurrencyConverter( + private val currentStateProvider: Provider, + private val cryptoCurrencyStatusProvider: Provider, +) : Converter { + override fun convert(value: Boolean): SendUiState { + val state = currentStateProvider() + val amountState = state.amountState ?: return state + val amountTextField = amountState.amountTextField + val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() + + val isValidFiatRate = cryptoCurrencyStatus.value.fiatRate.isNullOrZero() + return if (amountTextField.isFiatValue == value && !isValidFiatRate) { + state + } else { + return state.copy( + amountState = amountState.copy( + amountTextField = amountTextField.copy( + isFiatValue = value, + ), + ), + ) + } + } +} \ No newline at end of file