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..0cdd1a739 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Fiat.kt @@ -0,0 +1,19 @@ +package com.flipcash.app.core.util + +import com.getcode.opencode.model.financial.Fiat + +/** + * 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 { + val abbreviation = decimalValue.abbreviatedIn(maxDigits) + ?: return formatted(rule = Fiat.FormattingRule.Truncated) + + return Fiat(fiat = abbreviation.value, currencyCode = currencyCode) + .formatted(rule = Fiat.FormattingRule.Length(abbreviation.decimals)) + abbreviation.suffix +} 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/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/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)) + } } 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,