diff --git a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift index b25f4b19e..02c8aa437 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift @@ -22,6 +22,7 @@ struct ActivityRow: View { let activity: Activity @Environment(SessionContainer.self) private var sessionContainer + @Environment(RatesController.self) private var ratesController private var session: Session { sessionContainer.session } /// The counterparty's resolved display name (cached profile / contact). @@ -72,7 +73,14 @@ struct ActivityRow: View { // MARK: - Amount /// A swap shows the converted (From) fiat amount over its fee; every other - /// row shows the single signed amount. + /// row leads with the amount in the viewer's own currency and, only when the + /// payment was denominated in someone else's, shows what actually moved — + /// flagged — underneath. A tip of 7,500 pesos reads "-$5.00" to a viewer in + /// dollars, with "-$7,500.00" under an Argentine flag below it. + /// + /// Converted here rather than where the feed is mapped so that a currency + /// changed on a screen stacked over the list reaches these rows: reading + /// ``RatesController`` in the body is what subscribes them to it. @ViewBuilder private var amount: some View { if let swap = activity.swapMetadata { VStack(alignment: .trailing, spacing: 2) { @@ -86,10 +94,27 @@ struct ActivityRow: View { .lineLimit(1) } } else { - Text(signedAmount) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - .lineLimit(1) + let amounts = activity.exchangedFiat.forViewer( + preferredRate: ratesController.rateForBalanceCurrency(), + rates: ratesController.cachedRates + ) + + VStack(alignment: .trailing, spacing: 2) { + Text(amounts.viewer.formatted(signPrefix: signPrefix)) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + + if let transferred = amounts.transferred { + HStack(spacing: 4) { + Flag(style: transferred.currency.flagStyle, size: .small) + Text(transferred.formatted(signPrefix: signPrefix)) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + } + } + } } } @@ -276,17 +301,18 @@ struct ActivityRow: View { // MARK: - Amount - private var signedAmount: String { - let formatted = activity.exchangedFiat.nativeAmount.formatted() + /// The sign both amount lines carry, so a debit reads as one whichever line + /// you look at, or `nil` for a row that renders unsigned. + private var signPrefix: String? { switch activity.kind { case .received, .deposited, .bought, .distributed, .sold: - return "+\(formatted)" + return "+" case .gave, .withdrew, .cashLink, .paid: - return "-\(formatted)" + return "-" case .swapped, .unknown: // A swap's net effect on the wallet isn't inherently in or out, so it // renders unsigned until the swap notification is modelled richly. - return formatted + return nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/ExchangedFiat+ViewerAmount.swift b/FlipcashCore/Sources/FlipcashCore/Models/ExchangedFiat+ViewerAmount.swift new file mode 100644 index 000000000..f21a34d53 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/ExchangedFiat+ViewerAmount.swift @@ -0,0 +1,85 @@ +// +// ExchangedFiat+ViewerAmount.swift +// FlipcashCore +// + +import Foundation + +/// What an activity row shows for its amount. +/// +/// - `viewer` is the entry in the currency the viewer reads money in — always the +/// row's headline. +/// - `transferred` is what actually moved, set only when the payment was +/// denominated in a currency that isn't the viewer's; `nil` when the two are the +/// same and a second line would just repeat the first. +public struct ViewerAmount: Equatable, Hashable, Sendable { + + public let viewer: FiatAmount + public let transferred: FiatAmount? + + public init(viewer: FiatAmount, transferred: FiatAmount?) { + self.viewer = viewer + self.transferred = transferred + } +} + +// MARK: - Viewer Currency - + +extension ExchangedFiat { + + /// Restates this amount in the viewer's own currency (`preferredRate`'s), + /// keeping what was actually transferred alongside it when the two differ — a + /// 7,500 ARS tip reads as its $5 to a viewer in dollars, with the pesos + /// underneath. + /// + /// A USDF payment carries its own USD value on-chain, fixed at the moment it + /// settled, so it converts from that: $5 of USDF stays $5 however far the peso + /// has moved since. Any other mint has no such anchor — `onChainAmount` holds + /// that mint's own quarks, not dollars — so it crosses through today's `rates` + /// instead, and falls back to the transferred amount alone when the source + /// currency has no rate to cross with. + public func forViewer(preferredRate: Rate, rates: [CurrencyCode: Rate]) -> ViewerAmount { + let transferred = nativeAmount + guard transferred.currency != preferredRate.currency else { + return ViewerAmount(viewer: transferred, transferred: nil) + } + + guard preferredRate.fx > 0, let usd = settledUSDValue(rates: rates) else { + return ViewerAmount(viewer: transferred, transferred: nil) + } + + return ViewerAmount(viewer: usd.converting(to: preferredRate), transferred: transferred) + } + + /// The entry's value in USD, or `nil` when it can't be established. See + /// ``forViewer(preferredRate:rates:)``. + /// + /// Deliberately not `usdfValue`: an activity's rate is synthesized from + /// `nativeAmount / onChainAmount` for a bonded mint, so dividing back through + /// it returns the token quantity rather than dollars. + private func settledUSDValue(rates: [CurrencyCode: Rate]) -> FiatAmount? { + if mint == .usdf { + return .usd(onChainAmount.decimalValue) + } + + guard let rate = rates[nativeAmount.currency], rate.fx > 0 else { return nil } + return nativeAmount.convertingToUSD(rate: rate) + } +} + +// MARK: - Signing - + +extension FiatAmount { + + /// This amount formatted with `signPrefix` ahead of it, or formatted as-is when + /// the value already carries its own sign. + /// + /// Activity amounts arrive as magnitudes with their direction alongside them, + /// which is why the row supplies a sign at all; a value that is genuinely + /// negative formats its own "-", and prefixing that as well would read + /// "--$5.00". + public func formatted(signPrefix: String?) -> String { + guard let signPrefix, value >= 0 else { return formatted() } + return signPrefix + formatted() + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ExchangedFiatViewerAmountTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ExchangedFiatViewerAmountTests.swift new file mode 100644 index 000000000..da63c742d --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ExchangedFiatViewerAmountTests.swift @@ -0,0 +1,142 @@ +// +// ExchangedFiatViewerAmountTests.swift +// FlipcashCore +// + +import Foundation +import Testing +import FlipcashCore + +/// An activity row leads with the viewer's own currency, so a tip denominated in +/// someone else's has to be restated — and the restated figure has to hold still, +/// which is why a USDF payment converts from the USD value it settled at rather +/// than from today's peso. +@Suite("ExchangedFiat Viewer Amount Tests") +struct ExchangedFiatViewerAmountTests { + + /// A mint that isn't USDF, so it takes the no-anchor path. + private let bondedMint = try! PublicKey(base58: "So11111111111111111111111111111111111111112") + + private let usdRate = Rate(fx: 1, currency: .usd) + private let eurRate = Rate(fx: 0.9, currency: .eur) + + private func usd(_ value: Decimal) -> FiatAmount { FiatAmount(value: value, currency: .usd) } + private func ars(_ value: Decimal) -> FiatAmount { FiatAmount(value: value, currency: .ars) } + private func eur(_ value: Decimal) -> FiatAmount { FiatAmount(value: value, currency: .eur) } + + /// A USDF payment as the feed carries it: the settled dollars on-chain, the + /// amount the payer entered, and the FX it settled at. + private func usdfPayment(dollars: Decimal, native: FiatAmount, fx: Decimal) -> ExchangedFiat { + ExchangedFiat( + onChainAmount: TokenAmount(wholeTokens: dollars, mint: .usdf), + nativeAmount: native, + currencyRate: Rate(fx: fx, currency: native.currency) + ) + } + + /// A bonded-mint payment. Its rate is the per-token one the feed synthesizes + /// from `nativeAmount / onChainAmount`, which is why the on-chain side is no + /// USD anchor. + private func bondedPayment(tokens: Decimal, native: FiatAmount) -> ExchangedFiat { + ExchangedFiat( + onChainAmount: TokenAmount(wholeTokens: tokens, mint: bondedMint), + nativeAmount: native, + currencyRate: Rate(fx: native.value / tokens, currency: native.currency) + ) + } + + @Test("An amount already in the viewer's currency shows one line") + func testSameCurrencyShowsOneLine() { + let amount = usdfPayment(dollars: 5, native: usd(5), fx: 1) + + let shown = amount.forViewer(preferredRate: usdRate, rates: [:]) + + #expect(shown.viewer == usd(5)) + #expect(shown.transferred == nil) + } + + @Test("A USDF tip in pesos leads with the dollars it settled at") + func testUSDFTipUsesSettledDollars() { + let amount = usdfPayment(dollars: 5, native: ars(7_500), fx: 1_500) + + // The peso has halved against the dollar since — the row still reads $5, + // not $2.50. + let shown = amount.forViewer( + preferredRate: usdRate, + rates: [.ars: Rate(fx: 3_000, currency: .ars)] + ) + + #expect(shown.viewer == usd(5)) + #expect(shown.transferred == ars(7_500)) + } + + @Test("A USDF tip crosses its settled dollars into whatever currency the viewer reads") + func testUSDFTipCrossesIntoViewerCurrency() { + let amount = usdfPayment(dollars: 5, native: ars(7_500), fx: 1_500) + + let shown = amount.forViewer(preferredRate: eurRate, rates: [:]) + + #expect(shown.viewer == eur(4.5)) + #expect(shown.transferred == ars(7_500)) + } + + @Test("A non-USDF tip has no settled dollars, so it crosses through today's rates") + func testBondedTipCrossesThroughCurrentRates() { + let amount = bondedPayment(tokens: 1_234, native: ars(7_500)) + + // `onChainAmount` holds the mint's own tokens here, not dollars, so it is + // ignored. + let shown = amount.forViewer( + preferredRate: usdRate, + rates: [.ars: Rate(fx: 1_500, currency: .ars)] + ) + + #expect(shown.viewer == usd(5)) + #expect(shown.transferred == ars(7_500)) + } + + @Test("A non-USDF tip with no rate to cross falls back to what was transferred") + func testBondedTipWithoutRateFallsBack() { + let amount = bondedPayment(tokens: 1_234, native: ars(7_500)) + + let shown = amount.forViewer(preferredRate: usdRate, rates: [:]) + + #expect(shown.viewer == ars(7_500)) + #expect(shown.transferred == nil) + } + + @Test("An unusable rate is treated as no rate at all") + func testZeroRateIsTreatedAsMissing() { + let amount = bondedPayment(tokens: 1_234, native: ars(7_500)) + + let shown = amount.forViewer( + preferredRate: usdRate, + rates: [.ars: Rate(fx: 0, currency: .ars)] + ) + + #expect(shown.viewer == ars(7_500)) + #expect(shown.transferred == nil) + } + + @Test("An unusable viewer rate leaves the transferred amount alone") + func testZeroViewerRateFallsBack() { + let amount = usdfPayment(dollars: 5, native: ars(7_500), fx: 1_500) + + let shown = amount.forViewer( + preferredRate: Rate(fx: 0, currency: .eur), + rates: [.ars: Rate(fx: 1_500, currency: .ars)] + ) + + #expect(shown.viewer == ars(7_500)) + #expect(shown.transferred == nil) + } + + @Test("An amount that signs itself is not signed twice") + func testAmountIsNotSignedTwice() { + // The row supplies the sign because feed amounts are magnitudes; one that + // isn't would otherwise format as "--$5.00". + #expect(usd(-5).formatted(signPrefix: "-") == usd(-5).formatted()) + #expect(usd(5).formatted(signPrefix: "-") == "-" + usd(5).formatted()) + #expect(usd(5).formatted(signPrefix: nil) == usd(5).formatted()) + } +}