diff --git a/CHANGELOG.md b/CHANGELOG.md index 43fec9fef9..dbaae23d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve Pubky profile restore, contact editing, and contact routing flows #905 ### Fixed +- Fix currency settings and calculator widget consistency with iOS #884 - Polish Terms of Use screen padding to match iOS #903 ## [2.2.0] - 2026-04-07 diff --git a/app/src/main/java/to/bitkit/data/WidgetsStore.kt b/app/src/main/java/to/bitkit/data/WidgetsStore.kt index 97ffaa39f9..d1da91aa23 100644 --- a/app/src/main/java/to/bitkit/data/WidgetsStore.kt +++ b/app/src/main/java/to/bitkit/data/WidgetsStore.kt @@ -146,7 +146,11 @@ class WidgetsStore @Inject constructor( if (!store.data.first().widgets.map { it.type }.contains(type)) return store.updateData { data -> - data.copy(widgets = data.widgets.filterNot { it.type == type }) + val updated = data.copy(widgets = data.widgets.filterNot { it.type == type }) + when (type) { + WidgetType.CALCULATOR -> updated.copy(calculatorValues = CalculatorValues()) + else -> updated + } } } diff --git a/app/src/main/java/to/bitkit/models/widget/CalculatorValues.kt b/app/src/main/java/to/bitkit/models/widget/CalculatorValues.kt index 5daa6ff2fb..7a40f051b0 100644 --- a/app/src/main/java/to/bitkit/models/widget/CalculatorValues.kt +++ b/app/src/main/java/to/bitkit/models/widget/CalculatorValues.kt @@ -4,6 +4,6 @@ import kotlinx.serialization.Serializable @Serializable data class CalculatorValues( - val btcValue: String = "", + val btcValue: String = "10000", val fiatValue: String = "", ) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt index 85797afffd..42c1c8b8a7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -22,11 +23,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -43,6 +44,7 @@ import to.bitkit.ui.utils.visualTransformation.BitcoinVisualTransformation import to.bitkit.ui.utils.visualTransformation.CalculatorFormatter import to.bitkit.ui.utils.visualTransformation.MonetaryVisualTransformation import to.bitkit.viewmodels.CurrencyViewModel +import java.math.BigDecimal @Composable fun CalculatorCard( @@ -55,34 +57,99 @@ fun CalculatorCard( val calculatorValues by calculatorViewModel.calculatorValues.collectAsStateWithLifecycle() var btcValue: String by rememberSaveable { mutableStateOf(calculatorValues.btcValue) } var fiatValue: String by rememberSaveable { mutableStateOf(calculatorValues.fiatValue) } + val displayedBtcValue = btcValue.ifEmpty { calculatorValues.btcValue } + val displayedFiatValue = fiatValue + + LaunchedEffect( + calculatorValues.btcValue, + calculatorValues.fiatValue, + currencyUiState.displayUnit, + currencyUiState.selectedCurrency, + ) { + if (!shouldHydrateFiatFromStoredBtc( + storedBtcValue = calculatorValues.btcValue, + storedFiatValue = calculatorValues.fiatValue, + currentFiatValue = fiatValue, + displayUnit = currencyUiState.displayUnit, + ) + ) { + return@LaunchedEffect + } + val convertedFiat = CalculatorFormatter.convertBtcToFiat( + btcValue = calculatorValues.btcValue, + displayUnit = currencyUiState.displayUnit, + currencyViewModel = currencyViewModel, + ).orEmpty() + if (convertedFiat.isEmpty()) { + return@LaunchedEffect + } + fiatValue = convertedFiat + calculatorViewModel.updateCalculatorValues( + fiatValue = convertedFiat, + btcValue = calculatorValues.btcValue, + ) + } + + LaunchedEffect(currencyUiState.selectedCurrency, currencyUiState.displayUnit) { + val sourceBtc = btcValue.ifEmpty { calculatorValues.btcValue } + if (sourceBtc.isEmpty() || isZeroBtcValue(sourceBtc, currencyUiState.displayUnit)) { + return@LaunchedEffect + } + val convertedFiat = CalculatorFormatter.convertBtcToFiat( + btcValue = sourceBtc, + displayUnit = currencyUiState.displayUnit, + currencyViewModel = currencyViewModel, + ).orEmpty() + if (convertedFiat.isEmpty()) { + return@LaunchedEffect + } + fiatValue = convertedFiat + calculatorViewModel.updateCalculatorValues( + fiatValue = convertedFiat, + btcValue = sourceBtc, + ) + } CalculatorCardContent( modifier = modifier, showWidgetTitle = showWidgetTitle, btcPrimaryDisplayUnit = currencyUiState.displayUnit, - btcValue = btcValue.ifEmpty { calculatorValues.btcValue }, - onBtcChange = { newValue -> - btcValue = newValue - val convertedFiat = CalculatorFormatter.convertBtcToFiat( - btcValue = btcValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel - ) - fiatValue = convertedFiat.orEmpty() + btcValue = displayedBtcValue, + onBtcChange = { rawValue -> + val sanitized = if (currencyUiState.displayUnit.isModern()) { + sanitizeIntegerInput(rawValue) + } else { + sanitizeDecimalInput(rawValue) + } + btcValue = sanitized + fiatValue = if (sanitized.isEmpty()) { + "" + } else { + CalculatorFormatter.convertBtcToFiat( + btcValue = btcValue, + displayUnit = currencyUiState.displayUnit, + currencyViewModel = currencyViewModel, + ).orEmpty() + } calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) }, fiatSymbol = currencyUiState.currencySymbol, fiatName = currencyUiState.selectedCurrency, - fiatValue = fiatValue.ifEmpty { calculatorValues.fiatValue }, - onFiatChange = { newValue -> - fiatValue = newValue - btcValue = CalculatorFormatter.convertFiatToBtc( - fiatValue = fiatValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel - ) + fiatValue = displayedFiatValue, + onFiatChange = { rawValue -> + val sanitized = sanitizeDecimalInput(rawValue) + fiatValue = sanitized + btcValue = if (sanitized.isEmpty()) { + "" + } else { + CalculatorFormatter.convertFiatToBtc( + fiatValue = fiatValue, + displayUnit = currencyUiState.displayUnit, + currencyViewModel = currencyViewModel, + ) + } calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) - } + }, ) } @@ -115,14 +182,13 @@ fun CalculatorCardContent( // Bitcoin input with visual transformation CalculatorInput( - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focusState -> if (focusState.hasFocus) onBtcChange("") }, value = btcValue, onValueChange = onBtcChange, currencySymbol = BITCOIN_SYMBOL, currencyName = stringResource(R.string.settings__general__unit_bitcoin), - visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit) + keyboardType = if (btcPrimaryDisplayUnit.isModern()) KeyboardType.Number else KeyboardType.Decimal, + visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit), + modifier = Modifier.fillMaxWidth() ) VerticalSpacer(16.dp) @@ -133,15 +199,40 @@ fun CalculatorCardContent( onValueChange = onFiatChange, currencySymbol = fiatSymbol, currencyName = fiatName, + keyboardType = KeyboardType.Decimal, visualTransformation = MonetaryVisualTransformation(decimalPlaces = 2), - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focusState -> if (focusState.hasFocus) onFiatChange("") } + modifier = Modifier.fillMaxWidth() ) } } } +internal fun shouldHydrateFiatFromStoredBtc( + storedBtcValue: String, + storedFiatValue: String, + currentFiatValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean { + if (storedBtcValue.isEmpty()) { + return false + } + if (isZeroBtcValue(storedBtcValue, displayUnit)) { + return false + } + if (storedFiatValue.isNotEmpty()) { + return false + } + return currentFiatValue.isEmpty() +} + +internal fun isZeroBtcValue( + btcValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean = when (displayUnit) { + BitcoinDisplayUnit.MODERN -> btcValue == "0" + BitcoinDisplayUnit.CLASSIC -> btcValue.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) == 0 +} + @Composable private fun WidgetTitleRow() { Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt index 182cd5c72c..6a134f2cf3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt @@ -31,8 +31,11 @@ fun CalculatorInput( currencySymbol: String, currencyName: String, modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Number, visualTransformation: VisualTransformation = VisualTransformation.None, ) { + val displayCurrencySymbol = currencySymbol.toCalculatorDisplaySymbol() + TextInput( value = value, singleLine = true, @@ -44,11 +47,11 @@ fun CalculatorInput( .background(color = Colors.Gray6, shape = CircleShape) .size(32.dp) ) { - BodyMSB(currencySymbol, color = Colors.Brand) + BodyMSB(displayCurrencySymbol, color = Colors.Brand) } }, keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number + keyboardType = keyboardType, ), suffix = { CaptionB(currencyName.uppercase(), color = Colors.Gray1) }, colors = AppTextFieldDefaults.noIndicatorColors.copy( @@ -60,6 +63,26 @@ fun CalculatorInput( ) } +internal fun sanitizeIntegerInput(raw: String): String = + raw.filter { it.isDigit() } + +internal fun sanitizeDecimalInput(raw: String): String { + val filtered = raw.filter { it.isDigit() || it == '.' } + val dotIndex = filtered.indexOf('.') + if (dotIndex == -1) return filtered + return filtered.substring(0, dotIndex + 1) + + filtered.substring(dotIndex + 1).replace(".", "") +} + +internal fun String.toCalculatorDisplaySymbol(): String { + val symbol = trim() + return if (symbol.length >= 3) { + symbol.take(1) + } else { + symbol + } +} + @Preview(showBackground = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt index 80ece8fdfe..939efbbd61 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt @@ -122,7 +122,7 @@ fun LocalCurrencySettingsContent( } items(mostUsedRates) { rate -> SettingsButtonRow( - title = "${rate.quote} (${rate.currencySymbol})", + title = formatCurrencyTitle(rate), value = SettingsButtonValue.BooleanValue(selectedCurrency == rate.quote), onClick = { onCurrencyClick(rate.quote) }, ) @@ -135,7 +135,7 @@ fun LocalCurrencySettingsContent( items(otherCurrencies) { rate -> SettingsButtonRow( - title = rate.quote, + title = formatCurrencyTitle(rate), value = SettingsButtonValue.BooleanValue(selectedCurrency == rate.quote), onClick = { onCurrencyClick(rate.quote) }, ) @@ -150,6 +150,11 @@ fun LocalCurrencySettingsContent( } } +private fun formatCurrencyTitle(rate: FxRate): String { + val symbol = rate.currencySymbol.trim() + return if (symbol.isNotEmpty()) "${rate.quote} ($symbol)" else rate.quote +} + @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt index 53c7ef8bf7..73471e20fd 100644 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt +++ b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.SATS_GROUPING_SEPARATOR -import to.bitkit.models.formatToModernDisplay import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale @@ -16,78 +15,116 @@ class BitcoinVisualTransformation( ) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { - val originalText = text.text + val rawText = text.text + val sanitizedText = sanitizeInput(rawText) - if (originalText.isEmpty()) { - return TransformedText(text, OffsetMapping.Identity) + if (sanitizedText.isEmpty()) { + return TransformedText(AnnotatedString(""), OffsetMapping.Identity) } val formattedText = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> formatModernDisplay(originalText) - BitcoinDisplayUnit.CLASSIC -> formatClassicDisplay(originalText) + BitcoinDisplayUnit.MODERN -> formatModernDisplay(sanitizedText) + BitcoinDisplayUnit.CLASSIC -> formatClassicDisplay(sanitizedText) } - val offsetMapping = createOffsetMapping(originalText, formattedText) - return TransformedText( AnnotatedString(formattedText), - offsetMapping + createOffsetMapping(rawText, formattedText), ) } + private fun sanitizeInput(text: String): String = when (displayUnit) { + BitcoinDisplayUnit.MODERN -> text.filter { it.isDigit() } + BitcoinDisplayUnit.CLASSIC -> sanitizeClassicInput(text) + } + + private fun sanitizeClassicInput(text: String): String { + val filtered = text.filter { it.isDigit() || it == '.' } + val dotIndex = filtered.indexOf('.') + if (dotIndex == -1) { + return filtered + } + return filtered.substring(0, dotIndex + 1) + + filtered.substring(dotIndex + 1).replace(".", "") + } + private fun formatModernDisplay(text: String): String { - val longValue = text.replace("$SATS_GROUPING_SEPARATOR", "").toLongOrNull() ?: return text - return longValue.formatToModernDisplay() + val digits = text.replace("$SATS_GROUPING_SEPARATOR", "") + if (digits.isEmpty()) { + return "" + } + val normalizedDigits = digits.trimStart('0').ifEmpty { "0" } + return normalizedDigits.reversed().chunked(3).joinToString(" ").reversed() } private fun formatClassicDisplay(text: String): String { val cleanText = text.replace(" ", "").replace(",", "") - val doubleValue = cleanText.toDoubleOrNull() ?: return text + if (cleanText.isEmpty() || cleanText == ".") { + return cleanText + } + + val endsWithDecimal = cleanText.endsWith(".") + val textToFormat = if (endsWithDecimal) cleanText.dropLast(1) else cleanText + if (textToFormat.isEmpty()) { + return cleanText + } + + val doubleValue = textToFormat.toDoubleOrNull() ?: return cleanText val formatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply { groupingSeparator = ' ' decimalSeparator = '.' } val formatter = DecimalFormat("#,##0.########", formatSymbols) - return formatter.format(doubleValue) + val formatted = formatter.format(doubleValue) + return if (endsWithDecimal) "$formatted." else formatted } - private fun createOffsetMapping(original: String, transformed: String): OffsetMapping { + private fun createOffsetMapping(rawOriginal: String, transformed: String): OffsetMapping { + val rawToSanitizedCount = IntArray(rawOriginal.length + 1) + var dotSeen = false + var sanitizedSoFar = 0 + for (i in rawOriginal.indices) { + val char = rawOriginal[i] + val isKept = when { + displayUnit == BitcoinDisplayUnit.MODERN -> char.isDigit() + char.isDigit() -> true + char == '.' && !dotSeen -> { + dotSeen = true + true + } + else -> false + } + if (isKept) sanitizedSoFar++ + rawToSanitizedCount[i + 1] = sanitizedSoFar + } + val totalSanitized = sanitizedSoFar + return object : OffsetMapping { override fun originalToTransformed(offset: Int): Int { - val cleanOriginal = original.take(offset).replace(" ", "") + val clamped = offset.coerceIn(0, rawOriginal.length) + val validCount = rawToSanitizedCount[clamped] + if (validCount >= totalSanitized) return transformed.length var transformedOffset = 0 - var cleanOffset = 0 - - for (char in transformed) { - if (char == ' ') { - transformedOffset++ - } else { - if (cleanOffset >= cleanOriginal.length) break - cleanOffset++ - transformedOffset++ - } + var counted = 0 + while (transformedOffset < transformed.length && counted < validCount) { + if (transformed[transformedOffset] != ' ') counted++ + transformedOffset++ } - - return transformedOffset.coerceAtMost(transformed.length) + while (transformedOffset < transformed.length && transformed[transformedOffset] == ' ') { + transformedOffset++ + } + return transformedOffset } override fun transformedToOriginal(offset: Int): Int { - val transformedSubstring = transformed.take(offset) - val cleanCount = transformedSubstring.count { it != ' ' } - - var originalOffset = 0 - var cleanOffset = 0 - - for (char in original) { - if (char != ' ') { - if (cleanOffset >= cleanCount) break - cleanOffset++ - } - originalOffset++ + val clamped = offset.coerceIn(0, transformed.length) + if (clamped >= transformed.length) return rawOriginal.length + val validCount = transformed.take(clamped).count { it != ' ' } + for (i in 0..rawOriginal.length) { + if (rawToSanitizedCount[i] >= validCount) return i } - - return originalOffset.coerceAtMost(original.length) + return rawOriginal.length } } } diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt index 49ec3ce11e..25ae408ff6 100644 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt +++ b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt @@ -10,9 +10,13 @@ import java.util.Locale import kotlin.text.iterator class MonetaryVisualTransformation( - private val decimalPlaces: Int = 2 + private val decimalPlaces: Int = 2, ) : VisualTransformation { + companion object { + private const val GROUPING_SEPARATOR = ' ' + } + override fun filter(text: AnnotatedString): TransformedText { val originalText = text.text @@ -32,7 +36,7 @@ class MonetaryVisualTransformation( } private fun limitDecimalPlaces(text: String): String { - val cleanText = text.replace(",", "").replace(" ", "") + val cleanText = text.replace(",", "").replace("$GROUPING_SEPARATOR", "") val decimalIndex = cleanText.indexOf('.') if (decimalIndex == -1) { @@ -72,11 +76,10 @@ class MonetaryVisualTransformation( val doubleValue = textToFormat.toDoubleOrNull() ?: return text val formatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply { - groupingSeparator = ',' + groupingSeparator = GROUPING_SEPARATOR decimalSeparator = '.' } - // Only format the integer part if user is typing a decimal val formatter = if (endsWithDecimal) { DecimalFormat("#,##0", formatSymbols) } else { @@ -105,8 +108,7 @@ class MonetaryVisualTransformation( for (char in transformed) { if (originalIndex >= originalSubstring.length) break - if (char == ',') { - // Skip comma in transformed, don't advance original + if (char == GROUPING_SEPARATOR) { transformedOffset++ } else if (originalIndex < originalSubstring.length && originalSubstring[originalIndex] == char @@ -148,9 +150,8 @@ class MonetaryVisualTransformation( transformedIndex++ originalOffset++ } else if (transformedIndex < transformedSubstring.length - 1 && - transformedSubstring[transformedIndex] == ',' + transformedSubstring[transformedIndex] == GROUPING_SEPARATOR ) { - // Skip comma in transformed transformedIndex++ if (transformedIndex < transformedSubstring.length && char == transformedSubstring[transformedIndex] diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt new file mode 100644 index 0000000000..80fefe190a --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt @@ -0,0 +1,110 @@ +package to.bitkit.ui.screens.widgets.calculator.components + +import org.junit.Test +import to.bitkit.models.BitcoinDisplayUnit +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CalculatorCardStateTest { + + @Test + fun `shouldHydrateFiatFromStoredBtc returns true when btc exists and fiat values are empty`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "10000", + storedFiatValue = "", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertTrue(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when stored fiat exists`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "10000", + storedFiatValue = "6.25", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when current fiat is already set`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "10000", + storedFiatValue = "", + currentFiatValue = "1.23", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when stored btc is empty`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "", + storedFiatValue = "", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when stored btc is zero`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "0", + storedFiatValue = "", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when classic btc is zero`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "0.00000000", + storedFiatValue = "", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.CLASSIC, + ) + + assertFalse(result) + } + + @Test + fun `toCalculatorDisplaySymbol trims and keeps up to two chars`() { + assertEquals("$", " $ ".toCalculatorDisplaySymbol()) + assertEquals("zł", "zł".toCalculatorDisplaySymbol()) + assertEquals("C", "CHF".toCalculatorDisplaySymbol()) + assertEquals("X", " XDR ".toCalculatorDisplaySymbol()) + } + + @Test + fun `sanitizeIntegerInput strips non-digit characters`() { + assertEquals("088800000000", sanitizeIntegerInput("0888,,,,,,,.00000000")) + assertEquals("12345", sanitizeIntegerInput("12,345")) + assertEquals("100", sanitizeIntegerInput("1.0.0")) + assertEquals("", sanitizeIntegerInput(".,,,")) + assertEquals("42", sanitizeIntegerInput("42")) + } + + @Test + fun `sanitizeDecimalInput allows single dot and digits only`() { + assertEquals("12.34", sanitizeDecimalInput("12.34")) + assertEquals("12.34", sanitizeDecimalInput("12.3.4")) + assertEquals("0.", sanitizeDecimalInput("0.")) + assertEquals(".5", sanitizeDecimalInput(".5")) + assertEquals("1234", sanitizeDecimalInput("1,234")) + assertEquals("", sanitizeDecimalInput(",,,")) + assertEquals("100.00", sanitizeDecimalInput("1,00.00")) + } +} diff --git a/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt b/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt new file mode 100644 index 0000000000..9fe35c8e1a --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt @@ -0,0 +1,25 @@ +package to.bitkit.ui.utils.visualTransformation + +import androidx.compose.ui.text.AnnotatedString +import org.junit.Test +import to.bitkit.models.BitcoinDisplayUnit +import kotlin.test.assertEquals + +class BitcoinVisualTransformationTest { + + @Test + fun `modern filter strips non-digits from pasted input`() { + val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) + .filter(AnnotatedString("1000087188..........,,,,,")) + + assertEquals("1 000 087 188", result.text.text) + } + + @Test + fun `classic filter keeps single decimal separator only`() { + val result = BitcoinVisualTransformation(BitcoinDisplayUnit.CLASSIC) + .filter(AnnotatedString("1,23.4.5")) + + assertEquals("123.45", result.text.text) + } +}