From 0b79d783da156c7110d79b7128fe1cf2cca520c6 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 2 Apr 2026 19:53:04 +0200 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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