From 5587428ca76ce4582e4bde50046f5d4b7347108d Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Wed, 9 Sep 2026 07:37:46 +0300 Subject: [PATCH] Add locale-aware digit grouping to displayed amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp --- iosApp/iosApp/ContentView.swift | 48 ++++++++++++------ .../satsprice/domain/NumberFormat.android.kt | 12 +++++ .../satsprice/domain/NumberFormat.apple.kt | 15 ++++++ .../xyz/tyiu/satsprice/domain/NumberFormat.kt | 45 +++++++++++++++-- .../xyz/tyiu/satsprice/ui/PriceScreen.kt | 47 +++++++++++++++-- .../moko-resources/base/strings.xml | 2 +- .../tyiu/satsprice/domain/NumberFormatTest.kt | 30 +++++++++++ .../tyiu/satsprice/domain/NumberFormat.jvm.kt | 12 +++++ .../satsprice/domain/NumberFormatJvmTest.kt | 50 +++++++++++++++++++ .../tyiu/satsprice/domain/NumberFormat.web.kt | 14 ++++++ 10 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 shared/src/androidMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.android.kt create mode 100644 shared/src/appleMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.apple.kt create mode 100644 shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.jvm.kt create mode 100644 shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatJvmTest.kt create mode 100644 shared/src/webMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.web.kt diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 2ba0464..b474f52 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -60,7 +60,7 @@ struct ContentView: View { onChange: { viewModel.onManualRateChanged($0) } ) } else if !state.defaultCurrencyRate.isEmpty { - Text(state.defaultCurrencyRate) + Text(NumberFormatKt.groupDigits(value: state.defaultCurrencyRate)) Spacer() } else { 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; - // falls back to plain text if that ever drifts apart. `maxSupplyBtcAmount` is the sanitized - // digit-only form CurrencyConverter.MAX_BTC_SUPPLY formats to, mirroring the shared Compose UI. - private static let maxSupplyLinkText = "21,000,000 BTC" + // `maxSupplyBtcAmount` is the sanitized digit-only form CurrencyConverter.MAX_BTC_SUPPLY + // formats to, mirroring the shared Compose UI. `maxSupplyText` is locale-grouped (e.g. + // "21,000,000 BTC" in en-US, "21.000.000 BTC" in de-DE) since it's substituted into the + // 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 var maxSupplyText: String { "\(NumberFormatKt.groupDigits(value: maxSupplyBtcAmount)) BTC" } private static let maxSupplyLinkURL = URL(string: "satsprice://set-max-supply")! 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) - if let range = attributed.range(of: Self.maxSupplyLinkText) { + if let range = attributed.range(of: linkText) { attributed[range].link = Self.maxSupplyLinkURL attributed[range].underlineStyle = .single // 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) .frame(maxWidth: 140) #endif - .onAppear { text = value } + .onAppear { text = NumberFormatKt.groupDigits(value: value) } .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) - if sanitized != newValue { - text = sanitized + let grouped = NumberFormatKt.groupDigits(value: sanitized) + if grouped != newValue { + text = grouped } if sanitized != value { onChange(sanitized) } } .onChange(of: value) { newValue in - if newValue != text { - text = newValue + let grouped = NumberFormatKt.groupDigits(value: 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 { + let decimalSeparator = NumberFormat_appleKt.localizedDecimalSeparator() var result = "" var seenDot = false for char in raw { if char.isNumber { result.append(char) - } else if char == "." && !seenDot { - result.append(char) + } else if !seenDot && String(char) == decimalSeparator { + result.append(".") seenDot = true } } diff --git a/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.android.kt b/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.android.kt new file mode 100644 index 0000000..3328bf4 --- /dev/null +++ b/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.android.kt @@ -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() diff --git a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.apple.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.apple.kt new file mode 100644 index 0000000..3a63790 --- /dev/null +++ b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.apple.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.kt index 7376a0d..97cac87 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.kt @@ -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 { + val localizedSeparator = localizedDecimalSeparator() val sb = StringBuilder() var seenDot = false for (c in raw) { when { c.isDigit() -> sb.append(c) - c == '.' && !seenDot -> { - sb.append(c) + !seenDot && (c == '.' || c.toString() == localizedSeparator) -> { + sb.append('.') seenDot = true } } @@ -64,3 +70,36 @@ fun formatAmountFixed(value: BigDecimal, decimals: Int): String { val fraction = text.substringAfter('.', "") 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 diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt index 374808d..e8ed8e2 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt @@ -52,6 +52,9 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString 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.withLink 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.domain.CurrencyConverter import xyz.tyiu.satsprice.domain.formatAmount +import xyz.tyiu.satsprice.domain.groupDigits +import xyz.tyiu.satsprice.domain.localizedDecimalSeparator import xyz.tyiu.satsprice.shared.MR private val SectionColors @@ -73,6 +78,33 @@ private val SectionHeaderStyle 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 fun PriceScreen( viewModel: PriceViewModel = viewModel { PriceViewModel() }, @@ -145,13 +177,14 @@ fun PriceScreen( label = { Text(stringResource(MR.strings.rate_label)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, + visualTransformation = DigitGroupingTransformation, modifier = Modifier.weight(1f), ) } else { val rate = state.defaultCurrencyRate() if (rate.isNotEmpty()) { Text( - text = rate, + text = groupDigits(rate), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f), ) @@ -202,6 +235,7 @@ fun PriceScreen( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, isError = exceedsMaxSupply, + visualTransformation = DigitGroupingTransformation, modifier = Modifier.fillMaxWidth(), ) @@ -212,14 +246,16 @@ fun PriceScreen( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, isError = exceedsMaxSupply, + visualTransformation = DigitGroupingTransformation, modifier = Modifier.fillMaxWidth(), ) if (exceedsMaxSupply) { - val warning = stringResource(MR.strings.exceeds_max_supply) - // Matches the literal text of MR.strings.exceeds_max_supply so it can be - // turned into a link; falls back to plain text if that ever drifts apart. - val maxSupplyText = "21,000,000 BTC" + // Locale-grouped (e.g. "21,000,000 BTC" in en-US, "21.000.000 BTC" in + // de-DE) since it's substituted into the string, then searched for + // verbatim below to turn it into a link — the two always match exactly. + 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 annotatedWarning = if (linkStart < 0) { AnnotatedString(warning) @@ -304,6 +340,7 @@ fun PriceScreen( } else { { Text(stringResource(MR.strings.currency_not_priced, state.sourceName)) } }, + visualTransformation = DigitGroupingTransformation, modifier = Modifier.weight(1f), ) CurrencyRowMenu( diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index f3446fa..03b7a67 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -11,7 +11,7 @@ %1$s - %2$s Options for %1$s Done - Exceeds the 21,000,000 BTC maximum supply + Exceeds the %1$s maximum supply Move %1$s down Move %1$s up Price Source diff --git a/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatTest.kt b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatTest.kt index 1dae861..b00b1e3 100644 --- a/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatTest.kt +++ b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatTest.kt @@ -4,6 +4,7 @@ import com.ionspin.kotlin.bignum.decimal.BigDecimal import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class NumberFormatTest { @@ -76,4 +77,33 @@ class NumberFormatTest { fun formatAmountFixed_dropsDecimalPointWhenZeroDecimals() { 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) + } } diff --git a/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.jvm.kt b/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.jvm.kt new file mode 100644 index 0000000..3328bf4 --- /dev/null +++ b/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.jvm.kt @@ -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() diff --git a/shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatJvmTest.kt b/shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatJvmTest.kt new file mode 100644 index 0000000..f1d42bd --- /dev/null +++ b/shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatJvmTest.kt @@ -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")) + } +} diff --git a/shared/src/webMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.web.kt b/shared/src/webMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.web.kt new file mode 100644 index 0000000..e0a6a6c --- /dev/null +++ b/shared/src/webMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.web.kt @@ -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; })()""", +)