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..986c51f83 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -0,0 +1,372 @@ +// +// 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") { + // 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) + } + } + .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 == "-") + } +}