From b6e3584c5f2a29aa78d49358ad66211f7634e7a5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 21:07:17 -0400 Subject: [PATCH 1/2] feat(activity): open a tapped activity row's details An activity row was a label everywhere it was drawn, so the only way to read an entry's rate, token quantity or id was not to have one, and the only way to cancel a pending cash link was to find it again in the per-token history. Android closed this in code-payments/code-android-app#1417 and code-payments/code-android-app#1418; this is the same screen on iOS. `TransactionDetails` maps an `Activity` to what the screen shows: which of the 13 kinds it was, its heading and subtitle, whether it can be cancelled, and whether it has a conversation to open. The three cash kinds stay distinct because `MessageMetadata` already distinguishes them, and there is no received-cash-link case because a claimed link arrives as a plain receive. `TransactionDetailsTests` mirrors Android's `TransactionDetailsMapperTest` case for case. `ActivityRow` now pushes `transactionDetails` itself rather than each of its three call sites wiring a tap, so a row opens the same screen in the wallet's Recent section, the currency info screen, the cross-token history and a token's own history. The avatar the row draws moves to `ActivityAvatar`, which the details header draws at 80pt from the same `ActivityResolution`, so the screen opens on exactly the avatar that was tapped. `TransactionHistoryScreen` drops its own cancel dialog; cancel now lives in the details screen's toolbar, as it does on Android. Layout follows Figma node 9708:105260, and the kind headings node 9708:118186, including the four places Android's implementation is still waiting on design: amount type size, line-item value size, gutter inset, and the header avatar's ring. --- .../Navigation/AppRouter+Destination.swift | 11 +- .../AppRouter+DestinationView.swift | 4 + .../Screens/Main/Home/ActivityAvatar.swift | 250 ++++++++++++ .../Main/Home/ActivityHistoryScreen.swift | 3 +- .../Core/Screens/Main/Home/ActivityRow.swift | 281 ++------------ .../Main/Home/RecentActivitySection.swift | 10 +- .../Main/Home/TransactionDetailsScreen.swift | 365 ++++++++++++++++++ .../Main/TransactionHistoryScreen.swift | 57 +-- .../FlipcashCore/Models/TokenAmount.swift | 11 + .../Models/TransactionDetails.swift | 326 ++++++++++++++++ .../TransactionDetailsTests.swift | 261 +++++++++++++ 11 files changed, 1278 insertions(+), 301 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift create mode 100644 Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 0d6e544e6..24e464820 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -28,6 +28,11 @@ extension AppRouter { /// The unified, cross-token activity history — the "dive in" from the /// Wallet's Recent section. `transactionHistory` is the per-token slice. case activity + /// One activity entry, opened by tapping its row anywhere the row is + /// drawn. Carries the whole ``Activity`` rather than an id: the row + /// already holds it, and the feed's local store has no by-id lookup to + /// re-read it from. + case transactionDetails(Activity) case give(PublicKey) /// Pushes the buy flow (`BuyAmountScreen`) onto the current stack instead /// of presenting it as a sheet — the currency-info "Get" tile. @@ -110,7 +115,8 @@ extension AppRouter { switch self { case .currencyInfo, .currencyInfoForDeposit, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, - .transactionHistory, .activity, .give, .buyCurrency, .convertCurrency, + .transactionHistory, .activity, .transactionDetails, .give, + .buyCurrency, .convertCurrency, .withdrawCurrency, .usdcDepositEducation, .usdcDepositAddress: return .balance case .settingsMyAccount, .changeDisplayName, .changeProfilePicture, .username, @@ -139,6 +145,7 @@ extension AppRouter { case .currencyCreationWizard: "currencyCreationWizard" case .transactionHistory: "transactionHistory" case .activity: "activity" + case .transactionDetails: "transactionDetails" case .give: "give" case .buyCurrency: "buyCurrency" case .convertCurrency: "convertCurrency" @@ -183,6 +190,8 @@ extension AppRouter { return mint.base58 case .withdrawCurrency(let mint): return mint?.base58 + case .transactionDetails(let activity): + return activity.id.base58 case .tipConversation(let conversationID), .tipConversationWithKeyboard(let conversationID): return conversationID.description diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 00a595b8a..93bab37c6 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -55,6 +55,10 @@ struct DestinationView: View { case .activity: ActivityHistoryScreen() + case .transactionDetails(let activity): + TransactionDetailsScreen(activity: activity) + .id(activity.id) + case .give(let mint): // `.id(mint)` for the same reason as `.currencyInfo` above — // a deeplink replacing `.give(A)` with `.give(B)` must build a diff --git a/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift b/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift new file mode 100644 index 000000000..595c8fa12 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift @@ -0,0 +1,250 @@ +// +// ActivityAvatar.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The avatar an activity draws — the counterparty's profile photo for peer +/// activity (tips/sends), the token image for token activity (deposits, buys), +/// two overlapping coins for a conversion, or a monogram fallback. A peer avatar +/// shows a face rather than a token, so it carries the transacted token as a coin +/// badge (Figma 8966:1910, 9717:14215). +/// +/// Shared by the activity row and the transaction details header at different +/// sizes, so the details screen opens on exactly the avatar that was tapped. Every +/// metric derives from `size`, which is what keeps the two in proportion. +struct ActivityAvatar: View { + + let activity: Activity + let resolution: ActivityResolution + var size: CGFloat = 40 + + @Environment(SessionContainer.self) private var sessionContainer + private var session: Session { sessionContainer.session } + + /// The token badge, and the ring that separates a coin from what's behind it, + /// as fractions of the avatar: a 40pt row avatar carries a 20pt badge on a 2pt + /// ring, and the details header's larger avatar keeps the same proportions. + private var badgeSize: CGFloat { size / 2 } + private var ringWidth: CGFloat { size / 20 } + private var badgeOverhang: CGFloat { size / 10 } + private var swapCoinSize: CGFloat { size * 0.65 } + + var body: some View { + if let swap = activity.swapMetadata { + swapAvatar(swap) + } else { + singleAvatar + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(alignment: .bottomTrailing) { + if Self.showsTokenBadge(for: activity) { + tokenBadge.offset(x: badgeOverhang, y: badgeOverhang) + } + } + // Reserves the badge's overhang so it doesn't eat into the gap + // before whatever sits beside the avatar. + .padding(.trailing, Self.showsTokenBadge(for: activity) ? badgeOverhang : 0) + } + } + + /// Whether the avatar carries a token badge: only a peer activity, whose + /// avatar is the counterparty rather than the token itself. + static func showsTokenBadge(for activity: Activity) -> Bool { + activity.swapMetadata == nil && activity.counterparty != nil + } + + /// The token the payment moved in, as a coin badge over the counterparty's + /// avatar (Figma 9717:14140) — a peer activity shows *who*, so this badge is + /// the only place the token reads. + @ViewBuilder private var tokenBadge: some View { + tokenCoin( + url: resolution.imageURL(for: activity.exchangedFiat.mint, fallback: resolution.entryMint, session: session), + monogramID: activity.exchangedFiat.mint.base58, + size: badgeSize + ) + .overlay(Circle().stroke(Color.backgroundMain, lineWidth: ringWidth)) + } + + @ViewBuilder private var singleAvatar: some View { + switch activity.counterparty { + case .user(let userID): + ContactAvatarView( + id: userID.uuidString, + displayName: resolution.counterpartyName ?? "", + imageData: resolution.avatarData, + blurhash: resolution.avatarBlurhash, + size: size + ) + case .phone(let e164): + ContactAvatarView( + id: e164, + displayName: resolution.counterpartyName ?? "", + imageData: resolution.avatarData, + size: size + ) + case .none: + tokenOrGenericAvatar() + } + } + + @ViewBuilder private func tokenOrGenericAvatar() -> some View { + if let url = resolution.imageURL(for: activity.exchangedFiat.mint, fallback: resolution.entryMint, session: session) { + RemoteImage(url: url) + } else { + ContactAvatarView(id: activity.id.base58, displayName: "", size: size) + } + } + + /// The two swapped tokens as overlapping coins — the destination (To) sits on + /// top of the source (From), per the Recent design. Each coin shows its mint + /// logo (held balance first, then the async-resolved fallback). + private func swapAvatar(_ swap: Activity.SwapMetadata) -> some View { + ZStack { + tokenCoin( + url: resolution.imageURL(for: swap.fromMint, fallback: resolution.swapFromMint, session: session), + monogramID: swap.fromMint.base58, + size: swapCoinSize + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + tokenCoin( + url: resolution.imageURL(for: swap.toMint, fallback: resolution.swapToMint, session: session), + monogramID: swap.toMint.base58, + size: swapCoinSize + ) + .overlay(Circle().stroke(Color.backgroundMain, lineWidth: ringWidth)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + } + .frame(width: size, height: size) + } + + @ViewBuilder private func tokenCoin(url: URL?, monogramID: String, size: CGFloat) -> some View { + Group { + if let url { + RemoteImage(url: url) + } else { + ContactAvatarView(id: monogramID, displayName: "", size: size) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } +} + +// MARK: - Resolution - + +/// The asynchronously resolved parts of an activity — the counterparty's name and +/// picture, and the mint metadata behind the avatar's coins — held together rather +/// than as loose fields on every view that draws one. +/// +/// Everything here is a fallback for something the caches already answer +/// instantly: a held balance names and illustrates its own mint, and an +/// unresolvable counterparty falls back to the server-rendered title and a +/// monogram. Views therefore render correctly before any of this lands. +@MainActor +@Observable +final class ActivityResolution { + + /// The counterparty's resolved display name (cached profile / contact). + private(set) var counterpartyName: String? + /// The counterparty's resolved avatar bytes + blurhash. + private(set) var avatarData: Data? + private(set) var avatarBlurhash: String? + + /// Conversion coin metadata resolved beyond the held-balance cache (local mint + /// store, then a server fetch) so a swapped-away token still shows its real + /// name and logo. + private(set) var swapFromMint: StoredMintMetadata? + private(set) var swapToMint: StoredMintMetadata? + + /// The transacted token's metadata, resolved the same way when it isn't a held + /// balance. + private(set) var entryMint: StoredMintMetadata? + + /// Resolves everything an activity's avatar and title need. + /// + /// A row only needs the entry's mint when it draws a badge over a face; the + /// details screen names the token under the amount whatever the kind, so it + /// asks for it with `resolvingEntryToken`. + func resolve( + activity: Activity, + in sessionContainer: SessionContainer, + resolvingEntryToken: Bool = false + ) async { + await resolveCounterparty(activity: activity, in: sessionContainer) + + let session = sessionContainer.session + if let swap = activity.swapMetadata { + swapFromMint = await resolveMintMetadata(swap.fromMint, session: session) + swapToMint = await resolveMintMetadata(swap.toMint, session: session) + } else if resolvingEntryToken || ActivityAvatar.showsTokenBadge(for: activity) { + let mint = activity.exchangedFiat.mint + guard session.balance(for: mint) == nil else { return } + entryMint = await resolveMintMetadata(mint, session: session) + } + } + + /// The token's display name: the held balance (instant, from cache) or the + /// async-resolved metadata. + func name(for mint: PublicKey, fallback: StoredMintMetadata?, session: Session) -> String? { + session.balance(for: mint)?.name ?? fallback?.name + } + + /// The token's logo, resolved the same way as ``name(for:fallback:session:)``. + func imageURL(for mint: PublicKey, fallback: StoredMintMetadata?, session: Session) -> URL? { + session.balance(for: mint)?.imageURL ?? fallback?.imageURL + } + + /// Resolves the counterparty's name and avatar: a cached profile (+ fetched + /// thumbnail) for a user, or an address-book contact for a phone number. + /// Uncached counterparties fall back to the server title + a monogram. + private func resolveCounterparty(activity: Activity, in sessionContainer: SessionContainer) async { + switch activity.counterparty { + case .user(let userID): + await resolveUser(userID, in: sessionContainer) + case .phone(let e164): + let contact = sessionContainer.contactSyncController.resolvedContacts.onFlipcash.first { $0.phoneE164 == e164 } + counterpartyName = contact?.displayName + avatarData = contact?.imageData + case .none: + break + } + } + + /// A cached full profile (someone you've viewed or tipped) is authoritative; + /// otherwise fall back to the tip conversation's member, which carries the + /// name + picture for counterparties you've only *received* tips from (those + /// are never written to the profile cache). Without this, received payments + /// show the server title and a monogram instead of a named row. + private func resolveUser(_ userID: UserID, in sessionContainer: SessionContainer) async { + let picture: ProfilePicture? + + if let profile = sessionContainer.session.cachedUserProfile(for: userID) { + counterpartyName = profile.displayName + picture = profile.profilePicture + } else if let member = sessionContainer.conversationController.conversations + .flatMap(\.members) + .first(where: { $0.userID == userID }) { + counterpartyName = member.displayName.isEmpty ? nil : member.displayName + picture = member.profilePicture + } else { + picture = nil + } + + avatarBlurhash = picture?.thumbnailBlurhash + guard let picture else { return } + await sessionContainer.tipAvatars.load(userID: userID, picture: picture) + avatarData = sessionContainer.tipAvatars.data(for: userID) + } + + private func resolveMintMetadata(_ mint: PublicKey, session: Session) async -> StoredMintMetadata? { + if let stored = session.storedMintMetadata(for: mint) { + return stored + } + return try? await session.fetchMintMetadata(mint: mint) + } +} diff --git a/Flipcash/Core/Screens/Main/Home/ActivityHistoryScreen.swift b/Flipcash/Core/Screens/Main/Home/ActivityHistoryScreen.swift index 502ca0c34..3b36f0079 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityHistoryScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityHistoryScreen.swift @@ -9,8 +9,7 @@ import FlipcashCore /// The unified, cross-token activity history — the "dive in" from the Wallet's /// Recent section. Lists every activity (newest first) with the same enriched -/// rows as the wallet preview. Rows are non-interactive; the per-token -/// ``TransactionHistoryScreen`` remains the place to cancel a pending cash link. +/// rows as the wallet preview. Tapping a row opens ``TransactionDetailsScreen``. struct ActivityHistoryScreen: View { @Environment(SessionContainer.self) private var sessionContainer diff --git a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift index 02c8aa437..99faa4780 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift @@ -10,40 +10,40 @@ import FlipcashCore /// The single activity row used across every surface — the Wallet and Currency /// Info "Recent" previews, the cross-token Activity history, and the per-token /// Transaction history (Figma 8966:1910, ported from Android's `ActivityFeedRow`): -/// a 40pt avatar, the title + relative time, and a signed amount. The avatar is -/// the counterparty's profile photo for peer activity (tips/sends), the token -/// image for token activity (deposits, buys), or a monogram fallback. Because a -/// peer avatar shows a face rather than a token, it carries the transacted token -/// as a coin badge (Figma 9717:14215). A peer payment reads "Tipped " or -/// "Sent to " once the counterparty resolves, and a swap reads -/// "" once both token names resolve. +/// a 40pt avatar, the title + relative time, and a signed amount. A peer payment +/// reads "Tipped " or "Sent to " once the counterparty resolves, and a +/// conversion reads "" once both token names resolve. +/// +/// Tapping a row pushes ``TransactionDetailsScreen`` onto whichever stack the row +/// is drawn on, which draws the same avatar at header size from the same +/// ``ActivityResolution``. The tap lives here rather than at each of the three +/// call sites so a row opens the same screen wherever it is shown. struct ActivityRow: View { let activity: Activity @Environment(SessionContainer.self) private var sessionContainer @Environment(RatesController.self) private var ratesController + @Environment(AppRouter.self) private var router private var session: Session { sessionContainer.session } - /// The counterparty's resolved display name (cached profile / contact). - @State private var counterpartyName: String? - /// The counterparty's resolved avatar bytes + blurhash. - @State private var avatarData: Data? - @State private var avatarBlurhash: String? - - /// Swap coin metadata resolved beyond the held-balance cache (local mint - /// store, then a server fetch) so a swapped-away token still shows its real - /// name and logo. - @State private var swapFromMint: StoredMintMetadata? - @State private var swapToMint: StoredMintMetadata? - - /// The transacted token's metadata for a peer row's avatar badge, resolved - /// the same way when it isn't a held balance. - @State private var badgeMint: StoredMintMetadata? + @State private var resolution = ActivityResolution() var body: some View { + Button { + router.push(.transactionDetails(activity)) + } label: { + content + } + .buttonStyle(.plain) + .task(id: activity.id) { + await resolution.resolve(activity: activity, in: sessionContainer) + } + } + + private var content: some View { HStack(spacing: 12) { - avatar + ActivityAvatar(activity: activity, resolution: resolution) VStack(alignment: .leading, spacing: 4) { Text(displayTitle) @@ -60,21 +60,14 @@ struct ActivityRow: View { amount } .padding(.vertical, 12) - .task(id: activity.id) { - await resolveCounterparty() - if let swap = activity.swapMetadata { - await resolveSwapMints(swap) - } else if showsTokenBadge { - await resolveBadgeMint() - } - } + .contentShape(Rectangle()) } // MARK: - Amount - /// A swap shows the converted (From) fiat amount over its fee; every other - /// 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 — + /// A conversion shows the converted (From) fiat amount over its fee; every + /// other 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. /// @@ -100,7 +93,7 @@ struct ActivityRow: View { ) VStack(alignment: .trailing, spacing: 2) { - Text(amounts.viewer.formatted(signPrefix: signPrefix)) + Text(amounts.viewer.formatted(signPrefix: activity.kind.signPrefix)) .font(.appTextMedium) .foregroundStyle(Color.textMain) .lineLimit(1) @@ -108,7 +101,7 @@ struct ActivityRow: View { if let transferred = amounts.transferred { HStack(spacing: 4) { Flag(style: transferred.currency.flagStyle, size: .small) - Text(transferred.formatted(signPrefix: signPrefix)) + Text(transferred.formatted(signPrefix: activity.kind.signPrefix)) .font(.appTextSmall) .foregroundStyle(Color.textSecondary) .lineLimit(1) @@ -120,20 +113,20 @@ struct ActivityRow: View { // MARK: - Title - /// A peer payment renders with the resolved counterparty name, and a swap - /// renders its two token names, once they resolve; every other row uses the - /// server-rendered title. + /// A peer payment renders with the resolved counterparty name, and a + /// conversion renders its two token names, once they resolve; every other row + /// uses the server-rendered title. private var displayTitle: String { if let swap = activity.swapMetadata { return Self.swapTitle( - from: mintName(for: swap.fromMint, resolved: swapFromMint), - to: mintName(for: swap.toMint, resolved: swapToMint) + from: resolution.name(for: swap.fromMint, fallback: resolution.swapFromMint, session: session), + to: resolution.name(for: swap.toMint, fallback: resolution.swapToMint, session: session) ) ?? activity.title } return Self.peerTitle( kind: activity.kind, - name: counterpartyName, + name: resolution.counterpartyName, serverTitle: activity.title ) ?? activity.title } @@ -144,19 +137,11 @@ struct ActivityRow: View { /// payment, or whose counterparty has not resolved yet, so the caller falls /// back to the server-rendered title. /// - /// The tip/send split rides on the server's verb alone: the backend picks it - /// from the payment's `ChatMetadata.TipDmPayment.Location`, so a tip-card tip - /// arrives titled "Tipped" and an in-chat send "Sent". `activity/v1` models - /// both as plain sent/received crypto with no structured tip flag, so there - /// is no other signal to read. Matching the verb is safe while `serverTitle` - /// is English-only; a localized feed would need the distinction promoted into - /// the notification metadata. + /// The tip/send split rides on the server's verb alone — see + /// ``Activity/isTipVerb(_:)``, which the details screen's kind reads too. static func peerTitle(kind: Activity.Kind, name: String?, serverTitle: String) -> String? { guard let name, !name.isEmpty else { return nil } - let isTip = serverTitle - .trimmingCharacters(in: .whitespaces) - .lowercased() - .hasPrefix("tip") + let isTip = Activity.isTipVerb(serverTitle) switch kind { case .gave: return isTip ? "Tipped \(name)" : "Sent to \(name)" @@ -169,194 +154,6 @@ struct ActivityRow: View { /// token name is still unresolved — the caller falls back to the /// server-rendered title rather than showing a half-empty pair. static func swapTitle(from: String?, to: String?) -> String? { - guard let from, !from.isEmpty, let to, !to.isEmpty else { return nil } - return "\(from) \u{2192} \(to)" - } - - /// The token's display name: the held balance (instant, from cache) or the - /// async-resolved metadata. - private func mintName(for mint: PublicKey, resolved: StoredMintMetadata?) -> String? { - session.balance(for: mint)?.name ?? resolved?.name - } - - // MARK: - Avatar - - @ViewBuilder private var avatar: some View { - if let swap = activity.swapMetadata { - swapAvatar(swap) - } else { - singleAvatar - .frame(width: 40, height: 40) - .clipShape(Circle()) - .overlay(alignment: .bottomTrailing) { - if showsTokenBadge { - tokenBadge.offset(x: 4, y: 4) - } - } - // Reserves the badge's overhang so it doesn't eat into the gap - // before the title. - .padding(.trailing, showsTokenBadge ? 4 : 0) - } - } - - /// Whether the avatar carries a token badge: only a peer row, whose avatar is - /// the counterparty rather than the token itself. - private var showsTokenBadge: Bool { - activity.swapMetadata == nil && activity.counterparty != nil - } - - /// The token the payment moved in, as a coin badge over the counterparty's - /// avatar (Figma 9717:14140) — a peer row shows *who*, so this badge is the - /// only place the token reads. - @ViewBuilder private var tokenBadge: some View { - tokenCoin( - url: coinURL(for: activity.exchangedFiat.mint, fallback: badgeMint), - monogramID: activity.exchangedFiat.mint.base58, - size: 20 - ) - .overlay(Circle().stroke(Color.backgroundMain, lineWidth: 2)) - } - - @ViewBuilder private var singleAvatar: some View { - switch activity.counterparty { - case .user(let userID): - ContactAvatarView( - id: userID.uuidString, - displayName: counterpartyName ?? "", - imageData: avatarData, - blurhash: avatarBlurhash, - size: 40 - ) - case .phone(let e164): - ContactAvatarView( - id: e164, - displayName: counterpartyName ?? "", - imageData: avatarData, - size: 40 - ) - case .none: - tokenOrGenericAvatar() - } - } - - @ViewBuilder private func tokenOrGenericAvatar() -> some View { - if let token = session.balance(for: activity.exchangedFiat.mint), let url = token.imageURL { - RemoteImage(url: url) - } else { - ContactAvatarView(id: activity.id.base58, displayName: "", size: 40) - } - } - - /// The two swapped tokens as overlapping coins — the destination (To) sits on - /// top of the source (From), per the Recent design. Each coin shows its mint - /// logo (held balance first, then the async-resolved fallback). - private func swapAvatar(_ swap: Activity.SwapMetadata) -> some View { - ZStack { - tokenCoin(url: coinURL(for: swap.fromMint, fallback: swapFromMint), monogramID: swap.fromMint.base58, size: 26) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - tokenCoin(url: coinURL(for: swap.toMint, fallback: swapToMint), monogramID: swap.toMint.base58, size: 26) - .overlay(Circle().stroke(Color.backgroundMain, lineWidth: 2)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) - } - .frame(width: 40, height: 40) - } - - /// The held-balance logo (instant, from cache) or the async-resolved fallback. - private func coinURL(for mint: PublicKey, fallback: StoredMintMetadata?) -> URL? { - session.balance(for: mint)?.imageURL ?? fallback?.imageURL - } - - @ViewBuilder private func tokenCoin(url: URL?, monogramID: String, size: CGFloat) -> some View { - Group { - if let url { - RemoteImage(url: url) - } else { - ContactAvatarView(id: monogramID, displayName: "", size: size) - } - } - .frame(width: size, height: size) - .clipShape(Circle()) - } - - /// Resolves both coins beyond the held-balance cache: the local mint store, - /// then a server fetch, so a swapped-away token still shows its name + logo. - private func resolveSwapMints(_ swap: Activity.SwapMetadata) async { - swapFromMint = await resolveMintMetadata(swap.fromMint) - swapToMint = await resolveMintMetadata(swap.toMint) - } - - /// Resolves the badge's token beyond the held-balance cache, so a token you - /// no longer hold still shows its logo. - private func resolveBadgeMint() async { - guard session.balance(for: activity.exchangedFiat.mint) == nil else { return } - badgeMint = await resolveMintMetadata(activity.exchangedFiat.mint) - } - - private func resolveMintMetadata(_ mint: PublicKey) async -> StoredMintMetadata? { - if let stored = session.storedMintMetadata(for: mint) { - return stored - } - return try? await session.fetchMintMetadata(mint: mint) - } - - // MARK: - Amount - - /// 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 "+" - case .gave, .withdrew, .cashLink, .paid: - 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 nil - } - } - - // MARK: - Resolution - - /// Resolves the counterparty's name and avatar: a cached profile (+ fetched - /// thumbnail) for a user, or an address-book contact for a phone number. - /// Uncached counterparties fall back to the server title + a monogram. - private func resolveCounterparty() async { - switch activity.counterparty { - case .user(let userID): - await resolveUser(userID) - case .phone(let e164): - let contact = sessionContainer.contactSyncController.resolvedContacts.onFlipcash.first { $0.phoneE164 == e164 } - counterpartyName = contact?.displayName - avatarData = contact?.imageData - case .none: - break - } - } - - /// A cached full profile (someone you've viewed or tipped) is authoritative; - /// otherwise fall back to the tip conversation's member, which carries the - /// name + picture for counterparties you've only *received* tips from (those - /// are never written to the profile cache). Without this, received payments - /// show the server title and a monogram instead of a named row. - private func resolveUser(_ userID: UserID) async { - let picture: ProfilePicture? - - if let profile = session.cachedUserProfile(for: userID) { - counterpartyName = profile.displayName - picture = profile.profilePicture - } else if let member = sessionContainer.conversationController.conversations - .flatMap(\.members) - .first(where: { $0.userID == userID }) { - counterpartyName = member.displayName.isEmpty ? nil : member.displayName - picture = member.profilePicture - } else { - picture = nil - } - - avatarBlurhash = picture?.thumbnailBlurhash - guard let picture else { return } - await sessionContainer.tipAvatars.load(userID: userID, picture: picture) - avatarData = sessionContainer.tipAvatars.data(for: userID) + TransactionDetails.conversionTitle(from: from, to: to) } } diff --git a/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift b/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift index 95b3fa6be..ef4033d14 100644 --- a/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift +++ b/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift @@ -7,9 +7,9 @@ import SwiftUI import FlipcashCore /// The "Recent" activity preview: a tappable header that opens the full history, -/// over a short list of ``ActivityRow``s. Shared by the wallet (all tokens) -/// and the currency info screen (a single token), which differ only in what the -/// header opens. +/// over a short list of ``ActivityRow``s, each of which opens its own entry. +/// Shared by the wallet (all tokens) and the currency info screen (a single +/// token), which differ only in what the header opens. struct RecentActivitySection: View { let activities: [Activity] @@ -18,8 +18,8 @@ struct RecentActivitySection: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // The header is the "dive in" affordance — the rows themselves are a - // non-interactive preview. + // The header is the "dive in" affordance; the rows themselves open + // the entry they show. Button(action: onShowAll) { HStack(spacing: 8) { Text("Recent") diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift new file mode 100644 index 000000000..0b3275ef2 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -0,0 +1,365 @@ +// +// TransactionDetailsScreen.swift +// Flipcash +// + +import SwiftUI +import UIKit +import FlipcashUI +import FlipcashCore + +/// One activity entry in full (Figma node 9708:105260) — what opens when a row is +/// tapped in the Wallet's Recent section, the cross-token history, or a token's +/// own history. +/// +/// The header restates the entry the way the user would: the row's own avatar, +/// then who or what it was, then its other side where the heading hasn't already +/// said it, then how much and in which direction, then when and in which token. +/// The navigation title stays the literal "Details", so the entry names itself in +/// the header rather than in the bar. +/// +/// Cancelling is the bar's trailing action rather than a control at the foot of +/// the scroll: it applies to the whole entry, not to anything in the receipt, and +/// below a variable-length card it would land somewhere different on every kind. +struct TransactionDetailsScreen: View { + + let activity: Activity + + @Environment(SessionContainer.self) private var sessionContainer + @Environment(RatesController.self) private var ratesController + @Environment(AppRouter.self) private var router + @Environment(\.dismiss) private var dismiss + + private var session: Session { sessionContainer.session } + + @State private var resolution = ActivityResolution() + @State private var dialogItem: DialogItem? + @State private var didCopyID = false + + /// How long the copy control stays on the checkmark before reverting. + private static let copyConfirmationDuration: Duration = .seconds(1.5) + + /// The header avatar, twice the row's — the same drawing at the size Figma + /// gives the header. + private static let avatarSize: CGFloat = 80 + + private var details: TransactionDetails { + TransactionDetails( + activity: activity, + counterpartyName: resolution.counterpartyName, + fromTokenName: fromTokenName, + toTokenName: toTokenName, + ) + } + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 16) { + header + receiptCard + idCard + + if let userID = activity.counterparty?.userID, details.canViewInChat { + Button("View in Chat") { + router.navigate(to: .tipConversationForUser(userID)) + } + .buttonStyle(.filled) + .padding(.top, 4) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + } + .navigationTitle("Details") + .toolbarTitleDisplayMode(.inline) + .toolbar { + if details.canCancel { + ToolbarItem(placement: .topBarTrailing) { + Button("Cancel", action: confirmCancelAction) + .font(.appTextMedium) + .foregroundStyle(Color.textError) + } + } + } + .dialog(item: $dialogItem) + .task(id: activity.id) { + await resolution.resolve(activity: activity, in: sessionContainer, resolvingEntryToken: true) + } + } + + // MARK: - Header + + /// Who or what it was, then how much and when — two blocks rather than one + /// evenly spaced stack, because the wider gap either side of the amount is + /// what separates the two facts. + private var header: some View { + VStack(spacing: 24) { + VStack(spacing: 16) { + ActivityAvatar(activity: activity, resolution: resolution, size: Self.avatarSize) + + Text(details.title) + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.center) + + if let subtitle = details.subtitle { + Text(subtitle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + } + } + + VStack(spacing: 8) { + amount + + // When, and in which token — the mint is what "$20.00" alone + // never says. + HStack(spacing: 8) { + Text(activity.date.formattedRelatively(useTimeForToday: true)) + + if let tokenName { + // A drawn dot rather than a "•" glyph: the glyph's size + // and its offset from the baseline are the font's to + // decide, and this wants a small circle on the line. + Circle() + .fill(Color.textSecondary) + .frame(width: 3, height: 3) + + HStack(spacing: 4) { + if let url = tokenImageURL { + RemoteImage(url: url) + .frame(width: 16, height: 16) + .clipShape(Circle()) + } + Text(tokenName) + } + } + } + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + .padding(.top, 12) + .frame(maxWidth: .infinity) + } + + /// The entry's amount in the viewer's own currency, with what actually moved + /// underneath when the two differ — the same reading the row shows, at the + /// size the header gives it. + private var amount: some View { + let amounts = details.amount.forViewer( + preferredRate: ratesController.rateForBalanceCurrency(), + rates: ratesController.cachedRates + ) + + return VStack(spacing: 4) { + HStack(spacing: 8) { + Flag(style: amounts.viewer.currency.flagStyle, size: .small) + Text(amounts.viewer.formatted(signPrefix: details.signPrefix)) + .font(.appDisplaySmall) + .foregroundStyle(Color.textMain) + } + + if let transferred = amounts.transferred { + HStack(spacing: 4) { + Flag(style: transferred.currency.flagStyle, size: .small) + Text(transferred.formatted(signPrefix: details.signPrefix)) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + } + } + + // MARK: - Receipt + + /// The receipt rows, in the order Figma node 9708:117417 lists them. A value + /// the entry doesn't carry leaves its row out rather than rendering an empty + /// one — a conversion is the only kind with a fee and a received amount. + private var receiptCard: some View { + DetailsCard { + ReceiptRow(label: "Currency", value: details.currency.rawValue.uppercased()) + ReceiptRow(label: "Exchange Rate", value: Self.rateFormatter.string(for: details.exchangeRate) ?? "") + ReceiptRow(label: "Date", value: details.date.formatted(Self.dateFormat)) + ReceiptRow(label: "Tokens", value: details.tokenAmount.formattedQuantity()) + + if let fee = details.fee { + ReceiptRow(label: "Fee", value: fee.formatted()) + } + if let received = details.received { + ReceiptRow(label: "Received", value: received.formatted()) + } + + ReceiptRow(label: "Status", value: details.status.label) + } + } + + /// The entry's id, in its own card so the copy control has an obvious target. + /// The id is long and meaningless to read, so it middle-truncates — both ends + /// stay legible, which is what someone comparing it against a support ticket + /// actually reads. + private var idCard: some View { + Button(action: copyID) { + DetailsCard { + HStack(spacing: 8) { + Text("ID") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + + Text(details.id) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .trailing) + + Group { + if didCopyID { + Image.system(.circleCheck) + .renderingMode(.template) + } else { + Image.asset(.squareBehindSquare) + .renderingMode(.template) + } + } + .frame(width: 16, height: 16) + .foregroundStyle(Color.textSecondary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Copy transaction ID") + } + + // MARK: - Token + + /// The mint's metadata fallback for the entry's own token — a conversion's + /// header names the leg that was given up, which is the mint the entry + /// carries. + private var entryMintFallback: StoredMintMetadata? { + activity.swapMetadata == nil ? resolution.entryMint : resolution.swapFromMint + } + + private var tokenName: String? { + resolution.name(for: activity.exchangedFiat.mint, fallback: entryMintFallback, session: session) + } + + private var tokenImageURL: URL? { + resolution.imageURL(for: activity.exchangedFiat.mint, fallback: entryMintFallback, session: session) + } + + private var fromTokenName: String? { + guard let swap = activity.swapMetadata else { return nil } + return resolution.name(for: swap.fromMint, fallback: resolution.swapFromMint, session: session) + } + + private var toTokenName: String? { + guard let swap = activity.swapMetadata else { return nil } + return resolution.name(for: swap.toMint, fallback: resolution.swapToMint, session: session) + } + + // MARK: - Formatting + + /// The settled rate, at the precision a rate is quoted in — enough decimals + /// to reproduce the amounts above it. + private static let rateFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 6 + formatter.maximumFractionDigits = 6 + return formatter + }() + + private static let dateFormat = Date.FormatStyle(date: .numeric, time: .shortened) + + // MARK: - Actions + + private func copyID() { + UIPasteboard.general.string = details.id + withAnimation(.easeInOut(duration: 0.15)) { + didCopyID = true + } + Task { + try? await Task.sleep(for: Self.copyConfirmationDuration) + withAnimation(.easeInOut(duration: 0.15)) { + didCopyID = false + } + } + } + + private func confirmCancelAction() { + guard let metadata = activity.cancellableCashLinkMetadata else { return } + + dialogItem = .alert( + title: "Cancel \(activity.exchangedFiat.nativeAmount.formatted()) Transfer?", + subtitle: "The money will be returned to your wallet." + ) { + .destructive("Cancel Transfer") { + cancelCashLink(metadata: metadata) + }; + .cancel() + } + } + + private func cancelCashLink(metadata: Activity.CashLinkMetadata) { + Task { + do { + try await session.cancelCashLink(giftCardVault: metadata.vault) + // The entry this screen is drawn from is now spent; the list it + // was opened from reloads on the DB change behind us. + dismiss() + } catch { + ErrorReporting.captureError(error, reason: "Failed to cancel cash link", metadata: [ + "vault": metadata.vault.base58, + ], userFacing: true) + dialogItem = .error( + title: "Failed to Cancel Transfer", + subtitle: "Something went wrong. Please try again later" + ) + } + } + } +} + +// MARK: - Components - + +/// One receipt line: what it is on the left, what it was on the right. +private struct ReceiptRow: View { + + let label: String + let value: String + + var body: some View { + LabeledContent { + Text(value) + .foregroundStyle(Color.textMain) + .lineLimit(1) + .truncationMode(.middle) + } label: { + Text(label) + .foregroundStyle(Color.textSecondary) + } + .font(.appTextSmall) + } +} + +/// The panel the receipt and the id sit in (Figma node 9708:117417) — a tint over +/// the background and a small radius, no outline: the cards are the only things on +/// the background, so a border would draw a boundary the fill already draws. +private struct DetailsCard: View { + + @ViewBuilder let content: Content + + var body: some View { + VStack(spacing: 12) { + content + } + .padding(12) + .frame(maxWidth: .infinity) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) + } +} diff --git a/Flipcash/Core/Screens/Main/TransactionHistoryScreen.swift b/Flipcash/Core/Screens/Main/TransactionHistoryScreen.swift index caecf2a97..3b4dd5512 100644 --- a/Flipcash/Core/Screens/Main/TransactionHistoryScreen.swift +++ b/Flipcash/Core/Screens/Main/TransactionHistoryScreen.swift @@ -9,13 +9,13 @@ import SwiftUI import FlipcashUI import FlipcashCore +/// One token's activity, newest first. Tapping a row opens +/// ``TransactionDetailsScreen``, which is also where a pending cash link is +/// cancelled. struct TransactionHistoryScreen: View { - @Environment(Session.self) private var session @Environment(HistoryController.self) private var historyController - @State private var dialogItem: DialogItem? - private let mint: PublicKey // MARK: - Init - @@ -33,14 +33,9 @@ struct TransactionHistoryScreen: View { switch historyController.loadingState { case .loaded(let activities): ForEach(activities) { activity in - Button { - rowAction(activity: activity) - } label: { - ActivityRow(activity: activity) - .padding(.horizontal, 20) - } - .buttonStyle(.plain) - .listRowBackground(Color.clear) + ActivityRow(activity: activity) + .padding(.horizontal, 20) + .listRowBackground(Color.clear) } case .loading: ProgressView() @@ -57,48 +52,8 @@ struct TransactionHistoryScreen: View { .scrollContentBackground(.hidden) .navigationTitle("Activity") } - .dialog(item: $dialogItem) .task(id: mint) { await historyController.setActiveMint(mint) } } - - // MARK: - Action - - - private func rowAction(activity: Activity) { - if let cashLinkMetadata = activity.cancellableCashLinkMetadata { - cancelCashLinkAction( - activity: activity, - metadata: cashLinkMetadata - ) - } - } - - private func cancelCashLinkAction(activity: Activity, metadata: Activity.CashLinkMetadata) { - dialogItem = .alert( - title: "Cancel \(activity.exchangedFiat.nativeAmount.formatted()) Transfer?", - subtitle: "The money will be returned to your wallet." - ) { - .destructive("Cancel Transfer") { - cancelCashLink(metadata: metadata) - }; - .cancel() - } - } - - private func cancelCashLink(metadata: Activity.CashLinkMetadata) { - Task { - do { - try await session.cancelCashLink(giftCardVault: metadata.vault) - } catch { - ErrorReporting.captureError(error, reason: "Failed to cancel cash link", metadata: [ - "vault": metadata.vault.base58, - ], userFacing: true) - dialogItem = .error( - title: "Failed to Cancel Transfer", - subtitle: "Something went wrong. Please try again later" - ) - } - } - } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TokenAmount.swift b/FlipcashCore/Sources/FlipcashCore/Models/TokenAmount.swift index 17627ba23..430a957de 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TokenAmount.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TokenAmount.swift @@ -63,6 +63,17 @@ extension TokenAmount: Comparable { } } +// MARK: - Formatting - + +extension TokenAmount { + + /// The on-chain quantity as a person reads it: grouped, and carrying at most + /// the mint's own decimals with trailing zeros dropped. + public func formattedQuantity() -> String { + decimalValue.formatted(.number.precision(.fractionLength(0...decimals))) + } +} + // MARK: - Description - extension TokenAmount: CustomStringConvertible, CustomDebugStringConvertible { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift new file mode 100644 index 000000000..4533ee991 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift @@ -0,0 +1,326 @@ +// +// TransactionDetails.swift +// FlipcashCore +// + +import Foundation + +/// What an activity entry *was*, as the details screen states it (Figma node +/// 9708:118186). +/// +/// The feed's own row title is server-authored prose ("Tipped", "Purchased", +/// "{0} sent you"): it reads fine inline but can't carry a screen. The details +/// screen states the kind in the user's own voice instead, derived from the +/// entry's kind and counterparty — the only structured signal the client gets. +/// +/// The three cash kinds are distinct because the feed distinguishes them and a +/// user would too. ``gaveCash`` and ``receivedCash`` are a hand-to-hand +/// exchange — a send or a receive carrying neither a user id nor a phone number, +/// so there is nobody to name. ``sentCashLink`` is money sitting in a gift-card +/// vault until somebody opens the link, which is why it is the only kind that +/// can still be cancelled. There is no received-cash-link counterpart, because +/// collecting one is indistinguishable in the feed from taking a bill in +/// person — both arrive as an unattributed receive. +public enum TransactionKind: String, Sendable, Equatable, Hashable, CaseIterable { + + case tipped + case received + case sent + case gaveCash + case receivedCash + case sentCashLink + case buy + case sell + case withdraw + case deposit + case convert + case poolPayment + case unknown + + /// How the screen titles the entry when there is no counterparty to name it with. + public var heading: String { + switch self { + case .tipped: "You tipped" + case .received: "You received" + case .sent: "You sent" + case .gaveCash: "You gave cash" + case .receivedCash: "You received cash" + case .sentCashLink: "You sent a cash link" + case .buy: "Buy" + case .sell: "Sell" + case .withdraw: "Withdraw" + case .deposit: "Deposit" + case .convert: "Convert" + case .poolPayment: "Pool payment" + case .unknown: "Transaction" + } + } +} + +/// An entry's settlement state, as the details screen's Status row phrases it. +public enum TransactionStatus: String, Sendable, Equatable, Hashable, CaseIterable { + + case pending + case completed + case failed + case unknown + + public var label: String { + switch self { + case .pending: "Pending" + case .completed: "Completed" + case .failed: "Failed" + case .unknown: "Unknown" + } + } +} + +/// Everything the transaction details screen draws, resolved from the activity +/// the user tapped (Figma node 9708:105260). +/// +/// Reads the same entry the activity row reads, through the same helpers +/// (``Activity/Kind/signPrefix``, ``Activity/isTipVerb(_:)``, +/// ``conversionTitle(from:to:)``), so a row and the screen it opens can never +/// disagree about what the entry was. What it adds is everything a row has no +/// space for: the kind stated in the user's own voice, the receipt values, and +/// the actions. +/// +/// Pure and synchronous: the counterparty's name and the two token names arrive +/// already resolved, so an unresolved one simply fills in when it lands. +public struct TransactionDetails: Sendable, Equatable, Hashable { + + /// The entry's id, base58-encoded — what the copy control puts on the + /// clipboard, and the value someone pastes into a support ticket. + public let id: String + + public let kind: TransactionKind + + /// What the screen is titled when the entry has something better to say than + /// its ``kind`` — the person-to-person case: a tip, a send and a receive are + /// all headed by the counterparty's display name, and the +/- on the amount + /// is what states the direction. `nil` falls back to the kind's own heading, + /// which is also what an unresolved counterparty gets: "You tipped" beats a + /// blank line. + public let heading: String? + + /// The other side of the movement, under the heading — "In Person" for cash + /// handed over, "Dollars → Jeffy" for a conversion. `nil` wherever the header + /// already says everything: a person entry, whose name is the ``heading``, + /// and a cash link, whose own heading names it. + public let subtitle: String? + + /// The direction marker the amount carries, matching the activity row's. + public let signPrefix: String? + + /// The entry's amount, as the feed settled it. + public let amount: ExchangedFiat + + public let date: Date + public let status: TransactionStatus + + /// What the movement cost. Conversions only. + public let fee: FiatAmount? + + /// A conversion's destination amount, `nil` while the swap is still pending — + /// which is exactly when the receipt row is left out rather than shown as zero. + public let received: FiatAmount? + + /// Whether the movement can still be pulled back — an open cash link, and + /// nothing else. + public let canCancel: Bool + + /// Whether the counterparty's conversation can be opened from here. + public let canViewInChat: Bool + + /// The line the header actually renders. + public var title: String { heading ?? kind.heading } + + /// The currency the entry was denominated in — the receipt's Currency row. + public var currency: CurrencyCode { amount.nativeAmount.currency } + + /// The rate the entry settled at — the receipt's Exchange Rate row. + public var exchangeRate: Decimal { amount.currencyRate.fx } + + /// What moved on-chain — the receipt's Tokens row. Unlike Android, this is + /// the quantity the feed recorded rather than one re-estimated from the + /// mint's current supply, because the iOS feed carries `onChainAmount`. + public var tokenAmount: TokenAmount { amount.onChainAmount } +} + +// MARK: - Mapping - + +extension TransactionDetails { + + /// Resolves an activity into the details screen's state. + /// + /// - Parameters: + /// - counterpartyName: the other party's display name, once it resolves. + /// - fromTokenName: the entry's mint name, used by a conversion's subtitle. + /// - toTokenName: a conversion's destination mint name. + public init( + activity: Activity, + counterpartyName: String? = nil, + fromTokenName: String? = nil, + toTokenName: String? = nil, + ) { + let kind = TransactionKind(activity: activity) + let swap = activity.swapMetadata + + self.id = activity.id.base58 + self.kind = kind + self.heading = counterpartyName?.trimmingCharacters(in: .whitespaces).nilIfEmpty + self.subtitle = Self.subtitle(kind: kind, fromTokenName: fromTokenName, toTokenName: toTokenName) + self.signPrefix = activity.kind.signPrefix + self.amount = activity.exchangedFiat + self.date = activity.date + self.status = TransactionStatus(activity: activity) + self.fee = swap?.fee + self.received = swap?.toFiat + self.canCancel = activity.cancellableCashLinkMetadata != nil + // Opening the conversation needs somebody to open it with, and only a + // user id identifies one — a phone-number counterparty has no chat. The + // display name is not required: the chat screen derives its own header + // from the id (see `AppRouter.Destination.tipConversationForUser`). + self.canViewInChat = activity.counterparty?.userID != nil + } + + /// The other side of the movement, for the kinds whose heading doesn't + /// already carry it. `nil` wherever the header already says everything, and + /// wherever the feed can't answer it: a buy or a sell records only the mint + /// that moved, and a withdrawal or deposit's other side is an address the + /// feed doesn't carry. + private static func subtitle(kind: TransactionKind, fromTokenName: String?, toTokenName: String?) -> String? { + switch kind { + case .gaveCash, .receivedCash: + return "In Person" + case .convert: + return conversionTitle(from: fromTokenName, to: toTokenName) + case .tipped, .received, .sent, .sentCashLink, .buy, .sell, + .withdraw, .deposit, .poolPayment, .unknown: + return nil + } + } + + /// The "" pair a conversion is named by, or `nil` when either + /// leg's token name is still unresolved — a half-empty pair says less than + /// the server-rendered title it would replace. + /// + /// Shared with the activity row's own conversion title so the row and the + /// screen it opens read alike. + public static func conversionTitle(from: String?, to: String?) -> String? { + guard + let from = from?.nilIfEmpty, + let to = to?.nilIfEmpty + else { return nil } + return "\(from) \u{2192} \(to)" + } +} + +// MARK: - Kind - + +extension TransactionKind { + + /// What the entry was, from the kind and counterparty the feed carries. + /// + /// The two hand-to-hand kinds are the send/receive pair with no counterparty + /// at all — a bill hand-off never exchanges identities, so there is genuinely + /// nobody to name. The server's verb separates a tip from a plain send, which + /// the kind alone doesn't; see ``Activity/isTipVerb(_:)``. + init(activity: Activity) { + switch activity.kind { + case .gave: + if activity.counterparty == nil { + self = .gaveCash + } else { + self = Activity.isTipVerb(activity.title) ? .tipped : .sent + } + case .received: + self = activity.counterparty == nil ? .receivedCash : .received + case .cashLink: self = .sentCashLink + case .bought: self = .buy + case .sold: self = .sell + case .withdrew: self = .withdraw + case .deposited: self = .deposit + case .swapped: self = .convert + case .paid: self = .poolPayment + // Neither `paid` nor `distributed` is produced by the current activity + // contract; both are legacy kinds that survive in old local rows. + // `paid` has a heading of its own above; a distribution has none to + // borrow, so it falls back rather than claiming a movement it isn't. + case .distributed, .unknown: + self = .unknown + } + } +} + +// MARK: - Status - + +extension TransactionStatus { + + /// The settlement state the Status row reads out. + /// + /// A conversion is settled by its swap, not by the notification: the entry + /// itself completes as soon as the source side is debited, so a failed swap + /// would otherwise read "Completed". + init(activity: Activity) { + if let swap = activity.swapMetadata { + switch swap.state { + case .failed: + self = .failed + return + case .pending: + self = .pending + return + case .unknown, .succeeded, .none: + break + } + } + + switch activity.state { + case .pending: self = .pending + case .completed: self = .completed + case .unknown: self = .unknown + } + } +} + +// MARK: - Shared Readings - + +extension Activity { + + /// Whether a server-rendered activity title uses the tip verb. + /// + /// The backend picks the verb from the payment's location, so a tip-card tip + /// arrives titled "Tipped" and an in-chat send "Sent"; `activity/v1` models + /// both as plain sent crypto with no structured tip flag, so there is no + /// other signal to read. Matching the verb is safe while the feed's titles + /// are English-only. + public static func isTipVerb(_ title: String) -> Bool { + title + .trimmingCharacters(in: .whitespaces) + .lowercased() + .hasPrefix("tip") + } +} + +extension Activity.Kind { + + /// The sign an amount of this kind carries, so a debit reads as one wherever + /// it is shown, or `nil` for a kind that renders unsigned. + public var signPrefix: String? { + switch self { + case .received, .deposited, .bought, .distributed, .sold: + return "+" + case .gave, .withdrew, .cashLink, .paid: + 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 nil + } + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift new file mode 100644 index 000000000..79ae3e70a --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift @@ -0,0 +1,261 @@ +// +// TransactionDetailsTests.swift +// FlipcashCore +// + +import Foundation +import Testing +@testable import FlipcashCore + +/// The details mapper's readings of an activity — which kind, which status, which +/// actions. +/// +/// Mirrors Android's `TransactionDetailsMapperTest`: the row and the details +/// screen read the same entry through the same helpers, and the failure worth +/// catching is the two disagreeing about what the entry was. +@Suite("Transaction details mapping") +struct TransactionDetailsTests { + + private let counterparty = Activity.Counterparty.user(UUID()) + private let vault = try! PublicKey(base58: "11111111111111111111111111111111") + + private func activity( + kind: Activity.Kind, + title: String = "Sent", + state: Activity.State = .completed, + metadata: Activity.Metadata? = nil, + counterparty: Activity.Counterparty? = nil, + amount: ExchangedFiat? = nil, + ) -> Activity { + Activity( + id: .jeffy, + state: state, + kind: kind, + title: title, + exchangedFiat: amount ?? Self.usd(20), + date: Date(timeIntervalSince1970: 1_700_000_000), + metadata: metadata, + counterparty: counterparty, + ) + } + + private static func usd(_ value: Decimal, mint: PublicKey = .usdf) -> ExchangedFiat { + ExchangedFiat( + onChainAmount: TokenAmount(wholeTokens: value, mint: mint), + nativeAmount: .usd(value), + currencyRate: .oneToOne, + ) + } + + private func swapMetadata( + toFiat: FiatAmount? = .usd(19), + fee: FiatAmount = .usd(1), + state: Activity.SwapMetadata.State = .succeeded, + ) -> Activity.Metadata { + .swap( + Activity.SwapMetadata( + fromMint: .usdf, + fromQuarks: 20_000_000, + fromFiat: .usd(20), + toMint: .jeffy, + toQuarks: toFiat == nil ? nil : 19_000_000, + toFiat: toFiat, + fee: fee, + state: state, + ) + ) + } + + // MARK: - Kind + + @Test("The verb separates a tip from a plain send") + func verbSeparatesTipFromSend() { + let tipped = TransactionDetails(activity: activity(kind: .gave, title: "Tipped", counterparty: counterparty)) + let sent = TransactionDetails(activity: activity(kind: .gave, title: "Sent", counterparty: counterparty)) + + #expect(tipped.kind == .tipped) + #expect(sent.kind == .sent) + } + + @Test("A send with nobody named is a bill handed over, not a send") + func sendWithNoCounterpartyIsCash() { + let details = TransactionDetails(activity: activity(kind: .gave)) + + #expect(details.kind == .gaveCash) + // Nobody to head the screen with, so the kind's own heading stands. + #expect(details.heading == nil) + #expect(details.title == "You gave cash") + #expect(details.subtitle == "In Person") + } + + @Test("A receive with nobody named is cash taken in person") + func receiveWithNoCounterpartyIsCash() { + let details = TransactionDetails(activity: activity(kind: .received, title: "Received")) + + #expect(details.kind == .receivedCash) + #expect(details.subtitle == "In Person") + } + + @Test("A resolved counterparty heads the screen") + func resolvedCounterpartyHeadsTheScreen() { + let details = TransactionDetails( + activity: activity(kind: .received, title: "Received", counterparty: counterparty), + counterpartyName: "Sally The Streamer", + ) + + #expect(details.kind == .received) + #expect(details.heading == "Sally The Streamer") + #expect(details.title == "Sally The Streamer") + #expect(details.subtitle == nil) + #expect(details.signPrefix == "+") + } + + @Test("An unresolved name leaves the heading to the kind, but still opens the chat") + func unresolvedNameFallsBackToTheKind() { + // Unlike Android, the chat action rides on the counterparty's id rather + // than a resolved profile: the conversation screen derives its own header + // from the id, so a name that hasn't landed yet doesn't withhold it. + let details = TransactionDetails(activity: activity(kind: .received, title: "Received", counterparty: counterparty)) + + #expect(details.heading == nil) + #expect(details.title == "You received") + #expect(details.canViewInChat) + } + + @Test("A phone-number counterparty has no conversation to open") + func phoneCounterpartyHasNoChat() { + let details = TransactionDetails(activity: activity(kind: .gave, counterparty: .phone("+15551234567"))) + + #expect(details.kind == .sent) + #expect(details.canViewInChat == false) + } + + // MARK: - Actions + + @Test("Only an open cash link can be cancelled") + func onlyAnOpenCashLinkCanBeCancelled() { + func cashLink(canCancel: Bool, state: Activity.State) -> TransactionDetails { + TransactionDetails( + activity: activity( + kind: .cashLink, + state: state, + metadata: .cashLink(Activity.CashLinkMetadata(vault: vault, canCancel: canCancel)), + ) + ) + } + + let open = cashLink(canCancel: true, state: .pending) + + #expect(open.kind == .sentCashLink) + #expect(open.canCancel) + #expect(cashLink(canCancel: false, state: .pending).canCancel == false) + // Claimed: the link is spent, whatever the cancel flag still says. + #expect(cashLink(canCancel: true, state: .completed).canCancel == false) + #expect(TransactionDetails(activity: activity(kind: .gave, counterparty: counterparty)).canCancel == false) + } + + // MARK: - Conversions + + @Test("A convert names both mints and draws both sides") + func convertNamesBothMints() { + let details = TransactionDetails( + activity: activity(kind: .swapped, title: "Converted", metadata: swapMetadata()), + fromTokenName: "Dollars", + toTokenName: "Jeffy", + ) + + #expect(details.kind == .convert) + #expect(details.subtitle == "Dollars → Jeffy") + #expect(details.fee == .usd(1)) + #expect(details.received == .usd(19)) + #expect(details.status == .completed) + // Unlike Android, a conversion renders unsigned: the row it opens from + // shows the source leg without a sign, and the two must agree. + #expect(details.signPrefix == nil) + } + + @Test("A half-resolved conversion pair says nothing rather than half of it") + func halfResolvedConversionHasNoSubtitle() { + let details = TransactionDetails( + activity: activity(kind: .swapped, title: "Converted", metadata: swapMetadata()), + fromTokenName: "Dollars", + ) + + #expect(details.subtitle == nil) + } + + @Test("A failed swap fails the entry, whatever the notification says") + func failedSwapFailsTheEntry() { + // The entry itself completes as soon as the source side is debited. + let details = TransactionDetails( + activity: activity( + kind: .swapped, + title: "Converted", + state: .completed, + metadata: swapMetadata(toFiat: nil, fee: .usd(0), state: .failed), + ) + ) + + #expect(details.status == .failed) + #expect(details.received == nil) + } + + @Test("A pending swap is pending even once the entry has settled") + func pendingSwapIsPending() { + let details = TransactionDetails( + activity: activity( + kind: .swapped, + title: "Converted", + state: .completed, + metadata: swapMetadata(toFiat: nil, state: .pending), + ) + ) + + #expect(details.status == .pending) + } + + // MARK: - Status + + @Test("A pending entry reads as pending") + func pendingEntryReadsAsPending() { + let details = TransactionDetails(activity: activity(kind: .deposited, title: "Deposited", state: .pending)) + + #expect(details.kind == .deposit) + #expect(details.status == .pending) + #expect(details.signPrefix == "+") + } + + // MARK: - Receipt + + @Test("The copied id is the entry's base58 id") + func copiedIdIsBase58() { + let entry = activity(kind: .deposited, title: "Deposited") + + #expect(TransactionDetails(activity: entry).id == entry.id.base58) + } + + @Test("The receipt reads the amount the entry settled at") + func receiptReadsTheSettledAmount() { + let amount = ExchangedFiat( + onChainAmount: TokenAmount(quarks: 1_500_000, mint: .usdf), + nativeAmount: FiatAmount(value: 2_100, currency: .cad), + currencyRate: Rate(fx: 1.4, currency: .cad), + ) + let details = TransactionDetails(activity: activity(kind: .bought, title: "Bought", amount: amount)) + + #expect(details.currency == .cad) + #expect(details.exchangeRate == 1.4) + #expect(details.tokenAmount == amount.onChainAmount) + } + + @Test("A withdrawal has no second line to draw") + func withdrawalHasNoSubtitle() { + // The feed carries no destination address, so there is nothing to put + // under the heading until it does. + let details = TransactionDetails(activity: activity(kind: .withdrew, title: "Withdrew")) + + #expect(details.kind == .withdraw) + #expect(details.subtitle == nil) + #expect(details.signPrefix == "-") + } +} From 2e81dc94b0eee8f490cd3136083d966ffc457f44 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 10:28:09 -0400 Subject: [PATCH 2/2] fix(activity): push the chat from transaction details instead of switching tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "View in Chat" called `navigate(to:)`, which reaches the Chat tab's stack by bringing the tab forward. That swaps the tab out from under the transition: the tab bar and the conversation list are both visible for a beat before the chat pushes in over them. Push it onto the stack the details screen is already on. The chat slides in from the entry it belongs to, back returns to the entry, and nothing changes underneath. `ConversationScreen` names no stack of its own, and the pushes it makes — a profile, a currency — resolve against whichever stack is topmost, so it carries over unchanged. --- .../Screens/Main/Home/TransactionDetailsScreen.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index 0b3275ef2..986c51f83 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -62,7 +62,14 @@ struct TransactionDetailsScreen: View { if let userID = activity.counterparty?.userID, details.canViewInChat { Button("View in Chat") { - router.navigate(to: .tipConversationForUser(userID)) + // Pushed onto the stack this screen is already on, + // not routed to the Chat tab: a cross-stack jump + // swaps the tab out from under the transition, so + // the bar and the conversation list both show + // before the chat lands. Pushed, the chat arrives + // from the entry it belongs to and back returns + // here. + router.push(.tipConversationForUser(userID)) } .buttonStyle(.filled) .padding(.top, 4)