Skip to content

Commit e30a29b

Browse files
committed
fix: properly support i18n number text formatting bidirectionally
Keypad supports using ',' as a separator but existing parsing was failing to split at the ',' due to how toDouble() works internally Signed-off-by: Brandon McAnsh <[email protected]>
1 parent df5a7fa commit e30a29b

File tree

8 files changed

+63
-36
lines changed

8 files changed

+63
-36
lines changed

apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,18 @@ internal class CashScreenViewModel @Inject constructor(
7373
val preferredOnRampProvider: OnRampProvider? = null,
7474
) {
7575
val canGive: Boolean
76-
get() = (amountAnimatedModel.amountData.amount.toDoubleOrNull() ?: 0.0) > 0.00
76+
get() = (amountAnimatedModel.amountData.amount) > 0.00
7777

7878
val maxAvailableForGive: String
7979
get() = maxForGive?.let { Fiat(it.first, it.second).formatted() }.orEmpty()
8080

8181

8282
val isError: Boolean
8383
get() {
84-
if (amountAnimatedModel.amountData.amount.isEmpty()) return false
84+
if (amountAnimatedModel.amountData.isEmpty()) return false
8585
if (maxForGive != null) {
8686
val enteredAmount = Fiat(
87-
fiat = amountAnimatedModel.amountData.amount.toDoubleOrNull() ?: 0.0,
87+
fiat = amountAnimatedModel.amountData.amount,
8888
currencyCode = maxForGive.second
8989
)
9090
val limit = Fiat(maxForGive.first, maxForGive.second)
@@ -124,7 +124,7 @@ internal class CashScreenViewModel @Inject constructor(
124124
val checkBalanceLimit: () -> Boolean = {
125125
// this balance check differs from withdrawal due to the fact this is a localized check
126126
// whereas withdrawal is USD locked
127-
val amount = stateFlow.value.amountAnimatedModel.amountData.amount.toDoubleOrNull() ?: 0.0
127+
val amount = stateFlow.value.amountAnimatedModel.amountData.amount
128128
val enteredAmount = Fiat(
129129
fiat = amount,
130130
currencyCode = stateFlow.value.currencyModel.code ?: CurrencyCode.USD
@@ -173,7 +173,7 @@ internal class CashScreenViewModel @Inject constructor(
173173
isOverBalance
174174
}
175175
val checkSendLimit: () -> Boolean = {
176-
val amount = stateFlow.value.amountAnimatedModel.amountData.amount.toDoubleOrNull() ?: 0.0
176+
val amount = stateFlow.value.amountAnimatedModel.amountData.amount
177177
val currency = stateFlow.value.currencyModel
178178
val sendLimit =
179179
currency.code?.let { stateFlow.value.limits?.sendLimitFor(it) } ?: SendLimit.Zero

apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,17 @@ internal data class AmountEntryState(
4848
val selectedAmount: Fiat = Fiat.Zero,
4949
) {
5050
val canAdd: Boolean
51-
get() = (amountAnimatedModel.amountData.amount.toDoubleOrNull()
52-
?: 0.0) > 0.00
51+
get() = (amountAnimatedModel.amountData.amount) > 0.00
5352

5453
val maxAvailableToAdd: String
5554
get() = maxToAdd?.let { Fiat(it.first, it.second).formatted() }.orEmpty()
5655

5756
val isError: Boolean
5857
get() {
59-
if (amountAnimatedModel.amountData.amount.isEmpty()) return false
58+
if (amountAnimatedModel.amountData.isEmpty()) return false
6059

6160
if (maxToAdd != null) {
62-
if ((amountAnimatedModel.amountData.amount.toDoubleOrNull()
63-
?: 0.0) <= maxToAdd.first
61+
if ((amountAnimatedModel.amountData.amount) <= maxToAdd.first
6462
) {
6563
return false
6664
}
@@ -140,9 +138,7 @@ internal class OnRampViewModel @Inject constructor(
140138
}
141139

142140
val checkFundingAmount: () -> Boolean = {
143-
val amount =
144-
stateFlow.value.amountEntryState.amountAnimatedModel.amountData.amount.toDoubleOrNull()
145-
?: 0.0
141+
val amount = stateFlow.value.amountEntryState.amountAnimatedModel.amountData.amount
146142
val currency = stateFlow.value.amountEntryState.currencyModel
147143
val sendLimit =
148144
currency.code?.let { stateFlow.value.amountEntryState.limits?.sendLimitFor(it) }

apps/flipcash/features/pools/src/main/kotlin/com/flipcash/app/pools/internal/create/PoolCreateViewModel.kt

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,17 @@ internal data class BidEntryState(
5858
val selectedAmount: Fiat = Fiat.Zero,
5959
) {
6060
val canSet: Boolean
61-
get() = (amountAnimatedModel.amountData.amount.toDoubleOrNull()
62-
?: 0.0) > 0.00
61+
get() = (amountAnimatedModel.amountData.amount) > 0.00
6362

6463
val maxAvailableForBid: String
6564
get() = maxForBid?.let { Fiat(it.first, it.second).formatted() }.orEmpty()
6665

6766
val isError: Boolean
6867
get() {
69-
if (amountAnimatedModel.amountData.amount.isEmpty()) return false
68+
if (amountAnimatedModel.amountData.isEmpty()) return false
7069

7170
if (maxForBid != null) {
72-
if ((amountAnimatedModel.amountData.amount.toDoubleOrNull()
73-
?: 0.0) <= maxForBid.first
71+
if ((amountAnimatedModel.amountData.amount) <= maxForBid.first
7472
) {
7573
return false
7674
}
@@ -140,9 +138,7 @@ internal class PoolCreateViewModel @Inject constructor(
140138
}
141139

142140
val checkBidLimit: () -> Boolean = {
143-
val amount =
144-
stateFlow.value.bidEntryState.amountAnimatedModel.amountData.amount.toDoubleOrNull()
145-
?: 0.0
141+
val amount = stateFlow.value.bidEntryState.amountAnimatedModel.amountData.amount
146142
val currency = stateFlow.value.bidEntryState.currencyModel
147143
val sendLimit =
148144
currency.code?.let { stateFlow.value.bidEntryState.limits?.sendLimitFor(it) }

apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,16 @@ internal class WithdrawalViewModel @Inject constructor(
8989
val withdrawalState: LoadingSuccessState = LoadingSuccessState(),
9090
) {
9191
val canWithdraw: Boolean
92-
get() = (amountEntryState.amountAnimatedModel.amountData.amount.toDoubleOrNull()
93-
?: 0.0) > 0.00
92+
get() = (amountEntryState.amountAnimatedModel.amountData.amount) > 0.00
9493

9594
val tokenBalance: Fiat
9695
get() = token?.balance ?: Fiat.Zero
9796

9897
val isError: Boolean
9998
get() {
100-
if (amountEntryState.amountAnimatedModel.amountData.amount.isEmpty()) return false
99+
if (amountEntryState.amountAnimatedModel.amountData.isEmpty()) return false
101100
val enteredAmount = Fiat(
102-
fiat = amountEntryState.amountAnimatedModel.amountData.amount.toDoubleOrNull() ?: 0.0,
101+
fiat = amountEntryState.amountAnimatedModel.amountData.amount,
103102
currencyCode = tokenBalance.currencyCode
104103
)
105104

@@ -154,8 +153,7 @@ internal class WithdrawalViewModel @Inject constructor(
154153

155154
val checkBalanceLimit: () -> Boolean = {
156155
val amount =
157-
stateFlow.value.amountEntryState.amountAnimatedModel.amountData.amount.toDoubleOrNull()
158-
?: 0.0
156+
stateFlow.value.amountEntryState.amountAnimatedModel.amountData.amount
159157
val conversionRate =
160158
exchange.rateToUsd(
161159
stateFlow.value.amountEntryState.currencyModel.code ?: CurrencyCode.USD

services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import com.flipcash.libs.currency.math.Estimator
55
import com.flipcash.libs.currency.math.divideWithHighPrecision
66
import com.getcode.opencode.internal.extensions.fractionDigits
77
import com.getcode.opencode.utils.roundTo
8+
import com.getcode.opencode.utils.toLocaleAwareDoubleOrNull
89
import com.getcode.solana.keys.Mint
910
import kotlinx.parcelize.Parcelize
1011
import kotlinx.serialization.Serializable
1112
import java.math.BigDecimal
1213
import java.math.RoundingMode
1314
import java.text.DecimalFormat
15+
import java.text.NumberFormat
1416
import java.util.Currency
1517
import java.util.Locale
1618

@@ -79,7 +81,7 @@ data class Fiat(
7981
false
8082
}
8183

82-
val formatter = DecimalFormat.getInstance(Locale.US).apply {
84+
val formatter = DecimalFormat.getInstance().apply {
8385
val decimalDigits = currencyCode.fractionDigits
8486
val preferredDigits = when (rule) {
8587
is FormattingRule.Length -> rule.decimalPlaces
@@ -131,7 +133,7 @@ data class Fiat(
131133
fun toDouble() = formatted(
132134
showPrefix = false,
133135
includeCommas = false
134-
).toDouble()
136+
).toLocaleAwareDoubleOrNull() ?: 0.0
135137

136138
fun valueNonZero(): Boolean = toDouble() != 0.0
137139

@@ -169,7 +171,7 @@ data class Fiat(
169171
val Zero = Fiat(0, CurrencyCode.USD)
170172

171173
private fun parseStringToDouble(stringAmount: String, decimalPlaces: Int = 6): Double {
172-
val formatter = DecimalFormat.getNumberInstance(Locale.getDefault()).apply {
174+
val formatter = DecimalFormat.getNumberInstance(Locale.US).apply {
173175
isParseIntegerOnly = false
174176
minimumFractionDigits = decimalPlaces
175177
maximumFractionDigits = decimalPlaces

services/opencode/src/main/kotlin/com/getcode/opencode/utils/String.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.getcode.opencode.utils
22

33
import com.getcode.utils.encodeBase64
44
import com.getcode.vendor.Base58
5+
import java.text.DecimalFormatSymbols
6+
import java.text.NumberFormat
57

68
fun String.addLeadingZero(upTo: Int): String {
79
if (upTo < length) return this
@@ -39,5 +41,21 @@ fun String.padded(minCount: Int): String {
3941
}
4042
}
4143

44+
fun String.toLocaleAwareDoubleOrNull(decimalPlaces: Int = 6): Double? {
45+
val separator = DecimalFormatSymbols.getInstance().decimalSeparator
46+
// Use locale-aware NumberFormat for parsing to correctly handle decimal separators.
47+
// We also need to handle the case where the input is just the separator (e.g., "," or ".")
48+
// or ends with it, which parse() would fail on.
49+
val sanitizedText = if (endsWith(separator)) this + "0" else this
50+
return runCatching {
51+
NumberFormat.getInstance().apply {
52+
isGroupingUsed = false
53+
isParseIntegerOnly = false
54+
maximumFractionDigits = decimalPlaces
55+
minimumFractionDigits = decimalPlaces
56+
}.parse(sanitizedText)?.toDouble()
57+
}.getOrNull()
58+
}
59+
4260
typealias Base64String = String
4361
typealias Base58String = String

ui/components/src/main/kotlin/com/getcode/ui/components/text/AmountTextAnimated.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ internal fun AmountTextAnimated(
9191
val staticX5 = CodeTheme.dimens.staticGrid.x5
9292

9393
// Initialize visibility states based on uiModel
94-
val initialAmount = uiModel.amountData.amount
94+
val initialAmount = uiModel.amountData.amountText
9595
val isInitiallyZero = initialAmount == "0" || initialAmount.isEmpty()
9696
var decimalPointVisibility by remember { mutableStateOf(initialAmount.contains(DECIMAL_SEPARATOR)) }
9797
var zeroVisibility by remember { mutableStateOf(isInitiallyZero) }
@@ -108,16 +108,16 @@ internal fun AmountTextAnimated(
108108
val maxFontSize = textStyle.fontSize
109109

110110
val commaVisibility = uiModel.amountData.commaVisibility
111-
val amountSplit = uiModel.amountData.amount.split(DECIMAL_SEPARATOR)
112-
val amountLastSplit = uiModel.amountDataLast.amount.split(DECIMAL_SEPARATOR)
111+
val amountSplit = uiModel.amountData.amountText.split(DECIMAL_SEPARATOR)
112+
val amountLastSplit = uiModel.amountDataLast.amountText.split(DECIMAL_SEPARATOR)
113113

114114
val length1 = amountSplit[0].length
115115
val length2 = if (amountSplit.size > 1) amountSplit[1].length else 0
116116
val isDecimal = amountSplit.size > 1
117117
val isZero = amountSplit[0] == "0" || amountSplit[0].isEmpty()
118118

119119
if (amountSplit.firstOrNull() != null && !isZero) {
120-
firstDigit = uiModel.amountData.amount.first().toString()
120+
firstDigit = uiModel.amountData.amountText.first().toString()
121121
}
122122

123123
fun getValue(i1: Int, i2: Int): String? =

ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberInputHelper.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.getcode.ui.components.text
22

3+
import java.text.NumberFormat
34
import java.text.DecimalFormatSymbols
5+
import java.util.Locale
46
import kotlin.math.min
57

68

@@ -73,7 +75,7 @@ class NumberInputHelper {
7375
}
7476

7577
private fun applyValue() {
76-
amount = amountText.toDoubleOrNull() ?: 0.0
78+
amount = amountText.toLocaleAwareDoubleOrNull() ?: 0.0
7779
}
7880

7981
private fun formatAmount(amount: Double): String {
@@ -115,5 +117,20 @@ class NumberInputHelper {
115117
val GROUPING_SEPARATOR: Char get() = DecimalFormatSymbols.getInstance().groupingSeparator
116118
}
117119

118-
data class AmountAnimatedData(val amount: String = "0", val commaVisibility: List<Boolean> = listOf())
120+
data class AmountAnimatedData(
121+
internal val amountText: String = "0",
122+
val commaVisibility: List<Boolean> = listOf()
123+
) {
124+
fun isEmpty(): Boolean = amountText.isEmpty()
125+
val amount: Double get() = amountText.toLocaleAwareDoubleOrNull() ?: 0.0
126+
}
127+
}
128+
129+
private fun String.toLocaleAwareDoubleOrNull(): Double? {
130+
val separator = DecimalFormatSymbols.getInstance().decimalSeparator
131+
// Use locale-aware NumberFormat for parsing to correctly handle decimal separators.
132+
// We also need to handle the case where the input is just the separator (e.g., "," or ".")
133+
// or ends with it, which parse() would fail on.
134+
val sanitizedText = if (endsWith(separator)) this + "0" else this
135+
return runCatching { NumberFormat.getInstance().parse(sanitizedText)?.toDouble() }.getOrNull()
119136
}

0 commit comments

Comments
 (0)