Skip to content

Commit 41ff9c9

Browse files
authored
Merge pull request #884 from synonymdev/fix/currency-widget-consistency-881
2 parents d60467a + fa9474a commit 41ff9c9

10 files changed

Lines changed: 476 additions & 82 deletions

File tree

app/src/main/java/to/bitkit/data/WidgetsStore.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,11 @@ class WidgetsStore @Inject constructor(
146146
if (!store.data.first().widgets.map { it.type }.contains(type)) return
147147

148148
store.updateData { data ->
149-
data.copy(widgets = data.widgets.filterNot { it.type == type })
149+
val updated = data.copy(widgets = data.widgets.filterNot { it.type == type })
150+
when (type) {
151+
WidgetType.CALCULATOR -> updated.copy(calculatorValues = CalculatorValues())
152+
else -> updated
153+
}
150154
}
151155
}
152156

app/src/main/java/to/bitkit/models/widget/CalculatorValues.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ import kotlinx.serialization.Serializable
44

55
@Serializable
66
data class CalculatorValues(
7-
val btcValue: String = "",
7+
val btcValue: String = "10000",
88
val fiatValue: String = "",
99
)

app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt

Lines changed: 125 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,19 @@ import androidx.compose.foundation.layout.width
1515
import androidx.compose.material3.Icon
1616
import androidx.compose.material3.MaterialTheme
1717
import androidx.compose.runtime.Composable
18+
import androidx.compose.runtime.LaunchedEffect
1819
import androidx.compose.runtime.getValue
1920
import androidx.compose.runtime.mutableStateOf
2021
import androidx.compose.runtime.saveable.rememberSaveable
2122
import androidx.compose.runtime.setValue
2223
import androidx.compose.ui.Alignment
2324
import androidx.compose.ui.Modifier
2425
import androidx.compose.ui.draw.clip
25-
import androidx.compose.ui.focus.onFocusChanged
2626
import androidx.compose.ui.graphics.Color
2727
import androidx.compose.ui.platform.testTag
2828
import androidx.compose.ui.res.painterResource
2929
import androidx.compose.ui.res.stringResource
30+
import androidx.compose.ui.text.input.KeyboardType
3031
import androidx.compose.ui.tooling.preview.Preview
3132
import androidx.compose.ui.unit.dp
3233
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -43,6 +44,9 @@ import to.bitkit.ui.utils.visualTransformation.BitcoinVisualTransformation
4344
import to.bitkit.ui.utils.visualTransformation.CalculatorFormatter
4445
import to.bitkit.ui.utils.visualTransformation.MonetaryVisualTransformation
4546
import to.bitkit.viewmodels.CurrencyViewModel
47+
import java.math.BigDecimal
48+
49+
private const val FIAT_DECIMAL_PLACES = 2
4650

