diff --git a/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift b/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift index 036b28515..bd471fcd7 100644 --- a/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift @@ -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 @@ -91,8 +95,10 @@ 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 @@ -100,14 +106,6 @@ struct SendTipSheet: View { .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): @@ -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 @@ -147,6 +147,7 @@ private struct TipAmountChip: View { .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel ?? title) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift b/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift index 19e0c8c68..86e96b508 100644 --- a/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift +++ b/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift @@ -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" @@ -23,7 +24,7 @@ import Foundation /// // → "$100K" /// /// Text(-12_400, format: .compactCurrency(code: .usd)) -/// // → "-$12K" +/// // → "-$12.4K" /// ``` public struct CompactCurrencyFormatStyle: FormatStyle { @@ -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) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift index 7387092ba..586bcef8d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift @@ -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 - diff --git a/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift index c5487e5e2..5a572d848 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift @@ -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") @@ -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") @@ -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") } } diff --git a/FlipcashTests/FiatAmountDisplayTests.swift b/FlipcashTests/FiatAmountDisplayTests.swift index ac200280c..8190b9b8b 100644 --- a/FlipcashTests/FiatAmountDisplayTests.swift +++ b/FlipcashTests/FiatAmountDisplayTests.swift @@ -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") + } +}