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) }
|
||||
)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user