4751
@Composable
4852
fun CalculatorCard(
@@ -55,34 +59,104 @@ fun CalculatorCard(
5559
val calculatorValues by calculatorViewModel.calculatorValues.collectAsStateWithLifecycle()
5660
var btcValue: String by rememberSaveable { mutableStateOf(calculatorValues.btcValue) }
5761
var fiatValue: String by rememberSaveable { mutableStateOf(calculatorValues.fiatValue) }
62+
val displayedBtcValue = btcValue.ifEmpty { calculatorValues.btcValue }
63+
val displayedFiatValue = fiatValue
64+
65+
LaunchedEffect(
66+
calculatorValues.btcValue,
67+
calculatorValues.fiatValue,
68+
currencyUiState.displayUnit,
69+
currencyUiState.selectedCurrency,
70+
) {
71+
if (!shouldHydrateFiatFromStoredBtc(
72+
storedBtcValue = calculatorValues.btcValue,
73+
storedFiatValue = calculatorValues.fiatValue,
74+
currentFiatValue = fiatValue,
75+
displayUnit = currencyUiState.displayUnit,
76+
)
77+
) {
78+
return@LaunchedEffect
79+
}
80+
val convertedFiat = CalculatorFormatter.convertBtcToFiat(
81+
btcValue = calculatorValues.btcValue,
82+
displayUnit = currencyUiState.displayUnit,
83+
currencyViewModel = currencyViewModel,
84+
).orEmpty()
85+
if (convertedFiat.isEmpty()) {
86+
return@LaunchedEffect
87+
}
88+
fiatValue = convertedFiat
89+
calculatorViewModel.updateCalculatorValues(
90+
fiatValue = convertedFiat,
91+
btcValue = calculatorValues.btcValue,
92+
)
93+
}
94+
95+
LaunchedEffect(currencyUiState.selectedCurrency, currencyUiState.displayUnit) {
96+
val sourceBtc = btcValue.ifEmpty { calculatorValues.btcValue }
97+
if (sourceBtc.isEmpty() || isZeroBtcValue(sourceBtc, currencyUiState.displayUnit)) {
98+
return@LaunchedEffect
99+
}
100+
val convertedFiat = CalculatorFormatter.convertBtcToFiat(
101+
btcValue = sourceBtc,
102+
displayUnit = currencyUiState.displayUnit,
103+
currencyViewModel = currencyViewModel,
104+
).orEmpty()
105+
if (convertedFiat.isEmpty()) {
106+
return@LaunchedEffect
107+
}
108+
fiatValue = convertedFiat
109+
calculatorViewModel.updateCalculatorValues(
110+
fiatValue = convertedFiat,
111+
btcValue = sourceBtc,
112+
)
113+
}
58114

59115
CalculatorCardContent(
60116
modifier = modifier,
61117
showWidgetTitle = showWidgetTitle,
62118
btcPrimaryDisplayUnit = currencyUiState.displayUnit,
63-
btcValue = btcValue.ifEmpty { calculatorValues.btcValue },
64-
onBtcChange = { newValue ->
65-
btcValue = newValue
66-
val convertedFiat = CalculatorFormatter.convertBtcToFiat(
67-
btcValue = btcValue,
68-
displayUnit = currencyUiState.displayUnit,
69-
currencyViewModel = currencyViewModel
70-
)
71-
fiatValue = convertedFiat.orEmpty()
119+
btcValue = displayedBtcValue,
120+
onBtcChange = { rawValue ->
121+
val sanitized = if (currencyUiState.displayUnit.isModern()) {
122+
sanitizeIntegerInput(rawValue)
123+
} else {
124+
sanitizeDecimalInput(rawValue)
125+
}
126+
btcValue = sanitized
127+
fiatValue = if (sanitized.isEmpty()) {
128+
""
129+
} else {
130+
CalculatorFormatter.convertBtcToFiat(
131+
btcValue = btcValue,
132+
displayUnit = currencyUiState.displayUnit,
133+
currencyViewModel = currencyViewModel,
134+
).orEmpty()
135+
}
72136
calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue)
73137
},
74138
fiatSymbol = currencyUiState.currencySymbol,
75139
fiatName = currencyUiState.selectedCurrency,
76-
fiatValue = fiatValue.ifEmpty { calculatorValues.fiatValue },
77-
onFiatChange = { newValue ->
78-
fiatValue = newValue
79-
btcValue = CalculatorFormatter.convertFiatToBtc(
80-
fiatValue = fiatValue,
81-
displayUnit = currencyUiState.displayUnit,
82-
currencyViewModel = currencyViewModel
83-
)
140+
fiatValue = displayedFiatValue,
141+
onFiatChange = { rawValue ->
142+
val sanitized = sanitizeDecimalInput(rawValue, maxDecimalPlaces = FIAT_DECIMAL_PLACES)
143+
fiatValue = sanitized
144+
btcValue = if (sanitized.isEmpty()) {
145+
""
146+
} else {
147+
val converted = CalculatorFormatter.convertFiatToBtc(
148+
fiatValue = fiatValue,
149+
displayUnit = currencyUiState.displayUnit,
150+
currencyViewModel = currencyViewModel,
151+
)
152+
if (currencyUiState.displayUnit.isModern()) {
153+
converted.filter { it.isDigit() }
154+
} else {
155+
converted
156+
}
157+
}
84158
calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue)
85-
}
159+
},
86160
)
87161
}
88162

@@ -115,14 +189,13 @@ fun CalculatorCardContent(
115189

116190
// Bitcoin input with visual transformation
117191
CalculatorInput(
118-
modifier = Modifier
119-
.fillMaxWidth()
120-
.onFocusChanged { focusState -> if (focusState.hasFocus) onBtcChange("") },
121192
value = btcValue,
122193
onValueChange = onBtcChange,
123194
currencySymbol = BITCOIN_SYMBOL,
124195
currencyName = stringResource(R.string.settings__general__unit_bitcoin),
125-
visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit)
196+
keyboardType = if (btcPrimaryDisplayUnit.isModern()) KeyboardType.Number else KeyboardType.Decimal,
197+
visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit),
198+
modifier = Modifier.fillMaxWidth()
126199
)
127200

128201
VerticalSpacer(16.dp)
@@ -133,15 +206,40 @@ fun CalculatorCardContent(
133206
onValueChange = onFiatChange,
134207
currencySymbol = fiatSymbol,
135208
currencyName = fiatName,
136-
visualTransformation = MonetaryVisualTransformation(decimalPlaces = 2),
137-
modifier = Modifier
138-
.fillMaxWidth()
139-
.onFocusChanged { focusState -> if (focusState.hasFocus) onFiatChange("") }
209+
keyboardType = KeyboardType.Decimal,
210+
visualTransformation = MonetaryVisualTransformation(decimalPlaces = FIAT_DECIMAL_PLACES),
211+
modifier = Modifier.fillMaxWidth()
140212
)
141213
}
142214
}
143215
}
144216

