From 0b79d783da156c7110d79b7128fe1cf2cca520c6 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 2 Apr 2026 19:53:04 +0200 Subject: [PATCH 01/17] fix: align currency and calc widget with ios Made-with: Cursor --- .../bitkit/models/widget/CalculatorValues.kt | 2 +- .../calculator/components/CalculatorCard.kt | 61 +++++++++++++--- .../calculator/components/CalculatorInput.kt | 16 ++++- .../general/LocalCurrencySettingsScreen.kt | 9 ++- .../MonetaryVisualTransformation.kt | 17 ++--- .../components/CalculatorCardStateTest.kt | 72 +++++++++++++++++++ 6 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt 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..45ea3917cc 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 @@ -55,12 +56,38 @@ 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(calculatorValues.btcValue, calculatorValues.fiatValue, fiatValue)) { + 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, + ) + } CalculatorCardContent( modifier = modifier, showWidgetTitle = showWidgetTitle, btcPrimaryDisplayUnit = currencyUiState.displayUnit, - btcValue = btcValue.ifEmpty { calculatorValues.btcValue }, + btcValue = displayedBtcValue, onBtcChange = { newValue -> btcValue = newValue val convertedFiat = CalculatorFormatter.convertBtcToFiat( @@ -73,7 +100,7 @@ fun CalculatorCard( }, fiatSymbol = currencyUiState.currencySymbol, fiatName = currencyUiState.selectedCurrency, - fiatValue = fiatValue.ifEmpty { calculatorValues.fiatValue }, + fiatValue = displayedFiatValue, onFiatChange = { newValue -> fiatValue = newValue btcValue = CalculatorFormatter.convertFiatToBtc( @@ -115,14 +142,12 @@ 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) + visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit), + modifier = Modifier.fillMaxWidth() ) VerticalSpacer(16.dp) @@ -133,15 +158,31 @@ 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, +): Boolean { + if (storedBtcValue.isEmpty()) { + return false + } + if (storedBtcValue == "0") { + return false + } + if (storedFiatValue.isNotEmpty()) { + return false + } + return currentFiatValue.isEmpty() +} + @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..52fb461684 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,15 @@ fun CalculatorInput( ) } +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/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..50c4d35365 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt @@ -0,0 +1,72 @@ +package to.bitkit.ui.screens.widgets.calculator.components + +import org.junit.Test +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 = "", + ) + + assertTrue(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when stored fiat exists`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "10000", + storedFiatValue = "6.25", + currentFiatValue = "", + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when current fiat is already set`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "10000", + storedFiatValue = "", + currentFiatValue = "1.23", + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when stored btc is empty`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "", + storedFiatValue = "", + currentFiatValue = "", + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when stored btc is zero`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "0", + storedFiatValue = "", + currentFiatValue = "", + ) + + 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()) + } +} From 64e4eed124bed710f624c82174ce5074e07ca414 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 2 Apr 2026 19:54:13 +0200 Subject: [PATCH 02/17] doc: changelog entry Made-with: Cursor --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 333a54e01b..c59d8a5dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- Fix currency settings and calculator widget consistency with iOS #884 - Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880 - Fix crash when returning app to foreground on Receive screen #875 - Show loading state on Spending tab when node is not running #875 From 902e3f06e04cb15d610820429f38c3e640805a52 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 3 Apr 2026 17:06:05 +0200 Subject: [PATCH 03/17] fix: sanitize calculator keyboard input Made-with: Cursor --- .../calculator/components/CalculatorCard.kt | 19 ++++++++++++------ .../calculator/components/CalculatorInput.kt | 11 ++++++++++ .../components/CalculatorCardStateTest.kt | 20 +++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) 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 45ea3917cc..c346c26ad5 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 @@ -88,12 +88,17 @@ fun CalculatorCard( showWidgetTitle = showWidgetTitle, btcPrimaryDisplayUnit = currencyUiState.displayUnit, btcValue = displayedBtcValue, - onBtcChange = { newValue -> - btcValue = newValue + onBtcChange = { rawValue -> + val sanitized = if (currencyUiState.displayUnit.isModern()) { + sanitizeIntegerInput(rawValue) + } else { + sanitizeDecimalInput(rawValue) + } + btcValue = sanitized val convertedFiat = CalculatorFormatter.convertBtcToFiat( btcValue = btcValue, displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel + currencyViewModel = currencyViewModel, ) fiatValue = convertedFiat.orEmpty() calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) @@ -101,12 +106,13 @@ fun CalculatorCard( fiatSymbol = currencyUiState.currencySymbol, fiatName = currencyUiState.selectedCurrency, fiatValue = displayedFiatValue, - onFiatChange = { newValue -> - fiatValue = newValue + onFiatChange = { rawValue -> + val sanitized = sanitizeDecimalInput(rawValue) + fiatValue = sanitized btcValue = CalculatorFormatter.convertFiatToBtc( fiatValue = fiatValue, displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel + currencyViewModel = currencyViewModel, ) calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) } @@ -146,6 +152,7 @@ fun CalculatorCardContent( onValueChange = onBtcChange, currencySymbol = BITCOIN_SYMBOL, currencyName = stringResource(R.string.settings__general__unit_bitcoin), + keyboardType = if (btcPrimaryDisplayUnit.isModern()) KeyboardType.Number else KeyboardType.Decimal, visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit), modifier = Modifier.fillMaxWidth() ) 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 52fb461684..4d0df76a9e 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 @@ -63,6 +63,17 @@ 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) { 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 index 50c4d35365..85305a2b53 100644 --- 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 @@ -69,4 +69,24 @@ class CalculatorCardStateTest { 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")) + } } From c4d651ee3a23ad0c080e81300ea9bdbf2b94fee9 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 3 Apr 2026 17:48:29 +0200 Subject: [PATCH 04/17] fix: stabilize calculator widget input Made-with: Cursor --- .../calculator/components/CalculatorCard.kt | 20 ++++++++- .../BitcoinVisualTransformation.kt | 43 ++++++++++++++++--- .../components/CalculatorCardStateTest.kt | 18 ++++++++ .../BitcoinVisualTransformationTest.kt | 25 +++++++++++ 4 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt 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 c346c26ad5..5a46394eee 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 @@ -44,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( @@ -65,7 +66,13 @@ fun CalculatorCard( currencyUiState.displayUnit, currencyUiState.selectedCurrency, ) { - if (!shouldHydrateFiatFromStoredBtc(calculatorValues.btcValue, calculatorValues.fiatValue, fiatValue)) { + if (!shouldHydrateFiatFromStoredBtc( + storedBtcValue = calculatorValues.btcValue, + storedFiatValue = calculatorValues.fiatValue, + currentFiatValue = fiatValue, + displayUnit = currencyUiState.displayUnit, + ) + ) { return@LaunchedEffect } val convertedFiat = CalculatorFormatter.convertBtcToFiat( @@ -177,11 +184,12 @@ internal fun shouldHydrateFiatFromStoredBtc( storedBtcValue: String, storedFiatValue: String, currentFiatValue: String, + displayUnit: BitcoinDisplayUnit, ): Boolean { if (storedBtcValue.isEmpty()) { return false } - if (storedBtcValue == "0") { + if (isZeroBtcValue(storedBtcValue, displayUnit)) { return false } if (storedFiatValue.isNotEmpty()) { @@ -190,6 +198,14 @@ internal fun shouldHydrateFiatFromStoredBtc( 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/utils/visualTransformation/BitcoinVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt index 53c7ef8bf7..455e8ce025 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,10 +15,10 @@ class BitcoinVisualTransformation( ) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { - val originalText = text.text + val originalText = sanitizeInput(text.text) if (originalText.isEmpty()) { - return TransformedText(text, OffsetMapping.Identity) + return TransformedText(AnnotatedString(""), OffsetMapping.Identity) } val formattedText = when (displayUnit) { @@ -35,21 +34,51 @@ class BitcoinVisualTransformation( ) } + 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 { 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 index 85305a2b53..80fefe190a 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -13,6 +14,7 @@ class CalculatorCardStateTest { storedBtcValue = "10000", storedFiatValue = "", currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, ) assertTrue(result) @@ -24,6 +26,7 @@ class CalculatorCardStateTest { storedBtcValue = "10000", storedFiatValue = "6.25", currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, ) assertFalse(result) @@ -35,6 +38,7 @@ class CalculatorCardStateTest { storedBtcValue = "10000", storedFiatValue = "", currentFiatValue = "1.23", + displayUnit = BitcoinDisplayUnit.MODERN, ) assertFalse(result) @@ -46,6 +50,7 @@ class CalculatorCardStateTest { storedBtcValue = "", storedFiatValue = "", currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, ) assertFalse(result) @@ -57,6 +62,19 @@ class CalculatorCardStateTest { 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) 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) + } +} From e57d0e848f9b3fb359d5852d5f215c4b4f031106 Mon Sep 17 00:00:00 2001 From: piotr-iohk <42900201+piotr-iohk@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:15:44 +0200 Subject: [PATCH 05/17] Update app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../ui/screens/widgets/calculator/components/CalculatorCard.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5a46394eee..c5c648c0e1 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 @@ -122,7 +122,7 @@ fun CalculatorCard( currencyViewModel = currencyViewModel, ) calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) - } + }, ) } From 6172c82044efd87b4d2e52b92e6c2e4efde6bdad Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 22 Apr 2026 13:26:29 -0300 Subject: [PATCH 06/17] chore: lint --- .../ui/screens/widgets/calculator/components/CalculatorInput.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4d0df76a9e..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 @@ -51,7 +51,7 @@ fun CalculatorInput( } }, keyboardOptions = KeyboardOptions( - keyboardType = keyboardType + keyboardType = keyboardType, ), suffix = { CaptionB(currencyName.uppercase(), color = Colors.Gray1) }, colors = AppTextFieldDefaults.noIndicatorColors.copy( From 43eba3ab9d8467efd482ae18fb5d5055bbcea0bb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 22 Apr 2026 13:44:01 -0300 Subject: [PATCH 07/17] fix: use raw text for offset mapping wile still formatting from the sanitized version --- .../BitcoinVisualTransformation.kt | 78 ++++++++++--------- 1 file changed, 43 insertions(+), 35 deletions(-) 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 455e8ce025..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 @@ -15,22 +15,21 @@ class BitcoinVisualTransformation( ) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { - val originalText = sanitizeInput(text.text) + val rawText = text.text + val sanitizedText = sanitizeInput(rawText) - if (originalText.isEmpty()) { + 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), ) } @@ -81,42 +80,51 @@ class BitcoinVisualTransformation( 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 } } } From 33eb984c5a2e8effed149a02fb9e3f385d298f00 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 22 Apr 2026 14:04:38 -0300 Subject: [PATCH 08/17] fix: update fiat value on currency change --- .../calculator/components/CalculatorCard.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 c5c648c0e1..31ed42a7f9 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 @@ -90,6 +90,26 @@ fun CalculatorCard( ) } + 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, From 837ccf48c683e608d11328ac549ee23cdd259801 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 23 Apr 2026 07:11:28 -0300 Subject: [PATCH 09/17] fix: remove duplicated changelog entry --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4990fee6..cec3cff00f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix currency settings and calculator widget consistency with iOS #884 -- Polish Primary, Secondary, and Tertiary buttons to match Figma design specs #887 - Retouch Primary, Secondary, and Tertiary buttons styling #887 - Avoid msat truncation when paying invoices and LNURL callbacks #879 - Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880 From 3410fad4351cc4627beba66191d4c493fdd5ac39 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 23 Apr 2026 07:35:03 -0300 Subject: [PATCH 10/17] fix: clear cached input values on widget delete --- app/src/main/java/to/bitkit/data/WidgetsStore.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 + } } } From 0cdbc47631131327ebca54bbf25c9a80b1eb2a5a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 23 Apr 2026 08:16:05 -0300 Subject: [PATCH 11/17] fix: add a guard for empty inputs --- .../calculator/components/CalculatorCard.kt | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) 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 31ed42a7f9..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 @@ -122,12 +122,15 @@ fun CalculatorCard( sanitizeDecimalInput(rawValue) } btcValue = sanitized - val convertedFiat = CalculatorFormatter.convertBtcToFiat( - btcValue = btcValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ) - fiatValue = convertedFiat.orEmpty() + fiatValue = if (sanitized.isEmpty()) { + "" + } else { + CalculatorFormatter.convertBtcToFiat( + btcValue = btcValue, + displayUnit = currencyUiState.displayUnit, + currencyViewModel = currencyViewModel, + ).orEmpty() + } calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) }, fiatSymbol = currencyUiState.currencySymbol, @@ -136,11 +139,15 @@ fun CalculatorCard( onFiatChange = { rawValue -> val sanitized = sanitizeDecimalInput(rawValue) fiatValue = sanitized - btcValue = CalculatorFormatter.convertFiatToBtc( - fiatValue = fiatValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ) + btcValue = if (sanitized.isEmpty()) { + "" + } else { + CalculatorFormatter.convertFiatToBtc( + fiatValue = fiatValue, + displayUnit = currencyUiState.displayUnit, + currencyViewModel = currencyViewModel, + ) + } calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) }, ) From 95f0bebca4287dcccea8421eefb291a7336a2af1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 29 Apr 2026 08:31:50 -0300 Subject: [PATCH 12/17] dix: fix changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7293f163a..dbaae23d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,12 @@ 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 ### Fixed -- Fix currency settings and calculator widget consistency with iOS #884 - Retouch Primary, Secondary, and Tertiary buttons styling #887 - Avoid msat truncation when paying invoices and LNURL callbacks #879 - Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880 From 980594902a09b33fe886d5c1a9f477d2392e5fa9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 30 Apr 2026 13:33:00 -0300 Subject: [PATCH 13/17] doc: migrate changelog entry to fragment changelog pattern --- CHANGELOG.md | 1 - changelog.d/next/884.fixed.md | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changelog.d/next/884.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9891224c17..db4df41e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ 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 - Fix probe results and add keysend probes #920 - Align top bar back arrow and passphrase input cursor/placeholder with iOS #906 - Polish Terms of Use screen padding to match iOS #903 diff --git a/changelog.d/next/884.fixed.md b/changelog.d/next/884.fixed.md new file mode 100644 index 0000000000..165a0ff394 --- /dev/null +++ b/changelog.d/next/884.fixed.md @@ -0,0 +1 @@ +Align currency settings and calculator widget behavior with iOS From e1e9f52b03dc38aea1130d7691544c3876df4275 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 5 May 2026 07:45:08 -0300 Subject: [PATCH 14/17] test: update tests with locale --- .../calculator/components/CalculatorCardStateTest.kt | 7 +++++++ .../BitcoinVisualTransformationTest.kt | 7 +++++++ 2 files changed, 14 insertions(+) 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 index 80fefe190a..06c56fbc3e 100644 --- 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 @@ -1,13 +1,20 @@ package to.bitkit.ui.screens.widgets.calculator.components +import org.junit.Before import org.junit.Test import to.bitkit.models.BitcoinDisplayUnit +import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class CalculatorCardStateTest { + @Before + fun setLocale() { + Locale.setDefault(Locale.US) + } + @Test fun `shouldHydrateFiatFromStoredBtc returns true when btc exists and fiat values are empty`() { val result = shouldHydrateFiatFromStoredBtc( 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 index 9fe35c8e1a..c9a3e104eb 100644 --- a/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt @@ -1,12 +1,19 @@ package to.bitkit.ui.utils.visualTransformation import androidx.compose.ui.text.AnnotatedString +import org.junit.Before import org.junit.Test import to.bitkit.models.BitcoinDisplayUnit +import java.util.Locale import kotlin.test.assertEquals class BitcoinVisualTransformationTest { + @Before + fun setLocale() { + Locale.setDefault(Locale.US) + } + @Test fun `modern filter strips non-digits from pasted input`() { val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) From 71ddf06bea4a818f86af6331c30e1a26af9c9d6c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 5 May 2026 07:47:07 -0300 Subject: [PATCH 15/17] fix: sanitize calc widget input across locales --- .../widgets/calculator/components/CalculatorCard.kt | 7 ++++++- .../widgets/calculator/components/CalculatorInput.kt | 5 ++++- .../visualTransformation/BitcoinVisualTransformation.kt | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) 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 42c1c8b8a7..86c12dcf95 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 @@ -142,11 +142,16 @@ fun CalculatorCard( btcValue = if (sanitized.isEmpty()) { "" } else { - CalculatorFormatter.convertFiatToBtc( + val converted = CalculatorFormatter.convertFiatToBtc( fiatValue = fiatValue, displayUnit = currencyUiState.displayUnit, currencyViewModel = currencyViewModel, ) + if (currencyUiState.displayUnit.isModern()) { + converted.filter { it.isDigit() } + } else { + converted + } } calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) }, 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 6a134f2cf3..58b2087ab7 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 @@ -23,6 +23,7 @@ import to.bitkit.ui.components.TextInput import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import java.text.DecimalFormatSymbols @Composable fun CalculatorInput( @@ -67,7 +68,9 @@ internal fun sanitizeIntegerInput(raw: String): String = raw.filter { it.isDigit() } internal fun sanitizeDecimalInput(raw: String): String { - val filtered = raw.filter { it.isDigit() || it == '.' } + val localDecimal = DecimalFormatSymbols.getInstance().decimalSeparator + val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw + val filtered = normalized.filter { it.isDigit() || it == '.' } val dotIndex = filtered.indexOf('.') if (dotIndex == -1) return filtered return filtered.substring(0, dotIndex + 1) + 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 73471e20fd..e18e2431bc 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 @@ -39,7 +39,9 @@ class BitcoinVisualTransformation( } private fun sanitizeClassicInput(text: String): String { - val filtered = text.filter { it.isDigit() || it == '.' } + val localDecimal = DecimalFormatSymbols.getInstance().decimalSeparator + val normalized = if (localDecimal == ',') text.replace(',', '.') else text + val filtered = normalized.filter { it.isDigit() || it == '.' } val dotIndex = filtered.indexOf('.') if (dotIndex == -1) { return filtered From 365ffecf936cc1ea7c85e155ed176fc6bec13f17 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 5 May 2026 09:22:34 -0300 Subject: [PATCH 16/17] fix: locale parameter --- .../screens/widgets/calculator/components/CalculatorInput.kt | 5 +++-- .../visualTransformation/BitcoinVisualTransformation.kt | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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 58b2087ab7..064be907cb 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 @@ -24,6 +24,7 @@ import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import java.text.DecimalFormatSymbols +import java.util.Locale @Composable fun CalculatorInput( @@ -67,8 +68,8 @@ fun CalculatorInput( internal fun sanitizeIntegerInput(raw: String): String = raw.filter { it.isDigit() } -internal fun sanitizeDecimalInput(raw: String): String { - val localDecimal = DecimalFormatSymbols.getInstance().decimalSeparator +internal fun sanitizeDecimalInput(raw: String, locale: Locale = Locale.getDefault()): String { + val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw val filtered = normalized.filter { it.isDigit() || it == '.' } val dotIndex = filtered.indexOf('.') 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 e18e2431bc..047e2e3dbe 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 @@ -38,8 +38,8 @@ class BitcoinVisualTransformation( BitcoinDisplayUnit.CLASSIC -> sanitizeClassicInput(text) } - private fun sanitizeClassicInput(text: String): String { - val localDecimal = DecimalFormatSymbols.getInstance().decimalSeparator + private fun sanitizeClassicInput(text: String, locale: Locale = Locale.getDefault()): String { + val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator val normalized = if (localDecimal == ',') text.replace(',', '.') else text val filtered = normalized.filter { it.isDigit() || it == '.' } val dotIndex = filtered.indexOf('.') From d36222faa4e63e1d13276d176af144187cb35f73 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 5 May 2026 09:44:39 -0300 Subject: [PATCH 17/17] fix: leading zeros logic and update tests --- .../calculator/components/CalculatorCard.kt | 6 ++-- .../calculator/components/CalculatorInput.kt | 28 ++++++++++++---- .../BitcoinVisualTransformation.kt | 13 ++++++-- .../components/CalculatorCardStateTest.kt | 19 +++++++++-- .../BitcoinVisualTransformationTest.kt | 32 +++++++++++++++++++ 5 files changed, 85 insertions(+), 13 deletions(-) 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 86c12dcf95..65883ab86d 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 @@ -46,6 +46,8 @@ import to.bitkit.ui.utils.visualTransformation.MonetaryVisualTransformation import to.bitkit.viewmodels.CurrencyViewModel import java.math.BigDecimal +private const val FIAT_DECIMAL_PLACES = 2 + @Composable fun CalculatorCard( modifier: Modifier = Modifier, @@ -137,7 +139,7 @@ fun CalculatorCard( fiatName = currencyUiState.selectedCurrency, fiatValue = displayedFiatValue, onFiatChange = { rawValue -> - val sanitized = sanitizeDecimalInput(rawValue) + val sanitized = sanitizeDecimalInput(rawValue, maxDecimalPlaces = FIAT_DECIMAL_PLACES) fiatValue = sanitized btcValue = if (sanitized.isEmpty()) { "" @@ -205,7 +207,7 @@ fun CalculatorCardContent( currencySymbol = fiatSymbol, currencyName = fiatName, keyboardType = KeyboardType.Decimal, - visualTransformation = MonetaryVisualTransformation(decimalPlaces = 2), + visualTransformation = MonetaryVisualTransformation(decimalPlaces = FIAT_DECIMAL_PLACES), modifier = Modifier.fillMaxWidth() ) } 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 064be907cb..d7f646d7f8 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 @@ -65,17 +65,33 @@ fun CalculatorInput( ) } -internal fun sanitizeIntegerInput(raw: String): String = - raw.filter { it.isDigit() } +internal fun sanitizeIntegerInput(raw: String): String { + val digits = raw.filter { it.isDigit() } + if (digits.isEmpty()) return digits + return digits.trimStart('0').ifEmpty { "0" } +} -internal fun sanitizeDecimalInput(raw: String, locale: Locale = Locale.getDefault()): String { +internal fun sanitizeDecimalInput( + raw: String, + locale: Locale = Locale.getDefault(), + maxDecimalPlaces: Int? = null, +): String { val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw val filtered = normalized.filter { it.isDigit() || it == '.' } val dotIndex = filtered.indexOf('.') - if (dotIndex == -1) return filtered - return filtered.substring(0, dotIndex + 1) + - filtered.substring(dotIndex + 1).replace(".", "") + val singleDot = if (dotIndex == -1) { + filtered + } else { + filtered.substring(0, dotIndex + 1) + + filtered.substring(dotIndex + 1).replace(".", "") + } + if (maxDecimalPlaces == null) return singleDot + val cappedDot = singleDot.indexOf('.') + if (cappedDot == -1) return singleDot + val fraction = singleDot.substring(cappedDot + 1) + if (fraction.length <= maxDecimalPlaces) return singleDot + return singleDot.substring(0, cappedDot + 1) + fraction.take(maxDecimalPlaces) } internal fun String.toCalculatorDisplaySymbol(): String { 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 047e2e3dbe..ccf9c1474b 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 @@ -101,12 +101,19 @@ class BitcoinVisualTransformation( rawToSanitizedCount[i + 1] = sanitizedSoFar } val totalSanitized = sanitizedSoFar + val transformedNonSpaceCount = transformed.count { it != ' ' } + // MODERN mode strips leading zeros via formatModernDisplay; account for that gap so + // cursor positions over stripped raw digits collapse to the start of the displayed text. + val leadingStripped = when (displayUnit) { + BitcoinDisplayUnit.MODERN -> (totalSanitized - transformedNonSpaceCount).coerceAtLeast(0) + BitcoinDisplayUnit.CLASSIC -> 0 + } return object : OffsetMapping { override fun originalToTransformed(offset: Int): Int { val clamped = offset.coerceIn(0, rawOriginal.length) - val validCount = rawToSanitizedCount[clamped] - if (validCount >= totalSanitized) return transformed.length + val validCount = (rawToSanitizedCount[clamped] - leadingStripped).coerceAtLeast(0) + if (validCount >= transformedNonSpaceCount) return transformed.length var transformedOffset = 0 var counted = 0 while (transformedOffset < transformed.length && counted < validCount) { @@ -122,7 +129,7 @@ class BitcoinVisualTransformation( override fun transformedToOriginal(offset: Int): Int { val clamped = offset.coerceIn(0, transformed.length) if (clamped >= transformed.length) return rawOriginal.length - val validCount = transformed.take(clamped).count { it != ' ' } + val validCount = transformed.take(clamped).count { it != ' ' } + leadingStripped for (i in 0..rawOriginal.length) { if (rawToSanitizedCount[i] >= validCount) return i } 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 index 06c56fbc3e..7574713a6e 100644 --- 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 @@ -96,12 +96,17 @@ class CalculatorCardStateTest { } @Test - fun `sanitizeIntegerInput strips non-digit characters`() { - assertEquals("088800000000", sanitizeIntegerInput("0888,,,,,,,.00000000")) + fun `sanitizeIntegerInput strips non-digit characters and leading zeros`() { + assertEquals("88800000000", sanitizeIntegerInput("0888,,,,,,,.00000000")) assertEquals("12345", sanitizeIntegerInput("12,345")) assertEquals("100", sanitizeIntegerInput("1.0.0")) assertEquals("", sanitizeIntegerInput(".,,,")) assertEquals("42", sanitizeIntegerInput("42")) + assertEquals("", sanitizeIntegerInput("")) + assertEquals("0", sanitizeIntegerInput("0")) + assertEquals("0", sanitizeIntegerInput("00")) + assertEquals("1000", sanitizeIntegerInput("01000")) + assertEquals("100", sanitizeIntegerInput("00100")) } @Test @@ -114,4 +119,14 @@ class CalculatorCardStateTest { assertEquals("", sanitizeDecimalInput(",,,")) assertEquals("100.00", sanitizeDecimalInput("1,00.00")) } + + @Test + fun `sanitizeDecimalInput caps fraction digits when maxDecimalPlaces given`() { + assertEquals("12.34", sanitizeDecimalInput("12.345678", maxDecimalPlaces = 2)) + assertEquals("12.34", sanitizeDecimalInput("12.34", maxDecimalPlaces = 2)) + assertEquals("12", sanitizeDecimalInput("12", maxDecimalPlaces = 2)) + assertEquals("0.", sanitizeDecimalInput("0.", maxDecimalPlaces = 2)) + assertEquals(".5", sanitizeDecimalInput(".5", maxDecimalPlaces = 2)) + assertEquals("12.", sanitizeDecimalInput("12.", maxDecimalPlaces = 2)) + } } 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 index c9a3e104eb..e799cf1534 100644 --- a/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt @@ -29,4 +29,36 @@ class BitcoinVisualTransformationTest { assertEquals("123.45", result.text.text) } + + @Test + fun `modern filter cursor mapping handles leading zeros`() { + val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) + .filter(AnnotatedString("01000")) + + assertEquals("1 000", result.text.text) + + val mapping = result.offsetMapping + assertEquals(0, mapping.originalToTransformed(0)) + // Raw offset 1 (after the stripped leading '0') should still land at the + // start of "1 000", not past the displayed '1'. + assertEquals(0, mapping.originalToTransformed(1)) + assertEquals(5, mapping.originalToTransformed(5)) + // Transformed offset 1 (after the '1') maps back to raw offset 2. + assertEquals(2, mapping.transformedToOriginal(1)) + } + + @Test + fun `modern filter cursor mapping handles multiple leading zeros`() { + val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) + .filter(AnnotatedString("00100")) + + assertEquals("100", result.text.text) + + val mapping = result.offsetMapping + assertEquals(0, mapping.originalToTransformed(0)) + assertEquals(0, mapping.originalToTransformed(1)) + assertEquals(0, mapping.originalToTransformed(2)) + assertEquals(1, mapping.originalToTransformed(3)) + assertEquals(3, mapping.originalToTransformed(5)) + } }