Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
/** 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",
)
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading