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:
2026-09-09 07:37:46 +03:00
co-authored by Claude Sonnet 5
parent 323ca43472
commit 5587428ca7
10 changed files with 252 additions and 23 deletions
+34 -14
View File
@@ -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
}
}