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; })()""",
+)