diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3fdd78afb..db86a0cdc 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1b3757f361c274f2cfab22a7a970c9c0a0dd394ec3d62435329c4458cf779ca3", + "originHash" : "1846cdd3f2b4b3cb3b2bf4598b1112117904f5b06345e8d788368cf0a0bf83ce", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash-shared-core-spm", "state" : { - "revision" : "67c3d2781883e25fdc9dad3b134aa42c3081cfa3", - "version" : "0.15.1" + "revision" : "7c48adfd001f076e5a6d9a3c0a76347d435836bb", + "version" : "0.16.0" } }, { diff --git a/CrossPlatformVectors/Package.swift b/CrossPlatformVectors/Package.swift index 4de7caf74..7441a3b29 100644 --- a/CrossPlatformVectors/Package.swift +++ b/CrossPlatformVectors/Package.swift @@ -18,7 +18,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.15.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.16.0")) }() // Cross-platform test-vector GATE (Track-B/C safety net). Asserts the ed25519 this app signs diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index c653be639..49d02f27e 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1614,7 +1614,7 @@ final class ConversationController { /// bump per feed apply costs a single window re-read for the open chat. private static let messageWriteOperations: Set = [ "upsert-messages", "apply-chat-events", "delta-batch", "load-messages", "load-older", - "send-message", "send-media-message", "reset-resync", "edit-message", "delete-message", + "send-message", "send-media-message", "send-payment-request", "reset-resync", "edit-message", "delete-message", "upsert-conversation", "replace-feed", "replace-group-feed", ] @@ -1638,11 +1638,13 @@ final class ConversationController { /// whoever wrote it. `currencyName` resolves a mint to its display name; nil — /// or the reserve, whose formatted amount already names itself — drops the /// "of …" suffix. `knownAuthors` names a group sender the chat's roster subset leaves out. - /// `viewerAmount` restates a payment in the display currency. + /// `viewerAmount` restates a payment in the display currency. `requestTitle` is a payment + /// request's title as the viewer reads it, nil while it isn't loaded or can't be opened. func lastMessagePreview( for conversation: Conversation, knownAuthors: KnownAuthorDirectory.Snapshot = .empty, viewerAmount: (ExchangedFiat) -> FiatAmount = { $0.nativeAmount }, + requestTitle: (PaymentRequestID) -> String? = { _ in nil }, currencyName: (PublicKey) -> String? ) -> String? { if isCounterpartTyping(in: conversation.id) { @@ -1706,6 +1708,14 @@ final class ConversationController { // The server sends it; no user is the sender to name. return "Shared a profile" + case .widget(.paymentRequest(let id)): + // A title that hasn't loaded or won't open drops the "for …" rather than blanking the line. + let subject = requestTitle(id).map { " for \($0)" } ?? "" + if isFromSelf { return "You sent request\(subject)" } + guard let senderName else { return nil } + let firstName = senderName.split(separator: " ").first.map(String.init) ?? senderName + return "\(firstName) sent request\(subject)" + case .widget(.unrecognized): return nil @@ -2229,6 +2239,9 @@ final class ConversationController { _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, linkPreview: pending.linkPreview, to: conversationID) case .media(_, let caption): await retryMedia(clientMessageID: clientMessageID, caption: caption, repliedTo: pending.repliedTo, in: conversationID) + case .widget(.paymentRequest(let id)): + store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) + _ = await deliverPaymentRequest(id, clientMessageID: clientMessageID, to: conversationID, attempts: 1) case .encrypted, .cash, .deleted, .widget, .system: if case .failure(let error) = Result(catching: { try pending.content.asProto() }) { logger.error("Cannot retry a send this client has no path to re-send", metadata: [ @@ -2295,6 +2308,70 @@ final class ConversationController { } } + /// Sends the widget for the payment request `id` into `conversationID` as a pending row, retrying a + /// failed send with the same request; returns whether it was delivered. A final failure leaves the + /// row failed in the transcript, where a tap retries it like any message. + @discardableResult + func sendPaymentRequest(_ id: PaymentRequestID, in conversationID: ConversationID, attempts: Int = 3) async -> Bool { + let clientMessageID = UUID() + let pending = ConversationMessage( + id: .unassigned, + senderID: selfUserID, + content: .widget(.paymentRequest(id)), + date: .now, + unreadSeq: 0, + status: .sending, + clientMessageID: clientMessageID + ) + let anchor = (try? database.newestMessageID(conversationID: conversationID)).flatMap { $0 }?.value ?? 0 + store.insertPending(pending, anchoredTo: anchor, into: conversationID) + receiptSettle.hold(clientMessageID.uuidString) + return await deliverPaymentRequest(id, clientMessageID: clientMessageID, to: conversationID, attempts: attempts) + } + + private func deliverPaymentRequest(_ id: PaymentRequestID, clientMessageID: UUID, to conversationID: ConversationID, attempts: Int) async -> Bool { + let chatType = conversation(withID: conversationID)?.type + var lastError: Error = ErrorSendMessage.unknown + for attempt in 0.. 0 { + try? await Task.sleep(for: .seconds(attempt)) + } + do { + var confirmed = try await messaging.sendPaymentRequestMessage( + owner: owner, + conversationID: conversationID, + paymentRequestID: id, + clientMessageID: clientMessageID + ) + confirmed.clientMessageID = clientMessageID + if persist(operation: "send-payment-request", { try database.upsertConversationMessages([confirmed], conversationID: conversationID) }) { + store.dropPending(clientMessageID: clientMessageID, confirmedAt: confirmed.id, in: conversationID) + } else { + scheduleGapCatchUp(conversationID) + } + store.advanceLastActivity(to: confirmed.date, in: conversationID) + if advanceReadPointer(to: confirmed.id, in: conversationID) { + confirmedOwnSendCounts[conversationID, default: 0] += 1 + } + refreshFeedPreview(for: conversationID) + persistConversation(conversationID) + Analytics.sentMessage(chatType: chatType) + return true + } catch { + lastError = error + logger.warning("Failed to send payment request message", metadata: [ + "conversationID": "\(conversationID)", + "attempt": "\(attempt)", + "error": "\(error)", + ]) + } + } + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + ErrorReporting.captureError(lastError, reason: "Failed to send payment request message") + Analytics.sentMessage(chatType: chatType, error: lastError) + return false + } + /// Sends each staged chip as its own message, in chip order, with `caption` under the last and /// `repliedTo` on the first; returns whether every one was delivered. /// @@ -2308,6 +2385,12 @@ final class ConversationController { try await messaging.photoSeal(owner: owner, conversationID: conversationID) } + /// The seal that opens `conversationID`'s payment-request text, nil while the chat's key is + /// unknown or the chat doesn't encrypt. + func openingSeal(for conversationID: ConversationID) async -> ChatSeal? { + await messaging.openingSeal(owner: owner, conversationID: conversationID) + } + /// Decrypts `conversationID`'s end-to-end encrypted photo blobs, or nil while the chat's key is /// unknown or the chat doesn't encrypt. func mediaBlobDecrypt(for conversationID: ConversationID) async -> (@Sendable (Data, BlobID, SealedBlob) throws -> Data)? { diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index a1bc59334..2183f7709 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -155,6 +155,16 @@ extension EncryptedChatClient: ConversationMessaging { } } + /// Requests exist only in private groups, which always encrypt, so a chat without a seal fails. + func sendPaymentRequestMessage(owner: KeyPair, conversationID: ConversationID, paymentRequestID id: PaymentRequestID, clientMessageID: UUID) async throws -> ConversationMessage { + try await refetchingOnRefusal(conversationID) { + guard let seal = try await sealForSending(conversationID, owner: owner) else { + throw ErrorSendMessage.encryptionRequired + } + return try await client.sendPaymentRequestMessage(owner: owner, conversationID: conversationID, paymentRequestID: id, seal: seal, clientMessageID: clientMessageID) + } + } + func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? { try await sealForSending(conversationID, owner: owner) } diff --git a/Flipcash/Core/Controllers/FlipClient+Protocols.swift b/Flipcash/Core/Controllers/FlipClient+Protocols.swift index 4e991ffc5..cffb0a183 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -124,6 +124,8 @@ protocol ConversationMessaging: AnyObject, Sendable { /// Sends the finalized `photo` as one media message, with `caption` under it; a sealed photo /// goes out inside `EncryptedContent`. func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage + /// Sends a widget naming the payment request `id`; it always goes out inside `EncryptedContent`. + func sendPaymentRequestMessage(owner: KeyPair, conversationID: ConversationID, paymentRequestID id: PaymentRequestID, clientMessageID: UUID) async throws -> ConversationMessage /// The seal a photo sent into `conversationID` is encrypted with, nil when it goes out in /// plaintext. Throws when the chat encrypts but its seal can't be built. func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? diff --git a/Flipcash/Core/Controllers/ReactionAlertController.swift b/Flipcash/Core/Controllers/ReactionAlertController.swift index aa4d220d0..387b1ede7 100644 --- a/Flipcash/Core/Controllers/ReactionAlertController.swift +++ b/Flipcash/Core/Controllers/ReactionAlertController.swift @@ -300,6 +300,7 @@ final class ReactionAlertController { case .media(_, let caption): ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)) case .deleted: ChatQuote.deletedSnippet case .widget(.shareProfile): ChatQuote.sharedProfileSnippet + case .widget(.paymentRequest): ChatQuote.paymentRequestSnippet case .encrypted, .widget(.unrecognized), .system: ChatQuote.unavailableSnippet } } diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index 8bf12799a..55dd9410b 100644 --- a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -27,27 +27,39 @@ private struct AppRouterNestedSheetModifier: ViewModifier { @Environment(\.nestedSheetDepth) private var depth func body(content: Content) -> some View { + let myDepth = depth + 1 + return content + .background( + Color.clear.sheet(item: binding(at: myDepth, fullScreen: false), onDismiss: router.sheetDidFinishDismissing) { nested in + NestedSheetRootView(sheet: nested) + .environment(\.nestedSheetDepth, myDepth) + } + ) + .background( + Color.clear.fullScreenCover(item: binding(at: myDepth, fullScreen: true), onDismiss: router.sheetDidFinishDismissing) { nested in + NestedSheetRootView(sheet: nested) + .environment(\.nestedSheetDepth, myDepth) + } + ) + } + + /// The presentation at `depth`, when it is the kind this host shows. + private func binding(at depth: Int, fullScreen: Bool) -> Binding { // SwiftUI also calls the setter with nil after a programmatic dismiss // completes; without the in-bounds guard that re-entry would pop the // parent sheet too. - let myDepth = depth + 1 - let binding = Binding( + Binding( get: { - guard router.presentedSheets.indices.contains(myDepth) else { return nil } - return router.presentedSheets[myDepth] + guard router.presentedSheets.indices.contains(depth) else { return nil } + let sheet = router.presentedSheets[depth] + return sheet.coversFullScreen == fullScreen ? sheet : nil }, set: { newValue in guard newValue == nil else { return } - guard router.presentedSheets.indices.contains(myDepth) else { return } + guard router.presentedSheets.indices.contains(depth) else { return } router.dismissSheet() } ) - return content.background( - Color.clear.sheet(item: binding, onDismiss: router.sheetDidFinishDismissing) { nested in - NestedSheetRootView(sheet: nested) - .environment(\.nestedSheetDepth, myDepth) - } - ) } } diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index 74c683255..814ea7610 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -31,6 +31,17 @@ extension AppRouter { var id: Self { self } + /// Whether this presentation covers the screen and closes only from its close button, as the + /// share screen does. + var coversFullScreen: Bool { + switch self { + case .sendAmount(.privateGroup): + true + case .give, .buy, .addMoney, .downloadApp, .sendAmount, .tips, .switchAccount: + false + } + } + /// The stack hosted inside this sheet. Inverse of `Stack.sheet`. /// Used by `dismissSheet` to clear the dismissed stack's path so a /// re-presentation starts at root rather than restoring the stale leaf. diff --git a/Flipcash/Core/Navigation/RootSheetHost.swift b/Flipcash/Core/Navigation/RootSheetHost.swift index 36e86807c..241805b2d 100644 --- a/Flipcash/Core/Navigation/RootSheetHost.swift +++ b/Flipcash/Core/Navigation/RootSheetHost.swift @@ -83,6 +83,18 @@ struct RootSheetHostModifier: ViewModifier { session.transactionNotificationPrompt.pending && !router.isSheetDismissing } + /// The root sheet when it presents the way `fullScreen` asks for, else nil; writing nil dismisses it. + private func presentedRootSheet(fullScreen: Bool) -> Binding { + Binding( + get: { router.rootSheet.flatMap { $0.coversFullScreen == fullScreen ? $0 : nil } }, + set: { newValue in + if newValue == nil { + router.dismissSheet() + } + } + ) + } + func body(content: Content) -> some View { content // Resume a tip held for profile creation the moment the profile @@ -108,14 +120,11 @@ struct RootSheetHostModifier: ViewModifier { // write through here. Bound to `rootSheet` (bottom of the sheet // stack) — nested sheets mount inside this root sheet's content // via `.appRouterNestedSheet`. - .sheet(item: Binding( - get: { router.rootSheet }, - set: { newValue in - if newValue == nil { - router.dismissSheet() - } - } - ), onDismiss: router.sheetDidFinishDismissing) { sheet in + .sheet(item: presentedRootSheet(fullScreen: false), onDismiss: router.sheetDidFinishDismissing) { sheet in + RoutedSheet(sheet: sheet) + .appRouterNestedSheet() + } + .fullScreenCover(item: presentedRootSheet(fullScreen: true), onDismiss: router.sheetDidFinishDismissing) { sheet in RoutedSheet(sheet: sheet) .appRouterNestedSheet() } diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 11b3446cd..7c28c65c9 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -108,6 +108,9 @@ extension ChatItem { /// defaults to no card. Takes the detected links rather than the text so the detector runs /// once per message, here. linkCard: ([DetectedLink]) -> LinkCard? = { _ in nil }, + /// How a payment-request widget draws; resolved by the caller from the request store so + /// the mapper stays pure. Defaults to the loading card. + paymentRequest: (ConversationMessage) -> PaymentRequestCardState = { _ in .loading }, /// Where the viewer's unread messages began at open; the divider heads the first message /// after it that someone else sent. unreadBoundary: UnreadBoundary = .none, @@ -310,6 +313,12 @@ extension ChatItem { ) case .widget(.shareProfile(let share)): content = .shareProfile(LinkCard.User(profileOf: share.username)) + case .widget(.paymentRequest(let id)): + switch paymentRequest(message) { + case .loading: content = .paymentRequest(ChatPaymentRequestContent(requestID: id.data, card: nil)) + case .card(let card): content = .paymentRequest(ChatPaymentRequestContent(requestID: id.data, card: card)) + case .unsupported: content = .unavailable(.updateApp) + } case .widget(.unrecognized): // A widget variant this build doesn't draw; a newer one will. content = .unavailable(.updateApp) @@ -504,6 +513,14 @@ extension ChatItem { kind: .text, authorID: original.senderID ) + case .widget(.paymentRequest): + return ChatQuote( + stableID: original.stableID, + authorName: authorName, + snippet: ChatQuote.paymentRequestSnippet, + kind: .text, + authorID: original.senderID + ) case .widget(.unrecognized): return ChatQuote( stableID: nil, diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index a4472069f..05f9433d9 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -27,6 +27,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Fired when the user taps a cash card; the argument is the message's stable id. The owner opens /// that token's currency info. let onCashCardTap: (String) -> Void + /// Fired with a payment request's id bytes when its card is tapped. The owner opens Request details. + var onPaymentRequestTap: (Data) -> Void = { _ in } /// Fired when the user taps a URL in a message. The owner routes it through the deep-link handler, /// falling back to the system browser. let onOpenURL: (URL) -> Void @@ -140,8 +142,12 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { var mediaBlobDecrypt: () async -> ChatMediaURLResolver.BlobDecrypt? = { nil } /// Fired when the user taps a photo the viewer may see. The owner opens the full-screen viewer. var onMediaTap: (ChatMediaViewerRequest) -> Void = { _ in } - /// Room the transcript reserves under the navigation bar for the join-requests anchor. + /// Room the transcript reserves under the navigation bar for the anchors above it. var transcriptTopInset: CGFloat = 0 + /// Fired as the reader scrolls, with how far in points they sit above the newest messages. + var onDistanceFromNewestChange: (CGFloat) -> Void = { _ in } + /// A band above the newest messages a released scroll settles out of, to either edge. + var snapDistanceFromNewest: CGFloat? = nil /// The latest request to land on a message, from a message link; each new token scrolls once. var messageJump: MessageJump? = nil @@ -169,9 +175,12 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.barRestingDrop = BarMetrics.compactDrop screen.authorAvatars = authorAvatars screen.transcriptTopInset = transcriptTopInset + screen.onDistanceFromNewestChange = onDistanceFromNewestChange + screen.snapDistanceFromNewest = snapDistanceFromNewest screen.onReachTop = onReachTop screen.onRetry = onRetry screen.onCashCardTap = onCashCardTap + screen.onPaymentRequestTap = onPaymentRequestTap screen.onOpenURL = onOpenURL screen.onMentionTap = onMentionTap screen.onShareProfile = onShareProfile @@ -232,10 +241,13 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.showsGatePlaceholder = showsGatePlaceholder screen.barRestingDrop = BarMetrics.compactDrop screen.transcriptTopInset = transcriptTopInset + screen.onDistanceFromNewestChange = onDistanceFromNewestChange + screen.snapDistanceFromNewest = snapDistanceFromNewest screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry screen.onCashCardTap = onCashCardTap + screen.onPaymentRequestTap = onPaymentRequestTap screen.onOpenURL = onOpenURL screen.onMentionTap = onMentionTap screen.onShareProfile = onShareProfile diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index 0fe289ae2..509ac72d5 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -61,6 +61,11 @@ final class ConversationLoadCoordinator { /// on every observation tick — see ``scheduleWindowExpiry(for:)``. @ObservationIgnored private var capabilityClock: Date = .now @ObservationIgnored private var expiryTask: Task? + /// Where request widgets read their cards; nil draws them loading. + @ObservationIgnored private let paymentRequests: PaymentRequests? + /// When this transcript opened; paid-share messages after it refetch their request once. + @ObservationIgnored private let openedAt: Date = .now + @ObservationIgnored private var refetchedPayments: Set = [] /// Fire a beat after the deadline, not on it. The window boundary is inclusive, so a timer that /// landed exactly on `date + window` would still resolve the capability as granted and then @@ -81,8 +86,10 @@ final class ConversationLoadCoordinator { controller: ConversationController, session: Session, knownAuthors: KnownAuthorDirectory, + paymentRequests: PaymentRequests? = nil, openingLoadLanded: Bool = false ) { + self.paymentRequests = paymentRequests self.conversationID = conversationID self.controller = controller self.session = session @@ -251,6 +258,23 @@ final class ConversationLoadCoordinator { quotedMessages[repliedTo.value] = persisted } } + // Read inside the observation scope, so a fetched or refetched request re-maps its row. + var paymentRequestCards: [UInt64: PaymentRequestCardState] = [:] + if let paymentRequests { + let copy = PaymentRequestCopy(session.displayCurrency) + for message in window { + if let id = message.paymentRequestID, message.date > openedAt, + refetchedPayments.insert(message.id.value).inserted { + Task { _ = try? await paymentRequests.refresh(id) } + } + guard case .widget(.paymentRequest(let id)) = message.content else { continue } + let state = paymentRequests.cardState(for: id, in: conversationID, sentBy: message.senderID, copy: copy, now: capabilityClock) + paymentRequestCards[message.id.value] = state + if state == .loading, paymentRequests.request(id) == nil { + Task { await paymentRequests.fetchIfMissing(id) } + } + } + } return Inputs( messages: window, selfUserID: controller.selfUserID, @@ -278,7 +302,8 @@ final class ConversationLoadCoordinator { // takes a dependency on the directory and never re-maps when it reloads. knownAuthors: namesAuthors ? knownAuthors.snapshot : .empty, headsHistory: headsHistory, - unreadBoundary: unreadBoundary(in: window) + unreadBoundary: unreadBoundary(in: window), + paymentRequestCards: paymentRequestCards ) } @@ -343,6 +368,7 @@ final class ConversationLoadCoordinator { // card is the link's identity, and the card view looks it up for itself. So nothing // here touches the network, and an answer landing cannot re-diff this window. linkCard: { links in classifier.firstCard(in: links) }, + paymentRequest: { inputs.paymentRequestCards[$0.id.value] ?? .loading }, unreadBoundary: inputs.unreadBoundary, headsHistory: inputs.headsHistory ) @@ -454,6 +480,8 @@ final class ConversationLoadCoordinator { var headsHistory: Bool /// Where the divider goes; `.none` draws none. var unreadBoundary: UnreadBoundary + /// Each request widget's card in the window, keyed by raw message id. + var paymentRequestCards: [UInt64: PaymentRequestCardState] = [:] struct Branding: Equatable, Sendable { var token: String diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index aa5e4e22f..1a4dc4d42 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -89,6 +89,24 @@ struct ConversationScreen: View { @State private var paidReactionTarget: ReactionBoostTarget? /// The group send whose card was tapped, shown in the details sheet. @State private var groupSendDetails: GroupSendDetails? + /// The payment request whose details sheet is open. + @State private var paymentRequestDetails: PaymentRequestSelection? + @State private var paymentRequestDeck = PaymentRequestDeckState() + @State private var deckWindow = PaymentRequestDeckWindow() + @State private var requestPillPresence = PaymentRequestPillPresence() + /// Paying a share from the owed strip's Pay button. + @State private var requestPayFlow = PaymentRequestPayFlow() + /// How far the reader's scroll away from the newest messages has shrunk the owed strip into + /// the app-bar pill. + @State private var stripCollapse = PaymentRequestStripCollapse() + /// The owed strip's measured height, reserved at the transcript's top; 0 while hidden. + @State private var stripHeight: CGFloat = 0 + /// Whether a private group's Money sheet (Send or Request) is open. + @State private var isShowingMoneyActions = false + /// The Money sheet's choice, run once that sheet has gone so the next one can present. + @State private var pendingMoneyAction: MoneyAction? + /// Whether the Request Money flow is open. + @State private var isShowingRequestMoney = false /// The stable id, pill set, and long-pressed emoji the reactors sheet is open for, or nil. @State private var reactorsRequest: ReactorsRequest? /// A reactor whose profile opens once the reactors sheet has finished dismissing. @@ -115,6 +133,7 @@ struct ConversationScreen: View { /// Whether the keyboard is up, which holds the update toast back. @State private var isKeyboardUp = false + /// This conversation's id when the viewer hosts it as a private group; the anchor is host-only. private var hostedConversationID: ConversationID? { guard let conversationID, @@ -133,6 +152,8 @@ struct ConversationScreen: View { private static let titleLeadingInset: CGFloat = 72 /// Where the title ends: the bar's trailing margin, since nothing sits on that side. private static let titleTrailingInset: CGFloat = 16 + /// Room the request pill takes at the bar's trailing edge. + private static let requestPillWidth: CGFloat = 150 /// The synced contact for the counterpart, resolved live from the directory /// so a `dmChatID` stored after the first payment flows in. Falls back to @@ -409,6 +430,7 @@ struct ConversationScreen: View { onReachTop: { if pagesHistory { coordinator?.reachedTop() } }, onRetry: retry, onCashCardTap: openCurrencyInfo, + onPaymentRequestTap: { paymentRequestDetails = PaymentRequestSelection(id: PaymentRequestID(data: $0)) }, onOpenURL: openLink, onMentionTap: openMention, onShareProfile: shareProfile, @@ -466,7 +488,11 @@ struct ConversationScreen: View { mintMediaURL: mintMediaURL, mediaBlobDecrypt: mediaBlobDecrypt, onMediaTap: openMediaViewer, - transcriptTopInset: anchorHeight > 0 ? anchorHeight + 8 : 0, + // Each anchor shown takes its height and the stack's 8-point gap. + transcriptTopInset: [anchorHeight, stripHeight].filter { $0 > 0 }.reduce(0) { $0 + $1 + 8 }, + onDistanceFromNewestChange: { [stripCollapse] in stripCollapse.update(distanceFromNewest: $0) }, + // Released mid-way, the strip finishes the move it's nearer to rather than resting half-shrunk. + snapDistanceFromNewest: viewerOwes && !stripCollapse.isPinned ? PaymentRequestStripCollapse.distance : nil, messageJump: messageJump ) } @@ -599,6 +625,9 @@ struct ConversationScreen: View { guard let hostedConversationID else { return } Task { await sessionContainer.hostedLobbies.refresh(hostedConversationID) } } + .onChange(of: requestDeckItems, initial: true) { _, items in + requestPillPresence.update(items) + } .onChange(of: conversationController.reactions.error) { _, _ in scheduleReactionErrorDismissal() } @@ -629,6 +658,20 @@ struct ConversationScreen: View { if let conversationID { await sessionContainer.groupRosters.refresh(conversationID) } } } + .fullScreenCover(item: $paymentRequestDetails) { selection in + PaymentRequestDetailsScreen(requestID: selection.id) + } + .sheet(isPresented: $isShowingMoneyActions, onDismiss: runPendingMoneyAction) { + MoneyActionsSheet( + sendMoney: { pendingMoneyAction = .send }, + requestMoney: { pendingMoneyAction = .request } + ) + } + .fullScreenCover(isPresented: $isShowingRequestMoney) { + if let conversationID { + PaymentRequestFlow(conversationID: conversationID) + } + } .sheet(item: $paidReactionTarget) { target in PaidReactionSheet(target: target, selfReactions: selfReactions(on: target.messageID)) } @@ -687,6 +730,24 @@ struct ConversationScreen: View { .onDisappear { anchorHeight = 0 } .transition(.opacity.combined(with: .move(edge: .top))) } + // Mounted for as long as the viewer owes, so scrolling only moves it: its slot at the + // transcript's top stays put, and the strip shrinks into the pill in step with the scroll. + if viewerOwes { + PaymentRequestPill( + items: requestDeckItems, + style: .strip, + accessibilityLabel: requestDeckAccessibilityLabel, + state: paymentRequestDeck, + pay: payRequest + ) + .modifier(PaymentRequestStripCollapseEffect(collapse: stripCollapse)) + // The slot, not the moving strip: the collapse's transforms leave it alone. + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { stripCollapse.stripFrame = $0 } + .padding(.horizontal, BarMetrics.edgeInset) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { stripHeight = $0 } + .onDisappear { stripHeight = 0 } + .transition(.scale(scale: 0.3, anchor: .topTrailing).combined(with: .opacity)) + } if let reactionToastText { ToastLabel(reactionToastText) .allowsHitTesting(false) @@ -694,6 +755,14 @@ struct ConversationScreen: View { } } .padding(.top, 8) + .animation(Self.deckStyleMotion, value: viewerOwes) + } + .paymentRequestPaySheet(requestPayFlow) + .onChange(of: requestDeckStyle, initial: true) { _, style in + paymentRequestDeck.source = style + } + .onChange(of: owedRequestIDs) { owed, owing in + if !owing.isSubset(of: owed) { stripCollapse.unpin() } } .background(Color.backgroundMain) .navigationTitle("") @@ -702,7 +771,8 @@ struct ConversationScreen: View { // What it holds changes: the counterpart's name and avatar go, since the edit is about one // message rather than the person, and the back button backs out of the edit rather than the // chat — leaving the chevron alone in the bar. - .navigationBarBackButtonHidden(composer.isEditing) + // An expanded request deck takes the back button the same way: back collapses it first. + .navigationBarBackButtonHidden(composer.isEditing || paymentRequestDeck.isOpen) .toolbar { if composer.isEditing { ToolbarItem(placement: .topBarLeading) { @@ -715,6 +785,18 @@ struct ConversationScreen: View { .accessibilityLabel("Stop editing") } } else { + // Follows the target, so the bar changes as the deck starts to close, not as it lands. + if paymentRequestDeck.isOpen { + ToolbarItem(placement: .topBarLeading) { + Button { + paymentRequestDeck.collapse() + } label: { + Image(systemName: "chevron.backward") + .foregroundStyle(Color.textMain) + } + .accessibilityLabel("Collapse requests") + } + } ToolbarItem(placement: .principal) { ConversationTitleItem( title: title, @@ -731,14 +813,114 @@ struct ConversationScreen: View { ?? tipCounterpart?.profilePicture?.thumbnailBlurhash, // Too wide to centre, the bar lays the item out from just past the back // button, so the width alone sets where it ends; no offset is needed. - width: max(navBarWidth - Self.titleLeadingInset - Self.titleTrailingInset, 0), + width: max(navBarWidth - Self.titleLeadingInset - titleTrailingInset, 0), mute: viewerMute, onTap: titleTapAction, opensProfile: profileTapAction != nil ) } + if requestPillPresence.isInBar { + // The pill draws its own capsule, so the iOS 26 platter stays off. + if #available(iOS 26.0, *) { + ToolbarItem(placement: .topBarTrailing) { requestPill } + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(placement: .topBarTrailing) { requestPill } + } + } } } + // The expanded deck goes in a window of its own so the backdrop covers the app bar too. + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { deckWindow.listFrame = $0 } + .onChange(of: paymentRequestDeck.phase != .collapsed) { _, isUp in + if isUp { + deckWindow.items = requestDeckItems + deckWindow.present(state: paymentRequestDeck) { id in + PaymentRequestDetailsScreen(requestID: id) + .injectingEnvironment(from: sessionContainer) + .injectingEnvironment(from: container) + } + } else { + deckWindow.dismiss() + } + } + .onChange(of: requestDeckItems.map(\.id)) { deckWindow.items = requestDeckItems } + .onChange(of: scenePhase, initial: true) { _, phase in deckWindow.scenePhase = phase } + .onDisappear { deckWindow.dismiss() } + // Each refetch puts the deck back in its default order. + .onChange(of: groupConversation.map { sessionContainer.paymentRequests.chatRefreshes[$0.id] }) { + paymentRequestDeck.order.reset() + } + } + + /// The private group's open and recently ended requests, worded for the viewer, in deck order. + private var requestDeckItems: [PaymentRequestDeckItem] { + guard let group = groupConversation, group.isPrivate else { return [] } + return sessionContainer.paymentRequests.deckItems( + for: group.id, + viewer: session.userID, + order: paymentRequestDeck.order, + copy: PaymentRequestCopy(session.displayCurrency), + now: .now + ) + } + + private static let deckStyleMotion = Animation.spring(duration: 0.4, bounce: 0.12) + + /// Where the collapsed deck sits: the strip under the bar while the viewer owes and reads the + /// newest messages, otherwise the app-bar pill. + private var requestDeckStyle: PaymentRequestDeckStyle { + .collapsed(owes: viewerOwes, isScrolledAway: stripCollapse.isScrolledAway) + } + + private var viewerOwes: Bool { + requestDeckItems.contains { $0.owedShare != nil } + } + + private var owedRequestIDs: Set { + Set(requestDeckItems.filter { $0.owedShare != nil }.map(\.id)) + } + + /// Kept in the bar under the strip, hidden, so the bar never rebuilds its item as the strip comes + /// and goes. Hidden, it still marks the bar's slot for the expanded deck's close button. + private var requestPill: some View { + let style = requestDeckStyle + return PaymentRequestPill( + items: requestPillPresence.items(requestDeckItems), + style: .pill, + accessibilityLabel: requestDeckAccessibilityLabel, + state: paymentRequestDeck + ) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { stripCollapse.pillFrame = $0 } + .modifier(PaymentRequestPillRevealEffect(collapse: stripCollapse, isBehindStrip: viewerOwes)) + .allowsHitTesting(style != .strip) + .accessibilityHidden(style == .strip) + .animation(Self.deckStyleMotion, value: viewerOwes) + .modifier(PaymentRequestPillAppearance(presence: requestPillPresence)) + } + + private var requestDeckAccessibilityLabel: String { + guard let front = requestDeckItems.first else { return "" } + if requestDeckStyle == .strip, let share = front.owedShare { + return PaymentRequestCopy.owedAccessibilityLabel( + share: share, + title: front.card.title, + deadline: front.request.deadline(at: .now), + count: requestDeckItems.count + ) + } + return PaymentRequestCopy(session.displayCurrency) + .deckAccessibilityLabel(front: front.request, title: front.card.title, viewerStatus: front.viewerStatus, count: requestDeckItems.count, now: .now) + } + + /// The title's right margin, widened to clear the request pill while one shows in the bar. + private var titleTrailingInset: CGFloat { + // Behind the strip the pill is only transparent, so it still takes its slot in the bar. + Self.titleTrailingInset + (requestPillPresence.isVisible ? Self.requestPillWidth : 0) + } + + private func payRequest(_ id: PaymentRequestID) { + Task { await requestPayFlow.start(id, sessionContainer: sessionContainer) } } /// The fetches the transcript needs: gate token names, sender names, and avatars. @@ -786,6 +968,24 @@ struct ConversationScreen: View { guard groupConversation != nil else { return } await sessionContainer.knownAuthors.reload() } + // Payment requests change with no event, so poll while the chat is on screen in the + // foreground. A return to the foreground re-keys the task, which refetches at once. + .task(id: PaymentRequestPoll(conversationID: groupConversation?.id, isActive: scenePhase == .active)) { + guard let groupID = groupConversation?.id, scenePhase == .active else { return } + let requests = sessionContainer.paymentRequests + var retries = 0 + while !Task.isCancelled { + await requests.refreshChat(groupID) + // A card still loading after a failed read retries sooner than the poll. + if requests.isSettling(groupID) { + try? await Task.sleep(for: PaymentRequests.retryDelay(attempt: retries)) + retries += 1 + } else { + try? await Task.sleep(for: PaymentRequests.pollInterval) + retries = 0 + } + } + } // One picker per visit, so its suggestion fetch runs once per visit. .onChange(of: groupConversation?.id, initial: true) { _, groupID in mentions = groupID.map { MentionPickerModel(source: sessionContainer.rosterSearch, chatID: $0) } @@ -1253,6 +1453,8 @@ struct ConversationScreen: View { (ChatQuote.deletedSnippet, .unavailable) case .widget(.shareProfile): (ChatQuote.sharedProfileSnippet, .text) + case .widget(.paymentRequest): + (ChatQuote.paymentRequestSnippet, .text) case .encrypted, .widget(.unrecognized), .system: (ChatQuote.unavailableSnippet, .unavailable) } @@ -1307,6 +1509,16 @@ struct ConversationScreen: View { private func sendCash() { guard let sendTarget else { return } + switch sendTarget { + case .privateGroup: + // Requesting needs no balance, so the give-cash gate waits for the Send choice. + isShowingMoneyActions = true + case .contact, .tip, .group: + presentSendAmount(sendTarget) + } + } + + private func presentSendAmount(_ sendTarget: SendTarget) { let context: AddMoneyContext = switch sendTarget { case .tip: .sendTips case .contact, .group, .privateGroup: .giveCash @@ -1319,6 +1531,20 @@ struct ConversationScreen: View { router.presentSendAmount(sendTarget) } + private enum MoneyAction { + case send + case request + } + + private func runPendingMoneyAction() { + guard let action = pendingMoneyAction, let sendTarget else { return } + pendingMoneyAction = nil + switch action { + case .send: presentSendAmount(sendTarget) + case .request: isShowingRequestMoney = true + } + } + /// Re-send a failed message tapped in the transcript. The id is the row's stable id, which for a /// failed message is its client message id. private func retry(messageID: String) { @@ -1631,6 +1857,7 @@ struct ConversationScreen: View { controller: conversationController, session: session, knownAuthors: sessionContainer.knownAuthors, + paymentRequests: sessionContainer.paymentRequests, openingLoadLanded: loadedConversationID == id ) } diff --git a/Flipcash/Core/Screens/Main/Search/ChatSearch.swift b/Flipcash/Core/Screens/Main/Search/ChatSearch.swift index 8b9f6c40c..9af630376 100644 --- a/Flipcash/Core/Screens/Main/Search/ChatSearch.swift +++ b/Flipcash/Core/Screens/Main/Search/ChatSearch.swift @@ -69,7 +69,7 @@ nonisolated enum ChatSearchMessages { caption.flatMap { $0.isEmpty ? nil : $0 } case .widget(.shareProfile(let widget)): widget.username.value - case .widget(.unrecognized), .cash, .deleted, .encrypted, .system: + case .widget(.unrecognized), .widget(.paymentRequest), .cash, .deleted, .encrypted, .system: nil } } diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 39422483f..252165032 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -393,6 +393,17 @@ struct TipConversationRow: View { .task(id: UnreadCountSubject(conversation: conversation, selfUserID: conversationController.selfUserID)) { await conversationController.resolveUnreadCount(for: conversation) } + // A request's preview names its title, which only the request itself carries, sealed to the chat. + .task(id: lastRequestID) { + guard let lastRequestID else { return } + await sessionContainer.paymentRequests.fetchIfMissing(lastRequestID) + await sessionContainer.paymentRequests.loadSeal(for: conversation.id) + } + } + + private var lastRequestID: PaymentRequestID? { + guard case .widget(.paymentRequest(let id)) = conversation.lastMessage?.content else { return nil } + return id } /// The preview line: the last message, or an italic "Nothing yet" for a chat with no message @@ -404,6 +415,9 @@ struct TipConversationRow: View { for: conversation, knownAuthors: sessionContainer.knownAuthors.snapshot, viewerAmount: session.displayCurrency.amount, + requestTitle: { id in + sessionContainer.paymentRequests.request(id).flatMap(sessionContainer.paymentRequests.text)?.title + }, currencyName: { session.balance(for: $0)?.name } ) { return AttributedString(preview) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCard.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCard.swift new file mode 100644 index 000000000..b2db951ab --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCard.swift @@ -0,0 +1,26 @@ +// +// PaymentRequestCard.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashUI +import FlipcashCore + +/// What a request card shows, already put into words. +typealias PaymentRequestCardContent = ChatPaymentRequestContent.Card + +extension ChatPaymentRequestContent.Card { + /// Words `request` for the viewer: amounts in their currency, the deadline as of `now`. + init(request: GroupPaymentRequest, title: String, copy: PaymentRequestCopy, now: Date) { + self.init( + title: title, + progressLine: copy.progressLine(request), + fraction: request.paidFraction, + statusLine: PaymentRequestCopy.statusLine(request.deadline(at: now)), + isEnded: request.isEnded(at: now) + ) + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift new file mode 100644 index 000000000..bb455f20d --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift @@ -0,0 +1,129 @@ +// +// PaymentRequestCopy.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// The words a payment request is shown with, its amounts restated in the viewer's display currency. +struct PaymentRequestCopy { + + let displayCurrency: CurrencyCode + let rates: [CurrencyCode: Rate] + + init(displayCurrency: CurrencyCode, rates: [CurrencyCode: Rate]) { + self.displayCurrency = displayCurrency + self.rates = rates + } + + init(_ display: DisplayCurrency) { + self.init(displayCurrency: display.currency, rates: display.rates) + } + + /// `native` in `currency`, restated for the viewer; the amount as it is when no rate converts it. + func viewerAmount(_ native: Decimal, currency: String) -> FiatAmount? { + guard let code = try? CurrencyCode(currencyCode: currency) else { return nil } + let amount = FiatAmount(value: native, currency: code) + return amount.converted(to: displayCurrency, rates: rates) ?? amount + } + + /// A whole amount without its fraction: "$20", "$2.50". + private func short(_ native: Decimal, currency: String) -> String { + viewerAmount(native, currency: currency)?.formattedDroppingZeroFraction() ?? "" + } + + /// "$20 paid, $520 requested". + func progressLine(_ request: GroupPaymentRequest) -> String { + let currency = request.split?.currency ?? "" + return "\(short(request.paidNative, currency: currency)) paid, \(short(request.requestedNative, currency: currency)) requested" + } + + /// The deck's amount line: "$180 of $520". + func deckAmounts(_ request: GroupPaymentRequest) -> String { + let currency = request.split?.currency ?? "" + return "\(short(request.paidNative, currency: currency)) of \(short(request.requestedNative, currency: currency))" + } + + /// A history row's amount line: "✓ $520 paid" once COMPLETED, otherwise the progress line. + func historyAmounts(_ request: GroupPaymentRequest) -> String { + switch request.status { + case .completed: + return "✓ \(short(request.requestedNative, currency: request.split?.currency ?? "")) paid" + case .open, .closed, .expired, .unknown: + return progressLine(request) + } + } + + /// One share's amount at full precision: "$20.00". + func shareAmount(_ share: GroupPaymentRequest.Share, of request: GroupPaymentRequest) -> FiatAmount? { + viewerAmount(Decimal(share.nativeAmount), currency: request.split?.currency ?? "") + } + + /// A share's amount as a details row and the Send button show it: "$20". + func shareText(_ share: GroupPaymentRequest.Share, of request: GroupPaymentRequest) -> String { + short(Decimal(share.nativeAmount), currency: request.split?.currency ?? "") + } + + /// The per-person amount of a request, taken from its first share. + func perPersonAmount(_ request: GroupPaymentRequest) -> FiatAmount? { + guard let share = request.split?.shares.first else { return nil } + return shareAmount(share, of: request) + } + + // MARK: - Static copy - + + /// The status line shared by the card, the deck and Request details. + static func statusLine(_ deadline: GroupPaymentRequest.Deadline) -> String { + switch deadline { + case .daysLeft(let days): + return days == 1 ? "1 day left to pay" : "\(days) days left to pay" + case .hoursLeft(let hours): + return hours == 1 ? "1 hour left to pay" : "\(hours) hours left to pay" + case .minutesLeft(let minutes): + return minutes == 1 ? "1 minute left to pay" : "\(minutes) minutes left to pay" + case .paidInFull: + return "Paid in full" + case .closed: + return "Closed" + case .expired: + return "Expired" + } + } + + /// The status line without "to pay", as the owed strip shows it: "2 days left". + static func timeLeft(_ deadline: GroupPaymentRequest.Deadline) -> String { + switch deadline { + case .daysLeft(let days): + return days == 1 ? "1 day left" : "\(days) days left" + case .hoursLeft(let hours): + return hours == 1 ? "1 hour left" : "\(hours) hours left" + case .minutesLeft(let minutes): + return minutes == 1 ? "1 minute left" : "\(minutes) minutes left" + case .paidInFull, .closed, .expired: + return statusLine(deadline) + } + } + + /// A share row's status: "✓ Paid", or "Hasn't paid yet" / "Didn't pay" depending on whether the + /// request is still open. + static func shareStatus(_ share: GroupPaymentRequest.Share, requestIsOpen: Bool) -> String { + if share.isPaid { return "✓ Paid" } + return requestIsOpen ? "Hasn't paid yet" : "Didn't pay" + } + + /// The deck's VoiceOver label: " requests, , <viewer status>, <paid> of <requested> paid, <status>". + func deckAccessibilityLabel(front: GroupPaymentRequest, title: String, viewerStatus: PaymentRequestViewerStatus, count: Int, now: Date) -> String { + let parts = [title, viewerStatus.accessibilityText, "\(deckAmounts(front)) paid", Self.statusLine(front.deadline(at: now))] + let summary = parts.compactMap { $0 }.joined(separator: ", ") + return count > 1 ? "\(count) requests, \(summary)" : summary + } + + /// The owed strip's VoiceOver label: "<N> requests, You owe <share> for <title>, <status>". + static func owedAccessibilityLabel(share: String, title: String, deadline: GroupPaymentRequest.Deadline, count: Int) -> String { + let summary = "You owe \(share) for \(title), \(statusLine(deadline))" + return count > 1 ? "\(count) requests, \(summary)" : summary + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift new file mode 100644 index 000000000..dfe6604ff --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift @@ -0,0 +1,208 @@ +// +// PaymentRequestCreateViewModel.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.payment-request-create") + +/// A new request in a private group: the per-person amount, its name, who is asked and when it +/// closes, then the create call and the widget that puts it in the chat. +@MainActor +@Observable +final class PaymentRequestCreateViewModel { + + enum Outcome: Equatable { + /// The request exists. Its chat message either went out or sits failed in the transcript, + /// where a tap retries it; the deck shows the request either way. + case sent + case failed(DialogCopy) + } + + /// The title and body of an alert raised by a failed create. + struct DialogCopy: Equatable { + let title: String + let subtitle: String + } + + var enteredAmount: String = "" + var title: String + var deadline: PaymentRequestDeadline = .oneWeek + private(set) var people: PaymentRequestPeople? + /// Set when checking a member would make a 101st share, until the sheet is dismissed. + var isShowingPeopleLimit = false + private(set) var isSending = false + + let conversationID: ConversationID + + /// One key per confirm: every retry of the same send reuses it, so the server creates one request. + @ObservationIgnored private var idempotencyKey: Data? + /// The request a confirm created, kept so a retry only resends the message. + @ObservationIgnored private var created: GroupPaymentRequest? + + @ObservationIgnored private let session: Session + @ObservationIgnored private let ratesController: RatesController + @ObservationIgnored private let rosters: GroupRosters + @ObservationIgnored private let conversationController: ConversationController + @ObservationIgnored private let requests: PaymentRequests + @ObservationIgnored private let amountValidator = AmountValidator() + + init(sessionContainer: SessionContainer, conversationID: ConversationID) { + self.session = sessionContainer.session + self.ratesController = sessionContainer.ratesController + self.rosters = sessionContainer.groupRosters + self.conversationController = sessionContainer.conversationController + self.requests = sessionContainer.paymentRequests + self.conversationID = conversationID + self.title = defaultPaymentRequestTitle() + } + + /// Clears the draft for a fresh New Request: amount, default title and deadline, everyone selected. + func startNewRequest() { + enteredAmount = "" + title = defaultPaymentRequestTitle() + deadline = .oneWeek + people = nil + idempotencyKey = nil + created = nil + seedPeopleIfNeeded() + } + + // MARK: - Amount - + + /// The per-person amount in the display currency, or nil at zero. + var amount: FiatAmount? { + guard let value = amountValidator.validate(enteredAmount), value > 0 else { return nil } + return FiatAmount(value: value, currency: ratesController.balanceCurrency) + } + + var canContinue: Bool { amount != nil } + + // MARK: - People - + + /// Every member, "You" included, in roster order with the host first. + var members: [GroupPortrait] { + let creator = conversationController.conversation(withID: conversationID)?.creator + return rosters.members(for: conversationID, creator: creator).compactMap { member in + guard let portrait = GroupPortrait(member, creator: creator) else { return nil } + guard portrait.userID == session.userID else { return portrait } + return GroupPortrait(userID: portrait.userID, name: "You", picture: portrait.picture, isHost: portrait.isHost) + } + } + + func refreshMembers() async { + await rosters.refresh(conversationID) + seedPeopleIfNeeded() + } + + /// Starts the selection once the roster is known; a refresh after that leaves the user's picks alone. + func seedPeopleIfNeeded() { + guard people == nil, !members.isEmpty else { return } + people = PaymentRequestPeople(members: members.map(\.userID), creator: session.userID) + } + + func isSelected(_ userID: UserID) -> Bool { + people?.selected.contains(userID) ?? false + } + + func toggle(_ userID: UserID) { + guard var people else { return } + if !people.toggle(userID) { + isShowingPeopleLimit = true + } + self.people = people + } + + var peopleValue: String { people?.summaryValue ?? "Choose People" } + var peopleSecondLine: String? { people?.summarySecondLine } + var hasValidPeople: Bool { people?.isValid ?? false } + var canSend: Bool { amount != nil && hasValidPeople && !isSending } + + var sendButtonTitle: String { + people?.buttonTitle(amount: amount?.formatted() ?? "") ?? "Request" + } + + // MARK: - Send - + + /// Called when the confirm sheet opens; the key it makes lives until a send succeeds. + func prepareConfirm() { + if idempotencyKey == nil { + idempotencyKey = Data((0..<16).map { _ in UInt8.random(in: .min ... .max) }) + } + } + + func send(now: Date = .now) async -> Outcome { + guard let amount, let people, people.isValid else { return .failed(Self.genericError) } + prepareConfirm() + guard let idempotencyKey else { return .failed(Self.genericError) } + isSending = true + defer { isSending = false } + + let request: GroupPaymentRequest + if let created { + request = created + } else { + do { + request = try await create(amount: amount, people: people, key: idempotencyKey, now: now) + created = request + } catch let error as ErrorCreatePaymentRequest { + return .failed(alert(for: error)) + } catch { + ErrorReporting.captureError(error, reason: "Failed to create payment request") + return .failed(Self.genericError) + } + } + + await conversationController.sendPaymentRequest(request.id, in: conversationID) + finish() + return .sent + } + + private func create(amount: FiatAmount, people: PaymentRequestPeople, key: Data, now: Date) async throws -> GroupPaymentRequest { + guard let seal = try await conversationController.photoSeal(for: conversationID) else { + throw ErrorCreatePaymentRequest.encryptionRequired + } + let title = PaymentRequestTitleValidator().validate(title) ?? defaultPaymentRequestTitle(at: now) + let split = GroupPaymentRequest.Split( + text: try seal.sealRequestText(title: title, description: ""), + currency: amount.currency.rawValue.lowercased(), + endsAt: deadline.endsAt(sentAt: now), + shares: people.shares(nativeAmount: NSDecimalNumber(decimal: amount.value).doubleValue) + ) + return try await requests.create(idempotencyKey: key, in: conversationID, split: split) + } + + /// A finished confirm: the next one, if the flow is reused, is a new request. + private func finish() { + idempotencyKey = nil + created = nil + } + + private func alert(for error: ErrorCreatePaymentRequest) -> DialogCopy { + switch error { + case .denied: + return DialogCopy(title: "Can't Send Request", subtitle: "You can't request money in this group.") + case .invalidEndsAt: + return DialogCopy(title: "Can't Send Request", subtitle: "Choose a deadline within the next 4 weeks.") + case .invalidShares: + Task { await refreshMembers() } + return DialogCopy(title: "Can't Send Request", subtitle: "Someone you included is no longer in the group. Check who's included and try again.") + case .invalidCurrency: + return DialogCopy(title: "Can't Send Request", subtitle: "Requests aren't available in this currency yet.") + case .chatNotSupported, .encryptionNotAllowed, .encryptionRequired, .textModerated, .unknown, .rejected: + logger.error("Payment request create was refused", metadata: ["result": "\(error)"]) + ErrorReporting.captureError(error, reason: "Payment request create was refused") + return Self.genericError + case .transportFailure, .cancelled: + return Self.networkError + } + } + + static let genericError = DialogCopy(title: "Something Went Wrong", subtitle: "Please try again later") + static let networkError = DialogCopy(title: "No Connection", subtitle: "Check your connection and try again.") +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift new file mode 100644 index 000000000..360a1ab47 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift @@ -0,0 +1,242 @@ +// +// PaymentRequestDeadlinePicker.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Observation + +/// The Time To Pay sheet's working choice: a preset or a custom day and time, picked from the days a +/// request can end on. Applied to the summary only on Done. +@MainActor +@Observable +final class PaymentRequestDeadlinePicker { + + /// The days in the grid, today's week first. + static let gridDayCount = 35 + /// How many days past today the grid lets the user pick. + static let lastDayOffset = 28 + /// The time a custom day starts at: 6:00 PM. + static let defaultCustomTime = DateComponents(hour: 18, minute: 0) + + /// The choice Done applies. + private(set) var choice: PaymentRequestDeadline + + let openedAt: Date + let calendar: Calendar + let locale: Locale + + /// The hour and minute the last custom day was given, kept when the user picks another day. + private var customTime: DateComponents + + /// Opens on `deadline` at `now`, reading days in `calendar` and words in `locale`. + init(deadline: PaymentRequestDeadline, now: Date = .now, calendar: Calendar = .current, locale: Locale = .current) { + self.openedAt = now + self.calendar = calendar + self.locale = locale + switch deadline { + case .custom(let date): + customTime = calendar.dateComponents([.hour, .minute], from: date) + case .oneDay, .threeDays, .oneWeek, .twoWeeks, .fourWeeks: + customTime = Self.defaultCustomTime + } + choice = deadline + } + + // MARK: - Grid - + + /// The first day of today's week, by the calendar's first weekday. + var gridStart: Date { + calendar.dateInterval(of: .weekOfYear, for: openedAt)?.start ?? calendar.startOfDay(for: openedAt) + } + + /// Every day in the grid, 5 rows of 7. + var gridDays: [Date] { + (0..<Self.gridDayCount).compactMap { calendar.date(byAdding: .day, value: $0, to: gridStart) } + } + + var today: Date { calendar.startOfDay(for: openedAt) } + + /// The last day a request can end on. + var lastDay: Date { + calendar.date(byAdding: .day, value: Self.lastDayOffset, to: today) ?? today + } + + /// Whether `day` can be tapped: today through today + 28 days. + func isEnabled(_ day: Date) -> Bool { + let start = calendar.startOfDay(for: day) + return start >= today && start <= lastDay + } + + /// The day the choice ends on. + var selectedDay: Date { + calendar.startOfDay(for: choice.endsAt(sentAt: openedAt)) + } + + /// The range's grey start circle: today, or nil when the choice closes today. + var rangeStart: Date? { + selectedDay > today ? today : nil + } + + /// The part of the range band `day`'s cell draws, or nil when the band does not reach it. + func band(for day: Date) -> RangeBand? { + guard let start = rangeStart else { return nil } + let day = calendar.startOfDay(for: day) + guard day >= start, day <= selectedDay else { return nil } + let column = gridDays.firstIndex(of: day).map { $0 % 7 } + let fromCenter = day == start + let toCenter = day == selectedDay + return RangeBand( + fromCenter: fromCenter, + toCenter: toCenter, + roundedLeading: !fromCenter && column == 0, + roundedTrailing: !toCenter && column == 6 + ) + } + + /// The preset currently chosen, nil for a custom day. + var selectedPreset: PaymentRequestDeadline? { + switch choice { + case .oneDay, .threeDays, .oneWeek, .twoWeeks, .fourWeeks: choice + case .custom: nil + } + } + + // MARK: - Choosing - + + func select(_ preset: PaymentRequestDeadline) { + choice = preset + } + + /// Makes `day` the custom choice at the last custom time, 6:00 PM at first, moved inside the + /// allowed range when it falls outside it. + func select(day: Date) { + guard isEnabled(day) else { return } + var components = calendar.dateComponents([.year, .month, .day], from: day) + components.hour = customTime.hour + components.minute = customTime.minute + let date = calendar.date(from: components) ?? day + choice = .custom(clamped(date)) + } + + /// The custom date and time, or nil while a preset is chosen. + var customDate: Date? { + switch choice { + case .custom(let date): date + case .oneDay, .threeDays, .oneWeek, .twoWeeks, .fourWeeks: nil + } + } + + /// The time the Time row shows: the custom time, or for a preset the time it closes if sent now. + var timeRowDate: Date { + customDate ?? choice.endsAt(sentAt: openedAt) + } + + /// Sets the time from the Time row on the selected day and keeps it for later days; a preset + /// becomes a custom choice on its closing day. + func setTime(_ date: Date) { + var components = calendar.dateComponents([.year, .month, .day], from: selectedDay) + let time = calendar.dateComponents([.hour, .minute], from: date) + components.hour = time.hour + components.minute = time.minute + let clamped = clamped(calendar.date(from: components) ?? date) + customTime = calendar.dateComponents([.hour, .minute], from: clamped) + choice = .custom(clamped) + } + + /// The times the Time row offers on the selected day. + var timeRange: ClosedRange<Date> { + let bounds = allowedRange + guard let dayEnd = calendar.date(byAdding: DateComponents(day: 1, minute: -1), to: selectedDay) else { + return bounds + } + let lower = max(selectedDay, bounds.lowerBound) + let upper = min(dayEnd, bounds.upperBound) + return lower <= upper ? lower...upper : bounds + } + + /// From an hour out to five minutes short of 28 days, each bound rounded to the minute inward. + var allowedRange: ClosedRange<Date> { + let range = PaymentRequestDeadline.customRange(openedAt: openedAt) + let lower = roundedToMinute(range.lowerBound, up: true) + let upper = roundedToMinute(range.upperBound, up: false) + return lower...upper + } + + private func clamped(_ date: Date) -> Date { + let range = allowedRange + return min(max(date, range.lowerBound), range.upperBound) + } + + private func roundedToMinute(_ date: Date, up: Bool) -> Date { + guard let minute = calendar.dateInterval(of: .minute, for: date)?.start else { return date } + guard up, minute < date else { return minute } + return minute.addingTimeInterval(60) + } + + // MARK: - Starts / Closes - + + /// The Closes field: "≈ Mon, Nov 2" for a preset, "Thu, Nov 5, 6:00 PM" for a custom day. + var closesValue: String { + guard let date = customDate else { return "≈ \(dayText(choice.endsAt(sentAt: openedAt)))" } + return "\(dayText(date)), \(timeText(date))" + } + + /// The grey line under the fields: "1 Week · closes 7 days after you send", or "Custom · 10 days". + var summaryLine: String { + guard let date = customDate else { + return "\(choice.rowTitle) · closes \(Self.days(presetDays)) after you send" + } + let days = calendar.dateComponents([.day], from: today, to: calendar.startOfDay(for: date)).day ?? 0 + return days == 0 ? "Custom · Today" : "Custom · \(Self.days(days))" + } + + /// The Time row's value: "6:00 PM". + func timeText(_ date: Date) -> String { + date.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: locale, calendar: calendar, timeZone: calendar.timeZone)) + } + + /// A day's spoken name: "Thursday, November 5". + func accessibilityDay(_ day: Date) -> String { + day.formatted( + Date.FormatStyle(locale: locale, calendar: calendar, timeZone: calendar.timeZone) + .weekday(.wide).month(.wide).day() + ) + } + + private func dayText(_ date: Date) -> String { + date.formatted( + Date.FormatStyle(locale: locale, calendar: calendar, timeZone: calendar.timeZone) + .weekday(.abbreviated).month(.abbreviated).day() + ) + } + + private var presetDays: Int { + switch choice { + case .oneDay: 1 + case .threeDays: 3 + case .oneWeek: 7 + case .twoWeeks: 14 + case .fourWeeks: 28 + case .custom: 0 + } + } + + private static func days(_ count: Int) -> String { + count == 1 ? "1 day" : "\(count) days" + } +} + +/// The slice of the Time To Pay range band one grid cell draws. +struct RangeBand: Equatable { + /// Starts at the cell's centre, under the grey start circle. + var fromCenter: Bool + /// Stops at the cell's centre, under the white closing circle. + var toCenter: Bool + /// Ends rounded at the row's leading edge, where the range wraps from the week before. + var roundedLeading: Bool + /// Ends rounded at the row's trailing edge, where the range wraps to the next week. + var roundedTrailing: Bool +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift new file mode 100644 index 000000000..475c899c9 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -0,0 +1,1011 @@ +// +// PaymentRequestDeck.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// One request in the deck, already put into words. +struct PaymentRequestDeckItem: Identifiable, Hashable { + let request: GroupPaymentRequest + let card: PaymentRequestCardContent + /// "$180 of $520". + let amounts: String + let viewerStatus: PaymentRequestViewerStatus + /// The viewer's unpaid share while the request is open: "$20". Nil when they owe nothing on it. + let owedShare: String? + /// The status line without "to pay": "2 days left". + let timeLeft: String + + var id: PaymentRequestID { request.id } +} + +/// The deck's open state, shared by the app-bar pill and the expanded overlay so the front card can +/// move between them. +@MainActor +@Observable +final class PaymentRequestDeckState { + + enum Phase { + case collapsed + /// The overlay is up and the front card is moving, or resting at the pill on its way out. + case presenting + case expanded + } + + var order = PaymentRequestDeckOrder() + /// How far the front card is from the pill (0) to the expanded list (1). Only ever changed under + /// one spring, so a new target retargets it from wherever it is. + private(set) var progress: CGFloat = 0 + /// The target: the deck is open or on its way there. + private(set) var isOpen = false + private(set) var isOverlayPresented = false + /// Open and at rest, so the list's own front card shows in place of the moving one. + private(set) var isSettled = false + /// The front card's parts in global coordinates in each collapsed face, and in the expanded list. + var faceFrames: [PaymentRequestDeckStyle: [DeckPart: CGRect]] = [:] + var cardFrames: [DeckPart: CGRect] = [:] + /// The front card's parts in the face the deck collapses into. + var pillFrames: [DeckPart: CGRect] { faceFrames[source] ?? [:] } + /// The app-bar pill's frame, where the expanded deck's close button sits whichever face it opened from. + var barFrame: CGRect? { faceFrames[.pill]?[.container] } + /// Each collapsed face's Dynamic Type size. The toolbar can lay its text out at a different size + /// than the overlay, so the moving copy draws the face's text at its own size to land on it exactly. + var faceTypeSizes: [PaymentRequestDeckStyle: DynamicTypeSize] = [:] + /// The Dynamic Type size of the face the deck collapses into. + var pillTypeSize: DynamicTypeSize { faceTypeSizes[source] ?? .large } + /// The face the deck collapses into, so the moving card starts from it and lands back on it. + var source: PaymentRequestDeckStyle = .pill + private var generation = 0 + + private static let motion = Animation.spring(duration: 0.4, bounce: 0.12) + + var phase: Phase { + guard isOverlayPresented else { return .collapsed } + return isOpen ? .expanded : .presenting + } + + /// Grows the front card from the pill into the expanded list, from wherever it is. + func expand() { + guard !isOpen else { return } + isOpen = true + generation += 1 + let generation = generation + if isOverlayPresented { + move(to: 1, generation: generation) + } else { + isOverlayPresented = true + // One layout pass first, so the list's front card is measured before it moves. + Task { @MainActor in self.move(to: 1, generation: generation) } + } + } + + /// Shrinks the front card back into the pill, from wherever it is. + func collapse() { + guard isOpen else { return } + isOpen = false + isSettled = false + generation += 1 + move(to: 0, generation: generation) + } + + /// Puts the front card back at the pill with no animation, for when the deck is hidden behind a + /// screen. The overlay stays up, drawing the card where the pill is, until `endPresentation()`. + func snapToPill() { + guard isOpen else { return } + isOpen = false + isSettled = false + generation += 1 + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { progress = 0 } + } + + /// Takes the overlay down once the card rests at the pill, handing back to the pill itself. + func endPresentation() { + guard !isOpen, progress == 0 else { return } + isOverlayPresented = false + } + + private func move(to target: CGFloat, generation: Int) { + guard generation == self.generation else { return } + // `.removed` waits for the spring to come fully to rest, so nothing swaps while it still moves. + withAnimation(Self.motion, completionCriteria: .removed) { + progress = target + } completion: { + guard generation == self.generation else { return } + if target == 1 { + self.isSettled = true + } else { + self.isOverlayPresented = false + } + } + } +} + +// MARK: - Presence - + +/// Whether the pill is in the app bar, and how far it has faded in. It fades in and out in place +/// when requests arrive or run out after the chat is on screen, and is simply there when the chat +/// opens with requests already known. +@MainActor +@Observable +final class PaymentRequestPillPresence { + + /// The toolbar item exists; it outlives `isVisible` by the fade-out. + private(set) var isInBar = false + /// Drives the pill's opacity and scale, and the room the title leaves for it. + private(set) var isVisible = false + /// The last non-empty deck, drawn while the pill fades out after the requests are gone. + private(set) var lastItems: [PaymentRequestDeckItem] = [] + + /// The pill fades in when it first shows rather than being there from its first frame. + private(set) var fadesIn = false + private var hasSettled = false + + static let fade = Animation.easeOut(duration: 0.25) + + /// The items the pill draws: the live deck, or the last one while it fades out. + func items(_ live: [PaymentRequestDeckItem]) -> [PaymentRequestDeckItem] { + live.isEmpty ? lastItems : live + } + + /// Follows the deck's contents; the first call sets the state without a fade. + func update(_ items: [PaymentRequestDeckItem]) { + if !items.isEmpty { lastItems = items } + let hasItems = !items.isEmpty + guard hasSettled else { + hasSettled = true + isInBar = hasItems + isVisible = hasItems + return + } + guard hasItems != isVisible else { return } + if hasItems { + // The toolbar builds the item's view on its own schedule, so the pill runs its own + // fade once it appears; this animation only moves the title out of its way. + fadesIn = true + isInBar = true + withAnimation(Self.fade) { isVisible = true } + } else { + withAnimation(Self.fade) { + isVisible = false + } completion: { [weak self] in + guard let self, !self.isVisible else { return } + self.isInBar = false + self.lastItems = [] + } + } + } +} + +/// Fades and scales the pill in place as `presence` shows and hides it. +struct PaymentRequestPillAppearance: ViewModifier { + + let presence: PaymentRequestPillPresence + @State private var hasAppeared = false + + private var isShown: Bool { + presence.isVisible && (hasAppeared || !presence.fadesIn) + } + + func body(content: Content) -> some View { + content + .opacity(isShown ? 1 : 0) + .scaleEffect(isShown ? 1 : 0.95) + .allowsHitTesting(isShown) + .onAppear { + withAnimation(PaymentRequestPillPresence.fade) { hasAppeared = true } + } + } +} + +// MARK: - Strip collapse - + +/// How far the owed strip has shrunk into the app-bar pill, following the reader's scroll away +/// from the newest messages until it has shrunk all the way, then pinned there for the visit. +@MainActor +@Observable +final class PaymentRequestStripCollapse { + /// 0 with the strip fully out under the bar, 1 with it fully shrunk into the pill. + private(set) var progress: CGFloat = 0 + /// Whether the collapsed deck reads as the pill. Flips past the middle of the collapse, with a + /// band either side so a small scroll back and forth never flaps it. + private(set) var isScrolledAway = false + /// Whether the strip has shrunk all the way into the pill, after which scrolling no longer moves it. + private(set) var isPinned = false + + /// The strip's slot under the bar and the pill's in it, in global space, where the collapse + /// carries the one onto the other. + var stripFrame: CGRect = .zero + var pillFrame: CGRect = .zero + + /// Points scrolled away from the newest messages over which the strip shrinks into the pill. + static let distance: CGFloat = 140 + + /// How far the moving strip has handed over to the pill, 0 to 1: it does so over the last part + /// of the move, once their bounds nearly agree. + static func handover(_ progress: CGFloat) -> CGFloat { + min(max((progress - 0.6) / 0.4, 0), 1) + } + private static let awayAt: CGFloat = 0.65 + private static let backAt: CGFloat = 0.35 + + /// Follows the reader `distanceFromNewest` points above the newest messages. + func update(distanceFromNewest: CGFloat) { + guard !isPinned else { return } + let next = min(max(distanceFromNewest / Self.distance, 0), 1) + if abs(next - progress) > 0.001 || (next != progress && (next == 0 || next == 1)) { + progress = next + } + if !isScrolledAway, next >= Self.awayAt { + isScrolledAway = true + } else if isScrolledAway, next <= Self.backAt { + isScrolledAway = false + } + if next >= 1 - 0.001 { + progress = 1 + isScrolledAway = true + isPinned = true + } + } + + /// Brings the strip back out under the bar, for a request the viewer newly owes on. + func unpin() { + isPinned = false + isScrolledAway = false + progress = 0 + } +} + +/// Carries the owed strip into the app-bar pill as one stack as `collapse` runs: its bounds move +/// and shrink from the strip's slot onto the pill's, then it hands over to the pill in place. It +/// stops taking touches once it is mostly the pill's. +struct PaymentRequestStripCollapseEffect: ViewModifier { + let collapse: PaymentRequestStripCollapse + + func body(content: Content) -> some View { + let progress = collapse.progress + let morph = Morph(from: collapse.stripFrame, to: collapse.pillFrame, progress: progress) + content + // The strip lays out at the width that, once scaled, spans the moving bounds, so its + // text stays upright instead of squeezing. + .frame(width: progress > 0 ? morph?.layoutWidth : nil, alignment: .leading) + .environment(\.paymentRequestStripCollapse, progress) + .scaleEffect(morph?.scale ?? 1, anchor: .topLeading) + .offset(morph?.offset ?? .zero) + // One layer, so the cards beneath never show through the front one as it fades. + .compositingGroup() + .opacity(1 - PaymentRequestStripCollapse.handover(progress)) + .modifier(StripSlot()) + .allowsHitTesting(progress < 0.5) + .accessibilityHidden(progress >= 0.5) + } + + /// The strip's bounds partway onto the pill's, as a layout width plus a scale and offset that + /// leave its layout slot alone. Nil until both have been measured. + private struct Morph { + let layoutWidth: CGFloat + let scale: CGFloat + let offset: CGSize + + init?(from strip: CGRect, to pill: CGRect, progress: CGFloat) { + guard strip.height > 0, strip.width > 0, pill.height > 0, pill.width > 0 else { return nil } + let endScale = pill.height / strip.height + scale = 1 + (endScale - 1) * progress + layoutWidth = strip.width + (pill.width / endScale - strip.width) * progress + offset = CGSize( + width: (pill.minX - strip.minX) * progress, + height: (pill.minY - strip.minY) * progress + ) + } + } +} + +/// Takes the width it's offered whatever the moving strip inside lays out at, so the slot measured +/// around it never follows the strip's own width back into the morph. +private struct StripSlot: ViewModifier { + func body(content: Content) -> some View { + StripSlotLayout { content } + } +} + +private struct StripSlotLayout: Layout { + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let child = subviews.first?.sizeThatFits(proposal) ?? .zero + return CGSize(width: proposal.width ?? child.width, height: child.height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + subviews.first?.place(at: bounds.origin, anchor: .topLeading, proposal: proposal) + } +} + +/// Fades the app-bar pill in as the strip hands over to it at the end of `collapse`. +struct PaymentRequestPillRevealEffect: ViewModifier { + let collapse: PaymentRequestStripCollapse + let isBehindStrip: Bool + + func body(content: Content) -> some View { + content + .compositingGroup() + .opacity(isBehindStrip ? PaymentRequestStripCollapse.handover(collapse.progress) : 1) + } +} + +extension EnvironmentValues { + /// How far the strip around this view has shrunk into the pill, 0 to 1. + @Entry var paymentRequestStripCollapse: CGFloat = 0 +} + +// MARK: - Style - + +/// Where the collapsed deck sits: the app-bar pill, or the strip under the bar while the viewer +/// owes and reads the newest messages. +enum PaymentRequestDeckStyle: Hashable { + case pill + case strip + + /// The style for a deck the viewer `owes` on: the strip shrinks into the pill while they read + /// older messages, and grows back at the newest. + static func collapsed(owes: Bool, isScrolledAway: Bool) -> PaymentRequestDeckStyle { + owes && !isScrolledAway ? .strip : .pill + } + + var ringDiameter: CGFloat { + switch self { + case .pill: 26 + case .strip: 32 + } + } + + var cornerRadius: CGFloat { + switch self { + case .pill: 18 + case .strip: 14 + } + } + + /// The title's font and its point size, which the expand morph scales from. + var titleFont: Font { + switch self { + case .pill: .appTextCaption + case .strip: .appTextSmall + } + } + + var titleSize: CGFloat { + switch self { + case .pill: 12 + case .strip: 14 + } + } + + /// The face's second line: "You owe $5 · 2 days left" on the strip while owed, otherwise the + /// viewer status line. + @ViewBuilder + func amounts(_ item: PaymentRequestDeckItem) -> some View { + if self == .strip, let share = item.owedShare { + Text(PaymentRequestViewerStatus.owedLead(share) + AttributedString(" · \(item.timeLeft)")) + } else { + PaymentRequestViewerStatusLine(status: item.viewerStatus, amounts: item.amounts) + } + } +} + +// MARK: - Pill - + +/// The collapsed deck (concept B): the front request with up to two more peeking beneath it, as a +/// stack of cards, in the app bar or as the strip under it. Swipe down cycles; tap expands. +struct PaymentRequestPill: View { + + let items: [PaymentRequestDeckItem] + var style: PaymentRequestDeckStyle = .pill + let accessibilityLabel: String + @Bindable var state: PaymentRequestDeckState + /// Starts paying the viewer's share of a request, from the strip's Pay button. + var pay: ((PaymentRequestID) -> Void)? = nil + + @Environment(\.dynamicTypeSize) private var typeSize + /// The finger's downward travel, not the front card's: the card rubber-bands past one pill height. + @State private var drag: CGFloat = 0 + @State private var frontHeight: CGFloat = 43 + + private static let peekStep: CGFloat = 5 + /// How much each step back shrinks a card, so its sides tuck in under the one in front. + private static let depthScale: CGFloat = 0.08 + private static let depthDim: CGFloat = 0.25 + private static let settle = Animation.spring(duration: 0.35, bounce: 0.15) + + var body: some View { + // The front request at rest sets the pill's size and is what the deck measures; the cards + // drawn over it move freely without feeding back into the bar's layout. + ZStack(alignment: .top) { + if let front = items.first { + PaymentRequestPillFace(item: front, style: style, measure: report) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { frontHeight = $0 } + .opacity(0) + } + } + .overlay(alignment: .top) { + ZStack(alignment: .top) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + card(item, at: index) + } + } + } + .padding(.bottom, Self.peekStep * CGFloat(min(max(items.count - 1, 0), 2))) + .onChange(of: typeSize, initial: true) { state.faceTypeSizes[style] = typeSize } + .onChange(of: style) { state.faceTypeSizes[style] = typeSize } + .contentShape(Rectangle()) + .onTapGesture { state.expand() } + .gesture(cycleGesture) + .accessibilityRepresentation { + Button(accessibilityLabel) { state.expand() } + .accessibilityAction(named: "Next request") { + guard items.count > 1 else { return } + withAnimation(Self.settle) { state.order.cycle() } + } + if style == .strip, let front = items.first, let share = front.owedShare, let pay { + Button("Pay \(share)") { pay(front.id) } + } + } + } + + /// `item` at `index` in the deck: the front card follows the finger, and each card behind rises + /// a step toward the front as the drag nears one pill height. Past the second card they stack + /// in the back slot, so a card sent to the back slides up under the others. + /// Records a part of the front card for the expand morph under this face, so a face animating + /// out of view can't overwrite the one the deck collapses into. + private func report(_ part: DeckPart, _ frame: CGRect) { + state.faceFrames[style, default: [:]][part] = frame + } + + private func card(_ item: PaymentRequestDeckItem, at index: Int) -> some View { + let height = max(frontHeight, 1) + let rise = items.count > 1 ? min(1, drag / height) : 0 + let depth = index == 0 ? 0 : min(max(0, CGFloat(index) - rise), 2) + let scale = index == 0 ? 1 - Self.depthScale * 0.5 * rise : 1 - Self.depthScale * depth + // Anchored at the top, a card's scaled bottom sits a peek step lower per step back. + let offset = index == 0 ? frontTravel(height: height) : height * (1 - scale) + Self.peekStep * depth + return PaymentRequestPillFace(item: item, style: style, fillsWidth: true, pay: pay) + .overlay { + RoundedRectangle(cornerRadius: style.cornerRadius) + .fill(Color.backgroundMain.opacity(Self.depthDim * depth)) + .allowsHitTesting(false) + } + .scaleEffect(scale, anchor: .top) + .offset(y: offset) + // The moving copy stands in for the front card until it lands back here. + .opacity(index == 0 && state.source == style && state.phase != .collapsed ? 0 : 1) + // Only the front card's Pay button takes a tap; the rest peek. + .allowsHitTesting(index == 0) + .zIndex(Double(items.count - index)) + } + + /// How far the front card has moved: with the finger at first, then rubber-banding so it never + /// travels more than one pill height. A lone request only pulls elastically. + private func frontTravel(height: CGFloat) -> CGFloat { + guard items.count > 1 else { return sqrt(drag) * 2 } + let knee = height * 0.7 + guard drag > knee else { return drag } + let room = height - knee + return knee + room * (1 - exp(-(drag - knee) / room)) + } + + private var cycleGesture: some Gesture { + DragGesture(minimumDistance: 6) + .onChanged { value in + // A short spring picks the cards up from wherever a release left them mid-flight. + withAnimation(.interactiveSpring(duration: 0.15)) { + drag = max(0, value.translation.height) + } + } + .onEnded { value in + let finishes = items.count > 1 && PaymentRequestDeckOrder.finishesCycle( + translation: value.translation.height, + predictedTranslation: value.predictedEndTranslation.height, + height: frontHeight + ) + // Past the threshold the front card drops behind into the back slot as the rest + // finish rising; short of it, everything springs back together. + withAnimation(Self.settle) { + if finishes { state.order.cycle() } + drag = 0 + } + } + } +} + +/// The parts of the front card measured in both states, so the moving card can lay each one out +/// between where it sits in the pill and where it sits in the expanded list. +enum DeckPart: Hashable { + case container + case ring + case title + case amounts + /// The parts only the expanded card shows. + case label + case bar + case status +} + +/// Reports a part's global frame. +typealias DeckPartMeasure = (DeckPart, CGRect) -> Void + +private extension View { + /// Reports this view's global frame as `part`, when `measure` is set. + @ViewBuilder + func deckPart(_ part: DeckPart, _ measure: DeckPartMeasure?) -> some View { + if let measure { + onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { measure(part, $0) } + } else { + self + } + } +} + +/// A card in the collapsed deck: the ring, the title and "<paid> of <requested>" in the pill, and on +/// the strip, the owed line with a Pay button. +struct PaymentRequestPillFace: View { + + let item: PaymentRequestDeckItem + var style: PaymentRequestDeckStyle = .pill + /// Stretches the card to the width it's offered, so a card in the stack matches the front one. + var fillsWidth = false + var measure: DeckPartMeasure? = nil + var pay: ((PaymentRequestID) -> Void)? = nil + + @Environment(\.paymentRequestStripCollapse) private var stripCollapse + + var body: some View { + switch style { + case .pill: pill + case .strip: strip + } + } + + private var ring: some View { + PaymentRequestRing( + fraction: item.card.fraction, + isEnded: item.card.isEnded, + isClosed: item.request.status == .closed, + diameter: style.ringDiameter + ) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: style.ringDiameter, isEnded: item.card.isEnded) + .deckPart(.ring, measure) + } + + private var pill: some View { + HStack(spacing: 8) { + ring + VStack(alignment: .leading, spacing: 2) { + Text(item.card.title) + .font(.appTextCaption) + .foregroundStyle(Color.textMain) + .deckPart(.title, measure) + style.amounts(item) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .deckPart(.amounts, measure) + } + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 90, alignment: .leading) + } + .frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading) + .padding(.leading, 5) + .padding(.trailing, 12) + .padding(.vertical, 5) + .opacity(item.card.isEnded ? 0.4 : 1) + .background { + RoundedRectangle(cornerRadius: 18) + .fill(Color.backgroundSecondary) + .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.rowSeparator)) + .opacity(item.card.isEnded ? 0.4 : 1) + // The base keeps an ended card solid over the cards beneath it. + .background(Color.backgroundMain, in: .rect(cornerRadius: 18)) + .deckPart(.container, measure) + } + } + + private var strip: some View { + HStack(spacing: 12) { + ring + VStack(alignment: .leading, spacing: 2) { + Text(item.card.title) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .deckPart(.title, measure) + style.amounts(item) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .deckPart(.amounts, measure) + } + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + if item.owedShare != nil { + Button("Pay") { pay?(item.id) } + .opacity(max(0, 1 - 2 * Double(stripCollapse))) + .font(.appTextSmall) + .foregroundStyle(Color.backgroundMain) + .padding(.horizontal, 18) + .frame(height: 32) + .background(Color.success, in: .capsule) + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(item.card.isEnded ? 0.4 : 1) + .background { + RoundedRectangle(cornerRadius: 14) + .fill(Color.backgroundSecondary) + .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(item.owedShare != nil ? Color.success : Color.rowSeparator)) + .opacity(item.card.isEnded ? 0.4 : 1) + .background(Color.backgroundMain, in: .rect(cornerRadius: 14)) + .deckPart(.container, measure) + } + } +} + +/// A request in the expanded deck: the timeline card's lines under the ring and title, on the app's +/// opaque card surface. +private struct PaymentRequestDeckCard: View { + + let item: PaymentRequestDeckItem + var measure: DeckPartMeasure? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded, isClosed: item.request.status == .closed, diameter: 36) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: 36, isEnded: item.card.isEnded) + .deckPart(.ring, measure) + VStack(alignment: .leading, spacing: 2) { + Text("Request") + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .deckPart(.label, measure) + Text(item.card.title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(2) + .deckPart(.title, measure) + } + } + Text(item.card.progressLine) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .deckPart(.amounts, measure) + PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: item.card.isEnded) + .deckPart(.bar, measure) + PaymentRequestDeckCardFooter(item: item) + .deckPart(.status, measure) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .opacity(item.card.isEnded ? 0.4 : 1) + .background { + RoundedRectangle(cornerRadius: 12) + .fill(Color.backgroundSecondary) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.rowSeparator)) + .deckPart(.container, measure) + } + .accessibilityElement(children: .combine) + } +} + +/// An expanded card's footer: the time left, and the viewer's part of the status at the trailing end. +private struct PaymentRequestDeckCardFooter: View { + + let item: PaymentRequestDeckItem + + var body: some View { + HStack(spacing: 8) { + Text(item.card.statusLine) + .lineLimit(1) + Spacer(minLength: 0) + if let lead = item.viewerStatus.lead { + Text(lead) + .lineLimit(1) + .layoutPriority(1) + } + } + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } +} + +// MARK: - Expanded - + +/// The expanded deck: every request as a full-width card beneath the app bar. `PaymentRequestDeckWindow` +/// puts it over the chat's backdrop. +/// While it opens or closes, `PaymentRequestDeckMorph` draws the front card between the pill and the +/// list. +struct PaymentRequestDeckOverlay: View { + + let items: [PaymentRequestDeckItem] + @Bindable var state: PaymentRequestDeckState + let open: (PaymentRequestID) -> Void + + @State private var origin: CGPoint = .zero + @State private var cardHeight: CGFloat = 0 + @State private var viewportHeight: CGFloat = 0 + + var body: some View { + ZStack(alignment: .top) { + list + if let front = items.first { + // Kept mounted and hidden at rest: inserted in the same update that starts a collapse, + // it would have no starting value to animate from and jump straight to the pill. + PaymentRequestDeckMorph( + item: front, + progress: state.progress, + pill: state.pillFrames, + card: state.cardFrames, + pillTypeSize: state.pillTypeSize, + source: state.source, + origin: origin + ) + .opacity(state.isSettled ? 0 : 1) + .allowsHitTesting(false) + } + } + .onGeometryChange(for: CGPoint.self) { $0.frame(in: .global).origin } action: { origin = $0 } + .gesture(DragGesture(minimumDistance: 12).onEnded { value in + // Dragging up, or swiping in from the leading edge as swipe-back would, collapses. + let fromEdge = value.startLocation.x < 24 && value.translation.width > 60 + if value.translation.height < -40 || fromEdge { state.collapse() } + }) + } + + private var list: some View { + ScrollView { + VStack(spacing: 10) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + if index == 0 { + // Always laid out and measured; it shows only at rest, when the moving copy + // drawn from its measurements would sit exactly on it. + card(item) { state.cardFrames[$0] = $1 } + .opacity(state.isSettled ? 1 : 0) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { cardHeight = $0 } + } else { + // The rest slide out from beneath the front card and back under it on collapse. + card(item) + .offset(y: -CGFloat(index) * (cardHeight + 10) * (1 - state.progress)) + .opacity(state.progress) + .zIndex(Double(-index)) + } + } + + } + .padding(.horizontal, 20) + .padding(.top, 8) + // The scroll view covers the backdrop, so the space below the cards takes the backdrop's + // tap; the cards' own buttons win over it. + .frame(minHeight: viewportHeight, alignment: .top) + .contentShape(Rectangle()) + .onTapGesture { state.collapse() } + } + .scrollBounceBehavior(.basedOnSize) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { viewportHeight = $0 } + .allowsHitTesting(state.isOpen) + } + + private func card(_ item: PaymentRequestDeckItem, measure: DeckPartMeasure? = nil) -> some View { + Button { open(item.id) } label: { + PaymentRequestDeckCard(item: item, measure: measure) + } + .buttonStyle(.plain) + } +} + +/// The front card part way between the pill (`progress` 0) and the expanded list (1), laid out from +/// both states' measured frames so it lands exactly on either. Text the two share moves and scales +/// across; the expanded card's own lines ride inside the container and fade in over the last 40%. +private struct PaymentRequestDeckMorph: View, Animatable { + + let item: PaymentRequestDeckItem + var progress: CGFloat + let pill: [DeckPart: CGRect] + let card: [DeckPart: CGRect] + let pillTypeSize: DynamicTypeSize + /// The collapsed face the card moves from and back to. + let source: PaymentRequestDeckStyle + let origin: CGPoint + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + private static let cardRing: CGFloat = 36 + /// appTextMedium over the collapsed title's size. + private var titleScale: CGFloat { 16 / source.titleSize } + /// appTextSmall over appTextCaption, the amounts' two sizes. + private static let amountsScale: CGFloat = 14 / 12 + + var body: some View { + let p = min(max(progress, 0), 1) + if let pillBox = pill[.container], let cardBox = card[.container] { + let box = lerp(pillBox, cardBox, p) + let content = item.card.isEnded ? 0.4 : 1.0 + // The title reads the same in both, so its two sizes cross over the whole move. The + // amounts read differently, so one leaves before the other arrives rather than overlap. + let outgoing = 1 - p * p + let incoming = 1 - (1 - p) * (1 - p) + let amountsOut = max(0, 1 - p / 0.35) + let amountsIn = min(1, max(0, (p - 0.35) / 0.3)) + let extras = max(0, (p - 0.6) / 0.4) + + let radius = source.cornerRadius + (12 - source.cornerRadius) * p + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: radius) + .fill(Color.backgroundSecondary) + .opacity(item.card.isEnded ? 0.4 + 0.6 * p : 1) + .background(Color.backgroundMain, in: .rect(cornerRadius: radius)) + .overlay(RoundedRectangle(cornerRadius: radius).strokeBorder(Color.rowSeparator)) + .overlay { + if source == .strip, item.owedShare != nil { + RoundedRectangle(cornerRadius: radius).strokeBorder(Color.success).opacity(1 - p) + } + } + .place(box, origin) + + Group { + ring(p) + shared(.title, scale: titleScale, p: p, outgoing: outgoing, incoming: incoming) { isCard in + Text(item.card.title) + .font(isCard ? .appTextMedium : source.titleFont) + .foregroundStyle(Color.textMain) + .lineLimit(isCard ? 2 : 1) + } + shared(.amounts, scale: Self.amountsScale, p: p, outgoing: amountsOut, incoming: amountsIn) { isCard in + Group { + if isCard { + Text(item.card.progressLine) + } else { + source.amounts(item) + } + } + .font(isCard ? .appTextSmall : .appTextCaption) + .foregroundStyle(isCard ? Color.textMain : Color.textSecondary) + .lineLimit(1) + } + Group { + extra(.label, in: box, from: cardBox) { + Text("Request").font(.appTextCaption).foregroundStyle(Color.textSecondary) + } + extra(.bar, in: box, from: cardBox, stretches: true) { + PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: item.card.isEnded) + } + extra(.status, in: box, from: cardBox, stretches: true) { + PaymentRequestDeckCardFooter(item: item) + } + } + .opacity(extras) + } + .opacity(content) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + + /// The ring at its size part way between the two, drawn from whichever end is nearer so its "$" + /// keeps the right weight. + @ViewBuilder + private func ring(_ p: CGFloat) -> some View { + if let from = pill[.ring], let to = card[.ring] { + let frame = lerp(from, to, p) + let base = p < 0.5 ? source.ringDiameter : Self.cardRing + PaymentRequestRing( + fraction: item.card.fraction, + isEnded: item.card.isEnded, + isClosed: item.request.status == .closed, + diameter: base + ) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: base, isEnded: item.card.isEnded) + .scaleEffect(frame.width / base) + .frame(width: base, height: base) + .position(x: frame.midX - origin.x, y: frame.midY - origin.y) + } + } + + /// A part both states show: the pill's version scales up and fades out while the card's scales + /// down to meet it and fades in, both anchored at the moving top-leading corner. + @ViewBuilder + private func shared( + _ part: DeckPart, + scale: CGFloat, + p: CGFloat, + outgoing: CGFloat, + incoming: CGFloat, + @ViewBuilder text: @escaping (Bool) -> some View + ) -> some View { + if let from = pill[part], let to = card[part] { + let corner = CGPoint(x: from.minX + (to.minX - from.minX) * p - origin.x, y: from.minY + (to.minY - from.minY) * p - origin.y) + let grow = 1 + (scale - 1) * p + text(false) + .dynamicTypeSize(pillTypeSize) + .frame(width: from.width, height: from.height, alignment: .topLeading) + .scaleEffect(grow, anchor: .topLeading) + .offset(x: corner.x, y: corner.y) + .opacity(outgoing) + text(true) + .frame(width: to.width, height: to.height, alignment: .topLeading) + .scaleEffect(grow / scale, anchor: .topLeading) + .offset(x: corner.x, y: corner.y) + .opacity(incoming) + } + } + + /// A part only the expanded card shows, kept at the same relative spot inside the moving container. + @ViewBuilder + private func extra( + _ part: DeckPart, + in box: CGRect, + from cardBox: CGRect, + stretches: Bool = false, + @ViewBuilder content: () -> some View + ) -> some View { + if let rest = card[part], cardBox.width > 0, cardBox.height > 0 { + let x = box.minX + (rest.minX - cardBox.minX) * box.width / cardBox.width + let y = box.minY + (rest.minY - cardBox.minY) * box.height / cardBox.height + content() + .frame(width: stretches ? rest.width * box.width / cardBox.width : rest.width, height: rest.height, alignment: .topLeading) + .offset(x: x - origin.x, y: y - origin.y) + } + } + + private func lerp(_ a: CGRect, _ b: CGRect, _ t: CGFloat) -> CGRect { + CGRect( + x: a.minX + (b.minX - a.minX) * t, + y: a.minY + (b.minY - a.minY) * t, + width: a.width + (b.width - a.width) * t, + height: a.height + (b.height - a.height) * t + ) + } +} + +private extension View { + /// Lays this view out at `frame`, a global frame, inside a top-leading container at `origin`. + func place(_ frame: CGRect, _ origin: CGPoint) -> some View { + self.frame(width: frame.width, height: frame.height) + .offset(x: frame.minX - origin.x, y: frame.minY - origin.y) + } +} + +extension PaymentRequests { + /// `conversationID`'s deck, worded for the viewer, rotated by `order`. Requests whose title + /// isn't open yet stay out until it is. + func deckItems( + for conversationID: ConversationID, + viewer: UserID?, + order: PaymentRequestDeckOrder, + copy: PaymentRequestCopy, + now: Date + ) -> [PaymentRequestDeckItem] { + order.apply(to: deck(for: conversationID, viewer: viewer, at: now)).compactMap { request in + guard let text = text(of: request) else { return nil } + let owed = viewer.flatMap { viewer in + request.isOwed(by: viewer, at: now) ? request.share(for: viewer) : nil + } + let deadline = request.deadline(at: now) + return PaymentRequestDeckItem( + request: request, + card: PaymentRequestCardContent(request: request, title: text.title, copy: copy, now: now), + amounts: copy.deckAmounts(request), + viewerStatus: PaymentRequestViewerStatus(request: request, viewer: viewer, copy: copy), + owedShare: owed.map { copy.shareText($0, of: request) }, + timeLeft: PaymentRequestCopy.timeLeft(deadline) + ) + } + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckOrder.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckOrder.swift new file mode 100644 index 000000000..32140f630 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckOrder.swift @@ -0,0 +1,48 @@ +// +// PaymentRequestDeckOrder.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// The deck's view-only cycling: how many times the front card was sent to the back since the deck +/// last returned to its default order. +struct PaymentRequestDeckOrder: Hashable { + + /// The pill's height fraction a downward drag must pass to finish a cycle. + static let cycleThreshold: CGFloat = 0.4 + + private(set) var rotation = 0 + + /// `requests`, in default deck order, rotated by the cycles made so far. + func apply(to requests: [GroupPaymentRequest]) -> [GroupPaymentRequest] { + guard requests.count > 1 else { return requests } + let shift = rotation % requests.count + return Array(requests[shift...] + requests[..<shift]) + } + + /// Sends the front card to the back. + mutating func cycle() { + rotation += 1 + } + + /// Returns to the default order, as every refetch and every reopen of the chat does. + mutating func reset() { + rotation = 0 + } + + /// Brings the card at `index` of the default order to the front. + mutating func bringToFront(_ index: Int) { + rotation = index + } + + /// Whether a drag of `translation` points over a pill `height` points tall, ending at + /// `predictedTranslation`, finishes a cycle. + static func finishesCycle(translation: CGFloat, predictedTranslation: CGFloat, height: CGFloat) -> Bool { + guard height > 0 else { return false } + return translation > height * cycleThreshold || predictedTranslation > height * 1.5 + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift new file mode 100644 index 000000000..3a1b01b83 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift @@ -0,0 +1,135 @@ +// +// PaymentRequestDeckWindow.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// Hosts the expanded deck in a window above the app's own, so its backdrop can cover the app bar's +/// back button, picture and title the way a long-pressed message's menu does. A screen overlay +/// can't: the navigation bar draws over everything the screen puts on top of itself. +@MainActor +@Observable +final class PaymentRequestDeckWindow { + + /// The deck's items, kept current while the window is up. + var items: [PaymentRequestDeckItem] = [] + /// Where the expanded list goes, in screen coordinates: the screen's content area below the bar. + var listFrame: CGRect = .zero + /// The request whose details show over the expanded deck. They open from this window so nothing + /// of the deck draws over them; as they start to close, while they still cover the screen, the + /// deck snaps back to the pill, so going back finds it minimized. + var details: PaymentRequestSelection? + /// The app's scene phase, which a second window's views don't receive on their own. + var scenePhase: ScenePhase = .active + + private var window: UIWindow? + + /// Puts the window up over the app. Presenting twice is a no-op. + /// `details` builds a request's details screen with the environment it needs, which a second + /// window doesn't inherit from the app's. + func present<Details: View>(state: PaymentRequestDeckState, details: @escaping (PaymentRequestID) -> Details) { + let scene = UIApplication.shared.firstWindowScene + guard window == nil, let scene else { return } + let content = PaymentRequestDeckWindowContent(window: self, state: state) { AnyView(details($0)) } + let host = UIHostingController(rootView: content) + host.view.backgroundColor = .clear + let window = UIWindow(windowScene: scene) + // Above the app, below the dialogs `DialogWindow` raises at `.alert`. + window.windowLevel = .normal + 1 + window.overrideUserInterfaceStyle = .dark + window.rootViewController = host + window.isHidden = false + self.window = window + } + + /// Takes the window down. + func dismiss() { + details = nil + window?.isHidden = true + window = nil + } +} + +private struct PaymentRequestDeckWindowContent: View { + + @Bindable var window: PaymentRequestDeckWindow + @Bindable var state: PaymentRequestDeckState + let details: (PaymentRequestID) -> AnyView + + var body: some View { + ZStack(alignment: .topLeading) { + ChatBackdropView() + .opacity(state.progress) + .contentShape(Rectangle()) + .onTapGesture { state.collapse() } + .accessibilityLabel("Collapse requests") + .accessibilityAddTraits(.isButton) + + PaymentRequestDeckOverlay(items: window.items, state: state) { window.details = PaymentRequestSelection(id: $0) } + .frame(width: window.listFrame.width, height: window.listFrame.height) + .padding(.leading, window.listFrame.minX) + .padding(.top, window.listFrame.minY) + + closeButton + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .ignoresSafeArea() + .fullScreenCover(item: $window.details, onDismiss: state.endPresentation) { + details($0.id) + // Details poll and fetch the pay intent only while active. + .environment(\.scenePhase, window.scenePhase) + .background(DisappearanceReader(willDisappear: state.snapToPill)) + } + } + + /// Sits where the app-bar pill does, so it reads as the pill turned into a close. It fades in + /// with the backdrop and out over the first quarter of a collapse, before the front card gets + /// back to the pill. + @ViewBuilder + private var closeButton: some View { + if let pill = state.barFrame { + let opacity = state.isOpen ? state.progress : max(0, (state.progress - 0.75) / 0.25) + CloseButton(style: .glass) { state.collapse() } + .accessibilityLabel("Collapse requests") + .position(x: pill.maxX - pill.height / 2, y: pill.midY) + .opacity(opacity) + .allowsHitTesting(state.isOpen) + } + } +} + +/// Calls `willDisappear` as the screen it sits in starts to go, before any dismissal transition +/// uncovers what is beneath. SwiftUI's `onDisappear` comes only once it has gone, and the +/// appearance callbacks arrive while a presentation is still sliding in. +private struct DisappearanceReader: UIViewControllerRepresentable { + + let willDisappear: () -> Void + + func makeUIViewController(context: Context) -> Controller { + Controller(willDisappear: willDisappear) + } + + func updateUIViewController(_ controller: Controller, context: Context) {} + + final class Controller: UIViewController { + + let willDisappear: () -> Void + + init(willDisappear: @escaping () -> Void) { + self.willDisappear = willDisappear + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + willDisappear() + } + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsContent.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsContent.swift new file mode 100644 index 000000000..22009b044 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsContent.swift @@ -0,0 +1,103 @@ +// +// PaymentRequestDetailsContent.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// Request details put into words for one viewer. +struct PaymentRequestDetailsContent: Hashable { + + /// What tapping a share row does. + enum RowAction: Hashable { + case none + /// The creator may mark this unpaid share paid. + case markPaid + } + + struct Row: Identifiable, Hashable { + let userID: UserID + let name: String + let amount: String + let status: String + let isPaid: Bool + let action: RowAction + + var id: UserID { userID } + } + + let title: String + let requestedBy: String + let amounts: String + let statusLine: String + let rows: [Row] + /// The viewer's unpaid share when they can pay it now; `nil` hides "Send <amount>". + let payableShare: GroupPaymentRequest.Share? + /// "Send $20" for ``payableShare``. + let sendTitle: String? + /// Whether the "•••" menu with Close Request shows. + let canClose: Bool + let isEnded: Bool + + /// `name` gives a member's display name; the viewer's own row reads "You". + init( + request: GroupPaymentRequest, + title: String, + viewer: UserID, + hasPaymentIntent: Bool, + copy: PaymentRequestCopy, + now: Date, + name: (UserID) -> String + ) { + let isOpen: Bool + switch request.effectiveStatus(at: now) { + case .open: isOpen = true + case .completed, .closed, .expired, .unknown: isOpen = false + } + let isCreator = request.creator == viewer + + self.title = title + if isCreator { + requestedBy = "Requested by you" + } else { + requestedBy = "Requested by \(request.creator.map(name) ?? "")" + } + amounts = copy.progressLine(request) + statusLine = PaymentRequestCopy.statusLine(request.deadline(at: now)) + isEnded = !isOpen + canClose = isOpen && isCreator + + rows = (request.split?.shares ?? []).compactMap { share in + guard let userID = share.userID else { return nil } + let action: RowAction = isOpen && isCreator && !share.isPaid && userID != viewer ? .markPaid : .none + return Row( + userID: userID, + name: userID == viewer ? "You" : name(userID), + amount: copy.shareText(share, of: request), + status: PaymentRequestCopy.shareStatus(share, requestIsOpen: isOpen), + isPaid: share.isPaid, + action: action + ) + } + + if isOpen, !isCreator, hasPaymentIntent, let own = request.share(for: viewer), !own.isPaid { + payableShare = own + sendTitle = "Send \(copy.shareText(own, of: request))" + } else { + payableShare = nil + sendTitle = nil + } + } +} + +extension GroupPaymentRequest { + + /// Whether a widget sent by `sender` in `conversationID` may render this request. A request + /// from another chat, or one the sender did not create, renders as unsupported. + func belongs(to conversationID: ConversationID, sentBy sender: UserID?) -> Bool { + chatID == conversationID && creator != nil && creator == sender + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift new file mode 100644 index 000000000..5fb2b1c7a --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -0,0 +1,478 @@ +// +// PaymentRequestDetailsScreen.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// Request details: who asked, how much is paid, and one row per share, with paying, closing and +/// marking a share paid for the viewers allowed to. +struct PaymentRequestDetailsScreen: View { + + @Environment(Session.self) private var session + @Environment(SessionContainer.self) private var sessionContainer + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + + let requestID: PaymentRequestID + /// Whether the screen is pushed onto an existing navigation stack, which then supplies the back + /// button in place of the close button. + var isPushed = false + + @State private var dialogItem: DialogItem? + /// A confirm queued behind an options sheet, shown once that sheet is gone. + @State private var queuedDialog: DialogItem? + /// Whether the last fetch carried an intent ID; the ID itself stays with ``PaymentRequestPayment``. + @State private var hasPaymentIntent = false + @State private var payFlow = PaymentRequestPayFlow() + @State private var isUpdating = false + + var body: some View { + Group { + if isPushed { + screen + } else { + NavigationStack { screen } + } + } + // Like the chat, details refetch on a 45 s poll while on screen in the foreground. + .task(id: PaymentRequestDetailsPoll(requestID: requestID, isActive: scenePhase == .active)) { + guard scenePhase == .active else { return } + await load() + var retries = 0 + while !Task.isCancelled { + // Still loading after a failed read retries sooner than the poll. + if sessionContainer.paymentRequests.isSettling(request: requestID) { + try? await Task.sleep(for: PaymentRequests.retryDelay(attempt: retries)) + retries += 1 + } else { + try? await Task.sleep(for: PaymentRequests.pollInterval) + retries = 0 + } + guard !Task.isCancelled else { return } + await refresh() + } + } + .dialog(item: $dialogItem) + .paymentRequestPaySheet(payFlow) + } + + private var screen: some View { + // The status line counts down, so redraw at least once a minute. + TimelineView(.periodic(from: .now, by: 60)) { context in + if let content = content(at: context.date) { + loaded(content) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(Color.backgroundMain) + .navigationTitle("Request") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if content(at: .now)?.canClose == true { + Button { + presentCloseOptions() + } label: { + Image(systemName: "ellipsis") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + } + .accessibilityLabel("Options") + } + if !isPushed { + CloseButton(action: { dismiss() }) + } + } + } + } + + // MARK: - Content - + + private func loaded(_ content: PaymentRequestDetailsContent) -> some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 30) { + header(content) + VStack(spacing: 8) { + ForEach(content.rows) { row in + shareRow(row) + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 24) + } + .softScrollEdge(for: [.top, .bottom]) + if let sendTitle = content.sendTitle { + CodeButton(isLoading: payFlow.isPreparing, style: .filled, title: sendTitle) { + pay() + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + } + + private func header(_ content: PaymentRequestDetailsContent) -> some View { + let creator = sessionContainer.paymentRequests.request(requestID)?.creator + return VStack(alignment: .leading, spacing: 16) { + Text(content.title) + .font(.appDisplayCompact) + .foregroundStyle(Color.textMain) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + if let creator { + PaymentRequestAvatar(userID: creator, member: member(creator), size: 24) + } + Text(content.requestedBy) + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + } + VStack(alignment: .leading, spacing: 6) { + Text(content.amounts) + .font(.appDisplayXS) + .foregroundStyle(Color.textMain) + Text(content.statusLine) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + .accessibilityElement(children: .combine) + } + + /// A row with nothing to do is a plain view: a disabled button would dim the whole row. + @ViewBuilder + private func shareRow(_ row: PaymentRequestDetailsContent.Row) -> some View { + switch row.action { + case .markPaid: + Button { + presentMarkPaidOptions(row) + } label: { + shareRowContent(row) + } + .buttonStyle(.plain) + .disabled(isUpdating) + .accessibilityElement(children: .combine) + case .none: + shareRowContent(row) + .accessibilityElement(children: .combine) + } + } + + private func shareRowContent(_ row: PaymentRequestDetailsContent.Row) -> some View { + HStack(spacing: 12) { + PaymentRequestAvatar(userID: row.userID, member: member(row.userID), size: 40) + VStack(alignment: .leading, spacing: 2) { + Text(row.name) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + Text(row.status) + .font(.appTextSmall) + .foregroundStyle(row.isPaid ? Color.textSuccess : Color.textSecondary) + } + Spacer(minLength: 8) + Text(row.amount) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + } + .padding(.horizontal, 12) + .frame(minHeight: 64) + .background(Color.white.opacity(0.06), in: .rect(cornerRadius: 12)) + .contentShape(.rect) + } + + /// The viewer's reading of the request, or nil until it and its title are loaded. + private func content(at now: Date) -> PaymentRequestDetailsContent? { + let requests = sessionContainer.paymentRequests + guard let request = requests.request(requestID), let text = requests.text(of: request) else { return nil } + return PaymentRequestDetailsContent( + request: request, + title: text.title, + viewer: session.userID, + hasPaymentIntent: hasPaymentIntent, + copy: PaymentRequestCopy(session.displayCurrency), + now: now, + name: { member($0).map(ConversationLoadCoordinator.attributionName(for:)) ?? "Someone" } + ) + } + + private func member(_ userID: UserID) -> ConversationMember? { + guard let request = sessionContainer.paymentRequests.request(requestID), let chatID = request.chatID else { return nil } + return sessionContainer.groupRosters + .members(for: chatID, creator: request.creator) + .first { $0.userID == userID } + } + + // MARK: - Actions - + + private func load() async { + let requests = sessionContainer.paymentRequests + await refresh() + guard let chatID = requests.request(requestID)?.chatID else { return } + await sessionContainer.groupRosters.refresh(chatID) + } + + private func refresh() async { + do { + hasPaymentIntent = try await sessionContainer.paymentRequests.refresh(requestID) != nil + } catch { + hasPaymentIntent = false + } + } + + /// Refetches for the intent ID right before the swipe sheet, as the spec requires. + private func pay() { + Task { + switch await payFlow.start(requestID, sessionContainer: sessionContainer) { + case .ready: + break + case .nothingToPay, .closed, .failed: + hasPaymentIntent = false + } + } + } + + private func presentCloseOptions() { + present(.options { + DialogAction.destructive("Close Request") { + queuedDialog = closeConfirm() + } + DialogAction.cancel() + }) + } + + private func closeConfirm() -> DialogItem { + .info( + title: "Close Request?", + subtitle: "This will stop payments. Anyone who hasn't paid will be marked Didn't pay. Payments already made won't be affected." + ) { + DialogAction.destructive("Close Request") { + Task { await update { try await $0.close(requestID) } } + } + DialogAction.cancel() + } + } + + private func presentMarkPaidOptions(_ row: PaymentRequestDetailsContent.Row) { + present(.options(title: row.name, subtitle: "\(row.amount) · \(row.status)") { + DialogAction.standard("Mark as Paid") { + queuedDialog = markPaidConfirm(row) + } + DialogAction.cancel() + }) + } + + private func markPaidConfirm(_ row: PaymentRequestDetailsContent.Row) -> DialogItem { + let firstName = row.name.split(separator: " ").first.map(String.init) ?? row.name + return .info( + title: "Mark \(firstName) as Paid?", + subtitle: "Use this if \(firstName) paid you outside Flipcash. No money will be sent. This action cannot be reversed." + ) { + DialogAction.standard("Mark as Paid") { + Task { await update { try await $0.markPaid(requestID, userID: row.userID) } } + } + DialogAction.cancel() + } + } + + /// Shows `item`, then whatever its action queued once it has left the screen. + private func present(_ item: DialogItem) { + dialogItem = item.onDismiss { + guard let next = queuedDialog else { return } + queuedDialog = nil + dialogItem = next + } + } + + /// Runs a creator action; the store takes the server's response, and a failure refetches. + private func update(_ action: (PaymentRequests) async throws -> Void) async { + isUpdating = true + defer { isUpdating = false } + let requests = sessionContainer.paymentRequests + do { + try await action(requests) + } catch { + ErrorReporting.captureError(error, reason: "Payment request update failed") + _ = try? await requests.refresh(requestID) + session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We couldn't update this request. Please try again." + ) + } + } +} + +/// A member's avatar, loading their picture on first draw. +private struct PaymentRequestAvatar: View { + + @Environment(SessionContainer.self) private var sessionContainer + + let userID: UserID + let member: ConversationMember? + let size: CGFloat + + var body: some View { + ContactAvatarView( + id: userID.uuidString, + displayName: member.map(ConversationLoadCoordinator.attributionName(for:)) ?? "", + imageData: sessionContainer.profileAvatars.data(for: userID), + blurhash: member?.profilePicture?.thumbnailBlurhash, + size: size + ) + .task(id: userID) { + await sessionContainer.profileAvatars.load(userID: userID, picture: member?.profilePicture) + } + } +} + +private struct PaymentRequestPayDismissed: Error {} + +// MARK: - Pay flow - + +/// Paying a share from wherever it starts: refetches the request for its intent ID, then shows the +/// swipe sheet when the share is still payable. +@MainActor +@Observable +final class PaymentRequestPayFlow { + + private(set) var isPreparing = false + var isPaying = false + private(set) var payment: PaymentRequestPayment? + + /// Prepares to pay the viewer's share of `requestID` and shows the sheet when it is payable. + @discardableResult + func start(_ requestID: PaymentRequestID, sessionContainer: SessionContainer) async -> PaymentRequestPayment.Preparation { + guard !isPreparing else { return .failed } + isPreparing = true + defer { isPreparing = false } + let payment = self.payment.flatMap { $0.requestID == requestID ? $0 : nil } + ?? PaymentRequestPayment(requestID: requestID, sessionContainer: sessionContainer) + self.payment = payment + let preparation = await payment.prepare() + if preparation == .ready { isPaying = true } + return preparation + } +} + +extension View { + /// Shows `flow`'s swipe-to-send sheet while it is paying. + func paymentRequestPaySheet(_ flow: PaymentRequestPayFlow) -> some View { + modifier(PaymentRequestPaySheetPresenter(flow: flow)) + } +} + +private struct PaymentRequestPaySheetPresenter: ViewModifier { + + @Bindable var flow: PaymentRequestPayFlow + + @Environment(Session.self) private var session + @Environment(SessionContainer.self) private var sessionContainer + + func body(content: Content) -> some View { + content.sheet(isPresented: $flow.isPaying) { + // The share reads paid once the payment lands; the sheet stays up through its success state. + if let payment = flow.payment, let request = payment.request, let creator = request.creator { + PaymentRequestPaySheet( + payment: payment, + payee: request.chatID.flatMap { chatID in + sessionContainer.groupRosters + .members(for: chatID, creator: creator) + .first { $0.userID == creator } + }, + payeeID: creator, + amount: payment.share.map { PaymentRequestCopy(session.displayCurrency).shareText($0, of: request) } ?? "" + ) + .task { + // Opened from the chat's strip, nothing else has loaded the roster that names the payee. + if let chatID = request.chatID, !sessionContainer.groupRosters.isLoaded(chatID) { + await sessionContainer.groupRosters.refresh(chatID) + } + } + } + } + } +} + +/// The swipe-to-send sheet for the viewer's share: the creator, the amount, the token picker. +private struct PaymentRequestPaySheet: View { + + @Environment(\.dismiss) private var dismiss + + let payment: PaymentRequestPayment + let payee: ConversationMember? + let payeeID: UserID + let amount: String + + @State private var didSucceed = false + @State private var isShowingTokenSelection = false + + var body: some View { + PartialSheet(background: .clear) { + VStack(spacing: 25) { + VStack(spacing: 8) { + PaymentRequestAvatar(userID: payeeID, member: payee, size: 56) + Text(payee.map(ConversationLoadCoordinator.attributionName(for:)) ?? "") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .lineLimit(1) + if let handle = payee?.username?.handle { + Text(handle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + VStack(spacing: 8) { + Text(amount) + .font(.appDisplayLarge) + .foregroundStyle(Color.textMain) + .lineLimit(1) + .minimumScaleFactor(0.4) + TokenSelectorButton(selectedBalance: payment.selectedBalance) { + isShowingTokenSelection = true + } + .id(payment.selectedBalance?.stored.mint) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Capsule().fill(Color.white.opacity(0.1))) + .padding(.top, 4) + } + SwipeControl(text: "Swipe to Send") { + switch await payment.submit() { + case .success: + didSucceed = true + case .failed: + throw PaymentRequestPayDismissed() + } + } + } + .padding(.horizontal, 20) + .padding(.top, 30) + .padding(.bottom, bottomPadding) + } + // Hold the success checkmark briefly, then return to the details, now showing the row paid. + .task(id: didSucceed) { + guard didSucceed else { return } + try? await Task.delay(seconds: 1) + guard !Task.isCancelled else { return } + dismiss() + } + .sheet(isPresented: $isShowingTokenSelection) { + SelectCurrencyScreen(isPresented: $isShowingTokenSelection) { balance in + payment.selectCurrencyAction(exchangedBalance: balance) + } + } + } + + private var bottomPadding: CGFloat { + let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + return max(0, 20 - reserved) + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift new file mode 100644 index 000000000..1af23778a --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift @@ -0,0 +1,155 @@ +// +// PaymentRequestDraft.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// The deadline a new request is created with. +enum PaymentRequestDeadline: Hashable, Sendable { + case oneDay + case threeDays + case oneWeek + case twoWeeks + case fourWeeks + case custom(Date) + + /// The presets, in the order the Time To Pay sheet lists them before Custom. + static let presets: [PaymentRequestDeadline] = [.oneDay, .threeDays, .oneWeek, .twoWeeks, .fourWeeks] + + /// The server's limit less five minutes, so a device clock slightly ahead of the server's passes. + static let maximumLead: TimeInterval = 28 * 24 * 60 * 60 - 5 * 60 + /// The soonest a custom deadline can be picked. + static let minimumCustomLead: TimeInterval = 60 * 60 + + /// The dates the custom picker offers when it opens at `now`. + static func customRange(openedAt now: Date) -> ClosedRange<Date> { + now.addingTimeInterval(minimumCustomLead)...now.addingTimeInterval(maximumLead) + } + + /// `ends_at` for a request sent at `now`. + func endsAt(sentAt now: Date) -> Date { + let day: TimeInterval = 24 * 60 * 60 + switch self { + case .oneDay: return now.addingTimeInterval(day) + case .threeDays: return now.addingTimeInterval(3 * day) + case .oneWeek: return now.addingTimeInterval(7 * day) + case .twoWeeks: return now.addingTimeInterval(14 * day) + case .fourWeeks: return now.addingTimeInterval(Self.maximumLead) + case .custom(let date): return date + } + } + + /// The row's label on the Time To Pay sheet. + var rowTitle: String { + switch self { + case .oneDay: "1 Day" + case .threeDays: "3 Days" + case .oneWeek: "1 Week" + case .twoWeeks: "2 Weeks" + case .fourWeeks: "4 Weeks" + case .custom: "Custom" + } + } + + /// The Timing value on the summary. + func summaryValue(locale: Locale = .current) -> String { + switch self { + case .oneDay: return "1 Day To Pay" + case .threeDays: return "3 Days To Pay" + case .oneWeek: return "7 Days To Pay" + case .twoWeeks: return "14 Days To Pay" + case .fourWeeks: return "28 Days To Pay" + case .custom(let date): + let day = date.formatted(Date.FormatStyle(date: .abbreviated, time: .omitted, locale: locale)) + let time = date.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: locale)) + return "Until \(day), \(time)" + } + } +} + +/// Who a new request asks, and the words the summary says about them. +struct PaymentRequestPeople: Hashable, Sendable { + + /// The most shares a request can hold, the creator's included. + static let maximumShares = 100 + + /// Every member in roster order, the creator among them. + let members: [UserID] + let creator: UserID + private(set) var selected: Set<UserID> + + /// Everyone selected, unless the group is larger than a request can hold, which starts empty. + init(members: [UserID], creator: UserID) { + self.members = members + self.creator = creator + self.selected = members.count > Self.maximumShares ? [] : Set(members) + } + + var includesCreator: Bool { selected.contains(creator) } + + /// The members asked other than the creator. + var askedCount: Int { selected.subtracting([creator]).count } + + /// Whether at least one member other than the creator is selected. + var isValid: Bool { askedCount > 0 } + + /// Checks or unchecks `userID`. Returns `false`, leaving the selection as it was, when checking + /// would make more than ``maximumShares`` shares. + @discardableResult + mutating func toggle(_ userID: UserID) -> Bool { + if selected.contains(userID) { + selected.remove(userID) + return true + } + guard selected.count < Self.maximumShares else { return false } + selected.insert(userID) + return true + } + + /// The summary's People value. + var summaryValue: String { + guard !selected.isEmpty else { return "Choose People" } + let othersCount = members.filter { $0 != creator }.count + let everyoneElse = askedCount == othersCount + switch (everyoneElse, includesCreator) { + case (true, true): return "Everyone, Including You" + case (true, false): return "Everyone Except You" + case (false, true): return "\(Self.people(askedCount)), Including You" + case (false, false): return Self.people(askedCount) + } + } + + /// The summary's second People line, shown when the creator is included. + var summarySecondLine: String? { + includesCreator ? "You'll be marked as paid" : nil + } + + /// The selected members in roster order, the creator first when included. + var orderedSelection: [UserID] { + let others = members.filter { $0 != creator && selected.contains($0) } + return includesCreator ? [creator] + others : others + } + + /// One unpaid share per selected member, each of `nativeAmount`. + func shares(nativeAmount: Double) -> [GroupPaymentRequest.Share] { + orderedSelection.map { GroupPaymentRequest.Share(userID: $0, nativeAmount: nativeAmount) } + } + + /// "Request $20 from 25 People", counting only the members asked. + func buttonTitle(amount: String) -> String { + "Request \(amount) from \(Self.people(askedCount))" + } + + private static func people(_ count: Int) -> String { + count == 1 ? "1 Person" : "\(count) People" + } +} + +/// The default title of a new request: the weekday followed by " Request". +func defaultPaymentRequestTitle(at now: Date = .now, locale: Locale = .current) -> String { + "\(now.formatted(Date.FormatStyle(locale: locale).weekday(.wide))) Request" +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift new file mode 100644 index 000000000..2ee3d4c6d --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -0,0 +1,854 @@ +// +// PaymentRequestFlow.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +// MARK: - Money actions - + +/// The sheet a private group's send-cash button opens: send money as today, or request it. +/// +/// Two cards like the Add Money sheet's and nothing else, matching Android (node 12243:1009). +struct MoneyActionsSheet: View { + + @Environment(\.dismiss) private var dismiss + + let sendMoney: () -> Void + let requestMoney: () -> Void + + var body: some View { + PartialSheet { + VStack(spacing: 12) { + row(icon: .withdraw, title: "Send Money", subtitle: "Send money to someone in the group, or multiple people", identifier: "money-action-send") { + dismiss() + sendMoney() + } + row(icon: .deposit, title: "Request Money", subtitle: "Collect money from people in the group", identifier: "money-action-request") { + dismiss() + requestMoney() + } + } + .padding(.horizontal) + .padding(.vertical, 20) + } + } + + private func row(icon: Asset, title: String, subtitle: String, identifier: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Text(subtitle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .multilineTextAlignment(.leading) + + Spacer(minLength: 12) + + Image.asset(icon) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(Color.textMain) + } + .padding(20) + .frame(maxWidth: .infinity) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityIdentifier(identifier) + } +} + +// MARK: - Flow - + +private enum PaymentRequestStep: Hashable { + case amount + case summary + case name + case selectPeople + case details(PaymentRequestID) +} + +/// Request Money in a private group: the history, then New Request's amount, summary and confirm. +struct PaymentRequestFlow: View { + + @Environment(SessionContainer.self) private var sessionContainer + + let conversationID: ConversationID + + var body: some View { + PaymentRequestFlowContent(sessionContainer: sessionContainer, conversationID: conversationID) + } +} + +private struct PaymentRequestFlowContent: View { + + @Environment(\.dismiss) private var dismiss + + @State private var viewModel: PaymentRequestCreateViewModel + @State private var history: PaymentRequestHistory + @State private var path: [PaymentRequestStep] = [] + + init(sessionContainer: SessionContainer, conversationID: ConversationID) { + _viewModel = State(initialValue: PaymentRequestCreateViewModel( + sessionContainer: sessionContainer, + conversationID: conversationID + )) + _history = State(initialValue: PaymentRequestHistory( + requests: sessionContainer.paymentRequests, + conversationID: conversationID + )) + } + + var body: some View { + NavigationStack(path: $path) { + PaymentRequestHistoryScreen(history: history, newRequest: { + viewModel.startNewRequest() + path.append(.amount) + }, open: { path.append(.details($0)) }) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(action: { dismiss() }) + } + } + .navigationDestination(for: PaymentRequestStep.self) { step in + switch step { + case .amount: + PaymentRequestAmountScreen(viewModel: viewModel) { + path.append(.summary) + } + case .summary: + PaymentRequestSummaryScreen(viewModel: viewModel, editName: { + path.append(.name) + }, selectPeople: { + path.append(.selectPeople) + }, sent: { dismiss() }) + case .name: + PaymentRequestNameScreen(title: viewModel.title) { + viewModel.title = $0 + path.removeLast() + } + case .selectPeople: + PaymentRequestSelectPeopleScreen(viewModel: viewModel) { + path.removeLast() + } + case .details(let id): + PaymentRequestDetailsScreen(requestID: id, isPushed: true) + } + } + } + } +} + +// MARK: - History - + +/// Request Money history for one chat: its open requests, then its resolved ones a page at a time. +@MainActor +@Observable +final class PaymentRequestHistory { + + static let resolvedPageSize = 20 + + private(set) var openIDs: [PaymentRequestID] = [] + private(set) var resolvedIDs: [PaymentRequestID] = [] + private(set) var hasMoreResolved = true + private(set) var isLoading = false + + let conversationID: ConversationID + let requests: PaymentRequests + + @ObservationIgnored private var resolvedToken: Data? + + init(requests: PaymentRequests, conversationID: ConversationID) { + self.requests = requests + self.conversationID = conversationID + } + + /// Open requests, nearest deadline first. + var open: [GroupPaymentRequest] { + openIDs.compactMap(requests.request).sorted { + ($0.split?.endsAt ?? .distantFuture) < ($1.split?.endsAt ?? .distantFuture) + } + } + + /// Resolved requests, most recently ended first. + var resolved: [GroupPaymentRequest] { + resolvedIDs.compactMap(requests.request).sorted { + ($0.effectiveClosedAt(at: .now) ?? .distantPast) > ($1.effectiveClosedAt(at: .now) ?? .distantPast) + } + } + + /// Reads every OPEN request and the first RESOLVED page, starting the resolved list over. + func reload() async { + isLoading = true + defer { isLoading = false } + do { + var ids: [PaymentRequestID] = [] + var token: Data? + repeat { + let page = try await requests.page(conversationID, section: .open, pageSize: PaymentRequests.openPageSize, after: token) + ids += page.ids + token = page.nextPagingToken + } while token != nil + openIDs = ids + + let first = try await requests.page(conversationID, section: .resolved, pageSize: Self.resolvedPageSize, after: nil) + resolvedIDs = first.ids + resolvedToken = first.nextPagingToken + hasMoreResolved = first.nextPagingToken != nil + } catch { + logger.warning("Failed to load payment request history", metadata: ["error": "\(error)"]) + } + } + + /// Reads the next RESOLVED page, if there is one and no read is running. + func loadMoreResolved() async { + guard hasMoreResolved, !isLoading, let token = resolvedToken else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await requests.page(conversationID, section: .resolved, pageSize: Self.resolvedPageSize, after: token) + resolvedIDs += page.ids.filter { !resolvedIDs.contains($0) } + resolvedToken = page.nextPagingToken + hasMoreResolved = page.nextPagingToken != nil + } catch { + logger.warning("Failed to load more payment request history", metadata: ["error": "\(error)"]) + } + } +} + +private let logger = Logger(label: "flipcash.payment-request-history") + +private struct PaymentRequestHistoryScreen: View { + + @Environment(Session.self) private var session + + let history: PaymentRequestHistory + let newRequest: () -> Void + let open: (PaymentRequestID) -> Void + + var body: some View { + Background(color: .backgroundMain) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + CodeButton(style: .filled, title: "New Request", action: newRequest) + .padding(.bottom, 12) + + if !history.open.isEmpty { + header("Open Requests") + ForEach(history.open, id: \.id) { row($0) } + } + if !history.resolved.isEmpty { + header("Previous Requests") + .padding(.top, history.open.isEmpty ? 0 : 12) + ForEach(history.resolved, id: \.id) { request in + row(request) + .task { + guard request.id == history.resolved.last?.id else { return } + await history.loadMoreResolved() + } + } + } + } + .padding(20) + } + } + .navigationTitle("Request Money") + .navigationBarTitleDisplayMode(.inline) + .task { await history.reload() } + } + + private func header(_ title: String) -> some View { + Text(title) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + + private func row(_ request: GroupPaymentRequest) -> some View { + let copy = PaymentRequestCopy(session.displayCurrency) + return Button { + open(request.id) + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(history.requests.text(of: request)?.title ?? "Request") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .lineLimit(1) + if let createdAt = request.createdAt { + Text(createdAt.formatted(.dateTime.month(.wide).day())) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + } + } + Text(copy.historyAmounts(request)) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.textSecondary) + } + .padding(16) + .background(Color.white.opacity(0.06), in: .rect(cornerRadius: 12)) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Amount - + +private struct PaymentRequestAmountScreen: View { + + @Bindable var viewModel: PaymentRequestCreateViewModel + let next: () -> Void + + var body: some View { + Background(color: .backgroundMain) { + EnterAmountView( + mode: .currency, + enteredAmount: $viewModel.enteredAmount, + subtitle: .hidden, + actionState: .constant(.normal), + actionEnabled: { _ in viewModel.canContinue }, + action: next, + actionTitle: "Next" + ) + .foregroundStyle(.textMain) + .padding(20) + } + .ignoresSafeArea(.keyboard) + .navigationTitle("Amount Per Person") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Summary - + +private struct PaymentRequestSummaryScreen: View { + + @Bindable var viewModel: PaymentRequestCreateViewModel + let editName: () -> Void + let selectPeople: () -> Void + let sent: () -> Void + + @State private var isPickingDeadline = false + @State private var dialogItem: DialogItem? + + var body: some View { + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.amount?.formatted() ?? "") + .font(.appDisplayMedium) + .foregroundStyle(Color.textMain) + Text("per person") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + } + .padding(.bottom, 8) + + summaryRow(label: "Name", value: viewModel.title, secondLine: nil, action: editName) + summaryRow(label: "People", value: viewModel.peopleValue, secondLine: viewModel.peopleSecondLine, action: selectPeople) + summaryRow( + label: "Timing", + value: viewModel.deadline.summaryValue(), + secondLine: "Request automatically closes after" + ) { + isPickingDeadline = true + } + } + .padding(20) + } + + CodeButton( + isLoading: viewModel.isSending, + style: .filled, + title: viewModel.sendButtonTitle, + disabled: !viewModel.canSend + ) { + viewModel.prepareConfirm() + dialogItem = confirm() + } + .padding(20) + } + } + .navigationTitle("New Request") + .navigationBarTitleDisplayMode(.inline) + .task(id: viewModel.conversationID) { + await viewModel.refreshMembers() + } + .sheet(isPresented: $isPickingDeadline) { + PaymentRequestDeadlineSheet(deadline: $viewModel.deadline) + } + .dialog(item: $dialogItem) + } + + private func summaryRow(label: String, value: String, secondLine: String?, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + Text(value) + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .lineLimit(1) + if let secondLine { + Text(secondLine) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.textSecondary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.06), in: .rect(cornerRadius: 12)) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityHint("Double tap to change") + } + + private func confirm() -> DialogItem { + .info(title: "Send Request?", subtitle: "You will receive and hold the money collected") { + DialogAction.standard("Send Request") { + Task { await send() } + } + DialogAction.cancel() + } + } + + private func send() async { + switch await viewModel.send() { + case .sent: + sent() + case .failed(let copy): + dialogItem = .alert(title: copy.title, subtitle: copy.subtitle) { + DialogAction.okay(kind: .standard) + } + } + } +} + +// MARK: - Name - + +/// The Name row's destination, drawn the way ``EditGroupNameScreen`` and ``ProfileNameScreen`` draw +/// theirs: one field under a heading, a countdown near the cap, and a filled button at the bottom. +private struct PaymentRequestNameScreen: View { + + @State private var text: String + @FocusState private var isFocused: Bool + + let save: (String) -> Void + + /// Same threshold as the profile and group name screens. + private static let countdownThreshold = 10 + + init(title: String, save: @escaping (String) -> Void) { + _text = State(initialValue: title) + self.save = save + } + + private var validated: String? { PaymentRequestTitleValidator().validate(text) } + + private var remainingScalars: Int { + PaymentRequestTitleValidator.maxScalars - text.unicodeScalars.count + } + + var body: some View { + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 0) { + Text("What is the request for?") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .padding(.top, 20) + + TextField("Request Name", text: $text) + .font(.appDisplayMedium) + .foregroundStyle(Color.textMain) + .focused($isFocused) + .submitLabel(.done) + .onSubmit(done) + .padding(.top, 32) + + Spacer() + + if remainingScalars < Self.countdownThreshold { + Text("\(remainingScalars) characters") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 12) + } + + Button("Done", action: done) + .buttonStyle(.filled) + .disabled(validated == nil) + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .navigationBarTitleDisplayMode(.inline) + .focusAfterPush($isFocused) + } + + private func done() { + guard let validated else { return } + save(validated) + } +} + +// MARK: - Deadline - + +private struct PaymentRequestDeadlineSheet: View { + + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @Binding var deadline: PaymentRequestDeadline + + @State private var picker: PaymentRequestDeadlinePicker + @State private var height: CGFloat = 0 + + init(deadline: Binding<PaymentRequestDeadline>) { + _deadline = deadline + _picker = State(initialValue: PaymentRequestDeadlinePicker(deadline: deadline.wrappedValue)) + } + + var body: some View { + VStack(spacing: 16) { + Text("Time To Pay") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .padding(.top, 24) + + VStack(spacing: 10) { + HStack(spacing: 10) { + field(label: "Starts", value: "Today, on send", isEditing: false) + field(label: "Closes", value: picker.closesValue, isEditing: true) + .onChange(of: picker.closesValue) { _, value in + AccessibilityNotification.Announcement("Closes \(value)").post() + } + } + Text(picker.summaryLine) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + chips + + PaymentRequestDeadlineGrid(picker: picker) + + HStack { + Text("Time") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Spacer() + DatePicker( + "Time", + selection: Binding(get: { picker.timeRowDate }, set: { picker.setTime($0) }), + in: picker.timeRange, + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.compact) + .labelsHidden() + } + .padding(.leading, 16) + .padding(.trailing, 8) + .padding(.vertical, 6) + .background(Color.backgroundSecondary, in: .rect(cornerRadius: 12)) + + Button("Done") { + deadline = picker.choice + dismiss() + } + .buttonStyle(.filled) + .padding(.bottom, 8) + } + .padding(.horizontal, 20) + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height = $0 } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .presentationBackground(Color.backgroundMain) + .presentationDetents([height > 0 ? .height(height) : .large]) + .presentationDragIndicator(.visible) + } + + private func field(label: String, value: String, isEditing: Bool) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + Text(value) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.backgroundSecondary, in: .rect(cornerRadius: 12)) + .overlay { + if isEditing { + RoundedRectangle(cornerRadius: 12).strokeBorder(Color.white, lineWidth: 1.5) + } + } + .accessibilityElement(children: .combine) + } + + private var chips: some View { + HStack(spacing: 6) { + ForEach(PaymentRequestDeadline.presets, id: \.rowTitle) { preset in + let isSelected = picker.selectedPreset == preset + Button { + picker.select(preset) + } label: { + Text(preset.rowTitle) + .font(.appTextSmall) + .lineLimit(1) + .minimumScaleFactor(0.8) + .foregroundStyle(isSelected ? Color.black : Color.textMain) + .padding(.horizontal, 6) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .anchorPreference(key: SelectionAnchors<String>.self, value: .bounds) { [preset.rowTitle: $0] } + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + } + .frame(maxWidth: .infinity) + .backgroundPreferenceValue(SelectionAnchors<String>.self) { anchors in + GeometryReader { proxy in + ForEach(Array(anchors.keys), id: \.self) { title in + if let anchor = anchors[title] { + let rect = proxy[anchor] + Capsule().fill(Color.backgroundSecondary) + .frame(width: rect.width, height: rect.height) + .position(x: rect.midX, y: rect.midY) + } + } + // One capsule for the selection, so it slides between chips instead of fading. + if let title = picker.selectedPreset?.rowTitle, let anchor = anchors[title] { + let rect = proxy[anchor] + Capsule().fill(Color.white) + .frame(width: rect.width, height: rect.height) + .position(x: rect.midX, y: rect.midY) + } + } + } + .animation(reduceMotion ? nil : .snappy(duration: 0.3), value: picker.selectedPreset) + } +} + +/// The Time To Pay grid: five weeks from today's, with only today through today + 28 days tappable. +private struct PaymentRequestDeadlineGrid: View { + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let picker: PaymentRequestDeadlinePicker + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7) + + var body: some View { + LazyVGrid(columns: columns, spacing: 6) { + ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in + Text(symbol) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .accessibilityHidden(true) + } + ForEach(picker.gridDays, id: \.self) { day in + cell(day) + } + } + .backgroundPreferenceValue(SelectionAnchors<Date>.self) { anchors in + GeometryReader { proxy in + // One band per week row, so a change grows or shrinks it without seams between cells. + ForEach(0..<weeks.count, id: \.self) { row in + bandRow(weeks[row], anchors: anchors, proxy: proxy) + } + // One circle for the closing day, drawn under the cells so it slides between days instead of fading. + if let anchor = anchors[picker.selectedDay] { + let rect = proxy[anchor] + Circle().fill(Color.white).frame(width: Self.circle, height: Self.circle) + .position(x: rect.midX, y: rect.midY) + } + } + } + .padding(12) + .background(Color.backgroundSecondary, in: .rect(cornerRadius: 16)) + .animation(reduceMotion ? nil : .snappy(duration: 0.3), value: picker.selectedDay) + } + + private var weekdaySymbols: [String] { + let calendar = picker.calendar + let symbols = calendar.veryShortStandaloneWeekdaySymbols + let first = calendar.firstWeekday - 1 + return Array(symbols[first...] + symbols[..<first]) + } + + @ViewBuilder + private func cell(_ day: Date) -> some View { + let calendar = picker.calendar + let isEnabled = picker.isEnabled(day) + let isSelected = calendar.isDate(day, inSameDayAs: picker.selectedDay) + let isStart = picker.rangeStart.map { calendar.isDate(day, inSameDayAs: $0) } ?? false + let number = calendar.component(.day, from: day) + Button { + picker.select(day: day) + } label: { + ZStack { + if isStart { + Circle().fill(Self.startGrey).frame(width: Self.circle, height: Self.circle) + } + // The 1st stacks its month label above the number, centred as one group in the circle. + VStack(spacing: 0) { + if number == 1 { + Text(day.formatted(.dateTime.month(.abbreviated).locale(picker.locale)).uppercased()) + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(monthLabelColor(isEnabled: isEnabled, isSelected: isSelected, isStart: isStart, inBand: picker.band(for: day) != nil)) + } + Text("\(number)") + .font(.appTextMedium) + .foregroundStyle(isSelected ? Color.black : Color.textMain) + .opacity(isEnabled ? 1 : 0.3) + } + } + .frame(maxWidth: .infinity) + .frame(height: 38) + .contentShape(.rect) + .anchorPreference(key: SelectionAnchors<Date>.self, value: .bounds) { [calendar.startOfDay(for: day): $0] } + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .accessibilityHidden(!isEnabled) + .accessibilityLabel(picker.accessibilityDay(day)) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private func monthLabelColor(isEnabled: Bool, isSelected: Bool, isStart: Bool, inBand: Bool) -> Color { + if isSelected { return Self.grey(0x59) } + if isStart { return Self.grey(0xD9) } + if isEnabled && inBand { return Self.grey(0x9E) } + return Self.grey(0x7B) + } + + private static func grey(_ value: Double) -> Color { + Color(red: value / 255, green: value / 255, blue: value / 255) + } + + private static let circle: CGFloat = 34 + private static let startGrey = Color(red: 0x6B / 255, green: 0x6B / 255, blue: 0x6E / 255) + + private var weeks: [[Date]] { + stride(from: 0, to: picker.gridDays.count, by: 7).map { Array(picker.gridDays[$0..<min($0 + 7, picker.gridDays.count)]) } + } + + /// A week row's part of the range band, collapsed to the row's leading edge when the range misses it. + @ViewBuilder + private func bandRow(_ week: [Date], anchors: [Date: Anchor<CGRect>], proxy: GeometryProxy) -> some View { + if let first = week.first.flatMap({ anchors[$0] }), let last = week.last.flatMap({ anchors[$0] }) { + let rowStart = proxy[first] + let rowEnd = proxy[last] + let inRange = week.compactMap { day in picker.band(for: day).map { (day, $0) } } + let radius = Self.circle / 2 + let leading: CGFloat = inRange.first.flatMap { day, band in + anchors[day].map { band.fromCenter ? proxy[$0].midX : proxy[$0].minX } + } ?? rowStart.minX + let trailing: CGFloat = inRange.last.flatMap { day, band in + anchors[day].map { band.toCenter ? proxy[$0].midX : proxy[$0].maxX } + } ?? rowStart.minX + // Square ends clipped to a capsule spanning the row: only the row's edges ever round, so an end + // sliding with the circle never animates a corner radius. + Rectangle() + .fill(Color.white.opacity(0.12)) + .frame(width: max(trailing - leading, 0), height: Self.circle) + .position(x: (leading + trailing) / 2, y: rowEnd.midY) + .mask { + Capsule() + .frame(width: rowEnd.maxX - rowStart.minX, height: radius * 2) + .position(x: (rowStart.minX + rowEnd.maxX) / 2, y: rowEnd.midY) + } + .opacity(inRange.isEmpty ? 0 : 1) + } + } +} + +/// Each tappable item's bounds, so a selection highlight can be drawn once and slide between items. +private struct SelectionAnchors<ID: Hashable>: PreferenceKey { + static var defaultValue: [ID: Anchor<CGRect>] { [:] } + + static func reduce(value: inout [ID: Anchor<CGRect>], nextValue: () -> [ID: Anchor<CGRect>]) { + value.merge(nextValue()) { $1 } + } +} + +// MARK: - Select People - + +private struct PaymentRequestSelectPeopleScreen: View { + + let viewModel: PaymentRequestCreateViewModel + let done: () -> Void + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 0) { + SelectPeopleList( + members: viewModel.members, + isSelected: viewModel.isSelected, + toggle: viewModel.toggle, + rowIdentifier: "payment-request-member-row" + ) + CodeButton(style: .filled, title: "Done", disabled: !viewModel.hasValidPeople, action: done) + .padding(20) + } + } + .navigationTitle("Select People") + .navigationBarTitleDisplayMode(.inline) + .task(id: viewModel.conversationID) { + await viewModel.refreshMembers() + } + .dialog(item: Binding( + get: { viewModel.isShowingPeopleLimit ? peopleLimit : nil }, + set: { if $0 == nil { viewModel.isShowingPeopleLimit = false } } + )) + } + + private var peopleLimit: DialogItem { + .info(title: "100 People Max", subtitle: "A request can include up to 100 people.") { + DialogAction.okay(kind: .standard) + } + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift new file mode 100644 index 000000000..4596bb39f --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift @@ -0,0 +1,226 @@ +// +// PaymentRequestPayment.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// Pays the viewer's share of one payment request to its creator. +/// +/// The share is paid under the intent ID the server issued for it, so a retry, a second tap or a +/// second device can land only once. That ID is held in memory for this payment only and never +/// logged: anyone holding it before the share is paid can use it up. +@MainActor +@Observable +final class PaymentRequestPayment { + + enum Outcome: Equatable { + case success + case failed + } + + /// What ``prepare()`` found. + enum Preparation: Equatable { + /// The share is payable; show the swipe sheet. + case ready + /// The viewer has no unpaid share any more; the screen already shows the fetched request. + case nothingToPay + /// The request stopped accepting payments; "Request Closed" was shown. + case closed + case failed + } + + let requestID: PaymentRequestID + + private(set) var selectedBalance: ExchangedBalance? + + @ObservationIgnored private let requests: PaymentRequests + @ObservationIgnored private let session: Session + @ObservationIgnored private let ratesController: RatesController + @ObservationIgnored private let payer: any RequestedPaying + @ObservationIgnored private let resolver: any RecipientResolving + @ObservationIgnored private let clock: () -> Date + @ObservationIgnored private var intentID: Data? + /// Set after a payment whose outcome isn't known, so the next try refetches first. + @ObservationIgnored private var needsRefetch = false + + init( + requestID: PaymentRequestID, + sessionContainer: SessionContainer, + payer: (any RequestedPaying)? = nil, + resolver: (any RecipientResolving)? = nil, + clock: @escaping () -> Date = Date.init + ) { + let session = sessionContainer.session + self.requestID = requestID + self.requests = sessionContainer.paymentRequests + self.session = session + self.ratesController = sessionContainer.ratesController + self.payer = payer ?? session + self.resolver = resolver ?? session + self.clock = clock + self.selectedBalance = sessionContainer.ratesController.resolveInitialBalance(mint: nil, session: session) + } + + var request: GroupPaymentRequest? { + requests.request(requestID) + } + + /// The viewer's unpaid share, nil once paid. + var share: GroupPaymentRequest.Share? { + guard let own = request?.share(for: session.userID), !own.isPaid else { return nil } + return own + } + + func selectCurrencyAction(exchangedBalance: ExchangedBalance) { + selectedBalance = exchangedBalance + } + + /// Refetches the request right before the swipe sheet shows, taking the intent ID from it. + func prepare() async -> Preparation { + do { + intentID = try await requests.refresh(requestID) + } catch { + showSendError() + return .failed + } + needsRefetch = false + if !isOpen { + intentID = nil + showRequestClosed() + return .closed + } + return intentID != nil && share != nil ? .ready : .nothingToPay + } + + /// Pays exactly the share: its amount in the request's currency, priced into the picked token. + func submit() async -> Outcome { + if needsRefetch { + switch await prepare() { + case .ready: break + case .nothingToPay, .closed, .failed: return .failed + } + } + guard let request, let share, let creator = request.creator, + let intentID, let intentKey = try? PublicKey(intentID), + let chatID = request.chatID, + let currency = request.split.flatMap({ try? CurrencyCode(currencyCode: $0.currency) }), + let value = Decimal(string: "\(share.nativeAmount)") else { + showSendError() + return .failed + } + + guard let selectedBalance else { + ErrorReporting.captureError( + GroupSendViewModel.Error.noSpendableBalance, + reason: "No spendable balance at request payment", + metadata: ["conversationID": chatID.description] + ) + showSendError() + return .failed + } + + let mint = selectedBalance.stored.mint + guard let pin = await ratesController.currentPinnedState(for: currency, mint: mint), + let amount = session.price(FiatAmount(value: value, currency: currency), mint: mint, at: pin) else { + ErrorReporting.captureError( + RatesController.Error.exchangeRateUnavailable, + reason: "No fresh rate at request payment", + metadata: ["mint": mint.base58] + ) + session.dialogItem = .error( + title: "Rate Unavailable", + subtitle: "Couldn't get a fresh rate. Please try again." + ) + return .failed + } + + switch session.hasSufficientFunds(for: amount) { + case .insufficient(let shortfall): + if let shortfall { + session.dialogItem = .error( + title: "You're \(shortfall.nativeAmount.formatted()) Short", + subtitle: "Add more cash, or try again with a lower amount" + ) + } else { + session.dialogItem = .error( + title: "You Need More Cash", + subtitle: "Please add more cash, or try again with a lower amount" + ) + } + return .failed + case .sufficient: + break + } + + let sendLimit = session.sendLimitFor(currency: amount.nativeAmount.currency) ?? .zero + guard amount.nativeAmount.value <= sendLimit.nextTransaction.value else { + session.dialogItem = .error( + title: "Transaction Limit Reached", + subtitle: "Flipcash is designed for small, every day transactions. Send limits reset daily" + ) + return .failed + } + + let creatorOwner: PublicKey + do { + creatorOwner = try await resolver.resolveUserID(creator) + } catch { + showSendError() + return .failed + } + + do { + try await payer.payRequestShare( + amount: amount, + verifiedState: pin, + to: creatorOwner, + chatID: chatID, + paymentRequestID: requestID, + intentID: intentKey + ) + self.intentID = nil + Analytics.transfer(event: .sentCash, exchangedFiat: amount, grabTime: nil, successful: true, error: nil) + try? await requests.refresh(requestID) + return .success + } catch { + Analytics.transfer(event: .sentCash, exchangedFiat: amount, grabTime: nil, successful: false, error: error) + needsRefetch = true + guard !(error is CancellationError) else { return .failed } + // A refusal because the request ended reads as "Request Closed", not a send failure. + try? await requests.refresh(requestID) + if isOpen { + showSendError() + } else { + self.intentID = nil + showRequestClosed() + } + return .failed + } + } + + private var isOpen: Bool { + guard let request else { return false } + switch request.effectiveStatus(at: clock()) { + case .open: return true + case .completed, .closed, .expired, .unknown: return false + } + } + + private func showRequestClosed() { + session.dialogItem = .error( + title: "Request Closed", + subtitle: "This request is no longer accepting payments." + ) + } + + private func showSendError() { + session.dialogItem = .error( + title: "Couldn't Send", + subtitle: "We couldn't complete the transfer. Please try again." + ) + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestText.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestText.swift new file mode 100644 index 000000000..632b8e70f --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestText.swift @@ -0,0 +1,34 @@ +// +// PaymentRequestText.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// A request's title and description as the viewer reads them. +struct PaymentRequestText: Hashable { + let title: String + let description: String + + /// `request`'s text, opened with `seal` when it is encrypted. `nil` when the text is missing, its + /// scheme is unknown, or it fails to open: the card then renders as unsupported. + init?(request: GroupPaymentRequest, seal: ChatSeal?) { + switch request.split?.text { + case .plaintext(let title, let description): + self.title = title + self.description = description + case .encrypted(let scheme, let nonce, let ciphertext): + guard + let seal, + let opened = try? seal.openRequestText(scheme: scheme, nonce: nonce, ciphertext: ciphertext, creator: request.creator) + else { return nil } + self.title = opened.title + self.description = opened.description + case nil: + return nil + } + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestViewerStatus.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestViewerStatus.swift new file mode 100644 index 000000000..d290c9da7 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestViewerStatus.swift @@ -0,0 +1,113 @@ +// +// PaymentRequestViewerStatus.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// Where the viewer stands on one request, as every deck card says it. +enum PaymentRequestViewerStatus: Hashable { + case paid(share: String) + case owes(share: String) + case creator + case noShare + + /// `viewer`'s standing on `request`, their share worded by `copy`. The creator reads as the creator + /// whatever their own share says. + init(request: GroupPaymentRequest, viewer: UserID?, copy: PaymentRequestCopy) { + guard let viewer else { self = .noShare; return } + guard viewer != request.creator else { self = .creator; return } + guard let share = request.share(for: viewer) else { self = .noShare; return } + let text = copy.shareText(share, of: request) + self = share.isPaid ? .paid(share: text) : .owes(share: text) + } + + /// Whether the card's glyph carries the paid badge. + var showsPaidBadge: Bool { + switch self { + case .paid: true + case .owes, .creator, .noShare: false + } + } + + /// The viewer's part of the line: "✓ You paid $5" or "You owe $5" in the success color, or + /// "Your request". Nil with no share. + var lead: AttributedString? { + switch self { + case .paid(let share): + var lead = AttributedString("✓ You paid \(share)") + lead.foregroundColor = Color.textSuccess + return lead + case .owes(let share): + return Self.owedLead(share) + case .creator: + return AttributedString("Your request") + case .noShare: + return nil + } + } + + /// "You owe <share>" in the success color, which the owed strip leads with too. + static func owedLead(_ share: String) -> AttributedString { + var lead = AttributedString("You owe \(share)") + lead.foregroundColor = Color.textSuccess + return lead + } + + /// The card's line: "<lead> · <amounts>", or `amounts` alone with no share. + func line(amounts: String) -> AttributedString { + guard let lead else { return AttributedString(amounts) } + return lead + AttributedString(" · \(amounts)") + } + + /// The viewer's part for a screen reader: "you paid $5". Nil with no share. + var accessibilityText: String? { + switch self { + case .paid(let share): "you paid \(share)" + case .owes(let share): "you owe \(share)" + case .creator: "your request" + case .noShare: nil + } + } +} + +/// A card's viewer status line, dropping the amounts first when the whole line doesn't fit. +struct PaymentRequestViewerStatusLine: View { + let status: PaymentRequestViewerStatus + let amounts: String + + var body: some View { + ViewThatFits(in: .horizontal) { + Text(status.line(amounts: amounts)) + Text(status.lead ?? AttributedString(amounts)) + } + .lineLimit(1) + .truncationMode(.tail) + } +} + +extension View { + /// Marks a request glyph `diameter` across as paid by the viewer: a success-colored check at its + /// bottom-right, ringed in the card surface, grey on an ended request. Drawn as an overlay, so the + /// glyph keeps its own frame and the badge moves and scales with it. + func paidBadge(_ isShown: Bool, diameter: CGFloat, isEnded: Bool) -> some View { + overlay(alignment: .bottomTrailing) { + if isShown { + let size = diameter * 10 / 26 + Image(systemName: "checkmark") + .font(.system(size: size * 0.55, weight: .heavy)) + .foregroundStyle(Color.black) + .frame(width: size, height: size) + .background(isEnded ? Color.textSecondary : Color.success, in: .circle) + .padding(size * 0.12) + .background(Color.backgroundSecondary, in: .circle) + .offset(x: size * 0.3, y: size * 0.3) + .accessibilityHidden(true) + } + } + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift new file mode 100644 index 000000000..0340feb13 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -0,0 +1,284 @@ +// +// PaymentRequests.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Observation +import FlipcashCore + +nonisolated private let logger = Logger(label: "flipcash.payment-requests") + +/// The group payment requests read this session, merged by version. +/// +/// No event carries a request change, so every copy arrives from a fetch: a chat's sections, one +/// request, or the response to the viewer's own create, close or mark-paid. Each goes through +/// ``ingest(_:)``, which keeps the greater version and, at an equal version, EXPIRED over OPEN. +@MainActor +@Observable +final class PaymentRequests { + + typealias GetChatPage = @MainActor @Sendable (ConversationID, GroupPaymentRequest.Section, Int) async throws -> [GroupPaymentRequest] + typealias GetRequest = @MainActor @Sendable (PaymentRequestID) async throws -> (request: GroupPaymentRequest, paymentIntentID: Data?) + typealias OpeningSeal = @MainActor @Sendable (ConversationID) async -> ChatSeal? + typealias Create = @MainActor @Sendable (_ idempotencyKey: Data, ConversationID, GroupPaymentRequest.Split) async throws -> GroupPaymentRequest + typealias Close = @MainActor @Sendable (PaymentRequestID) async throws -> GroupPaymentRequest + typealias MarkPaid = @MainActor @Sendable (PaymentRequestID, UserID) async throws -> GroupPaymentRequest + typealias GetPage = @MainActor @Sendable (ConversationID, GroupPaymentRequest.Section, _ pageSize: Int, _ pagingToken: Data?) async throws -> (requests: [GroupPaymentRequest], nextPagingToken: Data?) + + /// A mutation called on a store built without it, as tests that only read do. + struct Unconfigured: Error {} + + /// The first page of OPEN requests a chat refetch reads. + static let openPageSize = 100 + /// The first page of RESOLVED requests a chat refetch reads, enough to fill the deck's 24 hours. + static let resolvedPageSize = 20 + /// How often an on-screen chat or details sheet refetches. + static let pollInterval: Duration = .seconds(45) + + /// How long an on-screen poll waits before its `attempt`th retry of a read that left something + /// loading: 2 s doubling, never past ``pollInterval``. + static func retryDelay(attempt: Int) -> Duration { + min(.seconds(2 << min(attempt, 5)), pollInterval) + } + + private var requests: [PaymentRequestID: GroupPaymentRequest] = [:] + /// Requests the server answered `DENIED` or `NOT_FOUND` for; their cards render as unsupported. + private(set) var unavailable: Set<PaymentRequestID> = [] + /// Bumped on every chat refetch, so a deck can return to its default order. + private(set) var chatRefreshes: [ConversationID: Int] = [:] + + /// Each chat's seal for opening encrypted request text, once loaded. + private var seals: [ConversationID: ChatSeal] = [:] + + @ObservationIgnored private let getChatPage: GetChatPage + @ObservationIgnored private let getRequest: GetRequest + @ObservationIgnored private let openingSeal: OpeningSeal + @ObservationIgnored private let createRequest: Create + @ObservationIgnored private let closeRequest: Close + @ObservationIgnored private let markPaidShare: MarkPaid + @ObservationIgnored private let getPage: GetPage + @ObservationIgnored private var inFlight: Set<PaymentRequestID> = [] + @ObservationIgnored private var sealReads: Set<ConversationID> = [] + /// Requests whose last read failed short of a server answer, retried on the next chat refetch. + @ObservationIgnored private var failedReads: Set<PaymentRequestID> = [] + /// Chats whose last section read failed. + @ObservationIgnored private var failedChats: Set<ConversationID> = [] + + init( + getChatPage: @escaping GetChatPage, + getRequest: @escaping GetRequest, + openingSeal: @escaping OpeningSeal = { _ in nil }, + create: @escaping Create = { _, _, _ in throw Unconfigured() }, + close: @escaping Close = { _ in throw Unconfigured() }, + markPaid: @escaping MarkPaid = { _, _ in throw Unconfigured() }, + getPage: @escaping GetPage = { _, _, _, _ in throw Unconfigured() } + ) { + self.getChatPage = getChatPage + self.getRequest = getRequest + self.openingSeal = openingSeal + self.createRequest = create + self.closeRequest = close + self.markPaidShare = markPaid + self.getPage = getPage + } + + /// The chat's held seal, for sealing a new request's text. + func seal(for conversationID: ConversationID) -> ChatSeal? { + seals[conversationID] + } + + /// Creates a request; retrying with the same `idempotencyKey` returns the same request. + func create(idempotencyKey: Data, in conversationID: ConversationID, split: GroupPaymentRequest.Split) async throws -> GroupPaymentRequest { + let created = try await createRequest(idempotencyKey, conversationID, split) + ingest([created]) + return created + } + + /// Closes `id` early. Creator only. + func close(_ id: PaymentRequestID) async throws { + ingest([try await closeRequest(id)]) + } + + /// Marks `userID`'s share of `id` paid outside Flipcash. Creator only. + func markPaid(_ id: PaymentRequestID, userID: UserID) async throws { + ingest([try await markPaidShare(id, userID)]) + } + + /// Loads `conversationID`'s opening seal if it isn't held or being read yet, so encrypted titles + /// can open. + func loadSeal(for conversationID: ConversationID) async { + guard seals[conversationID] == nil, sealReads.insert(conversationID).inserted else { return } + defer { sealReads.remove(conversationID) } + guard let seal = await openingSeal(conversationID) else { return } + seals[conversationID] = seal + } + + /// `request`'s title and description as the viewer reads them, nil when they can't be read. + func text(of request: GroupPaymentRequest) -> PaymentRequestText? { + PaymentRequestText(request: request, seal: request.chatID.flatMap { seals[$0] }) + } + + /// How a widget for `id`, sent by `sender` in `conversationID`, draws as of `now`. + func cardState( + for id: PaymentRequestID, + in conversationID: ConversationID, + sentBy sender: UserID?, + copy: PaymentRequestCopy, + now: Date + ) -> PaymentRequestCardState { + if unavailable.contains(id) { return .unsupported } + guard let request = requests[id] else { return .loading } + guard request.belongs(to: conversationID, sentBy: sender) else { return .unsupported } + if case .encrypted = request.split?.text, seals[conversationID] == nil { return .loading } + guard let text = text(of: request) else { return .unsupported } + return .card(ChatPaymentRequestContent.Card(request: request, title: text.title, copy: copy, now: now)) + } + + /// The stored copy of `id`, if any. + func request(_ id: PaymentRequestID) -> GroupPaymentRequest? { + requests[id] + } + + /// `conversationID`'s requests the deck shows to `viewer` as of `now`, in deck order. + func deck(for conversationID: ConversationID, viewer: UserID?, at now: Date) -> [GroupPaymentRequest] { + GroupPaymentRequest.deckOrder(requests.values.filter { $0.chatID == conversationID }, viewer: viewer, at: now) + } + + /// Merges fetched copies into the store. + func ingest(_ incoming: [GroupPaymentRequest]) { + for request in incoming { + if let existing = requests[request.id] { + let kept = GroupPaymentRequest.merged(existing: existing, incoming: request) + if kept != existing { requests[request.id] = kept } + } else { + requests[request.id] = request + } + unavailable.remove(request.id) + } + } + + /// Reads `conversationID`'s OPEN and RESOLVED first pages, and its seal if not held yet. A failed + /// read keeps what was held. + func refreshChat(_ conversationID: ConversationID) async { + async let sealed: Void = loadSeal(for: conversationID) + do { + async let open = getChatPage(conversationID, .open, Self.openPageSize) + async let resolved = getChatPage(conversationID, .resolved, Self.resolvedPageSize) + ingest(try await open + resolved) + chatRefreshes[conversationID, default: 0] += 1 + failedChats.remove(conversationID) + } catch { + failedChats.insert(conversationID) + logger.warning("Failed to read a chat's payment requests", metadata: [ + "conversationID": "\(conversationID)", + "error": "\(error)", + ]) + } + await sealed + for id in failedReads { + await fetchIfMissing(id) + } + } + + /// Whether `conversationID` still has something loading that a sooner refetch could fill: a failed + /// section or request read, or encrypted text with no seal to open it. + func isSettling(_ conversationID: ConversationID) -> Bool { + if failedChats.contains(conversationID) || !failedReads.isEmpty { return true } + guard seals[conversationID] == nil else { return false } + return requests.values.contains { $0.chatID == conversationID && $0.hasEncryptedText } + } + + /// Whether `id` is still loading: not read yet, or its encrypted text waits on the chat's seal. + func isSettling(request id: PaymentRequestID) -> Bool { + if unavailable.contains(id) { return false } + guard let request = requests[id] else { return true } + guard request.hasEncryptedText, let chatID = request.chatID else { return false } + return seals[chatID] == nil + } + + /// Reads one page of a chat's `section` for Request Money history and merges it. Returns the page's + /// request ids in server order and the token for the next page, `nil` after the last. + func page( + _ conversationID: ConversationID, + section: GroupPaymentRequest.Section, + pageSize: Int, + after pagingToken: Data? + ) async throws -> (ids: [PaymentRequestID], nextPagingToken: Data?) { + let page = try await getPage(conversationID, section, pageSize, pagingToken) + ingest(page.requests) + return (page.requests.map(\.id), page.nextPagingToken) + } + + /// Reads one request, and its chat's seal if not held yet, and merges it. Returns the intent id the + /// viewer pays under, `nil` when the viewer has no unpaid share. Throws what the read threw. + @discardableResult + func refresh(_ id: PaymentRequestID) async throws -> Data? { + do { + let fetched = try await getRequest(id) + ingest([fetched.request]) + if let chatID = fetched.request.chatID { await loadSeal(for: chatID) } + return fetched.paymentIntentID + } catch let error as ErrorGetPaymentRequest { + switch error { + case .denied, .notFound: + unavailable.insert(id) + case .ok, .unknown, .transportFailure, .cancelled, .rejected: + break + } + throw error + } + } + + /// Reads `id` when it isn't stored yet and no read of it is already running. + func fetchIfMissing(_ id: PaymentRequestID) async { + guard requests[id] == nil, !unavailable.contains(id), inFlight.insert(id).inserted else { return } + defer { inFlight.remove(id) } + do { + try await refresh(id) + failedReads.remove(id) + } catch { + if unavailable.contains(id) { + failedReads.remove(id) + } else { + failedReads.insert(id) + } + logger.warning("Failed to read a payment request", metadata: ["error": "\(error)"]) + } + } +} + +private extension GroupPaymentRequest { + var hasEncryptedText: Bool { + if case .encrypted = split?.text { return true } + return false + } +} + +/// How a payment-request widget draws in the transcript. +enum PaymentRequestCardState: Hashable, Sendable { + /// The request, or the chat's key, is still loading: the card's frame with placeholder lines. + case loading + case card(ChatPaymentRequestContent.Card) + /// The chat's unsupported-message bubble, for the cases the spec lists. + case unsupported +} + +/// A payment request picked to show, as a sheet item. +struct PaymentRequestSelection: Identifiable, Hashable { + let id: PaymentRequestID +} + +/// What keys the chat's request poll: it restarts when the chat changes or the app returns to the foreground. +struct PaymentRequestPoll: Hashable { + let conversationID: ConversationID? + let isActive: Bool +} + +/// What keys the details poll: it restarts when the request changes or the app returns to the foreground. +struct PaymentRequestDetailsPoll: Hashable { + let requestID: PaymentRequestID + let isActive: Bool +} diff --git a/Flipcash/Core/Screens/Send/GroupSendFlow.swift b/Flipcash/Core/Screens/Send/GroupSendFlow.swift index 0febf33f6..7ad221cfc 100644 --- a/Flipcash/Core/Screens/Send/GroupSendFlow.swift +++ b/Flipcash/Core/Screens/Send/GroupSendFlow.swift @@ -132,8 +132,6 @@ private struct GroupSendAmountScreen: View { private struct GroupSendSelectPeopleScreen: View { - @Environment(SessionContainer.self) private var sessionContainer - let viewModel: GroupSendViewModel @State private var isConfirming = false @@ -141,20 +139,12 @@ private struct GroupSendSelectPeopleScreen: View { var body: some View { Background(color: .backgroundMain) { VStack(spacing: 0) { - List { - Section { - ForEach(viewModel.members, id: \.userID) { portrait in - row(portrait) - } - } header: { - Text("Members") - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .textCase(nil) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) + SelectPeopleList( + members: viewModel.members, + isSelected: viewModel.isSelected, + toggle: viewModel.toggle, + rowIdentifier: "group-send-member-row" + ) CodeButton( style: .filled, @@ -177,9 +167,39 @@ private struct GroupSendSelectPeopleScreen: View { GroupSendConfirmSheet(viewModel: viewModel) } } +} + +// MARK: - Member checklist - + +/// The checklist of a group's members that group send and a new request both pick from. +struct SelectPeopleList: View { + + @Environment(SessionContainer.self) private var sessionContainer + + let members: [GroupPortrait] + let isSelected: (UserID) -> Bool + let toggle: (UserID) -> Void + let rowIdentifier: String + + var body: some View { + List { + Section { + ForEach(members, id: \.userID) { portrait in + row(portrait) + } + } header: { + Text("Members") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .textCase(nil) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } private func row(_ portrait: GroupPortrait) -> some View { - let isSelected = viewModel.isSelected(portrait.userID) + let checked = isSelected(portrait.userID) return RecipientRowScaffold( avatarID: portrait.userID.uuidString, title: portrait.name, @@ -187,14 +207,14 @@ private struct GroupSendSelectPeopleScreen: View { imageData: sessionContainer.profileAvatars.data(for: portrait.userID), blurhash: portrait.picture?.thumbnailBlurhash, accessibilityLabel: portrait.name, - onTap: { viewModel.toggle(portrait.userID) } + onTap: { toggle(portrait.userID) } ) { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + Image(systemName: checked ? "checkmark.circle.fill" : "circle") .font(.system(size: 22)) - .foregroundStyle(isSelected ? Color.textMain : Color.textSecondary) + .foregroundStyle(checked ? Color.textMain : Color.textSecondary) } - .accessibilityIdentifier("group-send-member-row") - .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityIdentifier(rowIdentifier) + .accessibilityAddTraits(checked ? .isSelected : []) .task(id: portrait.userID) { await sessionContainer.profileAvatars.load(userID: portrait.userID, picture: portrait.picture) } diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 1201030ab..895cb226a 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -1097,6 +1097,23 @@ class Session { /// VM time-authority and carried alongside `recipientOwner` so the /// server recognises a Code-managed destination. func send(amount: ExchangedFiat, verifiedState: VerifiedState, to recipientOwner: PublicKey, chat: ChatPaymentMetadata?) async throws { + try await submitDirect(amount: amount, verifiedState: verifiedState, to: recipientOwner, chat: chat, intentID: nil) + } + + /// Pays the viewer's share of a payment request to its creator under the server-issued + /// `intentID`, which is never logged or reported: anyone holding it before the share is paid + /// can use it up. + func payRequestShare(amount: ExchangedFiat, verifiedState: VerifiedState, to creatorOwner: PublicKey, chatID: ConversationID, paymentRequestID: PaymentRequestID, intentID: PublicKey) async throws { + try await submitDirect( + amount: amount, + verifiedState: verifiedState, + to: creatorOwner, + chat: .requestedPayment(chatID: chatID, paymentRequestID: paymentRequestID), + intentID: intentID + ) + } + + private func submitDirect(amount: ExchangedFiat, verifiedState: VerifiedState, to recipientOwner: PublicKey, chat: ChatPaymentMetadata?, intentID: PublicKey?) async throws { try assertFresh(verifiedState, operation: "send", currency: amount.nativeAmount.currency, mint: amount.mint) let mint = amount.mint @@ -1110,14 +1127,15 @@ class Session { timeAuthority: vmAuthority ).vault.publicKey - let rendezvous = PublicKey.generate()! + let rendezvous = intentID ?? PublicKey.generate()! + let tracesRendezvous = intentID == nil logger.info("Sending direct transfer", metadata: [ "amount": "\(amount.nativeAmount.formatted())", "mint": "\(mint.base58)", "recipientOwner": "\(recipientOwner.base58)", "recipientVault": "\(recipientVault.base58)", - "rendezvous": "\(rendezvous.base58)", + "rendezvous": "\(tracesRendezvous ? rendezvous.base58 : "server-issued")", "hasChatMetadata": "\(chat != nil)", ]) @@ -1138,16 +1156,20 @@ class Session { guard !(error is CancellationError) else { throw error } logger.error("Send failed", metadata: [ "recipientOwner": "\(recipientOwner.base58)", - "rendezvous": "\(rendezvous.base58)", + "rendezvous": "\(tracesRendezvous ? rendezvous.base58 : "server-issued")", "amount": "\(amount.nativeAmount.formatted())", "error": "\(error)", ]) - ErrorReporting.capturePayment( - error: error, - rendezvous: rendezvous, - exchangedFiat: amount, - userFacing: true - ) + if tracesRendezvous { + ErrorReporting.capturePayment( + error: error, + rendezvous: rendezvous, + exchangedFiat: amount, + userFacing: true + ) + } else { + ErrorReporting.captureError(error, reason: "Requested payment failed", userFacing: true) + } throw error } } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 3c5b051f6..4a636f161 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -654,6 +654,9 @@ final class SessionContainer { /// The members of the private groups the signed-in user has opened, kept live from the stream. let groupRosters: GroupRosters + /// The group payment requests the signed-in user's private chats carry, refetched by the chat screen. + let paymentRequests: PaymentRequests + /// The emoji the signed-in user reacts with most, for the reaction strip and picker. let recentReactions: RecentReactionsStore @@ -897,6 +900,41 @@ final class SessionContainer { ) conversationController.groupRosters = groupRosters self.groupRosters = groupRosters + self.paymentRequests = PaymentRequests( + getChatPage: { [flipClient, owner = session.ownerKeyPair] conversationID, section, pageSize in + try await flipClient.getChatPaymentRequestsPage( + owner: owner, + conversationID: conversationID, + section: section, + pageSize: pageSize, + pagingToken: nil + ).requests + }, + getRequest: { [flipClient, owner = session.ownerKeyPair] id in + try await flipClient.getPaymentRequest(owner: owner, id: id) + }, + openingSeal: { [conversationController] conversationID in + await conversationController.openingSeal(for: conversationID) + }, + create: { [flipClient, owner = session.ownerKeyPair] key, conversationID, split in + try await flipClient.createPaymentRequest(owner: owner, idempotencyKey: key, chatID: conversationID, split: split) + }, + close: { [flipClient, owner = session.ownerKeyPair] id in + try await flipClient.closePaymentRequest(owner: owner, id: id) + }, + markPaid: { [flipClient, owner = session.ownerKeyPair] id, userID in + try await flipClient.markPaid(owner: owner, id: id, userID: userID) + }, + getPage: { [flipClient, owner = session.ownerKeyPair] conversationID, section, pageSize, pagingToken in + try await flipClient.getChatPaymentRequestsPage( + owner: owner, + conversationID: conversationID, + section: section, + pageSize: pageSize, + pagingToken: pagingToken + ) + } + ) self.rosterSearch = mentionSearch // The blocklist drives which conversations the feed hides: it supplies the diff --git a/Flipcash/Core/Session/SessionProtocols.swift b/Flipcash/Core/Session/SessionProtocols.swift index 39448d417..cc7982384 100644 --- a/Flipcash/Core/Session/SessionProtocols.swift +++ b/Flipcash/Core/Session/SessionProtocols.swift @@ -89,6 +89,19 @@ protocol RecipientResolving: AnyObject { // MARK: - Direct send /// Direct on-chain payment to a resolved recipient owner. +/// Pays a share of a group payment request under the intent ID the server issued for it. +protocol RequestedPaying: AnyObject { + + func payRequestShare( + amount: ExchangedFiat, + verifiedState: VerifiedState, + to creatorOwner: PublicKey, + chatID: ConversationID, + paymentRequestID: PaymentRequestID, + intentID: PublicKey + ) async throws +} + protocol DirectSending: AnyObject { func send( @@ -128,5 +141,6 @@ extension Session: AccountProviding, CurrencyLaunching, RecipientResolving, DirectSending, + RequestedPaying, MultiSending, CashLinkSending {} diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 3871222e5..3d9b24dab 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -66,6 +66,7 @@ nonisolated struct ChatSpotlightItem { case .text(let text): text case .cash(let amount): "Cash · \(viewerAmount(amount).formatted())" case .widget(.shareProfile): "Shared a profile" + case .widget(.paymentRequest): "Request" case .media(_, let caption): ChatMediaStrings.listPreview(caption: caption) case .system(let text, _): text.isEmpty ? nil : text case .deleted, .encrypted, .widget(.unrecognized), nil: nil diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index da6803152..54b1a116d 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -49,7 +49,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.15.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.16.0")) }() let package = Package( diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 6bfb674fc..27e1847fd 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -292,6 +292,15 @@ extension FlipClient { } } + /// Sends a widget naming the payment request `id`, sealed with `seal`, and returns the server's + /// copy (decrypted). + @discardableResult + public func sendPaymentRequestMessage(owner: KeyPair, conversationID: ConversationID, paymentRequestID id: PaymentRequestID, seal: ChatSeal, clientMessageID: UUID) async throws -> ConversationMessage { + try await withCheckedThrowingContinuation { c in + chatMessagingService.sendPaymentRequestMessage(owner: owner, conversationID: conversationID, paymentRequestID: id, seal: seal, clientMessageID: clientMessageID) { c.resume(with: $0) } + } + } + public func editMessage( owner: KeyPair, conversationID: ConversationID, diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+PaymentRequest.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+PaymentRequest.swift index 59cebbdda..12b6884ce 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+PaymentRequest.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+PaymentRequest.swift @@ -42,6 +42,15 @@ extension FlipClient { return all } + /// One page of `conversationID`'s requests for `section`, with the token for the next page when + /// there is one. + public func getChatPaymentRequestsPage(owner: KeyPair, conversationID: ConversationID, section: GroupPaymentRequest.Section, pageSize: Int, pagingToken: Data?) async throws -> (requests: [GroupPaymentRequest], nextPagingToken: Data?) { + let page: PaymentRequestService.ChatPage = try await withCheckedThrowingContinuation { c in + paymentRequestService.getChatPaymentRequests(owner: owner, conversationID: conversationID, section: section, pageSize: pageSize, pagingToken: pagingToken) { c.resume(with: $0) } + } + return (page.requests, page.hasMore ? page.pagingToken : nil) + } + /// Closes an open request. Creator only. public func closePaymentRequest(owner: KeyPair, id: PaymentRequestID) async throws -> GroupPaymentRequest { try await withCheckedThrowingContinuation { c in diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 020000587..cddddf552 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -212,6 +212,19 @@ final class ChatMessagingService: Sendable { send(content, owner: owner, conversationID: conversationID, seal: seal, clientMessageID: clientMessageID, completion: completion) } + /// Sends a widget naming the payment request `id`, sealed with `seal`, as `EncryptedContent`. + func sendPaymentRequestMessage(owner: KeyPair, conversationID: ConversationID, paymentRequestID id: PaymentRequestID, seal: ChatSeal, clientMessageID: UUID, completion: @Sendable @escaping (Result<ConversationMessage, ErrorSendMessage>) -> Void) { + let content: Flipcash_Messaging_V1_Content + do { + content = try seal.seal(paymentRequest: id) + } catch { + logger.error("Failed to encrypt payment request message") + completion(.failure(.encryptionFailed)) + return + } + send(content, owner: owner, conversationID: conversationID, seal: seal, clientMessageID: clientMessageID, completion: completion) + } + private func send(_ content: Flipcash_Messaging_V1_Content, owner: KeyPair, conversationID: ConversationID, seal: ChatSeal?, clientMessageID: UUID, completion: @Sendable @escaping (Result<ConversationMessage, ErrorSendMessage>) -> Void) { let request = Flipcash_Messaging_V1_SendMessageRequest.with { $0.chatID = conversationID.proto diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index 71850aa04..7298ae005 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -34,6 +34,8 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { case shareProfile(LinkCard.User) /// A photo, drawn from its blob once a download URL is resolved and from its BlurHash until then. case media(ChatMediaContent) + /// A group payment request card, worded for the viewer; its frame alone while the request loads. + case paymentRequest(ChatPaymentRequestContent) } /// Why an unavailable message can't be shown, which picks the bubble's copy. @@ -159,7 +161,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { guard isEmojiOnly, quote == nil, linkPreview == nil else { return false } switch content { case .text: return true - case .cash, .deleted, .unavailable, .shareProfile, .media: return false + case .cash, .deleted, .unavailable, .shareProfile, .media, .paymentRequest: return false } } @@ -201,7 +203,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { return false case .media(let media) where media.isRedacted: return false - case .text, .cash, .shareProfile, .media: + case .text, .cash, .shareProfile, .media, .paymentRequest: return !isUnsent } } @@ -213,7 +215,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { guard offersReactionStrip else { return false } switch content { case .text, .media: return true - case .cash, .deleted, .unavailable, .shareProfile: return false + case .cash, .deleted, .unavailable, .shareProfile, .paymentRequest: return false } } @@ -440,6 +442,40 @@ public struct ChatMediaContent: Hashable, Sendable, Codable { } } +/// A payment request row's display data: the request it opens, and its card once the request is known. +public struct ChatPaymentRequestContent: Hashable, Sendable, Codable { + /// The request's id bytes. + public let requestID: Data + /// The worded card; nil while the request loads, which draws the frame with placeholder lines. + public let card: Card? + + /// What a request card shows, already put into words. + public struct Card: Hashable, Sendable, Codable { + public let title: String + /// "$40 paid, $60 requested", in the viewer's amounts. + public let progressLine: String + /// The paid share of the requested total, 0 through 1. + public let fraction: Double + /// "2 days left to pay", "Paid in full", and the other deadline lines. + public let statusLine: String + /// Whether the request has ended, which greys the card. + public let isEnded: Bool + + public init(title: String, progressLine: String, fraction: Double, statusLine: String, isEnded: Bool) { + self.title = title + self.progressLine = progressLine + self.fraction = fraction + self.statusLine = statusLine + self.isEnded = isEnded + } + } + + public init(requestID: Data, card: Card?) { + self.requestID = requestID + self.card = card + } +} + /// A cash payment row's display data — already formatted, so the cell renders strings + images. public struct ChatCashContent: Hashable, Sendable, Codable { /// The amount in the viewer's display currency, formatted for display (e.g. "$5.00"). diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index 7edb80853..c73ab3c89 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -96,6 +96,8 @@ extension ChatItem { )) case .widget(.shareProfile(let share)): content = .shareProfile(LinkCard.User(profileOf: share.username)) + case .widget(.paymentRequest(let id)): + content = .paymentRequest(ChatPaymentRequestContent(requestID: id.data, card: nil)) case .widget(.unrecognized): content = .unavailable(.updateApp) case .media(let attachments, let caption): diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift index 4d28da43c..82d0ad2c3 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift @@ -57,6 +57,9 @@ public struct ChatQuote: Hashable, Sendable, Codable { /// The line a quoted shared-profile widget shows: it has no text of its own. public static let sharedProfileSnippet = "Shared a profile" + /// The snippet for a quoted payment request. + public static let paymentRequestSnippet = "Request" + /// Copy for an original that has since been deleted. public static let deletedSnippet = "This message was deleted" diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift index 5a07e7600..74061e984 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift @@ -24,6 +24,8 @@ public enum ChatPaymentMetadata: Sendable { /// The payer must already have reacted; the server splits the payment between the message's /// sender and the group's creator and posts no message into the chat. case reactionBoost(chatID: ConversationID, messageID: MessageID, emoji: String) + /// A payment of the payer's share of a group payment request, paid to its creator. + case requestedPayment(chatID: ConversationID, paymentRequestID: PaymentRequestID) /// The chat this payment posts into. public var chatID: ConversationID { @@ -36,6 +38,8 @@ public enum ChatPaymentMetadata: Sendable { return chatID case .reactionBoost(let chatID, _, _): return chatID + case .requestedPayment(let chatID, _): + return chatID } } @@ -44,7 +48,7 @@ public enum ChatPaymentMetadata: Sendable { /// carries instead of re-deriving it. public var isTip: Bool { switch self { - case .contactDm, .groupPayment, .reactionBoost: + case .contactDm, .groupPayment, .reactionBoost, .requestedPayment: return false case .tipDm(_, _, let action): return action == .tip @@ -75,6 +79,8 @@ public enum ChatPaymentMetadata: Sendable { $0.messageID = messageID.proto $0.emoji = .with { $0.value = emoji } } + case .requestedPayment(_, let paymentRequestID): + $0.requestedPayment = .with { $0.paymentRequestID = paymentRequestID.proto } } } }.serializedData() diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift index f6aff27f9..27f0afca6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift @@ -97,7 +97,15 @@ public struct ChatSeal: @unchecked Sendable { try seal(.plaintext(media: photo.media, caption: caption, repliedTo: repliedTo)) } - private func seal(_ plaintext: Flipcash_Messaging_V1_Content) throws -> Flipcash_Messaging_V1_Content { + /// Encrypts a widget naming the payment request `id` into the content to send in its place. + public func seal(paymentRequest id: PaymentRequestID) throws -> Flipcash_Messaging_V1_Content { + try seal(.with { + $0.widget = .with { $0.paymentRequest = .with { $0.paymentRequestID = id.proto } } + }) + } + + /// Encrypts `plaintext` into the content to send in its place. + func seal(_ plaintext: Flipcash_Messaging_V1_Content) throws -> Flipcash_Messaging_V1_Content { let bytes = SharedBytes.shared let content = bytes.byteArray(data: try plaintext.serializedData()) let payload: EncryptedPayload @@ -209,12 +217,63 @@ public struct ChatSeal: @unchecked Sendable { return message.opened(.success((readable.content, readable.repliedTo)), linkPreview: readable.linkPreview) } + // MARK: - Payment request text - + + /// The wire `EncryptedPaymentRequestText.Scheme` a private group's request text is sealed with. + public static let requestTextScheme = Flipcash_Paymentrequest_V1_EncryptedPaymentRequestText.Scheme.chatKeyXchacha20Poly1305 + + /// Encrypts a request's `title` and `description`, created by this user, as the split's + /// `encrypted` text. Throws in a DM, which has no group key. + public func sealRequestText(title: String, description: String) throws -> GroupPaymentRequest.Text { + guard case .group(let groupKey) = mode else { throw RequestTextFailure.notGroup } + let bytes = SharedBytes.shared + let text = Flipcash_Paymentrequest_V1_PaymentRequestText.with { + $0.title = title + $0.description_p = description + } + let payload = try cipher.encryptPaymentRequestText( + text: bytes.byteArray(data: try text.serializedData()), + groupKey: groupKey, + chatId: chatID, + creatorId: userID(selfUserID) + ) + return .encrypted( + scheme: Self.requestTextScheme.rawValue, + nonce: bytes.data(bytes: payload.nonce), + ciphertext: bytes.data(bytes: payload.ciphertext) + ) + } + + /// Decrypts a request's encrypted text, created by `creator`. Throws when the scheme is not + /// ``requestTextScheme``, the seal is a DM's, or the text fails to authenticate or decode. + public func openRequestText(scheme: Int, nonce: Data, ciphertext: Data, creator: UserID?) throws -> (title: String, description: String) { + guard case .group(let groupKey) = mode else { throw RequestTextFailure.notGroup } + guard scheme == Self.requestTextScheme.rawValue, let creator else { throw RequestTextFailure.unsupported } + let bytes = SharedBytes.shared + let plaintext = try cipher.decryptPaymentRequestText( + payload: EncryptedPayload(nonce: bytes.byteArray(data: nonce), ciphertext: bytes.byteArray(data: ciphertext)), + groupKey: groupKey, + chatId: chatID, + creatorId: userID(creator) + ) + let text = try Flipcash_Paymentrequest_V1_PaymentRequestText(serializedBytes: bytes.data(bytes: plaintext)) + return (text.title, text.description_p) + } + /// The raw `UserId.value` bytes the group aads bind. private func userID(_ id: UserID) -> KotlinByteArray { SharedBytes.shared.byteArray(data: id.data) } } +/// Why a payment request's text could not be sealed or opened. +public enum RequestTextFailure: Error, Hashable, Sendable { + /// The seal is a DM's; request text is sealed only under a group key. + case notGroup + /// The scheme is unknown or the request names no creator. + case unsupported +} + /// Why a photo blob could not be decrypted; either way the photo draws as unsupported. public enum BlobOpenFailure: Error, Hashable, Sendable { /// The blob failed to authenticate under the chat key and its aad. @@ -333,7 +392,15 @@ extension Flipcash_Messaging_V1_Content { case .cash, .system, .widget, .deleted, .encrypted, .reply, nil: return nil } - case .cash, .system, .widget, .deleted, .encrypted, nil: + case .widget(let widget): + // A request is the one card a member seals; a profile share comes only from the server. + switch widget.type { + case .paymentRequest(let request) where request.hasPaymentRequestID: + return (.widget(.paymentRequest(PaymentRequestID(request.paymentRequestID))), nil, nil) + case .paymentRequest, .shareProfile, .poll, nil: + return nil + } + case .cash, .system, .deleted, .encrypted, nil: return nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index ee78705f3..3e3114610 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -102,6 +102,8 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// media and reply, so a widget decrypted out of `EncryptedContent` is still unsupported. public enum Widget: Hashable, Sendable { case shareProfile(ShareProfileWidget) + /// A group payment request card; the request itself is fetched by id. + case paymentRequest(PaymentRequestID) /// A variant this client doesn't recognize, one missing what it needs (a malformed /// username), or a message content type this client doesn't know. Drawn as an unsupported /// message. @@ -466,9 +468,7 @@ extension ConversationMessage { // Kept even when the variant is unknown: a widget has no fallback text, so the contract // is that a client which can't draw one shows the message as unsupported. A poll widget // is such a variant until the app can draw one. - // A payment-request widget carries only the request id; drawing it needs a fetch this model - // doesn't do, so it stays unsupported until the UI lands. - self.content = .widget(widgetContent.shareProfile.map(Widget.shareProfile) ?? .unrecognized) + self.content = .widget(Widget(widgetContent)) self.cashAction = nil repliedTo = nil case .system(let systemContent): diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index d5c05dcdc..d2c99624b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -126,6 +126,9 @@ extension MessageCapability { // so the only capability is Reply, which the quote panel supports. Reply follows the // chat's speaker rule like any message. Sharing is the card's own button, not a menu action. return [.reply] + case .widget(.paymentRequest): + // Server-authored like the profile card: Reply only. Paying and closing live on the card. + return [.reply] case .text: hasText = true hasCopyableText = true diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift index 2db73c6b6..8d7f19646 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift @@ -37,9 +37,24 @@ extension Flipcash_Messaging_V1_WidgetContent { var shareProfile: ShareProfileWidget? { switch type { case .shareProfile(let widget): ShareProfileWidget(widget) - // Poll and payment-request widgets have no local representation yet; they degrade to - // `.unrecognized` upstream. + // Poll and payment-request widgets aren't profile shares. case .poll, .paymentRequest, nil: nil } } } + +extension ConversationMessage.Widget { + + /// The widget `content` carries; `.unrecognized` for a poll, an unknown variant, or a + /// payment request with no id. + init(_ content: Flipcash_Messaging_V1_WidgetContent) { + switch content.type { + case .shareProfile: + self = content.shareProfile.map(Self.shareProfile) ?? .unrecognized + case .paymentRequest(let widget) where widget.hasPaymentRequestID: + self = .paymentRequest(PaymentRequestID(widget.paymentRequestID)) + case .paymentRequest, .poll, nil: + self = .unrecognized + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift new file mode 100644 index 000000000..3f4c14488 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift @@ -0,0 +1,163 @@ +// +// GroupPaymentRequest+Lifecycle.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +extension GroupPaymentRequest { + + /// How long an ended request stays in a chat's deck after it closed. + public static let deckRetention: TimeInterval = 24 * 60 * 60 + + /// The status as of `now`: an OPEN request whose deadline has passed reads as EXPIRED. + public func effectiveStatus(at now: Date) -> Status { + switch status { + case .open: + if let endsAt = split?.endsAt, endsAt <= now { + return .expired + } + return .open + case .completed, .closed, .expired, .unknown: + return status + } + } + + /// When the request ended as of `now`, taking the deadline for a request that expired on the clock. + public func effectiveClosedAt(at now: Date) -> Date? { + switch status { + case .open: + guard let endsAt = split?.endsAt, endsAt <= now else { return nil } + return endsAt + case .completed, .closed, .expired, .unknown: + return closedAt ?? split?.endsAt + } + } + + /// Whether the request has stopped taking payments as of `now`. + public func isEnded(at now: Date) -> Bool { + switch effectiveStatus(at: now) { + case .open: false + case .completed, .closed, .expired, .unknown: true + } + } + + /// The sum of every share's amount, in the split's currency. + public var requestedNative: Decimal { + (split?.shares ?? []).reduce(Decimal.zero) { $0 + Decimal($1.nativeAmount) } + } + + /// The sum of the paid shares' amounts, in the split's currency. + public var paidNative: Decimal { + (split?.shares ?? []).filter(\.isPaid).reduce(Decimal.zero) { $0 + Decimal($1.nativeAmount) } + } + + /// The paid share of the total, from 0 to 1; a COMPLETED request is always 1. + public var paidFraction: Double { + if status == .completed { return 1 } + let requested = requestedNative + guard requested > 0 else { return 0 } + return min(1, (paidNative / requested).doubleValue) + } + + /// The share owed by `userID`, if any. + public func share(for userID: UserID) -> Share? { + split?.shares.first { $0.userID == userID } + } + + /// The copy of a request to keep when `incoming` arrives for one already held as `existing`. + /// + /// The greater version wins whole; at an equal version EXPIRED beats OPEN, since expiry can land + /// without a version bump. + public static func merged(existing: GroupPaymentRequest, incoming: GroupPaymentRequest) -> GroupPaymentRequest { + if incoming.version != existing.version { + return incoming.version > existing.version ? incoming : existing + } + if existing.status == .expired && incoming.status == .open { + return existing + } + return incoming + } +} + +// MARK: - Deadline - + +extension GroupPaymentRequest { + + /// What the status line says, before it is put into words. + public enum Deadline: Hashable, Sendable { + case daysLeft(Int) + case hoursLeft(Int) + case minutesLeft(Int) + case paidInFull + case closed + case expired + } + + /// The status line's state as of `now`. More than 24 hours left reads in days rounded up, so + /// "1 day" never appears; 1–24 hours in hours rounded up; under an hour in minutes rounded up. + public func deadline(at now: Date) -> Deadline { + switch effectiveStatus(at: now) { + case .completed: return .paidInFull + case .closed: return .closed + case .expired, .unknown: return .expired + case .open: + guard let endsAt = split?.endsAt else { return .expired } + let remaining = endsAt.timeIntervalSince(now) + let hour: TimeInterval = 60 * 60 + let day: TimeInterval = 24 * hour + if remaining > day { + return .daysLeft(Int((remaining / day).rounded(.up))) + } + if remaining >= hour { + return .hoursLeft(Int((remaining / hour).rounded(.up))) + } + return .minutesLeft(max(1, Int((remaining / 60).rounded(.up)))) + } + } +} + +// MARK: - Deck - + +extension GroupPaymentRequest { + + /// Whether `userID` has an unpaid share in this request while it is open as of `now`. The creator + /// never owes on their own request. + public func isOwed(by userID: UserID, at now: Date) -> Bool { + guard userID != creator, effectiveStatus(at: now) == .open, let share = share(for: userID) else { return false } + return !share.isPaid + } + + /// The requests a chat's deck shows as of `now`, in deck order: open requests `viewer` owes on, + /// then other open requests, each by nearest deadline, then those that ended within + /// ``deckRetention``, most recently ended first. + public static func deckOrder(_ requests: [GroupPaymentRequest], viewer: UserID? = nil, at now: Date) -> [GroupPaymentRequest] { + var open: [GroupPaymentRequest] = [] + var ended: [(request: GroupPaymentRequest, closedAt: Date)] = [] + + for request in requests { + switch request.effectiveStatus(at: now) { + case .open: + open.append(request) + case .completed, .closed, .expired: + guard let closedAt = request.effectiveClosedAt(at: now), + now.timeIntervalSince(closedAt) < deckRetention else { continue } + ended.append((request, closedAt)) + case .unknown: + continue + } + } + + open.sort { lhs, rhs in + if let viewer { + let lhsOwed = lhs.isOwed(by: viewer, at: now) + if lhsOwed != rhs.isOwed(by: viewer, at: now) { return lhsOwed } + } + return (lhs.split?.endsAt ?? .distantFuture) < (rhs.split?.endsAt ?? .distantFuture) + } + ended.sort { $0.closedAt > $1.closedAt } + return open + ended.map(\.request) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift index 2f6c95d4e..0237eb5fa 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift @@ -66,6 +66,14 @@ public struct GroupPaymentRequest: Hashable, Sendable { public let settlement: Settlement? public var isPaid: Bool { settlement != nil } + + /// Creates a share; a share built for `CreatePaymentRequest` leaves `paidAt` and `settlement` unset. + public init(userID: UserID?, nativeAmount: Double, paidAt: Date? = nil, settlement: Settlement? = nil) { + self.userID = userID + self.nativeAmount = nativeAmount + self.paidAt = paidAt + self.settlement = settlement + } } /// An even or custom division of a bill across group members. @@ -75,6 +83,14 @@ public struct GroupPaymentRequest: Hashable, Sendable { public let currency: String public let endsAt: Date? public let shares: [Share] + + /// Creates a split from its text, currency, deadline and shares. + public init(text: Text?, currency: String, endsAt: Date?, shares: [Share]) { + self.text = text + self.currency = currency + self.endsAt = endsAt + self.shares = shares + } } public let id: PaymentRequestID @@ -87,6 +103,18 @@ public struct GroupPaymentRequest: Hashable, Sendable { public let chatID: ConversationID? /// `nil` for a request kind this build doesn't know. public let split: Split? + + /// Creates a request from its fields; the app builds these only in tests and previews. + public init(id: PaymentRequestID, creator: UserID?, createdAt: Date?, status: Status, closedAt: Date?, version: UInt64, chatID: ConversationID?, split: Split?) { + self.id = id + self.creator = creator + self.createdAt = createdAt + self.status = status + self.closedAt = closedAt + self.version = version + self.chatID = chatID + self.split = split + } } extension GroupPaymentRequest.Status { diff --git a/FlipcashCore/Sources/FlipcashCore/Validation/PaymentRequestTitleValidator.swift b/FlipcashCore/Sources/FlipcashCore/Validation/PaymentRequestTitleValidator.swift new file mode 100644 index 000000000..e0d5c45a0 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Validation/PaymentRequestTitleValidator.swift @@ -0,0 +1,27 @@ +// +// PaymentRequestTitleValidator.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Validates a payment request's title, returning it trimmed. +public struct PaymentRequestTitleValidator: Validator { + + /// PGV `max_len` on `paymentrequest.v1.PaymentRequestText.title`, which counts Unicode scalars. + public static let maxScalars = 100 + + public init() {} + + public func validate(_ input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty, trimmed.unicodeScalars.count <= Self.maxScalars else { + return nil + } + + return trimmed + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index a89d6bbe4..22ccebe10 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -641,11 +641,18 @@ nonisolated extension Database { encryptedNonce = nonce encryptedCiphertext = ciphertext case .widget(let widget): - // The username rides in `text`; a widget with none is one this client can't draw. - kind = 4 + // A profile share's username rides in `text`; a widget with none is one this client + // can't draw. A payment request is its own kind, its id base64 in `text`. switch widget { - case .shareProfile(let share): text = share.username.value - case .unrecognized: text = nil + case .shareProfile(let share): + kind = 4 + text = share.username.value + case .paymentRequest(let id): + kind = 7 + text = id.data.base64EncodedString() + case .unrecognized: + kind = 4 + text = nil } case .system(let fallbackText, let event): kind = 6 @@ -920,6 +927,9 @@ nonisolated extension Database { content = .widget( row[m.text].flatMap(Username.init).map { .shareProfile(ShareProfileWidget(username: $0)) } ?? .unrecognized ) + case 7: + guard let id = row[m.text].flatMap({ Data(base64Encoded: $0) }) else { return nil } + content = .widget(.paymentRequest(PaymentRequestID(data: id))) case 5: guard let data = row[m.mediaJson], let media = try? JSONDecoder().decode(StoredMedia.self, from: data) else { diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index d2414aeb6..8f4c34ac4 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 57 + public static let schemaVersion = 59 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Tests/FlipcashCoreTests/GroupChatSealTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/GroupChatSealTests.swift index a730db3c5..11bbf51b2 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/GroupChatSealTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/GroupChatSealTests.swift @@ -49,6 +49,26 @@ struct GroupChatSealTests { #expect(seal(selfID: hostID).open(try message(sealed, from: hostID)).content == .text("welcome")) } + @Test("A sealed payment request opens to its widget for another member and for the creator") + func paymentRequestRoundTrip() throws { + let id = PaymentRequestID(data: Data(repeating: 0x3C, count: 16)) + let sealed = try seal(selfID: hostID).seal(paymentRequest: id) + + #expect(seal(selfID: memberID).open(try message(sealed, from: hostID)).content == .widget(.paymentRequest(id))) + #expect(seal(selfID: hostID).open(try message(sealed, from: hostID)).content == .widget(.paymentRequest(id))) + } + + @Test("A sealed share-profile card is unsupported: only the server sends one") + func sealedShareProfileUnsupported() throws { + let plaintext = Flipcash_Messaging_V1_Content.with { + $0.widget = .with { $0.shareProfile = .init() } + } + let sealed = try seal(selfID: hostID).seal(plaintext) + let opened = seal(selfID: memberID).open(try message(sealed, from: hostID)) + + #expect(opened.decryptFailure == .unsupported) + } + @Test("A message claimed by a different sender fails authentication") func wrongSender() throws { let sealed = try seal(selfID: hostID).seal(text: "welcome", repliedTo: nil) diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/payment_request_text.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/payment_request_text.json new file mode 100644 index 000000000..1c12db3f1 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/payment_request_text.json @@ -0,0 +1,84 @@ +{ + "algorithm": "XChaCha20-Poly1305-IETF", + "spec": "docs/specs/group-requests.md; proto/paymentrequest/v1/model.proto EncryptedPaymentRequestText", + "note": "Encrypt each `texts` case with chatKey and its nonce and compare to ciphertext byte for byte; decrypt it and compare the parsed title and description. Every `rejects` case must fail to decrypt.", + "label": "flipcash-group-payment-request-e2ee-v1", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "chatId": "132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6", + "creatorId": "bf49c36befaf8d4827dcb9b4c22578f8", + "texts": [ + { + "name": "title only, empty description left off the wire", + "title": "Saturday Request", + "description": "", + "nonce": "d30390c86c2edec34f89a32cf57dcbfdbd90a7b7449c993a", + "plaintext": "0a1053617475726461792052657175657374", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8", + "ciphertext": "b292dfe53aa7003c77cfb742fbd7f51bcc7f00d2f88115ceb60a929885513bd56f5a" + }, + { + "name": "title and description", + "title": "Uber ride", + "description": "Airport run, split four ways.", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "plaintext": "0a09556265722072696465121d416972706f72742072756e2c2073706c697420666f757220776179732e", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6" + }, + { + "name": "non-ASCII title", + "title": "Café ☕ & 寿司 🍣", + "description": "", + "nonce": "60fbf57ac643214378c44edcd1d4b458c0dbf1d5e1c79548", + "plaintext": "0a17436166c3a920e29895202620e5afbfe58fb820f09f8da3", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8", + "ciphertext": "fb805623884104ac833aa2c79c4e8c8a07286f678eeb9b26acd7758bdcb104e9dadf6265c58d30f324" + }, + { + "name": "maximum lengths: 100-character title, 500-character description", + "title": "TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT", + "description": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "nonce": "72911065c4dcb4aae27f48b742f559dbdf203623dd9c4793", + "plaintext": "0a645454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545412f4036464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8", + "ciphertext": "6368e73db1da5e28a31227272577e330e9bf87d98e28ff7ff21331ac4993815387a6c7c07b4fb8f63e017ef0999f9b995379ceb859eeff2753cc96cc8ed13360c1865815c7b4be4a49af5a4738ae77746bb2384eae6011c0ef6ade9e8b932d613da434f858fd9be8ff0831815aa9a3b2a882ef8f5eb9c63031625ebfce0589c5404859ebe3bed34307ac99dfc4d80cc4ca5ca47fcae6f04ec77f4623ebe170348e72827c1aed76b1b3ab7f163d2cd7b2b985521d9679ffd85a866977df2ba0251da1cf4063a4d8f3ea89835422d11403cfb0350bfc647639a67a8b77e473dedb2eaf461313fd17f215ad44b7eec517b0dd00bc17d298c80e83fb8d3a025916da93ac4d9bbb7d79e70d2a886f055afd1d5abae1799275ce105f949cd595e62e97817a62b4f9091ea2486edc9e1cad407d51f026f620416bb3fd78f4dfdb8a95ebd136e450f0413ed64b9b8fe42127d3e6705611f8927da4834448a4ea038be102255eb6334f949d5130a210a63abb8acf6cd6b8a7fe812a5ce5350be34647b6cf8a060b384f3ac00ffc12fab587cbf67a30b8cdb373a26bcc53053071152f0dcccbae8c8afa40567d8f97a6861722e04264a46b6dfaf3079ae64390ee96586f35d2161232a26b70577bfdfae6662e76cd3c80527c61e99dcd9bb4eaf58157775ea6cea1d798952a759c546849694bf65691620cf391af74cc78630869b38d94c12e6f01ec0a65b1fe890d3ad3cc5f554b8f1809e86a192e0f631e7d5faa956fccdc225efe66cf66a59efad996138b6fac00aebb16e7b78195e8772b150cf4689a684ee281d28b3c6f80b4a57328c1842112f904ae0fc7b5952055d36044a2bfcfc15fb687f8fe28418ebb187f6c" + } + ], + "rejects": [ + { + "name": "text moved to another chat", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631446235d7c7fe8352f2ac4ecc078c5bb98b9865ec9b5261bac4819d5e33177345bf49c36befaf8d4827dcb9b4c22578f8" + }, + { + "name": "text attributed to another creator", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6ac04c96fcef918c9943261e7812c8098" + }, + { + "name": "tag tampered", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f7", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8" + }, + { + "name": "wrong chat key", + "chatKey": "d030bc85a871ba3a903909a90118a3cd8e40e5352e9638476a9cb08e5b8d060f", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8" + }, + { + "name": "aad label of another payload kind", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6", + "aad": "666c6970636173682d67726f75702d706f6c6c2d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8" + } + ] +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/PaymentRequestTextVectorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/PaymentRequestTextVectorTests.swift new file mode 100644 index 000000000..8d3383f90 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/PaymentRequestTextVectorTests.swift @@ -0,0 +1,140 @@ +// +// PaymentRequestTextVectorTests.swift +// FlipcashCoreVectorsTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import SharedCore +@testable import FlipcashCore + +/// Reproduces `payment_request_text.json` (canonical copy in the orchestrator's `test-vectors/`) +/// through `ChatSeal`, which reaches shared-core's request-text cipher. The nonce'd encrypt overload +/// is not exported to Swift, so encryption is checked byte for byte on the Kotlin side and by +/// round trip here; decryption is checked byte for byte. +@Suite("Payment request text vectors") +struct PaymentRequestTextVectorTests { + + private struct Vectors: Decodable { + struct Text: Decodable { + let name: String + let title: String + let description: String + let nonce: String + let ciphertext: String + } + struct Reject: Decodable { + let name: String + let chatKey: String + let nonce: String + let ciphertext: String + let aad: String + } + let label: String + let chatKey: String + let chatId: String + let creatorId: String + let texts: [Text] + let rejects: [Reject] + } + + private let vectors: Vectors + + init() throws { + let url = try #require(Bundle.module.url(forResource: "payment_request_text", withExtension: "json", subdirectory: "Fixtures")) + vectors = try JSONDecoder().decode(Vectors.self, from: Data(contentsOf: url)) + } + + private func seal(key: String, chat: Data, selfID: UUID) throws -> ChatSeal { + ChatSeal( + cipher: DefaultChatCipher.shared, + groupKey: try hex(key), + conversationID: ConversationID(data: chat), + selfUserID: selfID + ) + } + + @Test("Every text case decrypts to its title and description") + func textsDecrypt() throws { + #expect(vectors.texts.count == 4) + let creator = try UUID(data: hex(vectors.creatorId)) + let seal = try seal(key: vectors.chatKey, chat: hex(vectors.chatId), selfID: UUID()) + for v in vectors.texts { + let opened = try seal.openRequestText( + scheme: ChatSeal.requestTextScheme.rawValue, + nonce: hex(v.nonce), + ciphertext: hex(v.ciphertext), + creator: creator + ) + #expect(opened.title == v.title, "\(v.name)") + #expect(opened.description == v.description, "\(v.name)") + } + } + + @Test("Every text case round-trips through a fresh nonce") + func textsRoundTrip() throws { + let creator = try UUID(data: hex(vectors.creatorId)) + let seal = try seal(key: vectors.chatKey, chat: hex(vectors.chatId), selfID: creator) + for v in vectors.texts { + let sealed = try seal.sealRequestText(title: v.title, description: v.description) + guard case .encrypted(let scheme, let nonce, let ciphertext) = sealed else { + Issue.record("\(v.name): sealed as plaintext") + continue + } + #expect(nonce.count == 24, "\(v.name)") + #expect(nonce != (try hex(v.nonce)), "\(v.name)") + #expect(ciphertext.count == (try hex(v.ciphertext)).count, "\(v.name)") + let opened = try seal.openRequestText(scheme: scheme, nonce: nonce, ciphertext: ciphertext, creator: creator) + #expect(opened.title == v.title, "\(v.name)") + #expect(opened.description == v.description, "\(v.name)") + } + } + + @Test("Every reject case fails to decrypt") + func rejectsFail() throws { + #expect(vectors.rejects.count == 5) + let label = Data(vectors.label.utf8) + for v in vectors.rejects { + let aad = try hex(v.aad) + // The cipher always builds the aad from the request label, so a case that names another + // label is unreachable from Swift; its raw-aad check runs in the Kotlin vector test. + guard aad.prefix(label.count) == label else { + #expect(aad != label + (try hex(vectors.chatId)) + (try hex(vectors.creatorId)), "\(v.name)") + continue + } + let chat = aad.subdata(in: label.count..<label.count + 32) + let creator = aad.subdata(in: label.count + 32..<aad.count) + let seal = try seal(key: v.chatKey, chat: chat, selfID: UUID()) + #expect(throws: (any Error).self, "\(v.name)") { + try seal.openRequestText( + scheme: ChatSeal.requestTextScheme.rawValue, + nonce: hex(v.nonce), + ciphertext: hex(v.ciphertext), + creator: UUID(data: creator) + ) + } + } + } + + @Test("An unknown scheme is unsupported") + func unknownScheme() throws { + let v = try #require(vectors.texts.first) + let seal = try seal(key: vectors.chatKey, chat: hex(vectors.chatId), selfID: UUID()) + #expect(throws: RequestTextFailure.unsupported) { + try seal.openRequestText(scheme: 99, nonce: hex(v.nonce), ciphertext: hex(v.ciphertext), creator: UUID(data: hex(vectors.creatorId))) + } + } + + private func hex(_ string: String) throws -> Data { + var data = Data(capacity: string.count / 2) + var index = string.startIndex + while index < string.endIndex { + let next = string.index(index, offsetBy: 2) + data.append(try #require(UInt8(string[index..<next], radix: 16))) + index = next + } + return data + } +} diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 0cb191812..a97dc305b 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -678,6 +678,45 @@ struct ConversationControllerTests { #expect(messages.first?.id.value == 9) } + @Test("a request message that fails to send stays in the transcript as failed") + func paymentRequestSendFailureKeepsFailedRow() async { + let mock = MockConversations() + mock.sendError = ErrorSendMessage.transportFailure + let controller = makeController(mock, selfUserID: UUID()) + let id = PaymentRequestID(data: Data([1, 2, 3])) + + let ok = await controller.sendPaymentRequest(id, in: ConversationID.test(1), attempts: 2) + + #expect(!ok) + #expect(mock.sentClientIDs.count == 2) + #expect(Set(mock.sentClientIDs).count == 1) + let messages = controller.messages(for: ConversationID.test(1)) + #expect(messages.count == 1) + #expect(messages.first?.status == .failed) + #expect(messages.first?.content == .widget(.paymentRequest(id))) + } + + @Test("retry re-sends a failed request message with its client id") + func paymentRequestRetryReusesClientID() async throws { + let me = UUID() + let mock = MockConversations() + mock.sendError = ErrorSendMessage.transportFailure + let controller = makeController(mock, selfUserID: me) + let id = PaymentRequestID(data: Data([1, 2, 3])) + await controller.sendPaymentRequest(id, in: ConversationID.test(1), attempts: 1) + let clientID = try #require(controller.messages(for: ConversationID.test(1)).first?.clientMessageID) + + mock.sendError = nil + mock.sendResult = ConversationMessage(id: MessageID(value: 9), senderID: me, content: .widget(.paymentRequest(id)), date: Date(timeIntervalSince1970: 0), unreadSeq: 0) + await controller.retry(clientMessageID: clientID, in: ConversationID.test(1)) + + #expect(mock.sentClientIDs == [clientID, clientID]) + let messages = controller.messages(for: ConversationID.test(1)) + #expect(messages.count == 1) + #expect(messages.first?.status == .sent) + #expect(messages.first?.id.value == 9) + } + private struct ReadPointerRejected: Error {} /// A DM whose self row holds `pointer`, with an inbound message at each id in `messageIDs`. @@ -1760,6 +1799,45 @@ struct ConversationControllerTests { #expect(controller.lastMessagePreview(for: conversation, knownAuthors: knownAuthors) { _ in nil } == "Alice: gm") } + @Test("a request previews as \"<first name> sent request for <title>\", or \"You sent request for <title>\"") + func requestPreviewNamesSenderAndTitle() { + let me = UUID() + let them = UUID() + let controller = makeController(MockConversations(), selfUserID: me) + + let fromThem = requestConversation(from: them, members: [member(them, named: "Alice Smith")]) + let fromMe = requestConversation(from: me, members: []) + #expect(controller.lastMessagePreview(for: fromThem, requestTitle: { _ in "Dinner" }) { _ in nil } == "Alice sent request for Dinner") + #expect(controller.lastMessagePreview(for: fromMe, requestTitle: { _ in "Dinner" }) { _ in nil } == "You sent request for Dinner") + } + + @Test("a request whose title can't be read previews without it, and as nothing while its sender can't be named") + func requestPreviewWithoutTitleOrSender() { + let me = UUID() + let them = UUID() + let controller = makeController(MockConversations(), selfUserID: me) + + #expect(controller.lastMessagePreview(for: requestConversation(from: me, members: [])) { _ in nil } == "You sent request") + #expect(controller.lastMessagePreview(for: requestConversation(from: them, members: [member(them, named: "Alice")])) { _ in nil } == "Alice sent request") + #expect(controller.lastMessagePreview(for: requestConversation(from: UUID(), members: []), requestTitle: { _ in "Dinner" }) { _ in nil } == nil) + } + + private func requestConversation(from sender: UUID, members: [ConversationMember]) -> Conversation { + Conversation( + id: ConversationID.test(1), + members: members, + lastMessage: ConversationMessage( + id: MessageID(value: 1), + senderID: sender, + content: .widget(.paymentRequest(PaymentRequestID(data: Data(repeating: 7, count: 32)))), + date: Date(timeIntervalSince1970: 1), + unreadSeq: 1 + ), + lastActivity: Date(timeIntervalSince1970: 1), + type: .group + ) + } + @Test("only group senders nothing can name are asked for, once each") func unnamedLastMessageSendersListsOnlyUnnamedGroupSenders() { let me = UUID() diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 71599d4c1..5f8ac5799 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -164,7 +164,7 @@ struct DatabaseConversationsTests { #expect(loaded.map(\.cashReceiverID) == [receiver, nil]) } - @Test("Widget messages round-trip, and a widget with no username reads back as unrecognized") + @Test("Widget messages round-trip, including a payment request, and a widget with no username reads back as unrecognized") func widgetMessageRoundTrip() throws { let (database, url) = try Database.makeTemp() defer { Database.removeTemp(at: url) } @@ -175,12 +175,16 @@ struct DatabaseConversationsTests { date: Date(timeIntervalSince1970: TimeInterval(n)), unreadSeq: n) } try database.upsertConversationMessages( - [widget(1, .shareProfile(ShareProfileWidget(username: alice))), widget(2, .unrecognized)], + [ + widget(1, .shareProfile(ShareProfileWidget(username: alice))), widget(2, .unrecognized), + widget(3, .paymentRequest(PaymentRequestID(data: Data([1, 2, 3])))), + ], conversationID: id ) let loaded = try database.getConversationMessages(conversationID: id) #expect(loaded.map(\.content) == [ .widget(.shareProfile(ShareProfileWidget(username: alice))), .widget(.unrecognized), + .widget(.paymentRequest(PaymentRequestID(data: Data([1, 2, 3])))), ]) } diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift new file mode 100644 index 000000000..31c63a8c9 --- /dev/null +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -0,0 +1,649 @@ +// +// PaymentRequestSpecTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +/// The shared test cases from the group-requests spec, numbered as it numbers them. +@MainActor +@Suite("Group payment requests: shared spec cases") +struct PaymentRequestSpecTests { + + private let now = Date(timeIntervalSince1970: 1_800_000_000) + private let chat = ConversationID.test(1) + private let creator = UUID() + private let viewer = UUID() + private let copy = PaymentRequestCopy(displayCurrency: .usd, rates: [:]) + + private func request( + id: UInt8 = 1, + shares: Int = 4, + amount: Double = 10, + paid: Int = 0, + status: GroupPaymentRequest.Status = .open, + endsAt: Date? = nil, + closedAt: Date? = nil, + version: UInt64 = 1 + ) -> GroupPaymentRequest { + let list = (0..<shares).map { index in + GroupPaymentRequest.Share( + userID: index == 0 ? creator : (index == 1 ? viewer : UUID()), + nativeAmount: amount, + settlement: index < paid ? (index == 0 ? .creator : .manual) : nil + ) + } + return GroupPaymentRequest( + id: PaymentRequestID(data: Data([id])), + creator: creator, + createdAt: now.addingTimeInterval(-60), + status: status, + closedAt: closedAt, + version: version, + chatID: chat, + split: .init( + text: .plaintext(title: "Dinner", description: ""), + currency: "usd", + endsAt: endsAt ?? now.addingTimeInterval(7 * 24 * 3600), + shares: list + ) + ) + } + + private func status(endingIn seconds: TimeInterval) -> String { + PaymentRequestCopy.statusLine(request(endsAt: now.addingTimeInterval(seconds)).deadline(at: now)) + } + + // MARK: - Amounts - + + @Test("1: 26 shares of $20 with only the creator's paid") + func onlyCreatorPaid() { + let r = request(shares: 26, amount: 20, paid: 1) + #expect(copy.progressLine(r) == "$20 paid, $520 requested") + #expect(copy.deckAmounts(r) == "$20 of $520") + #expect(abs(r.paidFraction - 1.0 / 26.0) < 1e-12) + } + + @Test("2: 4 shares of $10, three paid, CLOSED") + func closedWithOneUnpaid() { + let r = request(paid: 3, status: .closed, closedAt: now.addingTimeInterval(-60)) + #expect(copy.progressLine(r) == "$30 paid, $40 requested") + #expect(PaymentRequestCopy.statusLine(r.deadline(at: now)) == "Closed") + let unpaid = try! #require(r.split?.shares.last) + #expect(PaymentRequestCopy.shareStatus(unpaid, requestIsOpen: r.effectiveStatus(at: now) == .open) == "Didn't pay") + } + + @Test("3: all shares paid") + func paidInFull() { + let r = request(paid: 4, status: .completed, closedAt: now.addingTimeInterval(-60)) + #expect(PaymentRequestCopy.statusLine(r.deadline(at: now)) == "Paid in full") + #expect(r.paidFraction == 1) + #expect(r.isEnded(at: now)) + } + + // MARK: - Status line - + + @Test("4: 7 days left") func sevenDays() { #expect(status(endingIn: 7 * 86_400) == "7 days left to pay") } + @Test("5: 6 d 1 h rounds up to 7 days") func sixDaysOneHour() { #expect(status(endingIn: 6 * 86_400 + 3_600) == "7 days left to pay") } + @Test("6: 23 h 10 min reads 24 hours") func underADay() { #expect(status(endingIn: 23 * 3_600 + 600) == "24 hours left to pay") } + @Test("22: exactly 24 hours reads in hours") func exactlyADay() { #expect(status(endingIn: 24 * 3_600) == "24 hours left to pay") } + @Test("7: exactly 1 hour") func oneHour() { #expect(status(endingIn: 3_600) == "1 hour left to pay") } + @Test("8: 30 s reads 1 minute") func thirtySeconds() { #expect(status(endingIn: 30) == "1 minute left to pay") } + + @Test("9: OPEN past its deadline reads as EXPIRED at ends_at") + func pastDeadline() { + let endsAt = now.addingTimeInterval(-1) + let r = request(endsAt: endsAt) + #expect(r.effectiveStatus(at: now) == .expired) + #expect(r.effectiveClosedAt(at: now) == endsAt) + #expect(PaymentRequestCopy.statusLine(r.deadline(at: now)) == "Expired") + #expect(r.isEnded(at: now)) + } + + // MARK: - Merge - + + private func store(holding existing: GroupPaymentRequest, thenIngest incoming: GroupPaymentRequest) -> GroupPaymentRequest? { + let store = PaymentRequests(getChatPage: { _, _, _ in [] }, getRequest: { _ in throw ErrorGetPaymentRequest.unknown }) + store.ingest([existing]) + store.ingest([incoming]) + return store.request(existing.id) + } + + @Test("10: an older version never replaces a newer one") + func olderVersion() { + let kept = store(holding: request(version: 5), thenIngest: request(status: .closed, version: 4)) + #expect(kept?.version == 5) + #expect(kept?.status == .open) + } + + @Test("11: at an equal version EXPIRED replaces OPEN") + func expiredReplacesOpen() { + #expect(store(holding: request(version: 5), thenIngest: request(status: .expired, version: 5))?.status == .expired) + } + + @Test("12: at an equal version OPEN never replaces EXPIRED") + func openKeepsExpired() { + #expect(store(holding: request(status: .expired, version: 5), thenIngest: request(version: 5))?.status == .expired) + } + + // MARK: - Deck - + + @Test("13: open by nearest deadline, then ended within 24 h") + func deckOrder() { + let twoDays = request(id: 1, endsAt: now.addingTimeInterval(2 * 86_400)) + let fiveDays = request(id: 2, endsAt: now.addingTimeInterval(5 * 86_400)) + let closed = request(id: 3, status: .closed, closedAt: now.addingTimeInterval(-3 * 3_600)) + let expired = request(id: 4, status: .expired, endsAt: now.addingTimeInterval(-30 * 3_600), closedAt: now.addingTimeInterval(-30 * 3_600)) + + let deck = GroupPaymentRequest.deckOrder([closed, expired, fiveDays, twoDays], at: now) + + #expect(deck.map(\.id) == [twoDays.id, fiveDays.id, closed.id]) + } + + @Test("23: an OPEN request the viewer owes on sorts before one they have paid") + func deckOrderOwedFirst() { + let owedFiveDays = request(id: 1, paid: 1, endsAt: now.addingTimeInterval(5 * 86_400)) + let paidTwoDays = request(id: 2, paid: 2, endsAt: now.addingTimeInterval(2 * 86_400)) + + let deck = GroupPaymentRequest.deckOrder([paidTwoDays, owedFiveDays], viewer: viewer, at: now) + + #expect(deck.map(\.id) == [owedFiveDays.id, paidTwoDays.id]) + #expect(owedFiveDays.isOwed(by: viewer, at: now)) + #expect(!paidTwoDays.isOwed(by: viewer, at: now)) + #expect(PaymentRequestCopy.timeLeft(owedFiveDays.deadline(at: now)) == "5 days left") + #expect(PaymentRequestCopy.owedAccessibilityLabel(share: "$20", title: "Dinner", deadline: owedFiveDays.deadline(at: now), count: 2) + == "2 requests, You owe $20 for Dinner, 5 days left to pay") + } + + @Test("24: the creator never owes, and an ended request is never owed") + func creatorNeverOwes() { + let open = request(id: 1, paid: 0) + let expired = request(id: 2, endsAt: now.addingTimeInterval(-60)) + + #expect(!open.isOwed(by: creator, at: now)) + #expect(!expired.isOwed(by: viewer, at: now)) + } + + @Test("25: the viewer's only owed share becomes paid, so nothing is owed") + func owedShareBecomesPaid() { + let owed = request(id: 1, paid: 1, version: 1) + let paid = request(id: 1, paid: 2, version: 2) + + #expect(owed.isOwed(by: viewer, at: now)) + #expect(!paid.isOwed(by: viewer, at: now)) + } + + @Test("26: viewer owes and scrolls away, so the strip shrinks into the pill") + func stripShrinksWhenScrolledAway() { + #expect(PaymentRequestDeckStyle.collapsed(owes: true, isScrolledAway: false) == .strip) + #expect(PaymentRequestDeckStyle.collapsed(owes: true, isScrolledAway: true) == .pill) + #expect(PaymentRequestDeckStyle.collapsed(owes: false, isScrolledAway: false) == .pill) + } + + @Test("26: the strip shrinks in step with the scroll, not at a single point") + @MainActor + func stripTracksScroll() { + let collapse = PaymentRequestStripCollapse() + let distance = PaymentRequestStripCollapse.distance + + collapse.update(distanceFromNewest: distance / 4) + #expect(collapse.progress == 0.25) + collapse.update(distanceFromNewest: distance / 2) + #expect(collapse.progress == 0.5) + collapse.update(distanceFromNewest: -20) + #expect(collapse.progress == 0) + #expect(!collapse.isPinned) + } + + @Test("26: once fully shrunk, scrolling back to the newest messages leaves the strip in the pill") + @MainActor + func stripStaysPinned() { + let collapse = PaymentRequestStripCollapse() + let distance = PaymentRequestStripCollapse.distance + + collapse.update(distanceFromNewest: distance * 3) + #expect(collapse.isPinned) + collapse.update(distanceFromNewest: 0) + #expect(collapse.progress == 1) + #expect(collapse.isScrolledAway) + } + + @Test("27: reopening the chat while still owing shows the strip again") + @MainActor + func reopenShowsStrip() { + let pinned = PaymentRequestStripCollapse() + pinned.update(distanceFromNewest: PaymentRequestStripCollapse.distance) + #expect(pinned.isPinned) + + // A visit owns its collapse, so a reopened chat starts from a fresh one. + let reopened = PaymentRequestStripCollapse() + #expect(reopened.progress == 0) + #expect(!reopened.isScrolledAway) + } + + @Test("28: a new owed request while the chat is open brings the strip back") + @MainActor + func newOwedRequestUnpins() { + let collapse = PaymentRequestStripCollapse() + collapse.update(distanceFromNewest: PaymentRequestStripCollapse.distance) + collapse.unpin() + #expect(!collapse.isPinned) + #expect(!collapse.isScrolledAway) + #expect(collapse.progress == 0) + collapse.update(distanceFromNewest: PaymentRequestStripCollapse.distance / 2) + #expect(collapse.progress == 0.5) + } + + @Test("26: a small scroll back and forth past the middle never flaps the style") + @MainActor + func stripStyleHasHysteresis() { + let collapse = PaymentRequestStripCollapse() + let distance = PaymentRequestStripCollapse.distance + + collapse.update(distanceFromNewest: distance * 0.55) + #expect(!collapse.isScrolledAway) + collapse.update(distanceFromNewest: distance * 0.7) + #expect(collapse.isScrolledAway) + collapse.update(distanceFromNewest: distance * 0.45) + #expect(collapse.isScrolledAway) + collapse.update(distanceFromNewest: distance * 0.3) + #expect(!collapse.isScrolledAway) + } + + // MARK: - Viewer status - + + /// A $45 request in $5 shares: the creator's, the viewer's, then seven others, `othersPaid` of + /// them paid. + private func ninePeople(viewerPaid: Bool, othersPaid: Int, version: UInt64 = 1) -> GroupPaymentRequest { + let others = (0..<7).map { index in + GroupPaymentRequest.Share(userID: UUID(), nativeAmount: 5, settlement: index < othersPaid ? .manual : nil) + } + let shares = [ + GroupPaymentRequest.Share(userID: creator, nativeAmount: 5, settlement: .creator), + GroupPaymentRequest.Share(userID: viewer, nativeAmount: 5, settlement: viewerPaid ? .manual : nil), + ] + others + return GroupPaymentRequest( + id: PaymentRequestID(data: Data([9])), + creator: creator, + createdAt: now.addingTimeInterval(-60), + status: .open, + closedAt: nil, + version: version, + chatID: chat, + split: .init( + text: .plaintext(title: "Dinner", description: ""), + currency: "usd", + endsAt: now.addingTimeInterval(7 * 24 * 3600), + shares: shares + ) + ) + } + + private func line(_ status: PaymentRequestViewerStatus, _ request: GroupPaymentRequest) -> String { + String(status.line(amounts: copy.deckAmounts(request)).characters) + } + + @Test("29: viewer paid their $5 share of a request at $15 of $45") + func viewerPaid() { + let r = ninePeople(viewerPaid: true, othersPaid: 1) + let status = PaymentRequestViewerStatus(request: r, viewer: viewer, copy: copy) + #expect(status.showsPaidBadge) + #expect(line(status, r) == "✓ You paid $5 · $15 of $45") + #expect(status.lead.map { String($0.characters) } == "✓ You paid $5") + #expect( + copy.deckAccessibilityLabel(front: r, title: "Dinner", viewerStatus: status, count: 1, now: now) + == "Dinner, you paid $5, $15 of $45 paid, 7 days left to pay" + ) + } + + @Test("30: viewer owes $5 on a request at $10 of $45") + func viewerOwes() { + let r = ninePeople(viewerPaid: false, othersPaid: 1) + let status = PaymentRequestViewerStatus(request: r, viewer: viewer, copy: copy) + #expect(!status.showsPaidBadge) + #expect(line(status, r) == "You owe $5 · $10 of $45") + } + + @Test("31: viewer created the request") + func viewerCreated() { + let r = ninePeople(viewerPaid: true, othersPaid: 1) + let status = PaymentRequestViewerStatus(request: r, viewer: creator, copy: copy) + #expect(!status.showsPaidBadge) + #expect(line(status, r) == "Your request · $15 of $45") + } + + @Test("32: viewer has no share") + func viewerHasNoShare() { + let r = ninePeople(viewerPaid: true, othersPaid: 1) + let status = PaymentRequestViewerStatus(request: r, viewer: UUID(), copy: copy) + #expect(!status.showsPaidBadge) + #expect(line(status, r) == "$15 of $45") + #expect( + copy.deckAccessibilityLabel(front: r, title: "Dinner", viewerStatus: status, count: 2, now: now) + == "2 requests, Dinner, $15 of $45 paid, 7 days left to pay" + ) + } + + @Test("33: the creator marks the viewer's share paid, and the refetched request shows it") + func markedPaidAfterRefetch() { + let before = ninePeople(viewerPaid: false, othersPaid: 1) + let after = GroupPaymentRequest.merged(existing: before, incoming: ninePeople(viewerPaid: true, othersPaid: 1, version: 2)) + #expect(!PaymentRequestViewerStatus(request: before, viewer: viewer, copy: copy).showsPaidBadge) + let status = PaymentRequestViewerStatus(request: after, viewer: viewer, copy: copy) + #expect(status.showsPaidBadge) + #expect(line(status, after) == "✓ You paid $5 · $15 of $45") + } + + // MARK: - People - + + private func people(count: Int) -> PaymentRequestPeople { + PaymentRequestPeople(members: [creator] + (1..<count).map { _ in UUID() }, creator: creator) + } + + @Test("14: everyone selected in a 26-member group") + func everyone() { + let p = people(count: 26) + #expect(p.summaryValue == "Everyone, Including You") + #expect(p.summarySecondLine == "You'll be marked as paid") + #expect(p.buttonTitle(amount: "$20") == "Request $20 from 25 People") + let shares = p.shares(nativeAmount: 20) + #expect(shares.count == 26) + #expect(shares.first?.userID == creator) + #expect(shares.allSatisfy { !$0.isPaid && $0.nativeAmount == 20 }) + } + + @Test("15: the creator and one other") + func creatorAndOne() { + var p = people(count: 3) + p.toggle(p.members[2]) + #expect(p.summaryValue == "1 Person, Including You") + #expect(p.buttonTitle(amount: "$20") == "Request $20 from 1 Person") + } + + @Test("16: everyone except the creator") + func everyoneExceptYou() { + var p = people(count: 5) + p.toggle(creator) + #expect(p.summaryValue == "Everyone Except You") + #expect(p.summarySecondLine == nil) + #expect(!p.shares(nativeAmount: 20).contains { $0.userID == creator }) + } + + @Test("17: only the creator selected disables the button") + func onlyCreator() { + var p = people(count: 3) + p.toggle(p.members[1]) + p.toggle(p.members[2]) + #expect(!p.isValid) + #expect(p.summaryValue == "0 People, Including You") + #expect(p.summarySecondLine == "You'll be marked as paid") + } + + @Test("nobody selected reads Choose People") + func nobodySelected() { + var p = people(count: 3) + for member in p.members where p.selected.contains(member) { p.toggle(member) } + #expect(p.selected.isEmpty) + #expect(p.summaryValue == "Choose People") + #expect(p.summarySecondLine == nil) + } + + @Test("18: checking a 101st share is refused and leaves the selection as it was") + func hundredFirst() { + var p = people(count: 101) + #expect(p.summaryValue == "Choose People") + for member in p.members.prefix(100) { + let added = p.toggle(member) + #expect(added) + } + let before = p.selected + let overflow = p.toggle(p.members[100]) + #expect(!overflow) + #expect(p.selected == before) + } + + @Test("Groups of 100 or fewer start with everyone selected") + func hundredStartsFull() { + #expect(people(count: 100).selected.count == 100) + } + + // MARK: - Widgets and details - + + @Test("19: a widget from another chat, or not from the creator, is unsupported") + func widgetBelongs() { + let r = request() + #expect(r.belongs(to: chat, sentBy: creator)) + #expect(!r.belongs(to: .test(2), sentBy: creator)) + #expect(!r.belongs(to: chat, sentBy: UUID())) + #expect(!r.belongs(to: chat, sentBy: nil)) + } + + private func details(_ r: GroupPaymentRequest, viewer: UserID, intent: Bool) -> PaymentRequestDetailsContent { + PaymentRequestDetailsContent(request: r, title: "Dinner", viewer: viewer, hasPaymentIntent: intent, copy: copy, now: now) { _ in "Ada" } + } + + @Test("20: no payment_intent_id for the viewer hides Send") + func noIntentNoSend() throws { + let r = request() + let debtor = try #require(r.split?.shares[1].userID) + #expect(details(r, viewer: debtor, intent: false).sendTitle == nil) + #expect(details(r, viewer: debtor, intent: true).sendTitle == "Send $10") + } + + @Test("Details rows: You, Paid, and the creator's mark-paid action only while OPEN") + func detailsRows() throws { + let open = details(request(paid: 2), viewer: creator, intent: false) + #expect(open.requestedBy == "Requested by you") + #expect(open.canClose) + #expect(open.rows.map(\.name) == ["You", "Ada", "Ada", "Ada"]) + #expect(open.rows.map(\.status) == ["✓ Paid", "✓ Paid", "Hasn't paid yet", "Hasn't paid yet"]) + #expect(open.rows.map(\.action) == [.none, .none, .markPaid, .markPaid]) + + let closed = details(request(paid: 2, status: .closed, closedAt: now), viewer: creator, intent: false) + #expect(!closed.canClose) + #expect(closed.rows.map(\.status) == ["✓ Paid", "✓ Paid", "Didn't pay", "Didn't pay"]) + #expect(closed.rows.allSatisfy { $0.action == .none }) + } +} + +@Suite("Group payment requests: create copy") +@MainActor +struct PaymentRequestCreateCopyTests { + + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test("The 4-week preset sits five minutes short of the server's limit") + func fourWeeks() { + #expect(PaymentRequestDeadline.fourWeeks.endsAt(sentAt: now) == now.addingTimeInterval(28 * 86_400 - 300)) + #expect(PaymentRequestDeadline.oneWeek.summaryValue() == "7 Days To Pay") + } + + @Test("the custom picker runs from an hour out to five minutes short of 28 days") + func customRange() { + let now = Date(timeIntervalSince1970: 1_000_000) + let range = PaymentRequestDeadline.customRange(openedAt: now) + #expect(range.lowerBound == now.addingTimeInterval(60 * 60)) + #expect(range.upperBound == now.addingTimeInterval(28 * 24 * 60 * 60 - 5 * 60)) + } + + @Test("a custom deadline reads in medium date and short time, year included") + func customSummary() throws { + let locale = Locale(identifier: "en_US") + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let date = try #require(calendar.date(from: DateComponents(year: 2026, month: 11, day: 2, hour: 18))) + #expect(PaymentRequestDeadline.custom(date).summaryValue(locale: locale) == "Until Nov 2, 2026, 6:00\u{202F}PM") + } + + @Test("The default title is the weekday followed by Request") + func defaultTitle() { + // 2027-01-15 08:00 UTC, a Friday from UTC−8 to UTC+15. + let friday = Date(timeIntervalSince1970: 1_800_000_000) + #expect(defaultPaymentRequestTitle(at: friday, locale: Locale(identifier: "en_US")) == "Friday Request") + } + + @Test("Titles are trimmed and limited to 100 scalars") + func titleValidator() { + let validator = PaymentRequestTitleValidator() + #expect(validator.validate(" Dinner ") == "Dinner") + #expect(validator.validate(" ") == nil) + #expect(validator.validate(String(repeating: "a", count: 100)) != nil) + #expect(validator.validate(String(repeating: "a", count: 101)) == nil) + } +} + +@Suite("Group payment requests: Time To Pay sheet") +@MainActor +struct PaymentRequestDeadlinePickerTests { + + private let locale = Locale(identifier: "en_US") + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + // A fixed offset, so the spec's wall-clock times hold across the November DST change. + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + calendar.firstWeekday = 1 + return calendar + }() + + private func date(_ month: Int, _ day: Int, _ hour: Int = 0, _ minute: Int = 0) -> Date { + calendar.date(from: DateComponents(year: 2026, month: month, day: day, hour: hour, minute: minute))! + } + + private func picker(_ deadline: PaymentRequestDeadline = .oneWeek, openedAt now: Date) -> PaymentRequestDeadlinePicker { + PaymentRequestDeadlinePicker(deadline: deadline, now: now, calendar: calendar, locale: locale) + } + + @Test("34: the grid starts on the locale's first weekday and enables today through today + 28 days") + func grid() { + let picker = picker(openedAt: date(10, 26, 11, 39)) + #expect(picker.gridDays.count == 35) + #expect(picker.gridDays.first == date(10, 25)) + #expect(!picker.isEnabled(date(10, 25))) + #expect(picker.isEnabled(date(10, 26))) + #expect(picker.isEnabled(date(11, 23))) + #expect(!picker.isEnabled(date(11, 24))) + #expect(picker.selectedPreset == .oneWeek) + #expect(picker.selectedDay == date(11, 2)) + #expect(picker.customDate == nil) + #expect(picker.rangeStart == date(10, 26)) + #expect(picker.band(for: date(10, 25)) == nil) + #expect(picker.band(for: date(10, 26)) == RangeBand(fromCenter: true, toCenter: false, roundedLeading: false, roundedTrailing: false)) + #expect(picker.band(for: date(10, 28)) == RangeBand(fromCenter: false, toCenter: false, roundedLeading: false, roundedTrailing: false)) + #expect(picker.band(for: date(10, 31)) == RangeBand(fromCenter: false, toCenter: false, roundedLeading: false, roundedTrailing: true)) + #expect(picker.band(for: date(11, 1)) == RangeBand(fromCenter: false, toCenter: false, roundedLeading: true, roundedTrailing: false)) + #expect(picker.band(for: date(11, 2)) == RangeBand(fromCenter: false, toCenter: true, roundedLeading: false, roundedTrailing: false)) + #expect(picker.band(for: date(11, 3)) == nil) + #expect(picker.closesValue == "≈ Mon, Nov 2") + #expect(picker.summaryLine == "1 Week · closes 7 days after you send") + #expect(picker.timeText(picker.timeRowDate) == "11:39\u{202F}AM") + } + + @Test("35: tapping a day makes it custom at 6:00 PM") + func customDay() throws { + let picker = picker(openedAt: date(10, 26, 11, 39)) + picker.select(day: date(11, 5)) + #expect(picker.selectedPreset == nil) + let custom = try #require(picker.customDate) + #expect(custom == date(11, 5, 18)) + #expect(picker.timeText(custom) == "6:00\u{202F}PM") + #expect(picker.closesValue == "Thu, Nov 5, 6:00\u{202F}PM") + #expect(picker.summaryLine == "Custom · 10 days") + #expect(picker.band(for: date(11, 5))?.toCenter == true) + #expect(picker.band(for: date(11, 6)) == nil) + } + + @Test("36: today's default time moves up to an hour from now") + func clampToday() { + let picker = picker(openedAt: date(10, 26, 17, 30)) + picker.select(day: date(10, 26)) + #expect(picker.customDate == date(10, 26, 18, 30)) + } + + @Test("37: the last day's default time moves down to 28 days less five minutes from now") + func clampLastDay() { + let picker = picker(openedAt: date(10, 26, 11, 39)) + picker.select(day: date(11, 23)) + #expect(picker.customDate == date(11, 23, 11, 34)) + } + + @Test("38: a chip after a custom day clears the custom date and highlights its day") + func chipAfterCustom() { + let picker = picker(openedAt: date(10, 26, 11, 39)) + picker.select(day: date(11, 5)) + picker.select(.twoWeeks) + #expect(picker.customDate == nil) + #expect(picker.selectedDay == date(11, 9)) + #expect(picker.band(for: date(11, 9))?.toCenter == true) + #expect(picker.summaryLine == "2 Weeks · closes 14 days after you send") + #expect(picker.timeRowDate == date(11, 9, 11, 39)) + } + + @Test("41: changing a preset's time makes its closing day custom at that time") + func presetTime() { + let picker = picker(openedAt: date(10, 26, 11, 39)) + picker.setTime(date(10, 26, 15)) + #expect(picker.selectedPreset == nil) + #expect(picker.customDate == date(11, 2, 15)) + #expect(picker.closesValue == "Mon, Nov 2, 3:00\u{202F}PM") + #expect(picker.summaryLine == "Custom · 7 days") + #expect(picker.choice == .custom(date(11, 2, 15))) + } + + @Test("42: a time past 4 Weeks moves down to 28 days less five minutes from now") + func presetTimeClamp() { + let picker = picker(openedAt: date(10, 26, 11, 39)) + picker.select(.fourWeeks) + picker.setTime(date(10, 26, 11, 50)) + #expect(picker.selectedPreset == nil) + #expect(picker.customDate == date(11, 23, 11, 34)) + #expect(picker.selectedDay == date(11, 23)) + } + + @Test("45: tapping a day after changing the time keeps that time") + func dayKeepsTime() { + let picker = picker(openedAt: date(10, 26, 11, 39)) + picker.setTime(date(10, 26, 15)) + picker.select(day: date(11, 5)) + #expect(picker.customDate == date(11, 5, 15)) + #expect(picker.closesValue == "Thu, Nov 5, 3:00\u{202F}PM") + } + + @Test("48: the band runs square up to the start and end circles") + func squareEnds() { + let picker = picker(openedAt: date(10, 26, 11, 39)) + let start = picker.band(for: date(10, 26)) + let end = picker.band(for: date(11, 2)) + #expect(start?.fromCenter == true) + #expect(start?.roundedLeading == false) + #expect(end?.toCenter == true) + #expect(end?.roundedTrailing == false) + } + + @Test("39: the summary keeps its choice until Done, and reopening shows the current one") + func dismissKeepsChoice() { + let deadline = PaymentRequestDeadline.oneWeek + let picker = picker(deadline, openedAt: date(10, 26, 11, 39)) + picker.select(day: date(11, 5)) + #expect(deadline.summaryValue() == "7 Days To Pay") + + let reopened = self.picker(picker.choice, openedAt: date(10, 26, 11, 45)) + #expect(reopened.customDate == date(11, 5, 18)) + reopened.select(day: date(11, 7)) + #expect(reopened.customDate == date(11, 7, 18)) + } + + @Test("40: today tapped shows only the closing circle, with no band") + func closesToday() { + let picker = picker(openedAt: date(10, 26, 11, 39)) + picker.select(day: date(10, 26)) + #expect(picker.selectedDay == date(10, 26)) + #expect(picker.rangeStart == nil) + #expect(picker.band(for: date(10, 26)) == nil) + #expect(picker.closesValue == "Mon, Oct 26, 6:00\u{202F}PM") + } +} diff --git a/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift new file mode 100644 index 000000000..4c09fa62d --- /dev/null +++ b/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift @@ -0,0 +1,139 @@ +// +// PaymentRequestsSealTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import SharedCore +@testable import FlipcashCore +@testable import Flipcash + +/// An encrypted request title waits on the chat's seal, so a failed seal read must not strand it. +@MainActor +@Suite("Payment requests: chat seal retries") +struct PaymentRequestsSealTests { + + private let chat = ConversationID.test(1) + + private static func seal() throws -> ChatSeal { + try ChatSeal( + cipher: DefaultChatCipher.shared, + owner: KeyPair.generate()!, + peerPublicKey: KeyPair.generate()!.publicKey, + conversationID: ConversationID(data: Data(repeating: 0x07, count: 32)), + selfUserID: UUID() + ) + } + + /// Answers `nil` until `available` is set, counting reads. + @MainActor + private final class SealSource { + var available: ChatSeal? + var reads = 0 + } + + private func store(_ source: SealSource) -> PaymentRequests { + PaymentRequests( + getChatPage: { _, _, _ in [] }, + getRequest: { _ in throw ErrorGetPaymentRequest.unknown }, + openingSeal: { _ in + source.reads += 1 + return source.available + } + ) + } + + @Test("A chat refresh retries a seal the last read could not open") + func refreshChatRetriesSeal() async throws { + let source = SealSource() + let store = store(source) + + await store.refreshChat(chat) + #expect(store.seal(for: chat) == nil) + + source.available = try Self.seal() + await store.refreshChat(chat) + #expect(store.seal(for: chat) != nil) + } + + @Test("A held seal is not read again") + func heldSealIsKept() async throws { + let source = SealSource() + source.available = try Self.seal() + let store = store(source) + + await store.refreshChat(chat) + await store.refreshChat(chat) + #expect(source.reads == 1) + } + + private func encryptedRequest() -> GroupPaymentRequest { + GroupPaymentRequest( + id: PaymentRequestID(data: Data([1])), + creator: UUID(), + createdAt: .now, + status: .open, + closedAt: nil, + version: 1, + chatID: chat, + split: .init( + text: .encrypted(scheme: 1, nonce: Data(repeating: 1, count: 24), ciphertext: Data([2])), + currency: "usd", + endsAt: .now.addingTimeInterval(3600), + shares: [] + ) + ) + } + + @Test("Encrypted text with no seal keeps the chat settling until the seal opens") + func settlesOnSeal() async throws { + let source = SealSource() + let store = store(source) + let request = encryptedRequest() + store.ingest([request]) + + await store.refreshChat(chat) + #expect(store.isSettling(chat)) + #expect(store.isSettling(request: request.id)) + + source.available = try Self.seal() + await store.refreshChat(chat) + #expect(!store.isSettling(chat)) + #expect(!store.isSettling(request: request.id)) + } + + @Test("A request read that failed is read again by the next chat refresh") + func failedReadRetried() async throws { + var reads = 0 + var fails = true + let request = encryptedRequest() + let store = PaymentRequests( + getChatPage: { _, _, _ in [] }, + getRequest: { _ in + reads += 1 + if fails { throw ErrorGetPaymentRequest.transportFailure } + return (request, nil) + }, + openingSeal: { _ in try? Self.seal() } + ) + + await store.fetchIfMissing(request.id) + #expect(store.isSettling(chat)) + + fails = false + await store.refreshChat(chat) + #expect(reads == 2) + #expect(store.request(request.id) != nil) + #expect(!store.isSettling(chat)) + } + + @Test("Retries back off from 2 s and stop at the poll interval") + func retryDelay() { + #expect(PaymentRequests.retryDelay(attempt: 0) == .seconds(2)) + #expect(PaymentRequests.retryDelay(attempt: 1) == .seconds(4)) + #expect(PaymentRequests.retryDelay(attempt: 10) == PaymentRequests.pollInterval) + } +} diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index 4ca4f8915..c92d5971c 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -445,6 +445,15 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con nil } + func sendPaymentRequestMessage(owner: KeyPair, conversationID: ConversationID, paymentRequestID id: PaymentRequestID, clientMessageID: UUID) async throws -> ConversationMessage { + lock.withLock { _sentClientIDs.append(clientMessageID) } + if let error = sendError { throw error } + return sendResult ?? ConversationMessage( + id: MessageID(value: 1), senderID: nil, content: .widget(.paymentRequest(id)), + date: Date(timeIntervalSince1970: 0), unreadSeq: 0 + ) + } + func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? { nil } diff --git a/FlipcashUI/Package.swift b/FlipcashUI/Package.swift index b72ace312..946c4e921 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -20,7 +20,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.15.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.16.0")) }() let package = Package( diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdropView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdropView.swift new file mode 100644 index 000000000..a804e7408 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdropView.swift @@ -0,0 +1,49 @@ +// +// ChatBackdropView.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import SwiftUI +import UIKit + +/// ``ChatBackdrop``'s frosting and dim as a SwiftUI view, for chat surfaces drawn in SwiftUI that +/// sit over the transcript the way a long-pressed message's menu does. Fade it with `opacity`. +public struct ChatBackdropView: UIViewRepresentable { + + public init() {} + + public func makeUIView(context: Context) -> UIVisualEffectView { + let effectView = UIVisualEffectView(effect: nil) + effectView.isUserInteractionEnabled = false + + let dim = UIView() + dim.backgroundColor = .black + dim.alpha = ChatBackdrop.dimAlpha + dim.autoresizingMask = [.flexibleWidth, .flexibleHeight] + dim.frame = effectView.contentView.bounds + effectView.contentView.addSubview(dim) + + context.coordinator.strength = ChatBackdrop.frost(effectView) + return effectView + } + + public func updateUIView(_ view: UIVisualEffectView, context: Context) {} + + public static func dismantleUIView(_ view: UIVisualEffectView, coordinator: Coordinator) { + coordinator.strength?.stopAnimation(true) + coordinator.strength = nil + } + + public func makeCoordinator() -> Coordinator { Coordinator() } + + /// Holds the blur at ``ChatBackdrop/blurFraction`` until the view is dismantled — see + /// ``ChatBackdrop/frost(_:)``. + @MainActor + public final class Coordinator { + var strength: UIViewPropertyAnimator? + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 990018e7a..c1bbf5ead 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -232,7 +232,7 @@ public final class ChatBubbleView: UIView { case .unavailable(let unavailableHint): hint = unavailableHint.text isUpdateBubble = false - case .text, .cash, .deleted, .shareProfile, .media: + case .text, .cash, .deleted, .shareProfile, .media, .paymentRequest: hint = nil isUpdateBubble = false } @@ -305,7 +305,7 @@ public final class ChatBubbleView: UIView { case .text: message.isEdited case .deleted: false case .cash: false - case .unavailable, .shareProfile, .media: false + case .unavailable, .shareProfile, .media, .paymentRequest: false } } @@ -315,7 +315,7 @@ public final class ChatBubbleView: UIView { switch message.content { case .unavailable(.updateApp): .dashedOverWash case .unavailable(.askToResend), .unavailable(.resend): .dashed - case .text, .cash, .deleted, .shareProfile, .media: .hairline + case .text, .cash, .deleted, .shareProfile, .media, .paymentRequest: .hairline } } @@ -371,7 +371,7 @@ public final class ChatBubbleView: UIView { return updateAppText() case .unavailable(.askToResend), .unavailable(.resend): return unavailableText() - case .cash, .shareProfile, .media: + case .cash, .shareProfile, .media, .paymentRequest: return nil } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift index 96136c7bd..4eb54808d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift @@ -188,7 +188,7 @@ public class ChatColumnCell: UICollectionViewCell { switch message.content { case .deleted: return nil - case .text, .cash, .shareProfile, .media, .unavailable: + case .text, .cash, .shareProfile, .media, .unavailable, .paymentRequest: return message.sentAt?.formattedTime() } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index bb3589150..ece2c8e9c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -47,6 +47,8 @@ extension ChatItem { ChatCashCardCell.reuseIdentifier case .shareProfile: ChatShareProfileCell.reuseIdentifier + case .paymentRequest: + ChatPaymentRequestCell.reuseIdentifier case .media: ChatMediaCell.reuseIdentifier } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPaymentRequestCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPaymentRequestCell.swift new file mode 100644 index 000000000..20f5b6b67 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPaymentRequestCell.swift @@ -0,0 +1,122 @@ +// +// ChatPaymentRequestCell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import SwiftUI +import FlipcashCore + +/// A recycled cell for a group payment request widget: the request card in bubble chrome. A tap on +/// the row opens the request's details. +public final class ChatPaymentRequestCell: ChatColumnCell { + + public static let reuseIdentifier = "ChatPaymentRequestCell" + + /// The designed card width; a narrower transcript caps it. + static let designedWidth: CGFloat = 290 + + private static let padding: CGFloat = 16 + + private let card = BubbleBackgroundView() + private let reactionRow = ReactionPillRowView() + private let content: any UIView & UIContentView + private var cardWidthConstraint: NSLayoutConstraint! + private var reactionRowWidthConstraint: NSLayoutConstraint! + private var shown: ChatPaymentRequestContent.Card?? + + public override init(frame: CGRect) { + // Made with the content type `draw` sets later: a hosting content view traps when handed a + // configuration of a different content type. + content = UIHostingConfiguration { PaymentRequestCard(content: nil) } + .margins(.all, Self.padding) + .makeContentView() + super.init(frame: frame) + + card.translatesAutoresizingMaskIntoConstraints = false + content.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(content) + + installColumn(content: card, accessory: reactionRow) + reactionRow.onToggle = { [weak self] emoji in self?.onReactionTap?(emoji) } + reactionRow.onLongPress = { [weak self] emoji in self?.onReactionLongPress?(emoji) } + reactionRow.onAdd = { [weak self] in self?.onReactionAdd?() } + reactionRow.onBoost = { [weak self] in self?.onReactionBoost?() } + + cardWidthConstraint = card.widthAnchor.constraint(equalToConstant: Self.designedWidth) + reactionRowWidthConstraint = reactionRow.widthAnchor.constraint(equalToConstant: Self.designedWidth) + NSLayoutConstraint.activate([ + cardWidthConstraint, + reactionRowWidthConstraint, + content.topAnchor.constraint(equalTo: card.topAnchor), + content.bottomAnchor.constraint(equalTo: card.bottomAnchor), + content.leadingAnchor.constraint(equalTo: card.leadingAnchor), + content.trailingAnchor.constraint(equalTo: card.trailingAnchor), + ]) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Fired when the viewer taps a reaction pill — the argument is the toggled emoji. + var onReactionTap: ((String) -> Void)? + /// Fired on a long-press of a reaction pill, to open the reactors sheet scoped to that emoji. + var onReactionLongPress: ((String) -> Void)? + /// Fired when the trailing "+" is tapped, to open the picker. + var onReactionAdd: (() -> Void)? + /// Called when the viewer taps the leading "$" to pay for a reaction. + var onReactionBoost: (() -> Void)? + + /// Scopes the row's touch target to the card, not the full-width cell. + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + card.convert(card.bounds, to: self).contains(point) || reactionRow.convert(reactionRow.bounds, to: self).contains(point) + || authorAvatarContains(point) + } + + public override func prepareForReuse() { + super.prepareForReuse() + shown = nil + reactionRow.prepareForReuse() + } + + /// - Parameter maxWidth: the widest the card may be; the designed width caps it from above. + public func configure(with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil) { + guard case .paymentRequest(let request) = message.content else { return } + let width = min(Self.designedWidth, maxWidth) + cardWidthConstraint.constant = width + reactionRowWidthConstraint.constant = width + + card.apply( + fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), + radii: BubbleBackgroundView.radii( + isFromSelf: message.sender == .me, + groupedAbove: message.joinsBubbleAbove, + groupedBelow: message.joinsBubbleBelow + ), + identity: message.id + ) + reactionRow.layoutWidth = width + reactionRow.hugsTrailingEdge = message.sender == .me + reactionRow.configure(pills: message.reactions, canReact: message.canReact, canBoost: message.canBoost) + updateColumn(for: message, authorImageData: authorImageData) + + // A reconfigure in place (a grouping or reaction change) keeps the card it drew. + guard shown != .some(request.card) else { return } + shown = .some(request.card) + content.configuration = UIHostingConfiguration { PaymentRequestCard(content: request.card) } + .margins(.all, Self.padding) + accessibilityTraits.insert(.button) + } +} + +extension ChatPaymentRequestCell: BubbleCarrying { + /// The card, not the cell: the cell spans the full row. + var liftPreviewView: UIView { card } + var liftPreviewMaskingPath: UIBezierPath? { card.maskingPath } + + func flashAttention(startedAt start: CFTimeInterval) { card.flashAttention(startedAt: start) } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 7d29696c9..6091f128c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -198,6 +198,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onCashCardTap = newValue } } + /// Called with a payment request's id bytes when its card is tapped. + public var onPaymentRequestTap: ((Data) -> Void)? { + get { transcript.onPaymentRequestTap } + set { transcript.onPaymentRequestTap = newValue } + } + public var onOpenURL: ((URL) -> Void)? { get { transcript.onOpenURL } set { transcript.onOpenURL = newValue } @@ -312,6 +318,27 @@ public final class ChatScreenViewController: UIViewController { } } + /// Fires when the reader leaves or comes back to the newest messages — see + /// ``ChatViewController/onScrolledAwayChange``. + public var onScrolledAwayChange: ((Bool) -> Void)? { + get { transcript.onScrolledAwayChange } + set { transcript.onScrolledAwayChange = newValue } + } + + /// Fires as the reader scrolls, with how far they sit from the newest messages — see + /// ``ChatViewController/onDistanceFromNewestChange``. + public var onDistanceFromNewestChange: ((CGFloat) -> Void)? { + get { transcript.onDistanceFromNewestChange } + set { transcript.onDistanceFromNewestChange = newValue } + } + + /// A band above the newest messages a released scroll never rests inside — see + /// ``ChatViewController/snapDistanceFromNewest``. + public var snapDistanceFromNewest: CGFloat? { + get { transcript.snapDistanceFromNewest } + set { transcript.snapDistanceFromNewest = newValue } + } + /// Fires on the reader's first drag after the opening scroll — see /// ``ChatViewController/onUserScrollBegan``. public var onUserScrollBegan: (() -> Void)? { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 61953328f..28f5a9456 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -49,6 +49,10 @@ public final class ChatViewController: UICollectionViewController { /// that token's currency info. Only cash rows are selectable (see `shouldHighlightItemAt`). public var onCashCardTap: ((String) -> Void)? + /// Called when the user taps a payment request card; the argument is the request's id bytes. The + /// owner opens the request's details. + public var onPaymentRequestTap: ((Data) -> Void)? + /// Called when the user taps a URL in a text bubble; the owner opens it. public var onOpenURL: ((URL) -> Void)? /// Fired when a shared-profile widget's Share button is tapped, with the person's display name @@ -238,10 +242,21 @@ public final class ChatViewController: UICollectionViewController { /// Fires when the jump-to-bottom button should appear or go: the opening scroll has landed and the /// reader is outside ``bottomThreshold`` of the newest message. var onJumpButtonVisibilityChange: ((Bool) -> Void)? + /// Fires when the reader leaves or comes back to the newest messages, once the opening scroll has + /// landed. Unlike the jump button it ignores a pending return to a quote. + var onScrolledAwayChange: ((Bool) -> Void)? + /// Fires on every scroll once the opening scroll has landed, with how far in points the + /// newest message's edge sits below the visible area; zero at the newest messages. + var onDistanceFromNewestChange: ((CGFloat) -> Void)? + /// A band this many points above the newest messages that a released scroll never rests inside: it + /// settles at whichever edge is nearer, so what follows the band (a collapsing header) ends at + /// one end or the other. Nil leaves releases alone. + var snapDistanceFromNewest: CGFloat? /// Fires when the reader starts a drag after the opening scroll has landed — the signal that /// ends the jump badge's frozen count. Programmatic scrolls never fire it. public var onUserScrollBegan: (() -> Void)? private var isJumpButtonVisible = false + private var isScrolledAway = false /// What this visit has reported read so far. private var readProgress = ReadProgress() /// Whether a read report is queued for the next main-queue turn, so a burst of scroll ticks @@ -384,6 +399,7 @@ public final class ChatViewController: UICollectionViewController { collectionView.register(ChatLinkMessageCell.self, forCellWithReuseIdentifier: ChatLinkMessageCell.reuseIdentifier) collectionView.register(ChatCashCardCell.self, forCellWithReuseIdentifier: ChatCashCardCell.reuseIdentifier) collectionView.register(ChatShareProfileCell.self, forCellWithReuseIdentifier: ChatShareProfileCell.reuseIdentifier) + collectionView.register(ChatPaymentRequestCell.self, forCellWithReuseIdentifier: ChatPaymentRequestCell.reuseIdentifier) collectionView.register(ChatMediaCell.self, forCellWithReuseIdentifier: ChatMediaCell.reuseIdentifier) collectionView.register(ChatDateSeparatorCell.self, forCellWithReuseIdentifier: ChatDateSeparatorCell.reuseIdentifier) collectionView.register(ChatUnreadDividerCell.self, forCellWithReuseIdentifier: ChatUnreadDividerCell.reuseIdentifier) @@ -755,6 +771,13 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } cell.onReactionBoost = { [weak self] in self?.onReactionBoost?(message.messageID) } + case let cell as ChatPaymentRequestCell: + cell.configure(with: message, maxWidth: cashCardWidth(available: width), authorImageData: authorImageData) + cell.onRetry = { [weak self] id in self?.onRetry?(id) } + cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } + cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } + cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } + cell.onReactionBoost = { [weak self] in self?.onReactionBoost?(message.messageID) } case let cell as ChatMediaCell: guard case .media(let media) = message.content else { return } // Before `configure`, which is where the caption's card draws. @@ -828,21 +851,36 @@ public final class ChatViewController: UICollectionViewController { // MARK: - Selection - /// Only cash cards are tappable — they open the token's currency info. Text bubbles and date + /// Only cash and payment request cards are tappable: a cash card opens the token's currency + /// info, a request card its details. Text bubbles and date /// separators opt out (a text row's only tap is retry, handled by its own recognizer). Gating /// highlight is enough to gate selection too: UIKit won't select a row it didn't highlight, and /// `didSelectItemAt` re-checks for cash as a backstop. public override func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { - cashMessageID(at: indexPath) != nil + cashMessageID(at: indexPath) != nil || paymentRequestID(at: indexPath) != nil } public override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { // Selection here is a momentary tap action, not a persisted state — clear it immediately. collectionView.deselectItem(at: indexPath, animated: false) + if let requestID = paymentRequestID(at: indexPath) { + onPaymentRequestTap?(requestID) + return + } guard let id = cashMessageID(at: indexPath) else { return } onCashCardTap?(id) } + /// The id of the payment request whose card is at `indexPath`, or nil if that row isn't one or + /// its message failed to send (the tap retries it instead). + private func paymentRequestID(at indexPath: IndexPath) -> Data? { + guard items.indices.contains(indexPath.item), + case .message(let message) = items[indexPath.item], + case .paymentRequest(let request) = message.content, + !message.isFailed else { return nil } + return request.requestID + } + /// The stable id of the cash message at `indexPath`, or nil if that row isn't a cash card. The /// index is bounds-checked: a tap can race a batch update, where the index may outrun `items`. private func cashMessageID(at indexPath: IndexPath) -> String? { @@ -1166,11 +1204,21 @@ public final class ChatViewController: UICollectionViewController { /// Whether the offset sits within ``bottomThreshold`` of the bottom edge. Geometry only; unlike /// ``wasAtBottom`` it reads the live offset, so it is right after a programmatic move. private var isNearBottomByGeometry: Bool { + distanceFromNewest < Self.bottomThreshold + } + + /// How far the offset sits above the bottom edge, from the live geometry. + private var distanceFromNewest: CGFloat { let maxOffset = chatLayout.collectionViewContentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom - return maxOffset - collectionView.contentOffset.y < Self.bottomThreshold + return maxOffset - collectionView.contentOffset.y } private func publishJumpButtonState() { + let away = hasPositioned && !wasAtBottom + if away != isScrolledAway { + isScrolledAway = away + onScrolledAwayChange?(away) + } let visible = hasPositioned && (!wasAtBottom || !replyReturns.isEmpty) guard visible != isJumpButtonVisible else { return } isJumpButtonVisible = visible @@ -1252,8 +1300,37 @@ public final class ChatViewController: UICollectionViewController { trackUserScrollPosition() } + public override func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer<CGPoint> + ) { + // A release with no fling is settled in `scrollViewDidEndDragging`: a target moved without + // velocity jumps rather than animates. + guard velocity.y != 0, let snapped = snappedOffset(forResting: targetContentOffset.pointee.y) else { return } + targetContentOffset.pointee.y = snapped + } + public override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { trackUserScrollPosition() } + if !decelerate { + if let snapped = snappedOffset(forResting: scrollView.contentOffset.y) { + scrollView.setContentOffset(CGPoint(x: 0, y: snapped), animated: true) + } + trackUserScrollPosition() + } + } + + /// Where a scroll resting at `offset` settles instead, when it would rest inside + /// ``snapDistanceFromNewest``; nil when it's outside the band or the transcript can't scroll + /// the band's full height. + private func snappedOffset(forResting offset: CGFloat) -> CGFloat? { + guard hasPositioned, let band = snapDistanceFromNewest, band > 0 else { return nil } + let maxOffset = chatLayout.collectionViewContentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + let minOffset = -collectionView.adjustedContentInset.top + let distance = maxOffset - offset + guard distance > 0.5, distance < band - 0.5 else { return nil } + guard maxOffset - minOffset >= band else { return maxOffset } + return distance < band / 2 ? maxOffset : maxOffset - band } public override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { @@ -1262,6 +1339,9 @@ public final class ChatViewController: UICollectionViewController { public override func scrollViewDidScroll(_ scrollView: UIScrollView) { onScroll?() + if hasPositioned { + onDistanceFromNewestChange?(max(0, distanceFromNewest)) + } scheduleReadReport() // Track "at the bottom" only from real user scrolling, so an inset change (keyboard) or // content settling doesn't flip it. @@ -1665,7 +1745,7 @@ extension ChatViewController { let body: String? = switch message.content { case .text(let text): message.part?.messageText ?? text case .media(let media): media.caption.flatMap { $0.isEmpty ? nil : $0 } - case .cash, .deleted, .unavailable, .shareProfile: nil + case .cash, .deleted, .unavailable, .shareProfile, .paymentRequest: nil } let rowID = message.messageID let handler = onMessageAction diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift new file mode 100644 index 000000000..b32332b5e --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift @@ -0,0 +1,73 @@ +// +// PaymentRequestCardView.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore + +/// The request card: "Request", the title, the progress line and bar, and the status line. `nil` +/// content draws the frame with placeholder lines while the request loads. +public struct PaymentRequestCard: View { + + let content: ChatPaymentRequestContent.Card? + + public init(content: ChatPaymentRequestContent.Card?) { + self.content = content + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Request") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + + Text(content?.title ?? "Request title") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .lineLimit(2) + + Text(content?.progressLine ?? "$0 paid, $0 requested") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + + PaymentRequestProgressBar(fraction: content?.fraction ?? 0, isEnded: content?.isEnded ?? false) + + Text(content?.statusLine ?? "0 days left to pay") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .redacted(reason: content == nil ? .placeholder : []) + .opacity(content?.isEnded == true ? 0.5 : 1) + .accessibilityElement(children: .combine) + } +} + +/// A capsule filled left to right with a request's paid fraction. +public struct PaymentRequestProgressBar: View { + + let fraction: Double + let isEnded: Bool + + public init(fraction: Double, isEnded: Bool) { + self.fraction = fraction + self.isEnded = isEnded + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.textMain.opacity(0.15)) + Capsule() + .fill(isEnded ? Color.textSecondary : Color.success) + .frame(width: proxy.size.width * max(0, min(1, fraction))) + } + } + .frame(height: 6) + .animation(.easeOut(duration: 0.3), value: fraction) + .accessibilityHidden(true) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift new file mode 100644 index 000000000..58375df8d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift @@ -0,0 +1,55 @@ +// +// PaymentRequestRing.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// A green "$" disc inside a 2 pt ring, filled clockwise from 12 o'clock with a request's paid fraction. +public struct PaymentRequestRing: View { + + let fraction: Double + let isEnded: Bool + let isClosed: Bool + let diameter: CGFloat + + /// `isClosed` draws "✓" in place of "$", for a request its creator closed. + public init(fraction: Double, isEnded: Bool, isClosed: Bool = false, diameter: CGFloat = 26) { + self.fraction = fraction + self.isEnded = isEnded + self.isClosed = isClosed + self.diameter = diameter + } + + private static let lineWidth: CGFloat = 2 + + /// The disc sits 4 pt inside a 26 pt ring, scaled with the diameter. + private var discDiameter: CGFloat { diameter - 2 * diameter * (4 / 26) } + + /// The arc and the disc share one color, green while the request is open. + private var accent: Color { isEnded ? Color.textSecondary : Color.success } + + public var body: some View { + ZStack { + Circle() + .inset(by: Self.lineWidth / 2) + .stroke(Color.rowSeparator, lineWidth: Self.lineWidth) + Circle() + .inset(by: Self.lineWidth / 2) + .trim(from: 0, to: max(0, min(1, fraction))) + .stroke(accent, lineWidth: Self.lineWidth) + .rotationEffect(.degrees(-90)) + Circle() + .fill(accent) + .frame(width: discDiameter, height: discDiameter) + Text(verbatim: isClosed ? "✓" : "$") + .font(diameter > 30 ? .appTextMedium : .appTextCaption) + .foregroundStyle(Color.black) + } + .frame(width: diameter, height: diameter) + .animation(.easeOut(duration: 0.3), value: fraction) + .accessibilityHidden(true) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Dialog/DialogItem+Factories.swift b/FlipcashUI/Sources/FlipcashUI/Dialog/DialogItem+Factories.swift index f685f8746..fbb1aa3b7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Dialog/DialogItem+Factories.swift +++ b/FlipcashUI/Sources/FlipcashUI/Dialog/DialogItem+Factories.swift @@ -68,3 +68,22 @@ extension DialogItem { ) } } + +extension DialogItem { + + /// Grey sheet of actions with no banner text, or with an optional heading, for an overflow menu. + public static func options( + title: String? = nil, + subtitle: String? = nil, + @ActionBuilder actions: () -> [DialogAction] + ) -> DialogItem { + DialogItem( + style: .standard, + title: title, + subtitle: subtitle, + dismissable: true, + tracked: false, + actions: actions + ) + } +} diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index a11496772..2ab5e6d27 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -111,6 +111,9 @@ private struct NotificationMessageRow: View { case .shareProfile(let profile): // The extension has no profile lookup; the handle is what the widget names. NotificationTextBubble(text: profile.linkedHandle ?? "Shared a profile", isFromSelf: message.sender == .me) + case .paymentRequest(let request): + // The extension has no request lookup; a title already resolved is all it can name. + NotificationTextBubble(text: request.card?.title ?? "Payment request", isFromSelf: message.sender == .me) case .media(let media): // The extension draws no photos; a text line stands in, like the chat list's preview. NotificationTextBubble(text: ChatMediaStrings.listPreview(caption: media.caption), isFromSelf: message.sender == .me)