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
46 changes: 36 additions & 10 deletions Flipcash/Core/Screens/Main/Home/ActivityRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
}
}
}
}

Expand Down Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}