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
25 changes: 13 additions & 12 deletions Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,14 @@ struct SendTipSheet: View {

// MARK: - Chips -

/// Chips sit four to a row, so an amount is abbreviated past three digits
/// ("$1.5K") rather than shrunk to fit — presets run large in currencies
/// with small units. VoiceOver still reads the amount in full.
private func presetChip(_ tier: TipSelection) -> some View {
let amount = tipFlow.amount(for: tier)
let amount = tipFlow.amount(for: tier).map { FiatAmount(value: $0, currency: displayCurrency) }
return TipAmountChip(
title: amount.map { FiatAmount(value: $0, currency: displayCurrency).formatted(minimumFractionDigits: 0) } ?? "–",
title: amount?.formattedAbbreviated() ?? "–",
accessibilityLabel: amount?.formattedDroppingZeroFraction(),
isSelected: tipFlow.selection == tier
) {
tipFlow.selection = tier
Expand All @@ -91,23 +95,17 @@ struct SendTipSheet: View {
/// The fourth slot: "…" until a custom amount is set, then that amount.
/// Tapping always opens the amount entry, so a set amount can be changed.
private var customChip: some View {
TipAmountChip(
title: tipFlow.amount(for: .custom).map(customTitle) ?? "…",
let amount = tipFlow.amount(for: .custom).map { FiatAmount(value: $0, currency: displayCurrency) }
return TipAmountChip(
title: amount?.formattedAbbreviated() ?? "…",
accessibilityLabel: amount?.formattedDroppingZeroFraction() ?? "Enter a custom amount",
isSelected: tipFlow.selection == .custom
) {
localSheet = .customAmount
}
.accessibilityIdentifier("tip-custom-chip")
}

/// Formats a custom amount, dropping the fraction only when it's whole
/// (`$11`, not `$11.00`) while keeping real fractions intact (`$2.50`).
private func customTitle(_ amount: Decimal) -> String {
let isWhole = amount == amount.rounded(to: 0)
return FiatAmount(value: amount, currency: displayCurrency)
.formatted(minimumFractionDigits: isWhole ? 0 : nil)
}

// MARK: - Currency -

/// The tip currency, shown as a pill in the sheet header (Figma 8966-2649):
Expand All @@ -131,6 +129,8 @@ struct SendTipSheet: View {
private struct TipAmountChip: View {

let title: String
/// Read instead of the abbreviated title; defaults to the title itself.
var accessibilityLabel: String?
let isSelected: Bool
let action: () -> Void

Expand All @@ -147,6 +147,7 @@ private struct TipAmountChip: View {
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel ?? title)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@

import Foundation

/// A `FormatStyle` that formats numeric values as compact currency strings.
/// A `FormatStyle` that formats numeric values as compact currency strings —
/// the `FormatStyle` entry point onto ``FiatAmount/formattedAbbreviated(maxDigits:)``,
/// for the `Double` figures (market caps, deltas) SwiftUI formats inline.
///
/// Uses the system's `.compactName` notation for large numbers and prepends
/// the currency symbol from `CurrencyCode`. Values below 100,000 are formatted
/// as whole numbers with grouping separators.
/// Sub-unit precision is dropped before formatting: at this scale it is noise,
/// and a market cap reads `$200`, not `$200.17`.
///
/// Usage with SwiftUI `Text`:
/// ```swift
/// Text(1_029_331.15, format: .compactCurrency(code: .usd))
/// // → "$1M"
/// // → "$1.03M"
///
/// Text(690_272.45, format: .compactCurrency(code: .usd))
/// // → "$690K"
Expand All @@ -23,7 +24,7 @@ import Foundation
/// // → "$100K"
///
/// Text(-12_400, format: .compactCurrency(code: .usd))
/// // → "-$12K"
/// // → "-$12.4K"
/// ```
public struct CompactCurrencyFormatStyle: FormatStyle {

Expand All @@ -34,20 +35,15 @@ public struct CompactCurrencyFormatStyle: FormatStyle {
}

public func format(_ value: Double) -> String {
let symbol = currencyCode.singleCharacterCurrencySymbols ?? ""
let whole = Int(value)
// The sign belongs outside the symbol ("-$12K"), so format the magnitude
// and prepend the minus ourselves rather than letting it land after the symbol.
let sign = whole < 0 ? "-" : ""
let compact = whole.magnitude.formatted(.number.notation(.compactName))
return "\(sign)\(symbol)\(compact)"
FiatAmount(value: Decimal(Int(value)), currency: currencyCode)
.formattedAbbreviated()
}
}

// MARK: - FormatStyle Extension -

extension FormatStyle where Self == CompactCurrencyFormatStyle {
/// Formats a number as a compact currency string (e.g. `$1M`, `$100K`, `$99,999`).
/// Formats a number as a compact currency string (e.g. `$1M`, `$100K`, `$999`).
public static func compactCurrency(code: CurrencyCode) -> CompactCurrencyFormatStyle {
CompactCurrencyFormatStyle(code: code)
}
Expand Down
80 changes: 80 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,86 @@ extension FiatAmount {
suffix: suffix,
).string(from: value as NSDecimalNumber)!
}

/// Format for display, dropping the fraction when the amount is whole (`$11`,
/// not `$11.00`) and keeping it when it isn't (`$2.50`). This is how amounts
/// are shown in fixed-width controls, and the unabbreviated form the same
/// controls hand to VoiceOver.
///
/// Paired with Android's `Fiat.FormattingRule.Truncated`.
public func formattedDroppingZeroFraction() -> String {
formatted(minimumFractionDigits: value == value.rounded(to: 0) ? 0 : nil)
}
}

// MARK: - Abbreviation -

extension FiatAmount {

/// The scales an abbreviated figure steps through, ascending; the largest one
/// the amount clears is the one it is printed in.
private static let abbreviationScales: [(scale: Decimal, suffix: String)] = [
(1_000, "K"),
(1_000_000, "M"),
(1_000_000_000, "B"),
(1_000_000_000_000, "T"),
]

/// 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 332,000, which shows as `332K` rather
/// than overflowing. The scale is chosen from the value, not the currency, so a
/// currency whose everyday amounts are large abbreviates on the same rule.
///
/// Paired with Android's `Fiat.abbreviated(maxDigits:)` — the two produce the
/// same string for the same amount. ``CompactCurrencyFormatStyle`` is the
/// `FormatStyle` entry point onto this.
public func formattedAbbreviated(maxDigits: Int = 3) -> String {
guard value != 0 else { return formattedDroppingZeroFraction() }

// 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 rather than printed as "1,000K".
let rounded = value.rounded(to: maxDigits - 1 - value.leadingExponent)

guard let step = Self.abbreviationScales.last(where: { abs(rounded) >= $0.scale }) else {
return formattedDroppingZeroFraction()
}

let scaled = rounded / step.scale
let wholeDigits = scaled.leadingExponent + 1
let fractionDigits = max(0, maxDigits - wholeDigits)

return NumberFormatter.fiat(
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
truncated: false,
suffix: step.suffix,
).string(from: scaled as NSDecimalNumber)!
}
}

private extension Decimal {
/// The power of ten of the leading digit — `floor(log10(abs(self)))`. Zero has
/// no leading digit and answers `0`.
var leadingExponent: Int {
var magnitude = abs(self)
var exponent = 0
while magnitude >= 10 {
magnitude /= 10
exponent += 1
}
while magnitude > 0, magnitude < 1 {
magnitude *= 10
exponent -= 1
}
return exponent
}
}

// MARK: - Display Threshold -
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ struct CompactCurrencyFormatStyleTests {
@Test("Millions are formatted with M suffix")
func millions() {
#expect(format.format(1_000_000) == "$1M")
#expect(format.format(1_029_331.15) == "$1M")
// Three digits, so the scaled figure keeps two decimals here and one at
// 10.5M — the cap decides, not the scale.
#expect(format.format(1_029_331.15) == "$1.03M")
#expect(format.format(1_299_217.10) == "$1.3M")
#expect(format.format(10_500_000) == "$10M")
#expect(format.format(10_500_000) == "$10.5M")
}

@Test("Thousands are formatted with K suffix")
Expand All @@ -30,13 +32,16 @@ struct CompactCurrencyFormatStyleTests {
@Test("Small values use compact notation")
func smallValues() {
#expect(format.format(99_999) == "$100K")
#expect(format.format(1_234) == "$1.2K")
#expect(format.format(1_234) == "$1.23K")
// Under a thousand the figure is shown whole — sub-unit precision is
// dropped rather than rounded into the display.
#expect(format.format(200.17) == "$200")
#expect(format.format(999.99) == "$999")
}

@Test("Negative values put the sign before the currency symbol")
func negativeValues() {
#expect(format.format(-12_400) == "-$12K")
#expect(format.format(-12_400) == "-$12.4K")
#expect(format.format(-6_600) == "-$6.6K")
#expect(format.format(-384) == "-$384")
#expect(format.format(-1_299_217.10) == "-$1.3M")
Expand All @@ -56,6 +61,6 @@ struct CompactCurrencyFormatStyleTests {
@Test("Works with Text format syntax")
func formatStyleExtension() {
let result = 1_029_331.15.formatted(.compactCurrency(code: .usd))
#expect(result == "$1M")
#expect(result == "$1.03M")
}
}
65 changes: 65 additions & 0 deletions FlipcashTests/FiatAmountDisplayTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,68 @@ struct FiatAmountFormattedTests {
)
}
}

@Suite("FiatAmount Abbreviated")
struct FiatAmountAbbreviatedTests {

@Test(
"formattedAbbreviated(maxDigits:) caps the figure at three digits",
arguments: [
// currency, value, expected

// Under the first scale — formatted as usual, fraction dropped when whole.
(CurrencyCode.usd, Decimal(5), "$5"),
(.usd, Decimal(999), "$999"),
(.usd, Decimal(string: "12.50")!, "$12.50"),

// Thousands, with the cap deciding how many decimals survive.
(.usd, Decimal(1_000), "$1K"),
(.usd, Decimal(1_500), "$1.5K"),
(.usd, Decimal(1_234), "$1.23K"),
(.usd, Decimal(12_345), "$12.3K"),
(.usd, Decimal(123_456), "$123K"),

// Millions, billions and trillions get their own suffix.
(.usd, Decimal(1_000_000), "$1M"),
(.usd, Decimal(2_500_000), "$2.5M"),
(.usd, Decimal(1_000_000_000), "$1B"),
(.usd, Decimal(1_000_000_000_000), "$1T"),

// An amount that rounds into the next scale is printed in that scale.
(.usd, Decimal(999_999), "$1M"),

(.usd, Decimal(0), "$0"),

// A zero-decimal currency abbreviates on the value, not its precision.
(.jpy, Decimal(750), "¥750"),
(.jpy, Decimal(3_000), "¥3K"),

// Small-unit currencies, where every everyday amount is four digits or
// more — the case the tip presets abbreviate for. The ARS figures are
// the $5 / $10 / $20 tiers in pesos.
(.ars, Decimal(7_500), "$7.5K"),
(.ars, Decimal(15_000), "$15K"),
(.ars, Decimal(30_000), "$30K"),
(.ars, Decimal(25_500), "$25.5K"),
(.ars, Decimal(123_456), "$123K"),
(.cop, Decimal(97_500), "$97.5K"),
(.vnd, Decimal(126_000), "₫126K"),
(.vnd, Decimal(1_315_000), "₫1.32M"),
// IDR has no single-character symbol, so it formats bare.
(.idr, Decimal(332_000), "332K"),
(.idr, Decimal(500), "500"),

// Negatives keep the minus ahead of the symbol.
(.usd, Decimal(-1_500), "-$1.5K"),
(.usd, Decimal(-2_400_000), "-$2.4M"),
] as [(CurrencyCode, Decimal, String)]
)
func formattedAbbreviated(currency: CurrencyCode, value: Decimal, expected: String) {
#expect(FiatAmount(value: value, currency: currency).formattedAbbreviated() == expected)
}

@Test("A lower cap allows fewer digits")
func lowerCap() {
#expect(FiatAmount.usd(1_234).formattedAbbreviated(maxDigits: 2) == "$1.2K")
}
}