diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b6d4bbe..6ffe223ab 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 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 5daa6ff2f..7a40f051b 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 85797afff..c5c648c0e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -22,11 +23,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -43,6 +44,7 @@ import to.bitkit.ui.utils.visualTransformation.BitcoinVisualTransformation import to.bitkit.ui.utils.visualTransformation.CalculatorFormatter import to.bitkit.ui.utils.visualTransformation.MonetaryVisualTransformation import to.bitkit.viewmodels.CurrencyViewModel +import java.math.BigDecimal @Composable fun CalculatorCard( @@ -55,34 +57,72 @@ fun CalculatorCard( val calculatorValues by calculatorViewModel.calculatorValues.collectAsStateWithLifecycle() var btcValue: String by rememberSaveable { mutableStateOf(calculatorValues.btcValue) } var fiatValue: String by rememberSaveable { mutableStateOf(calculatorValues.fiatValue) } + val displayedBtcValue = btcValue.ifEmpty { calculatorValues.btcValue } + val displayedFiatValue = fiatValue + + LaunchedEffect( + calculatorValues.btcValue, + calculatorValues.fiatValue, + currencyUiState.displayUnit, + currencyUiState.selectedCurrency, + ) { + if (!shouldHydrateFiatFromStoredBtc( + storedBtcValue = calculatorValues.btcValue, + storedFiatValue = calculatorValues.fiatValue, + currentFiatValue = fiatValue, + displayUnit = currencyUiState.displayUnit, + ) + ) { + return@LaunchedEffect + } + val convertedFiat = CalculatorFormatter.convertBtcToFiat( + btcValue = calculatorValues.btcValue, + displayUnit = currencyUiState.displayUnit, + currencyViewModel = currencyViewModel, + ).orEmpty() + if (convertedFiat.isEmpty()) { + return@LaunchedEffect + } + fiatValue = convertedFiat + calculatorViewModel.updateCalculatorValues( + fiatValue = convertedFiat, + btcValue = calculatorValues.btcValue, + ) + } CalculatorCardContent( modifier = modifier, showWidgetTitle = showWidgetTitle, btcPrimaryDisplayUnit = currencyUiState.displayUnit, - btcValue = btcValue.ifEmpty { calculatorValues.btcValue }, - onBtcChange = { newValue -> - btcValue = newValue + btcValue = displayedBtcValue, + 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) }, fiatSymbol = currencyUiState.currencySymbol, fiatName = currencyUiState.selectedCurrency, - fiatValue = fiatValue.ifEmpty { calculatorValues.fiatValue }, - onFiatChange = { newValue -> - fiatValue = newValue + fiatValue = displayedFiatValue, + 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) - } + }, ) } @@ -115,14 +155,13 @@ fun CalculatorCardContent( // Bitcoin input with visual transformation CalculatorInput( - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focusState -> if (focusState.hasFocus) onBtcChange("") }, value = btcValue, onValueChange = onBtcChange, currencySymbol = BITCOIN_SYMBOL, currencyName = stringResource(R.string.settings__general__unit_bitcoin), - visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit) + keyboardType = if (btcPrimaryDisplayUnit.isModern()) KeyboardType.Number else KeyboardType.Decimal, + visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit), + modifier = Modifier.fillMaxWidth() ) VerticalSpacer(16.dp) @@ -133,15 +172,40 @@ fun CalculatorCardContent( onValueChange = onFiatChange, currencySymbol = fiatSymbol, currencyName = fiatName, + keyboardType = KeyboardType.Decimal, visualTransformation = MonetaryVisualTransformation(decimalPlaces = 2), - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focusState -> if (focusState.hasFocus) onFiatChange("") } + modifier = Modifier.fillMaxWidth() ) } } } +internal fun shouldHydrateFiatFromStoredBtc( + storedBtcValue: String, + storedFiatValue: String, + currentFiatValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean { + if (storedBtcValue.isEmpty()) { + return false + } + if (isZeroBtcValue(storedBtcValue, displayUnit)) { + return false + } + if (storedFiatValue.isNotEmpty()) { + return false + } + return currentFiatValue.isEmpty() +} + +internal fun isZeroBtcValue( + btcValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean = when (displayUnit) { + BitcoinDisplayUnit.MODERN -> btcValue == "0" + BitcoinDisplayUnit.CLASSIC -> btcValue.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) == 0 +} + @Composable private fun WidgetTitleRow() { Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt index 182cd5c72..4d0df76a9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt @@ -31,8 +31,11 @@ fun CalculatorInput( currencySymbol: String, currencyName: String, modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Number, visualTransformation: VisualTransformation = VisualTransformation.None, ) { + val displayCurrencySymbol = currencySymbol.toCalculatorDisplaySymbol() + TextInput( value = value, singleLine = true, @@ -44,11 +47,11 @@ fun CalculatorInput( .background(color = Colors.Gray6, shape = CircleShape) .size(32.dp) ) { - BodyMSB(currencySymbol, color = Colors.Brand) + BodyMSB(displayCurrencySymbol, color = Colors.Brand) } }, keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number + keyboardType = keyboardType ), suffix = { CaptionB(currencyName.uppercase(), color = Colors.Gray1) }, colors = AppTextFieldDefaults.noIndicatorColors.copy( @@ -60,6 +63,26 @@ fun CalculatorInput( ) } +internal fun sanitizeIntegerInput(raw: String): String = + raw.filter { it.isDigit() } + +internal fun sanitizeDecimalInput(raw: String): String { + val filtered = raw.filter { it.isDigit() || it == '.' } + val dotIndex = filtered.indexOf('.') + if (dotIndex == -1) return filtered + return filtered.substring(0, dotIndex + 1) + + filtered.substring(dotIndex + 1).replace(".", "") +} + +internal fun String.toCalculatorDisplaySymbol(): String { + val symbol = trim() + return if (symbol.length >= 3) { + symbol.take(1) + } else { + symbol + } +} + @Preview(showBackground = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt index 80ece8fdf..939efbbd6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt @@ -122,7 +122,7 @@ fun LocalCurrencySettingsContent( } items(mostUsedRates) { rate -> SettingsButtonRow( - title = "${rate.quote} (${rate.currencySymbol})", + title = formatCurrencyTitle(rate), value = SettingsButtonValue.BooleanValue(selectedCurrency == rate.quote), onClick = { onCurrencyClick(rate.quote) }, ) @@ -135,7 +135,7 @@ fun LocalCurrencySettingsContent( items(otherCurrencies) { rate -> SettingsButtonRow( - title = rate.quote, + title = formatCurrencyTitle(rate), value = SettingsButtonValue.BooleanValue(selectedCurrency == rate.quote), onClick = { onCurrencyClick(rate.quote) }, ) @@ -150,6 +150,11 @@ fun LocalCurrencySettingsContent( } } +private fun formatCurrencyTitle(rate: FxRate): String { + val symbol = rate.currencySymbol.trim() + return if (symbol.isNotEmpty()) "${rate.quote} ($symbol)" else rate.quote +} + @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt index 53c7ef8bf..455e8ce02 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/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt index 49ec3ce11..25ae408ff 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 000000000..80fefe190 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt @@ -0,0 +1,110 @@ +package to.bitkit.ui.screens.widgets.calculator.components + +import org.junit.Test +import to.bitkit.models.BitcoinDisplayUnit +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CalculatorCardStateTest { + + @Test + fun `shouldHydrateFiatFromStoredBtc returns true when btc exists and fiat values are empty`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "10000", + storedFiatValue = "", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertTrue(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when stored fiat exists`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "10000", + storedFiatValue = "6.25", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when current fiat is already set`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "10000", + storedFiatValue = "", + currentFiatValue = "1.23", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when stored btc is empty`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "", + storedFiatValue = "", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when stored btc is zero`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "0", + storedFiatValue = "", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + + assertFalse(result) + } + + @Test + fun `shouldHydrateFiatFromStoredBtc returns false when classic btc is zero`() { + val result = shouldHydrateFiatFromStoredBtc( + storedBtcValue = "0.00000000", + storedFiatValue = "", + currentFiatValue = "", + displayUnit = BitcoinDisplayUnit.CLASSIC, + ) + + assertFalse(result) + } + + @Test + fun `toCalculatorDisplaySymbol trims and keeps up to two chars`() { + assertEquals("$", " $ ".toCalculatorDisplaySymbol()) + assertEquals("zł", "zł".toCalculatorDisplaySymbol()) + assertEquals("C", "CHF".toCalculatorDisplaySymbol()) + assertEquals("X", " XDR ".toCalculatorDisplaySymbol()) + } + + @Test + fun `sanitizeIntegerInput strips non-digit characters`() { + assertEquals("088800000000", sanitizeIntegerInput("0888,,,,,,,.00000000")) + assertEquals("12345", sanitizeIntegerInput("12,345")) + assertEquals("100", sanitizeIntegerInput("1.0.0")) + assertEquals("", sanitizeIntegerInput(".,,,")) + assertEquals("42", sanitizeIntegerInput("42")) + } + + @Test + fun `sanitizeDecimalInput allows single dot and digits only`() { + assertEquals("12.34", sanitizeDecimalInput("12.34")) + assertEquals("12.34", sanitizeDecimalInput("12.3.4")) + assertEquals("0.", sanitizeDecimalInput("0.")) + assertEquals(".5", sanitizeDecimalInput(".5")) + assertEquals("1234", sanitizeDecimalInput("1,234")) + assertEquals("", sanitizeDecimalInput(",,,")) + assertEquals("100.00", sanitizeDecimalInput("1,00.00")) + } +} diff --git a/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt b/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt new file mode 100644 index 000000000..9fe35c8e1 --- /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) + } +}