Add locale-aware digit grouping to displayed amounts
Adds groupDigits(), a display-only formatter that inserts the current locale's digit-grouping separators into an amount's integer part (e.g. "1,000,000.5" in en-US vs "1.000.000,5" in de-DE, where the roles of "," and "." are swapped, or hi-IN's irregular "12,34,567"). Delegates to each platform's native locale APIs (java.text.NumberFormat/DecimalFormatSymbols, NSNumberFormatter/NSLocale, Intl.NumberFormat with BigInt for arbitrary precision) rather than reimplementing grouping rules, and never touches the canonical '.'-decimal strings the rest of the app parses and stores. Applied via a Compose VisualTransformation (BTC/Sats/fiat/manual-rate fields, plus the read-only rate display) and, on the native SwiftUI screen, by re-deriving the field's displayed text on every change. Also made input parsing locale-aware: sanitizeDecimalInput now recognizes the locale's own decimal separator (e.g. what a locale-aware decimal keypad sends), not just "." — with a stricter rule on the SwiftUI side specifically, since its single text buffer re-feeds the grouped display back through sanitize, so a "." can be grouping noise there rather than a decimal point. The exceeds-max-supply warning's "21,000,000 BTC" is no longer baked into the localized string with hardcoded grouping; it's substituted in at runtime via groupDigits so the link-detection text always matches exactly, in every locale. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
This commit is contained in:
@@ -60,7 +60,7 @@ struct ContentView: View {
|
|||||||
onChange: { viewModel.onManualRateChanged($0) }
|
onChange: { viewModel.onManualRateChanged($0) }
|
||||||
)
|
)
|
||||||
} else if !state.defaultCurrencyRate.isEmpty {
|
} else if !state.defaultCurrencyRate.isEmpty {
|
||||||
Text(state.defaultCurrencyRate)
|
Text(NumberFormatKt.groupDigits(value: state.defaultCurrencyRate))
|
||||||
Spacer()
|
Spacer()
|
||||||
} else {
|
} else {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -179,17 +179,23 @@ struct ContentView: View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches the literal text of MR.strings.exceeds_max_supply so it can be turned into a link;
|
// `maxSupplyBtcAmount` is the sanitized digit-only form CurrencyConverter.MAX_BTC_SUPPLY
|
||||||
// falls back to plain text if that ever drifts apart. `maxSupplyBtcAmount` is the sanitized
|
// formats to, mirroring the shared Compose UI. `maxSupplyText` is locale-grouped (e.g.
|
||||||
// digit-only form CurrencyConverter.MAX_BTC_SUPPLY formats to, mirroring the shared Compose UI.
|
// "21,000,000 BTC" in en-US, "21.000.000 BTC" in de-DE) since it's substituted into the
|
||||||
private static let maxSupplyLinkText = "21,000,000 BTC"
|
// localized sentence below, then searched for verbatim to turn it into a link — the two
|
||||||
|
// always match exactly.
|
||||||
private static let maxSupplyBtcAmount = "21000000"
|
private static let maxSupplyBtcAmount = "21000000"
|
||||||
|
private static var maxSupplyText: String { "\(NumberFormatKt.groupDigits(value: maxSupplyBtcAmount)) BTC" }
|
||||||
private static let maxSupplyLinkURL = URL(string: "satsprice://set-max-supply")!
|
private static let maxSupplyLinkURL = URL(string: "satsprice://set-max-supply")!
|
||||||
|
|
||||||
private var exceedsMaxSupplyText: Text {
|
private var exceedsMaxSupplyText: Text {
|
||||||
let warning = IosLocalizationKt.localizedString(resource: MR.strings.shared.exceeds_max_supply)
|
let linkText = Self.maxSupplyText
|
||||||
|
let warning = IosLocalizationKt.localizedFormattedString(
|
||||||
|
resource: MR.strings.shared.exceeds_max_supply,
|
||||||
|
args: [linkText]
|
||||||
|
)
|
||||||
var attributed = AttributedString(warning)
|
var attributed = AttributedString(warning)
|
||||||
if let range = attributed.range(of: Self.maxSupplyLinkText) {
|
if let range = attributed.range(of: linkText) {
|
||||||
attributed[range].link = Self.maxSupplyLinkURL
|
attributed[range].link = Self.maxSupplyLinkURL
|
||||||
attributed[range].underlineStyle = .single
|
attributed[range].underlineStyle = .single
|
||||||
// SwiftUI renders `.link` runs in the accent color regardless of the Text's own
|
// SwiftUI renders `.link` runs in the accent color regardless of the Text's own
|
||||||
@@ -282,32 +288,46 @@ private struct NumericField: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(maxWidth: 140)
|
.frame(maxWidth: 140)
|
||||||
#endif
|
#endif
|
||||||
.onAppear { text = value }
|
.onAppear { text = NumberFormatKt.groupDigits(value: value) }
|
||||||
.onChange(of: text) { newValue in
|
.onChange(of: text) { newValue in
|
||||||
|
// sanitize() already drops whatever grouping separator groupDigits() inserts
|
||||||
|
// (neither a digit nor the locale's decimal separator), so it doubles as
|
||||||
|
// ungrouping the field's raw text.
|
||||||
let sanitized = sanitize(newValue)
|
let sanitized = sanitize(newValue)
|
||||||
if sanitized != newValue {
|
let grouped = NumberFormatKt.groupDigits(value: sanitized)
|
||||||
text = sanitized
|
if grouped != newValue {
|
||||||
|
text = grouped
|
||||||
}
|
}
|
||||||
if sanitized != value {
|
if sanitized != value {
|
||||||
onChange(sanitized)
|
onChange(sanitized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: value) { newValue in
|
.onChange(of: value) { newValue in
|
||||||
if newValue != text {
|
let grouped = NumberFormatKt.groupDigits(value: newValue)
|
||||||
text = newValue
|
if grouped != text {
|
||||||
|
text = grouped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unlike the shared Kotlin `sanitizeDecimalInput` (which never sees grouping separators, since
|
||||||
|
/// Compose's VisualTransformation never feeds its grouped display back into the real value),
|
||||||
|
/// `NumericField`'s single `text` buffer *is* the grouped display, re-fed through this on every
|
||||||
|
/// edit. So a raw "." can't get a universal pass as a stand-in decimal point here the way it does
|
||||||
|
/// in Kotlin — in a locale where "," is the decimal separator, groupDigits() uses "." for
|
||||||
|
/// grouping, and treating it as a second decimal point would corrupt the value. Only the exact
|
||||||
|
/// locale decimal separator (which is "." itself in e.g. en-US) counts; anything else non-numeric
|
||||||
|
/// is grouping noise to drop.
|
||||||
private func sanitizeDecimalInput(_ raw: String) -> String {
|
private func sanitizeDecimalInput(_ raw: String) -> String {
|
||||||
|
let decimalSeparator = NumberFormat_appleKt.localizedDecimalSeparator()
|
||||||
var result = ""
|
var result = ""
|
||||||
var seenDot = false
|
var seenDot = false
|
||||||
for char in raw {
|
for char in raw {
|
||||||
if char.isNumber {
|
if char.isNumber {
|
||||||
result.append(char)
|
result.append(char)
|
||||||
} else if char == "." && !seenDot {
|
} else if !seenDot && String(char) == decimalSeparator {
|
||||||
result.append(char)
|
result.append(".")
|
||||||
seenDot = true
|
seenDot = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package xyz.tyiu.satsprice.domain
|
||||||
|
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.text.DecimalFormatSymbols
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
actual fun localizedGroupedInteger(digits: String): String =
|
||||||
|
NumberFormat.getIntegerInstance(Locale.getDefault()).format(BigInteger(digits))
|
||||||
|
|
||||||
|
actual fun localizedDecimalSeparator(): String =
|
||||||
|
DecimalFormatSymbols.getInstance(Locale.getDefault()).decimalSeparator.toString()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package xyz.tyiu.satsprice.domain
|
||||||
|
|
||||||
|
import platform.Foundation.*
|
||||||
|
|
||||||
|
actual fun localizedGroupedInteger(digits: String): String {
|
||||||
|
val formatter = NSNumberFormatter().apply {
|
||||||
|
numberStyle = NSNumberFormatterDecimalStyle
|
||||||
|
locale = NSLocale.currentLocale
|
||||||
|
usesGroupingSeparator = true
|
||||||
|
}
|
||||||
|
val number = NSDecimalNumber(string = digits)
|
||||||
|
return formatter.stringFromNumber(number) ?: digits
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun localizedDecimalSeparator(): String = NSLocale.currentLocale.decimalSeparator
|
||||||
@@ -15,15 +15,21 @@ fun String.toBigDecimalOrNull(): BigDecimal? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strips a raw text-field edit down to digits and at most one decimal point. */
|
/**
|
||||||
|
* Strips a raw text-field edit down to digits and at most one decimal point. Recognizes either
|
||||||
|
* "." or the current locale's own decimal separator (e.g. a locale-aware decimal keypad's key,
|
||||||
|
* which shows "," in de-DE) as that decimal point — either way, the output always uses ".", the
|
||||||
|
* canonical form [toBigDecimalOrNull] and the rest of this file expect.
|
||||||
|
*/
|
||||||
fun sanitizeDecimalInput(raw: String): String {
|
fun sanitizeDecimalInput(raw: String): String {
|
||||||
|
val localizedSeparator = localizedDecimalSeparator()
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
var seenDot = false
|
var seenDot = false
|
||||||
for (c in raw) {
|
for (c in raw) {
|
||||||
when {
|
when {
|
||||||
c.isDigit() -> sb.append(c)
|
c.isDigit() -> sb.append(c)
|
||||||
c == '.' && !seenDot -> {
|
!seenDot && (c == '.' || c.toString() == localizedSeparator) -> {
|
||||||
sb.append(c)
|
sb.append('.')
|
||||||
seenDot = true
|
seenDot = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,3 +70,36 @@ fun formatAmountFixed(value: BigDecimal, decimals: Int): String {
|
|||||||
val fraction = text.substringAfter('.', "")
|
val fraction = text.substringAfter('.', "")
|
||||||
return "$whole.${fraction.padEnd(decimals, '0')}"
|
return "$whole.${fraction.padEnd(decimals, '0')}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts locale-appropriate digit-grouping separators into [value]'s integer part (never the
|
||||||
|
* fractional part), e.g. "1000000.5" -> "1,000,000.5" in en-US, or "1.000.000,5" in de-DE where
|
||||||
|
* the roles of "," and "." are swapped — and not necessarily every 3 digits, since some locales
|
||||||
|
* group differently (e.g. hi-IN's "12,34,567"). Display-only: [value] itself stays plain-digit
|
||||||
|
* and '.'-decimal, parseable by [toBigDecimalOrNull], so this is applied on top of already-
|
||||||
|
* formatted/stored amounts rather than baked into them — a partial value being typed (like "1."
|
||||||
|
* or a bare "-") passes through with its structure intact.
|
||||||
|
*/
|
||||||
|
fun groupDigits(value: String): String {
|
||||||
|
val negative = value.startsWith("-")
|
||||||
|
val unsigned = if (negative) value.substring(1) else value
|
||||||
|
val dotIndex = unsigned.indexOf('.')
|
||||||
|
val integerPart = if (dotIndex >= 0) unsigned.substring(0, dotIndex) else unsigned
|
||||||
|
val fractionPart = if (dotIndex >= 0) unsigned.substring(dotIndex + 1) else null
|
||||||
|
if (integerPart.isEmpty()) return value
|
||||||
|
|
||||||
|
return buildString {
|
||||||
|
if (negative) append('-')
|
||||||
|
append(localizedGroupedInteger(integerPart))
|
||||||
|
if (fractionPart != null) {
|
||||||
|
append(localizedDecimalSeparator())
|
||||||
|
append(fractionPart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a plain non-negative integer digit string with the current locale's grouping. */
|
||||||
|
expect fun localizedGroupedInteger(digits: String): String
|
||||||
|
|
||||||
|
/** The current locale's decimal-point character, e.g. "." in en-US or "," in de-DE. */
|
||||||
|
expect fun localizedDecimalSeparator(): String
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ import androidx.compose.ui.text.SpanStyle
|
|||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.OffsetMapping
|
||||||
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.withLink
|
import androidx.compose.ui.text.withLink
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -62,6 +65,8 @@ import dev.icerock.moko.resources.compose.stringResource
|
|||||||
import xyz.tyiu.satsprice.CurrencyInfo
|
import xyz.tyiu.satsprice.CurrencyInfo
|
||||||
import xyz.tyiu.satsprice.domain.CurrencyConverter
|
import xyz.tyiu.satsprice.domain.CurrencyConverter
|
||||||
import xyz.tyiu.satsprice.domain.formatAmount
|
import xyz.tyiu.satsprice.domain.formatAmount
|
||||||
|
import xyz.tyiu.satsprice.domain.groupDigits
|
||||||
|
import xyz.tyiu.satsprice.domain.localizedDecimalSeparator
|
||||||
import xyz.tyiu.satsprice.shared.MR
|
import xyz.tyiu.satsprice.shared.MR
|
||||||
|
|
||||||
private val SectionColors
|
private val SectionColors
|
||||||
@@ -73,6 +78,33 @@ private val SectionHeaderStyle
|
|||||||
letterSpacing = 1.2.sp,
|
letterSpacing = 1.2.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Displays a plain-digit amount field's value with locale-appropriate digit grouping, without touching it. */
|
||||||
|
private object DigitGroupingTransformation : VisualTransformation {
|
||||||
|
override fun filter(text: AnnotatedString): TransformedText {
|
||||||
|
val grouped = groupDigits(text.text)
|
||||||
|
val decimalSeparator = localizedDecimalSeparator()
|
||||||
|
// Everything grouping inserts is neither a digit, the sign, nor the (possibly localized,
|
||||||
|
// e.g. "," in de-DE) decimal point — so anything else is an inserted grouping separator
|
||||||
|
// to skip, whatever character the locale actually uses for it.
|
||||||
|
fun isGroupingSeparator(char: Char) = !char.isDigit() && char != '-' && char.toString() != decimalSeparator
|
||||||
|
|
||||||
|
val offsetMapping = object : OffsetMapping {
|
||||||
|
override fun originalToTransformed(offset: Int): Int {
|
||||||
|
var originalSeen = 0
|
||||||
|
for ((index, char) in grouped.withIndex()) {
|
||||||
|
if (originalSeen == offset) return index
|
||||||
|
if (!isGroupingSeparator(char)) originalSeen++
|
||||||
|
}
|
||||||
|
return grouped.length
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun transformedToOriginal(offset: Int): Int =
|
||||||
|
grouped.take(offset.coerceIn(0, grouped.length)).count { !isGroupingSeparator(it) }
|
||||||
|
}
|
||||||
|
return TransformedText(AnnotatedString(grouped), offsetMapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PriceScreen(
|
fun PriceScreen(
|
||||||
viewModel: PriceViewModel = viewModel { PriceViewModel() },
|
viewModel: PriceViewModel = viewModel { PriceViewModel() },
|
||||||
@@ -145,13 +177,14 @@ fun PriceScreen(
|
|||||||
label = { Text(stringResource(MR.strings.rate_label)) },
|
label = { Text(stringResource(MR.strings.rate_label)) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
visualTransformation = DigitGroupingTransformation,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val rate = state.defaultCurrencyRate()
|
val rate = state.defaultCurrencyRate()
|
||||||
if (rate.isNotEmpty()) {
|
if (rate.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = rate,
|
text = groupDigits(rate),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -202,6 +235,7 @@ fun PriceScreen(
|
|||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
isError = exceedsMaxSupply,
|
isError = exceedsMaxSupply,
|
||||||
|
visualTransformation = DigitGroupingTransformation,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,14 +246,16 @@ fun PriceScreen(
|
|||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
isError = exceedsMaxSupply,
|
isError = exceedsMaxSupply,
|
||||||
|
visualTransformation = DigitGroupingTransformation,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (exceedsMaxSupply) {
|
if (exceedsMaxSupply) {
|
||||||
val warning = stringResource(MR.strings.exceeds_max_supply)
|
// Locale-grouped (e.g. "21,000,000 BTC" in en-US, "21.000.000 BTC" in
|
||||||
// Matches the literal text of MR.strings.exceeds_max_supply so it can be
|
// de-DE) since it's substituted into the string, then searched for
|
||||||
// turned into a link; falls back to plain text if that ever drifts apart.
|
// verbatim below to turn it into a link — the two always match exactly.
|
||||||
val maxSupplyText = "21,000,000 BTC"
|
val maxSupplyText = groupDigits(formatAmount(CurrencyConverter.MAX_BTC_SUPPLY, 0)) + " BTC"
|
||||||
|
val warning = stringResource(MR.strings.exceeds_max_supply, maxSupplyText)
|
||||||
val linkStart = warning.indexOf(maxSupplyText)
|
val linkStart = warning.indexOf(maxSupplyText)
|
||||||
val annotatedWarning = if (linkStart < 0) {
|
val annotatedWarning = if (linkStart < 0) {
|
||||||
AnnotatedString(warning)
|
AnnotatedString(warning)
|
||||||
@@ -304,6 +340,7 @@ fun PriceScreen(
|
|||||||
} else {
|
} else {
|
||||||
{ Text(stringResource(MR.strings.currency_not_priced, state.sourceName)) }
|
{ Text(stringResource(MR.strings.currency_not_priced, state.sourceName)) }
|
||||||
},
|
},
|
||||||
|
visualTransformation = DigitGroupingTransformation,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
CurrencyRowMenu(
|
CurrencyRowMenu(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<string name="currency_option_label_local">%1$s - %2$s</string>
|
<string name="currency_option_label_local">%1$s - %2$s</string>
|
||||||
<string name="currency_options_content_description">Options for %1$s</string>
|
<string name="currency_options_content_description">Options for %1$s</string>
|
||||||
<string name="done">Done</string>
|
<string name="done">Done</string>
|
||||||
<string name="exceeds_max_supply">Exceeds the 21,000,000 BTC maximum supply</string>
|
<string name="exceeds_max_supply">Exceeds the %1$s maximum supply</string>
|
||||||
<string name="move_currency_down_content_description">Move %1$s down</string>
|
<string name="move_currency_down_content_description">Move %1$s down</string>
|
||||||
<string name="move_currency_up_content_description">Move %1$s up</string>
|
<string name="move_currency_up_content_description">Move %1$s up</string>
|
||||||
<string name="price_source">Price Source</string>
|
<string name="price_source">Price Source</string>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.ionspin.kotlin.bignum.decimal.BigDecimal
|
|||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class NumberFormatTest {
|
class NumberFormatTest {
|
||||||
|
|
||||||
@@ -76,4 +77,33 @@ class NumberFormatTest {
|
|||||||
fun formatAmountFixed_dropsDecimalPointWhenZeroDecimals() {
|
fun formatAmountFixed_dropsDecimalPointWhenZeroDecimals() {
|
||||||
assertEquals("100", formatAmountFixed(BigDecimal.fromLong(100), 0))
|
assertEquals("100", formatAmountFixed(BigDecimal.fromLong(100), 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// groupDigits' separator characters are locale-dependent (see localizedGroupedInteger /
|
||||||
|
// localizedDecimalSeparator), so asserting a specific separator belongs in a JVM-only test
|
||||||
|
// that can pin Locale.getDefault() — see NumberFormatJvmTest.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupDigits_passesThroughInputWithNoIntegerPartUnchanged() {
|
||||||
|
// No integer part to group, so these bail out before consulting the locale at all.
|
||||||
|
assertEquals("", groupDigits(""))
|
||||||
|
assertEquals("-", groupDigits("-"))
|
||||||
|
assertEquals(".5", groupDigits(".5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupDigits_usesLocaleDecimalSeparatorForATrailingDot() {
|
||||||
|
// "1." has an integer part, so unlike the cases above, this one *does* consult the
|
||||||
|
// locale — the trailing "." becomes whatever that locale's decimal separator is.
|
||||||
|
assertEquals("1" + localizedDecimalSeparator(), groupDigits("1."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupDigits_groupsLargeIntegersRegardlessOfLocale() {
|
||||||
|
val grouped = groupDigits("1000000")
|
||||||
|
|
||||||
|
// The separator character is locale-dependent, but the digits and the fact that
|
||||||
|
// grouping happened at all aren't.
|
||||||
|
assertEquals("1000000", grouped.filter { it.isDigit() })
|
||||||
|
assertTrue(grouped.length > "1000000".length)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package xyz.tyiu.satsprice.domain
|
||||||
|
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.text.DecimalFormatSymbols
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
actual fun localizedGroupedInteger(digits: String): String =
|
||||||
|
NumberFormat.getIntegerInstance(Locale.getDefault()).format(BigInteger(digits))
|
||||||
|
|
||||||
|
actual fun localizedDecimalSeparator(): String =
|
||||||
|
DecimalFormatSymbols.getInstance(Locale.getDefault()).decimalSeparator.toString()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package xyz.tyiu.satsprice.domain
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/** [groupDigits]'s separators follow [Locale.getDefault], not a hardcoded "," and ".". */
|
||||||
|
class NumberFormatJvmTest {
|
||||||
|
|
||||||
|
private val originalLocale: Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun restoreLocale() {
|
||||||
|
Locale.setDefault(originalLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupDigits_usesCommaGroupingAndPeriodDecimalInUsEnglish() {
|
||||||
|
Locale.setDefault(Locale.US)
|
||||||
|
|
||||||
|
assertEquals("1,000,000", groupDigits("1000000"))
|
||||||
|
assertEquals("1,000,000.5", groupDigits("1000000.5"))
|
||||||
|
assertEquals("-1,234", groupDigits("-1234"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupDigits_usesPeriodGroupingAndCommaDecimalInGerman() {
|
||||||
|
Locale.setDefault(Locale.GERMANY)
|
||||||
|
|
||||||
|
assertEquals("1.000.000", groupDigits("1000000"))
|
||||||
|
assertEquals("1.000.000,5", groupDigits("1000000.5"))
|
||||||
|
assertEquals("-1.234", groupDigits("-1234"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sanitizeDecimalInput_acceptsTheLocaleDecimalSeparatorInGerman() {
|
||||||
|
Locale.setDefault(Locale.GERMANY)
|
||||||
|
|
||||||
|
// A locale-aware decimal keypad's key sends ",", not ".", in German.
|
||||||
|
assertEquals("123.45", sanitizeDecimalInput("123,45"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sanitizeDecimalInput_stillAcceptsPeriodEvenInGerman() {
|
||||||
|
Locale.setDefault(Locale.GERMANY)
|
||||||
|
|
||||||
|
assertEquals("123.45", sanitizeDecimalInput("123.45"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class)
|
||||||
|
|
||||||
|
package xyz.tyiu.satsprice.domain
|
||||||
|
|
||||||
|
// BigInt keeps this precise for arbitrarily large integer parts (a plain JS number would lose
|
||||||
|
// precision past 2^53); binding each constructor result to a variable first, rather than
|
||||||
|
// chaining `new Foo(x).bar()`, avoids a known js() misparse — see SystemCurrencies.web.kt.
|
||||||
|
actual fun localizedGroupedInteger(digits: String): String = js(
|
||||||
|
"""(function() { var big = BigInt(digits); var fmt = new Intl.NumberFormat(); return fmt.format(big); })()""",
|
||||||
|
)
|
||||||
|
|
||||||
|
actual fun localizedDecimalSeparator(): String = js(
|
||||||
|
"""(function() { var fmt = new Intl.NumberFormat(); var parts = fmt.formatToParts(1.5); var found = "."; parts.forEach(function(p) { if (p.type === "decimal") { found = p.value; } }); return found; })()""",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user