Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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
- 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
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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

shouldHydrateFiatFromStoredBtc blocks recomputation after the initial hydration, making currencyUiState.selectedCurrency an ineffective LaunchedEffect key.

Trace:

  1. Initial hydration succeeds → fiatValue is set and calculatorViewModel.updateCalculatorValues(fiatValue = ...) persists a non-empty fiatValue to the store.
  2. User changes currency → selectedCurrency changes, LaunchedEffect re-fires.
  3. shouldHydrateFiatFromStoredBtc is called with storedFiatValue = calculatorValues.fiatValue which is now non-empty → returns false immediately.
  4. The fiat amount displayed is not recomputed for the new exchange rate.

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

Suggested fix: add a separate LaunchedEffect keyed only on currencyUiState.selectedCurrency that unconditionally recomputes the fiat from the stored BTC value (bypassing the hydration guard), or clear storedFiatValue when the currency changes so the guard allows recomputation.

) {
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing trailing comma in multi-line KeyboardOptions call.

Per CLAUDE.md: "ALWAYS add trailing commas in multi-line declarations"

},
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType
),
suffix = { CaptionB(currencyName.uppercase(), color = Colors.Gray1) },

Suggested change
keyboardType = keyboardType
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
),

),
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 == '.' }
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() {
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),
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

filter() builds the OffsetMapping against the sanitized text rather than the raw text.text, violating the VisualTransformation contract.

Compose calls originalToTransformed / transformedToOriginal with indices into text.text (the raw field value). When sanitizeInput strips characters (e.g. spaces or commas from a paste), sanitizeInput(text.text) is shorter than text.text, so Compose can query offsets beyond what the mapping covers, resulting in incorrect cursor placement.

override fun filter(text: AnnotatedString): TransformedText {
val originalText = sanitizeInput(text.text)
if (originalText.isEmpty()) {
return TransformedText(AnnotatedString(""), OffsetMapping.Identity)
}
val formattedText = when (displayUnit) {
BitcoinDisplayUnit.MODERN -> formatModernDisplay(originalText)
BitcoinDisplayUnit.CLASSIC -> formatClassicDisplay(originalText)
}
val offsetMapping = createOffsetMapping(originalText, formattedText)

Suggested fix: build the mapping between text.text and the formatted output, or remove sanitization from filter() entirely and rely solely on the caller (onBtcChange) which already sanitizes input before updating state.


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