217+
internal fun shouldHydrateFiatFromStoredBtc(
218+
storedBtcValue: String,
219+
storedFiatValue: String,
220+
currentFiatValue: String,
221+
displayUnit: BitcoinDisplayUnit,
222+
): Boolean {
223+
if (storedBtcValue.isEmpty()) {
224+
return false
225+
}
226+
if (isZeroBtcValue(storedBtcValue, displayUnit)) {
227+
return false
228+
}
229+
if (storedFiatValue.isNotEmpty()) {
230+
return false
231+
}
232+
return currentFiatValue.isEmpty()
233+
}
234+
235+
internal fun isZeroBtcValue(
236+
btcValue: String,
237+
displayUnit: BitcoinDisplayUnit,
238+
): Boolean = when (displayUnit) {
239+
BitcoinDisplayUnit.MODERN -> btcValue == "0"
240+
BitcoinDisplayUnit.CLASSIC -> btcValue.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) == 0
241+
}
242+
145243
@Composable
146244
private fun WidgetTitleRow() {
147245
Row(

app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import to.bitkit.ui.components.TextInput
2323
import to.bitkit.ui.theme.AppTextFieldDefaults
2424
import to.bitkit.ui.theme.AppThemeSurface
2525
import to.bitkit.ui.theme.Colors
26+
import java.text.DecimalFormatSymbols
27+
import java.util.Locale
2628

2729
@Composable
2830
fun CalculatorInput(
@@ -31,8 +33,11 @@ fun CalculatorInput(
3133
currencySymbol: String,
3234
currencyName: String,
3335
modifier: Modifier = Modifier,
36+
keyboardType: KeyboardType = KeyboardType.Number,
3437
visualTransformation: VisualTransformation = VisualTransformation.None,
3538
) {
39+
val displayCurrencySymbol = currencySymbol.toCalculatorDisplaySymbol()
40+
3641
TextInput(
3742
value = value,
3843
singleLine = true,
@@ -44,11 +49,11 @@ fun CalculatorInput(
4449
.background(color = Colors.Gray6, shape = CircleShape)
4550
.size(32.dp)
4651
) {
47-
BodyMSB(currencySymbol, color = Colors.Brand)
52+
BodyMSB(displayCurrencySymbol, color = Colors.Brand)
4853
}
4954
},
5055
keyboardOptions = KeyboardOptions(
51-
keyboardType = KeyboardType.Number
56+
keyboardType = keyboardType,
5257
),
5358
suffix = { CaptionB(currencyName.uppercase(), color = Colors.Gray1) },
5459
colors = AppTextFieldDefaults.noIndicatorColors.copy(
@@ -60,6 +65,44 @@ fun CalculatorInput(
6065
)
6166
}
6267

68+
internal fun sanitizeIntegerInput(raw: String): String {
69+
val digits = raw.filter { it.isDigit() }
70+
if (digits.isEmpty()) return digits
71+
return digits.trimStart('0').ifEmpty { "0" }
72+
}
73+
74+
internal fun sanitizeDecimalInput(
75+
raw: String,
76+
locale: Locale = Locale.getDefault(),
77+
maxDecimalPlaces: Int? = null,
78+
): String {
79+
val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator
80+
val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw
81+
val filtered = normalized.filter { it.isDigit() || it == '.' }
82+
val dotIndex = filtered.indexOf('.')
83+
val singleDot = if (dotIndex == -1) {
84+
filtered
85+
} else {
86+
filtered.substring(0, dotIndex + 1) +
87+
filtered.substring(dotIndex + 1).replace(".", "")
88+
}
89+
if (maxDecimalPlaces == null) return singleDot
90+
val cappedDot = singleDot.indexOf('.')
91+
if (cappedDot == -1) return singleDot
92+
val fraction = singleDot.substring(cappedDot + 1)
93+
if (fraction.length <= maxDecimalPlaces) return singleDot
94+
return singleDot.substring(0, cappedDot + 1) + fraction.take(maxDecimalPlaces)
95+
}
96+
97+
internal fun String.toCalculatorDisplaySymbol(): String {
98+
val symbol = trim()
99+
return if (symbol.length >= 3) {
100+
symbol.take(1)
101+
} else {
102+
symbol
103+
}
104+
}
105+
63106
@Preview(showBackground = true)
64107
@Composable
65108
private fun Preview() {

app/src/main/java/to/bitkit/ui/settings/general/LocalCurrencySettingsScreen.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ fun LocalCurrencySettingsContent(
122122
}
123123
items(mostUsedRates) { rate ->
124124
SettingsButtonRow(
125-
title = "${rate.quote} (${rate.currencySymbol})",
125+
title = formatCurrencyTitle(rate),
126126
value = SettingsButtonValue.BooleanValue(selectedCurrency == rate.quote),
127127
onClick = { onCurrencyClick(rate.quote) },
128128
)
@@ -135,7 +135,7 @@ fun LocalCurrencySettingsContent(
135135

136136
items(otherCurrencies) { rate ->
137137
SettingsButtonRow(
138-
title = rate.quote,
138+
title = formatCurrencyTitle(rate),
139139
value = SettingsButtonValue.BooleanValue(selectedCurrency == rate.quote),
140140
onClick = { onCurrencyClick(rate.quote) },
141141
)
@@ -150,6 +150,11 @@ fun LocalCurrencySettingsContent(
150150
}
151151
}
152152

153+
private fun formatCurrencyTitle(rate: FxRate): String {
154+
val symbol = rate.currencySymbol.trim()
155+
return if (symbol.isNotEmpty()) "${rate.quote} ($symbol)" else rate.quote
156+
}
157+
153158
@Preview(showSystemUi = true)
154159
@Composable
155160
private fun Preview() {

0 commit comments

Comments
 (0)