From 04238653f274011640af93ed41ad33f05cfddda6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 13:51:05 -0400 Subject: [PATCH 1/2] fix(tipping): cap the tip preset buttons at three digits The preset buttons split the modal's width three ways, and the amount inside each is written out in full. In USD the tiers are $5 / $10 / $20 and fit, but the same tiers localized to a low-value currency do not: 7,500 / 15,000 / 30,000 pesos, Rp332,000, or a custom amount larger still. `Fiat.abbreviated()` caps the visible amount at three digits. Anything under 1,000 formats as it did before, and larger amounts scale to K/M/B/T with only the decimals the cap leaves room for, trailing zeros dropped: 7.5K, 25.5K, 123K, 1.25M. It rounds to significant digits before choosing the scale, so 999,999 prints as $1M rather than $1,000K. The button's content description still carries the full amount, so the abbreviation is visual only and TalkBack reads "332,000". It lives beside the existing `Number.abbreviated()` in core and follows its K/M/B/T casing, but formats through `Fiat` so the currency symbol, grouping, and per-currency fraction digits stay intact. --- .../kotlin/com/flipcash/app/core/util/Fiat.kt | 54 ++++++++++++ .../com/flipcash/app/core/util/FiatTest.kt | 84 +++++++++++++++++++ .../flipcash/app/bills/modals/TipUserModal.kt | 5 +- 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Fiat.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/FiatTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Fiat.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Fiat.kt new file mode 100644 index 000000000..93133d597 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Fiat.kt @@ -0,0 +1,54 @@ +package com.flipcash.app.core.util + +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.utils.roundTo +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.log10 + +/** + * The amount formatted to fit a fixed-width control, capped at [maxDigits] digits: anything under + * the first scale is formatted as usual, and larger amounts are scaled to K/M/B/T with only as many + * decimals as the cap leaves room for — trailing zeros dropped. + * + * The cap is what keeps a localized amount inside its button: a $20 tip stays "$20", but the same + * tip in rupiah is Rp332,000, which shows as "Rp332K" rather than overflowing. + */ +fun Fiat.abbreviated(maxDigits: Int = 3): String { + if (decimalValue == 0.0) return formatted(rule = Fiat.FormattingRule.Truncated) + + // Round to [maxDigits] significant digits before picking the scale, so a value that carries + // into the next one (999,999 → 1M) is scaled by the one it lands in, not printed as "1,000K". + val exponent = floor(log10(abs(decimalValue))).toInt() + val value = decimalValue.roundTo(maxDigits - 1 - exponent) + + val (scale, suffix) = SCALES.lastOrNull { (scale, _) -> abs(value) >= scale } + ?: return formatted(rule = Fiat.FormattingRule.Truncated) + + val scaled = value / scale + val wholeDigits = abs(scaled).toLong().toString().length + val decimals = minimalDecimals(scaled, max = (maxDigits - wholeDigits).coerceAtLeast(0)) + + return Fiat(fiat = scaled, currencyCode = currencyCode) + .formatted(rule = Fiat.FormattingRule.Length(decimals)) + suffix +} + +/** + * The fewest decimal places that show [value] as precisely as [max] would — so a scaled 1.50 keeps + * one place ("1.5") and 2.00 none ("2"), instead of being padded out to the cap. + */ +private fun minimalDecimals(value: Double, max: Int): Int { + var decimals = max + while (decimals > 0 && value.roundTo(decimals) == value.roundTo(decimals - 1)) { + decimals-- + } + return decimals +} + +/** Scales in ascending order; the largest one the amount clears is the one it's printed in. */ +private val SCALES = listOf( + 1_000.0 to "K", + 1_000_000.0 to "M", + 1_000_000_000.0 to "B", + 1_000_000_000_000.0 to "T", +) diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/FiatTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/FiatTest.kt new file mode 100644 index 000000000..7b4d9a2e5 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/FiatTest.kt @@ -0,0 +1,84 @@ +package com.flipcash.app.core.util + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import kotlin.test.Test +import kotlin.test.assertEquals + +class FiatTest { + + @Test + fun `whole amounts below the first scale keep their normal formatting`() { + assertEquals("$5", Fiat(5.0).abbreviated()) + assertEquals("$999", Fiat(999.0).abbreviated()) + } + + @Test + fun `a fractional amount below the first scale keeps its cents`() { + assertEquals("$12.50", Fiat(12.5).abbreviated()) + } + + @Test + fun `thousands scale to K`() { + assertEquals("$1K", Fiat(1_000.0).abbreviated()) + assertEquals("$1.5K", Fiat(1_500.0).abbreviated()) + } + + @Test + fun `the digit cap decides how many decimals a scaled amount keeps`() { + assertEquals("$1.23K", Fiat(1_234.0).abbreviated()) + assertEquals("$12.3K", Fiat(12_345.0).abbreviated()) + assertEquals("$123K", Fiat(123_456.0).abbreviated()) + } + + @Test + fun `millions and billions scale to their own suffix`() { + assertEquals("$1M", Fiat(1_000_000.0).abbreviated()) + assertEquals("$2.5M", Fiat(2_500_000.0).abbreviated()) + assertEquals("$1B", Fiat(1_000_000_000.0).abbreviated()) + } + + @Test + fun `an amount that rounds into the next scale is printed in that scale`() { + assertEquals("$1M", Fiat(999_999.0).abbreviated()) + } + + @Test + fun `a lower cap allows fewer digits`() { + assertEquals("$1.2K", Fiat(1_234.0).abbreviated(maxDigits = 2)) + } + + @Test + fun `zero is left alone`() { + assertEquals("$0", Fiat.Zero.abbreviated()) + } + + @Test + fun `a high-denomination currency scales instead of overflowing`() { + // IDR carries no fraction digits and no single-character symbol, so the digits are all + // that's left to keep in check. + assertEquals("332K", Fiat(332_000.0, CurrencyCode.IDR).abbreviated()) + assertEquals("500", Fiat(500.0, CurrencyCode.IDR).abbreviated()) + } + + @Test + fun `a low-value currency's tip tiers stay within the cap`() { + // The $5 / $10 / $20 tiers in pesos, dong and yen — the amounts that overflow the button + // when written out in full (7,500 / 15,000 / 30,000 pesos). + assertEquals("7.5K", Fiat(7_500.0, CurrencyCode.ARS).abbreviated()) + assertEquals("15K", Fiat(15_000.0, CurrencyCode.ARS).abbreviated()) + assertEquals("30K", Fiat(30_000.0, CurrencyCode.ARS).abbreviated()) + + assertEquals("\u20ab126K", Fiat(126_000.0, CurrencyCode.VND).abbreviated()) + assertEquals("\u20ab1.32M", Fiat(1_315_000.0, CurrencyCode.VND).abbreviated()) + + assertEquals("\u00a5750", Fiat(750.0, CurrencyCode.JPY).abbreviated()) + assertEquals("\u00a53K", Fiat(3_000.0, CurrencyCode.JPY).abbreviated()) + } + + @Test + fun `an odd peso amount keeps a decimal only while it fits`() { + assertEquals("25.5K", Fiat(25_500.0, CurrencyCode.ARS).abbreviated()) + assertEquals("123K", Fiat(123_456.0, CurrencyCode.ARS).abbreviated()) + } +} diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/modals/TipUserModal.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/modals/TipUserModal.kt index 0e63a600d..cd81aa2ee 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/modals/TipUserModal.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/modals/TipUserModal.kt @@ -38,6 +38,7 @@ import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.core.tipping.TipAmount import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.ui.TokenSelectionPill +import com.flipcash.app.core.util.abbreviated import com.flipcash.shared.bills.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.opencode.model.financial.Fiat @@ -194,7 +195,9 @@ private fun RowScope.TipAmount( ) { if (fiat != null) { Text( - text = fiat.formatted(rule = Fiat.FormattingRule.Truncated), + // Abbreviated so a high-denomination currency (Rp332K, ₫526K) still fits the + // button; the full amount stays in the content description. + text = fiat.abbreviated(), style = CodeTheme.typography.textLarge, color = foregroundColor, textAlign = TextAlign.Center, From 07ac0938f4de8553c09edf00106f8222a98d0356 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 15:07:06 -0400 Subject: [PATCH 2/2] fix(discovery): abbreviate market cap on the same rule as tip presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery formatted market caps, holder counts and their deltas with its own rule: two decimals at every scale, so a delta could take five digits ("+$999.99K"). It also picked the scale from the unrounded value, printing 999,999 as "1000K", and compared signed values against the scale thresholds, so a negative holder delta never abbreviated at all — -12,345 rendered in full despite the call site expecting "-12.3K". Move the scale selection onto the three-digit rule the tip presets use and share it between the two: round to three significant digits first, pick the scale from the rounded value, then keep only the decimals the cap leaves room for. Discovery now reads 1.23K / 693K / -12.3K. Values below a thousand are now rounded rather than truncated, so a fractional market cap delta of 42.7 shows as 43 instead of 42. --- .../kotlin/com/flipcash/app/core/util/Fiat.kt | 41 +--------- .../com/flipcash/app/core/util/Number.kt | 75 ++++++++++++++++--- .../com/flipcash/app/core/util/NumberTest.kt | 29 +++++++ 3 files changed, 96 insertions(+), 49 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Fiat.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Fiat.kt index 93133d597..0cdd1a739 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Fiat.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Fiat.kt @@ -1,10 +1,6 @@ package com.flipcash.app.core.util import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.utils.roundTo -import kotlin.math.abs -import kotlin.math.floor -import kotlin.math.log10 /** * The amount formatted to fit a fixed-width control, capped at [maxDigits] digits: anything under @@ -15,40 +11,9 @@ import kotlin.math.log10 * tip in rupiah is Rp332,000, which shows as "Rp332K" rather than overflowing. */ fun Fiat.abbreviated(maxDigits: Int = 3): String { - if (decimalValue == 0.0) return formatted(rule = Fiat.FormattingRule.Truncated) - - // Round to [maxDigits] significant digits before picking the scale, so a value that carries - // into the next one (999,999 → 1M) is scaled by the one it lands in, not printed as "1,000K". - val exponent = floor(log10(abs(decimalValue))).toInt() - val value = decimalValue.roundTo(maxDigits - 1 - exponent) - - val (scale, suffix) = SCALES.lastOrNull { (scale, _) -> abs(value) >= scale } + val abbreviation = decimalValue.abbreviatedIn(maxDigits) ?: return formatted(rule = Fiat.FormattingRule.Truncated) - val scaled = value / scale - val wholeDigits = abs(scaled).toLong().toString().length - val decimals = minimalDecimals(scaled, max = (maxDigits - wholeDigits).coerceAtLeast(0)) - - return Fiat(fiat = scaled, currencyCode = currencyCode) - .formatted(rule = Fiat.FormattingRule.Length(decimals)) + suffix -} - -/** - * The fewest decimal places that show [value] as precisely as [max] would — so a scaled 1.50 keeps - * one place ("1.5") and 2.00 none ("2"), instead of being padded out to the cap. - */ -private fun minimalDecimals(value: Double, max: Int): Int { - var decimals = max - while (decimals > 0 && value.roundTo(decimals) == value.roundTo(decimals - 1)) { - decimals-- - } - return decimals + return Fiat(fiat = abbreviation.value, currencyCode = currencyCode) + .formatted(rule = Fiat.FormattingRule.Length(abbreviation.decimals)) + abbreviation.suffix } - -/** Scales in ascending order; the largest one the amount clears is the one it's printed in. */ -private val SCALES = listOf( - 1_000.0 to "K", - 1_000_000.0 to "M", - 1_000_000_000.0 to "B", - 1_000_000_000_000.0 to "T", -) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Number.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Number.kt index 835c6dc0c..62617c930 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Number.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Number.kt @@ -1,17 +1,70 @@ package com.flipcash.app.core.util -fun Number.abbreviated(): String { +import com.getcode.opencode.utils.roundTo +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.log10 + +/** + * This number capped at [maxDigits] digits: anything below the first scale is printed whole, and + * larger values are scaled to K/M/B/T with only as many decimals as the cap leaves room for — + * trailing zeros dropped. 1,234 reads "1.23K"; 693,000 reads "693K". + */ +fun Number.abbreviated(maxDigits: Int = 3): String { val value = toDouble() - return when { - value >= 1_000_000_000_000.0 -> "%.2fT".format(value / 1_000_000_000_000.0).trimTrailingZeros() - value >= 1_000_000_000.0 -> "%.2fB".format(value / 1_000_000_000.0).trimTrailingZeros() - value >= 1_000_000.0 -> "%.2fM".format(value / 1_000_000.0).trimTrailingZeros() - value >= 1_000.0 -> "%.2fK".format(value / 1_000.0).trimTrailingZeros() - else -> value.toLong().toString() + val abbreviation = value.abbreviatedIn(maxDigits) + ?: return value.roundTo(0).toLong().toString() + + return "%.${abbreviation.decimals}f".format(abbreviation.value) + abbreviation.suffix +} + +/** An amount rewritten into [suffix]'s scale, to be shown with [decimals] decimal places. */ +internal data class Abbreviation( + val value: Double, + val decimals: Int, + val suffix: String, +) + +/** + * This value rounded to [maxDigits] significant digits and expressed in the largest scale it + * clears, or null when it sits below the first scale and should be shown as it is. + * + * The rounding happens before the scale is picked, so a value that carries into the next one + * (999,999 → 1M) is printed in the scale it lands in rather than as "1,000K". + */ +internal fun Double.abbreviatedIn(maxDigits: Int): Abbreviation? { + if (this == 0.0) return null + + val exponent = floor(log10(abs(this))).toInt() + val rounded = roundTo(maxDigits - 1 - exponent) + + val (scale, suffix) = SCALES.lastOrNull { (scale, _) -> abs(rounded) >= scale } ?: return null + + val value = rounded / scale + val wholeDigits = abs(value).toLong().toString().length + return Abbreviation( + value = value, + decimals = minimalDecimals(value, max = (maxDigits - wholeDigits).coerceAtLeast(0)), + suffix = suffix, + ) +} + +/** + * The fewest decimal places that show [value] as precisely as [max] would — so a scaled 1.50 keeps + * one place ("1.5") and 2.00 none ("2"), instead of being padded out to the cap. + */ +private fun minimalDecimals(value: Double, max: Int): Int { + var decimals = max + while (decimals > 0 && value.roundTo(decimals) == value.roundTo(decimals - 1)) { + decimals-- } + return decimals } -private fun String.trimTrailingZeros(): String { - val suffix = last() - return dropLast(1).trimEnd('0').trimEnd('.') + suffix -} \ No newline at end of file +/** Scales in ascending order; the largest one the amount clears is the one it's printed in. */ +private val SCALES = listOf( + 1_000.0 to "K", + 1_000_000.0 to "M", + 1_000_000_000.0 to "B", + 1_000_000_000_000.0 to "T", +) diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/NumberTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/NumberTest.kt index 535feb5fe..14234d499 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/NumberTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/NumberTest.kt @@ -64,4 +64,33 @@ class NumberTest { fun doubleInput() { assertEquals("1.5K", 1500.0.abbreviated()) } + + @Test + fun cappedAtThreeDigits() { + assertEquals("1.23K", 1_234.abbreviated()) + assertEquals("12.3K", 12_345.abbreviated()) + assertEquals("123K", 123_456.abbreviated()) + assertEquals("693K", 693_000.abbreviated()) + assertEquals("1.25M", 1_250_000.abbreviated()) + } + + @Test + fun carriesIntoTheNextScale() { + assertEquals("1K", 999.99.abbreviated()) + assertEquals("1M", 999_999.abbreviated()) + assertEquals("1B", 999_999_999L.abbreviated()) + } + + @Test + fun negativesAbbreviateWithTheirSign() { + assertEquals("-1.5K", (-1_500).abbreviated()) + assertEquals("-12.3K", (-12_345).abbreviated()) + assertEquals("-2M", (-2_000_000).abbreviated()) + assertEquals("-999", (-999).abbreviated()) + } + + @Test + fun honoursALowerCap() { + assertEquals("1.2K", 1_234.abbreviated(maxDigits = 2)) + } }