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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user