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
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
- 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
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 Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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(
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
),
suffix = { CaptionB(currencyName.uppercase(), color = Colors.Gray1) },
colors = AppTextFieldDefaults.noIndicatorColors.copy(
Expand All @@ -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() {
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 @@ -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

Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading