Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0b79d78
fix: align currency and calc widget with ios
piotr-iohk Apr 2, 2026
64e4eed
doc: changelog entry
piotr-iohk Apr 2, 2026
902e3f0
fix: sanitize calculator keyboard input
piotr-iohk Apr 3, 2026
1c9e0ee
Merge branch 'master' into fix/currency-widget-consistency-881
piotr-iohk Apr 3, 2026
c4d651e
fix: stabilize calculator widget input
piotr-iohk Apr 3, 2026
e57d0e8
Update app/src/main/java/to/bitkit/ui/screens/widgets/calculator/comp…
piotr-iohk Apr 3, 2026
ab7f801
Merge branch 'master' into fix/currency-widget-consistency-881
piotr-iohk Apr 7, 2026
d365996
Merge branch 'master' into fix/currency-widget-consistency-881
piotr-iohk Apr 7, 2026
044cd74
Merge branch 'master' into fix/currency-widget-consistency-881
jvsena42 Apr 22, 2026
6172c82
chore: lint
jvsena42 Apr 22, 2026
43eba3a
fix: use raw text for offset mapping wile still formatting from the s…
jvsena42 Apr 22, 2026
33eb984
fix: update fiat value on currency change
jvsena42 Apr 22, 2026
837ccf4
fix: remove duplicated changelog entry
jvsena42 Apr 23, 2026
3410fad
fix: clear cached input values on widget delete
jvsena42 Apr 23, 2026
0cdbc47
fix: add a guard for empty inputs
jvsena42 Apr 23, 2026
6cc693e
Merge branch 'master' into fix/currency-widget-consistency-881
jvsena42 Apr 27, 2026
230097e
Merge branch 'master' into fix/currency-widget-consistency-881
jvsena42 Apr 28, 2026
95f0beb
dix: fix changelog entry
jvsena42 Apr 29, 2026
a632a4e
Merge branch 'master' into fix/currency-widget-consistency-881
jvsena42 Apr 30, 2026
4efd824
Merge branch 'master' into fix/currency-widget-consistency-881
jvsena42 Apr 30, 2026
9805949
doc: migrate changelog entry to fragment changelog pattern
jvsena42 Apr 30, 2026
dc4e55d
Merge branch 'master' into fix/currency-widget-consistency-881
jvsena42 May 4, 2026
e1e9f52
test: update tests with locale
jvsena42 May 5, 2026
71ddf06
fix: sanitize calc widget input across locales
jvsena42 May 5, 2026
365ffec
fix: locale parameter
jvsena42 May 5, 2026
d36222f
fix: leading zeros logic and update tests
jvsena42 May 5, 2026
8ad326f
Merge branch 'master' into fix/currency-widget-consistency-881
jvsena42 May 6, 2026
ad709ef
Merge branch 'master' into fix/currency-widget-consistency-881
ovitrif May 6, 2026
fa9474a
Merge branch 'master' into fix/currency-widget-consistency-881
ovitrif May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
- Polish Primary, Secondary, and Tertiary buttons to match Figma design specs #887
- Avoid msat truncation when paying invoices and LNURL callbacks #879
- Fix ANR on RGS server settings screen caused by catastrophic regex backtracking #880
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import kotlinx.serialization.Serializable

@Serializable
data class CalculatorValues(
val btcValue: String = "",
val btcValue: String = "10000",
val fiatValue: String = "",
)
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,19 @@ 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
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
Expand All @@ -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(
Expand All @@ -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,
Comment thread
jvsena42 marked this conversation as resolved.
) {
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)
}
},
)
}

Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
),
suffix = { CaptionB(currencyName.uppercase(), color = Colors.Gray1) },
colors = AppTextFieldDefaults.noIndicatorColors.copy(
Expand All @@ -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 == '.' }
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
val dotIndex = filtered.indexOf('.')
if (dotIndex == -1) return filtered
return filtered.substring(0, dotIndex + 1) +
filtered.substring(dotIndex + 1).replace(".", "")
}
Comment on lines +74 to +95
Copy link
Copy Markdown
Collaborator

@ovitrif ovitrif May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: would've been nice to add unit tests, even if it's low complexity, it's still logic.


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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
)
Expand All @@ -135,7 +135,7 @@ fun LocalCurrencySettingsContent(

items(otherCurrencies) { rate ->
SettingsButtonRow(
title = rate.quote,
title = formatCurrencyTitle(rate),
Comment thread
jvsena42 marked this conversation as resolved.
value = SettingsButtonValue.BooleanValue(selectedCurrency == rate.quote),
onClick = { onCurrencyClick(rate.quote) },
)
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,10 +15,10 @@ class BitcoinVisualTransformation(
) : VisualTransformation {

override fun filter(text: AnnotatedString): TransformedText {
val originalText = text.text
val originalText = sanitizeInput(text.text)
Comment thread
jvsena42 marked this conversation as resolved.
Outdated

if (originalText.isEmpty()) {
return TransformedText(text, OffsetMapping.Identity)
return TransformedText(AnnotatedString(""), OffsetMapping.Identity)
}

val formattedText = when (displayUnit) {
Expand All @@ -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 {
Expand Down
Loading
Loading