From b253a7322e42b9df09cec3059fbb3e80b342dba9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 1 Sep 2026 12:06:26 -0400 Subject: [PATCH] feat(activity): lead activity rows with the viewer's own currency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An activity row showed `nativeAmount` alone — the currency the payment was denominated in — so a 7,500 peso tip read "-$7,500.00" to someone who reads money in dollars, with nothing saying it was $5. The row now leads with the amount in the viewer's currency and, only when the transferred currency differs, shows what actually moved beneath it with that currency's flag in the secondary style. Both lines carry the same sign; a payment already in the viewer's currency renders one line as before. `ExchangedFiat.forViewer(preferredRate:rates:)` is the conversion. A USDF payment converts from the USD it settled at (`onChainAmount`), so a $5 tip keeps reading $5 however far the peso moves afterwards. Any other mint has no such anchor — `onChainAmount` holds that mint's own quarks — so it crosses through today's rates instead and falls back to a single line when the source currency has no rate to cross with. Deliberately not `usdfValue`: an activity's rate is synthesized as `nativeAmount / onChainAmount` for a bonded mint, so dividing back through it returns the token count rather than dollars. The conversion runs at render time rather than where the feed is mapped. Reading `RatesController` in the row's body is what subscribes the rows to it, so a currency changed on a screen stacked over the list reaches them. Feed amounts are magnitudes with the direction carried alongside, which is why the row supplies the sign at all. `FiatAmount.formatted(signPrefix:)` leaves a value that already carries its own "-" alone rather than printing "--$5.00". Swap rows keep the second line for the swap fee, unchanged. Ports code-payments/code-android-app#1388. Android updated two row implementations and flagged both lines in the per-token history, which had always flagged the one amount it showed. iOS has a single `ActivityRow` across the Recent previews, the cross-token history and the per-token history, and none of them flag today, so only the transferred line gains a flag. --- .../Core/Screens/Main/Home/ActivityRow.swift | 46 ++++-- .../Models/ExchangedFiat+ViewerAmount.swift | 85 +++++++++++ .../ExchangedFiatViewerAmountTests.swift | 142 ++++++++++++++++++ 3 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/ExchangedFiat+ViewerAmount.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ExchangedFiatViewerAmountTests.swift 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()) + } +}