From 1006543a5eb562ddf00d1463e47bd1d861bd93da Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 23:56:45 -0400 Subject: [PATCH 01/45] feat(group-requests): ask a private group for money and pay a share Adds group payment requests from the shared spec: the create flow behind the chat's money button, the request card in the transcript, the app-bar deck, request details with close and mark-paid for the creator, Request Money history, and paying a share under the server's payment intent ID. The request text is sealed and opened through shared-core's encryptPaymentRequestText / decryptPaymentRequestText. Until a shared-core release carries Android 62ac41455, the branch builds only with FLIPCASH_SHARED_CORE_LOCAL pointing at a local XCFramework. Database.schemaVersion is bumped for the new widget content. --- .../Controllers/ConversationController.swift | 58 +- .../Controllers/EncryptedChatClient.swift | 10 + .../Controllers/FlipClient+Protocols.swift | 2 + .../Conversation/ChatItem+Conversation.swift | 17 + .../ChatScreenRepresentable.swift | 4 + .../ConversationLoadCoordinator.swift | 30 +- .../Conversation/ConversationScreen.swift | 117 +++- .../Core/Screens/Main/Search/ChatSearch.swift | 2 +- .../Main/Tips/TipConversationsScreen.swift | 3 + .../PaymentRequest/PaymentRequestCard.swift | 26 + .../PaymentRequest/PaymentRequestCopy.swift | 108 +++ .../PaymentRequestCreateViewModel.swift | 213 ++++++ .../PaymentRequest/PaymentRequestDeck.swift | 257 +++++++ .../PaymentRequestDeckOrder.swift | 43 ++ .../PaymentRequestDetailsContent.swift | 103 +++ .../PaymentRequestDetailsScreen.swift | 388 +++++++++++ .../PaymentRequest/PaymentRequestDraft.swift | 152 +++++ .../PaymentRequest/PaymentRequestFlow.swift | 640 ++++++++++++++++++ .../PaymentRequestPayment.swift | 226 +++++++ .../PaymentRequest/PaymentRequestText.swift | 34 + .../PaymentRequest/PaymentRequests.swift | 226 +++++++ .../Core/Screens/Send/GroupSendFlow.swift | 64 +- Flipcash/Core/Session/Session.swift | 40 +- .../Core/Session/SessionAuthenticator.swift | 38 ++ Flipcash/Core/Session/SessionProtocols.swift | 14 + .../Core/Spotlight/ChatSpotlightItem.swift | 1 + .../Clients/Flip API/FlipClient+Chat.swift | 9 + .../Flip API/FlipClient+PaymentRequest.swift | 9 + .../Services/ChatMessagingService.swift | 13 + .../Models/Chat/ChatMessage.swift | 42 +- .../Models/Chat/ChatPreviewMapping.swift | 2 + .../FlipcashCore/Models/Chat/ChatQuote.swift | 3 + .../Conversation/ChatPaymentMetadata.swift | 8 +- .../Models/Conversation/ChatSeal.swift | 58 ++ .../Conversation/ConversationMessage.swift | 6 +- .../Conversation/MessageCapability.swift | 3 + .../Conversation/ShareProfileWidget.swift | 19 +- .../GroupPaymentRequest+Lifecycle.swift | 149 ++++ .../PaymentRequest/GroupPaymentRequest.swift | 28 + .../PaymentRequestTitleValidator.swift | 27 + .../Database+Conversations.swift | 18 +- .../Sources/FlipcashStore/Database.swift | 2 +- .../Fixtures/payment_request_text.json | 84 +++ .../PaymentRequestTextVectorTests.swift | 140 ++++ .../PaymentRequestSpecTests.swift | 274 ++++++++ .../TestSupport/MockConversations.swift | 4 + .../FlipcashUI/Chat/ChatBubbleView.swift | 8 +- .../FlipcashUI/Chat/ChatColumnCell.swift | 2 +- .../Chat/ChatItem+Differentiable.swift | 2 + .../Chat/ChatPaymentRequestCell.swift | 122 ++++ .../Chat/ChatScreenViewController.swift | 6 + .../FlipcashUI/Chat/ChatViewController.swift | 30 +- .../Chat/PaymentRequestCardView.swift | 73 ++ .../FlipcashUI/Chat/PaymentRequestRing.swift | 44 ++ .../Dialog/DialogItem+Factories.swift | 19 + .../NotificationTranscriptView.swift | 3 + 56 files changed, 3966 insertions(+), 57 deletions(-) create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestCard.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckOrder.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsContent.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestText.swift create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Validation/PaymentRequestTitleValidator.swift create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/payment_request_text.json create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/PaymentRequestTextVectorTests.swift create mode 100644 FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatPaymentRequestCell.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index c653be639..41ad076bd 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -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. `requestAmount` is a payment + /// request's per-person share as the viewer reads it, nil while the request isn't loaded. func lastMessagePreview( for conversation: Conversation, knownAuthors: KnownAuthorDirectory.Snapshot = .empty, viewerAmount: (ExchangedFiat) -> FiatAmount = { $0.nativeAmount }, + requestAmount: (PaymentRequestID) -> FiatAmount? = { _ in nil }, currencyName: (PublicKey) -> String? ) -> String? { if isCounterpartTyping(in: conversation.id) { @@ -1706,6 +1708,11 @@ final class ConversationController { // The server sends it; no user is the sender to name. return "Shared a profile" + case .widget(.paymentRequest(let id)): + let who = isFromSelf ? "You" : (senderName ?? "Someone") + guard let amount = requestAmount(id) else { return "\(who) sent a request" } + return "\(who) requested \(amount.formatted())" + case .widget(.unrecognized): return nil @@ -2304,10 +2311,59 @@ final class ConversationController { /// gets its turn. /// The seal a photo staged for `conversationID` is encrypted with, nil when the chat takes /// plaintext photos; throws when the chat encrypts but its seal can't be built. + /// Sends the widget for the payment request `id` into `conversationID`, retrying a failed send + /// with the same request. Throws the last send's error; the request itself already exists. + func sendPaymentRequest(_ id: PaymentRequestID, in conversationID: ConversationID, attempts: Int = 3) async throws { + let clientMessageID = UUID() + 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) }) { + 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 + } catch { + lastError = error + logger.warning("Failed to send payment request message", metadata: [ + "conversationID": "\(conversationID)", + "attempt": "\(attempt)", + "error": "\(error)", + ]) + } + } + ErrorReporting.captureError(lastError, reason: "Failed to send payment request message") + Analytics.sentMessage(chatType: chatType, error: lastError) + throw lastError + } + func photoSeal(for conversationID: ConversationID) async throws -> ChatSeal? { 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/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..ee2e9fecc 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 @@ -172,6 +174,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onReachTop = onReachTop screen.onRetry = onRetry screen.onCashCardTap = onCashCardTap + screen.onPaymentRequestTap = onPaymentRequestTap screen.onOpenURL = onOpenURL screen.onMentionTap = onMentionTap screen.onShareProfile = onShareProfile @@ -236,6 +239,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { 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..b5a22629a 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -89,6 +89,15 @@ 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() + /// 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. @@ -133,6 +142,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 +420,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, @@ -629,6 +641,20 @@ struct ConversationScreen: View { if let conversationID { await sessionContainer.groupRosters.refresh(conversationID) } } } + .sheet(item: $paymentRequestDetails) { selection in + PaymentRequestDetailsScreen(requestID: selection.id) + } + .sheet(isPresented: $isShowingMoneyActions, onDismiss: runPendingMoneyAction) { + MoneyActionsSheet( + sendMoney: { pendingMoneyAction = .send }, + requestMoney: { pendingMoneyAction = .request } + ) + } + .sheet(isPresented: $isShowingRequestMoney) { + if let conversationID { + PaymentRequestFlow(conversationID: conversationID) + } + } .sheet(item: $paidReactionTarget) { target in PaidReactionSheet(target: target, selfReactions: selfReactions(on: target.messageID)) } @@ -731,14 +757,65 @@ 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 !requestDeckItems.isEmpty { + // 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 } + } + } } } + .overlay { + if paymentRequestDeck.phase != .collapsed { + PaymentRequestDeckOverlay(items: requestDeckItems, state: paymentRequestDeck) { id in + paymentRequestDeck.collapse() + paymentRequestDetails = PaymentRequestSelection(id: id) + } + } + } + // 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, + order: paymentRequestDeck.order, + copy: PaymentRequestCopy(session.displayCurrency), + now: .now + ) + } + + private var requestPill: some View { + PaymentRequestPill( + items: requestDeckItems, + accessibilityLabel: requestDeckAccessibilityLabel, + state: paymentRequestDeck + ) + } + + private var requestDeckAccessibilityLabel: String { + guard let front = requestDeckItems.first else { return "" } + return PaymentRequestCopy(session.displayCurrency) + .deckAccessibilityLabel(front: front.request, title: front.card.title, count: requestDeckItems.count, now: .now) + } + + /// The title's right margin, widened to clear the request pill while one shows. + private var titleTrailingInset: CGFloat { + Self.titleTrailingInset + (requestDeckItems.isEmpty ? 0 : Self.requestPillWidth) } /// The fetches the transcript needs: gate token names, sender names, and avatars. @@ -786,6 +863,17 @@ 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 + await requests.loadSeal(for: groupID) + while !Task.isCancelled { + await requests.refreshChat(groupID) + try? await Task.sleep(for: PaymentRequests.pollInterval) + } + } // 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 +1341,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 +1397,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 +1419,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 +1745,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..3a2eb5be1 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -404,6 +404,9 @@ struct TipConversationRow: View { for: conversation, knownAuthors: sessionContainer.knownAuthors.snapshot, viewerAmount: session.displayCurrency.amount, + requestAmount: { id in + sessionContainer.paymentRequests.request(id).flatMap(PaymentRequestCopy(session.displayCurrency).perPersonAmount) + }, 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..835dd1068 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift @@ -0,0 +1,108 @@ +// +// 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" + } + } + + /// 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, , <paid> of <requested> paid, <status>". + func deckAccessibilityLabel(front: GroupPaymentRequest, title: String, count: Int, now: Date) -> String { + let summary = "\(title), \(deckAmounts(front)) paid, \(Self.statusLine(front.deadline(at: now)))" + 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..a11afeddf --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift @@ -0,0 +1,213 @@ +// +// 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 { + case sent + /// The request exists but its chat message never went out; the deck still shows it. + case messageFailed + 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) + } + } + + do { + try await conversationController.sendPaymentRequest(request.id, in: conversationID) + } catch { + finish() + return .messageFailed + } + 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/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift new file mode 100644 index 000000000..62a38d22b --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -0,0 +1,257 @@ +// +// 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 + let amounts: 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 with the front card still drawn at the pill's frame. + case presenting + case expanded + } + + var order = PaymentRequestDeckOrder() + private(set) var phase: Phase = .collapsed + /// The pill's frame in global coordinates, where the shared front card starts and ends. + var pillFrame: CGRect = .zero + + /// Lifts the front card into the overlay, then grows it into the expanded list. + func expand() { + guard phase == .collapsed else { return } + phase = .presenting + Task { @MainActor in + withAnimation(.spring(duration: 0.4, bounce: 0.15)) { phase = .expanded } + } + } + + /// Plays the expansion in reverse, back into the pill. + func collapse() { + guard phase == .expanded else { return } + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { + phase = .presenting + } completion: { + self.phase = .collapsed + } + } +} + +// MARK: - Pill - + +/// The app-bar pill: the front request with up to two more peeking beneath it. Swipe down cycles; +/// tap expands. +struct PaymentRequestPill: View { + + let items: [PaymentRequestDeckItem] + let accessibilityLabel: String + @Bindable var state: PaymentRequestDeckState + + @State private var drag: CGFloat = 0 + @State private var height: CGFloat = 43 + + private static let peekOffset: CGFloat = 5 + + var body: some View { + ZStack(alignment: .topLeading) { + ForEach(Array(items.prefix(3).enumerated().reversed()), id: \.element.id) { index, item in + if index == 0 { + PaymentRequestPillFace(item: item) + .offset(y: drag) + .opacity(1 - min(0.45, drag / max(height, 1) * 0.45)) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height = $0 } + } else { + PaymentRequestPillLayer(index: index) + .offset(x: CGFloat(index) * 7, y: CGFloat(index) * Self.peekOffset) + } + } + } + .opacity(state.phase == .collapsed ? 1 : 0) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { state.pillFrame = $0 } + .contentShape(Rectangle()) + .onTapGesture { state.expand() } + .gesture(cycleGesture) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.isButton) + .accessibilityAction { state.expand() } + .accessibilityAction(named: "Next request") { + guard items.count > 1 else { return } + state.order.cycle() + } + } + + private var cycleGesture: some Gesture { + DragGesture(minimumDistance: 6) + .onChanged { value in + let down = max(0, value.translation.height) + // One request only pulls elastically. + drag = items.count > 1 ? down : sqrt(down) * 2 + } + .onEnded { value in + let finishes = items.count > 1 && PaymentRequestDeckOrder.finishesCycle( + translation: value.translation.height, + predictedTranslation: value.predictedEndTranslation.height, + height: height + ) + withAnimation(.spring(duration: 0.3, bounce: 0.2)) { + if finishes { state.order.cycle() } + drag = 0 + } + } + } +} + +/// The front card of the pill: the ring, the title and "<paid> of <requested>". +struct PaymentRequestPillFace: View { + + let item: PaymentRequestDeckItem + + var body: some View { + HStack(spacing: 8) { + PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded) + VStack(alignment: .leading, spacing: 0) { + Text(item.card.title) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + Text(item.amounts) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 90, alignment: .leading) + } + .padding(.leading, 5) + .padding(.trailing, 12) + .padding(.vertical, 5) + .background(Color.backgroundMain, in: .rect(cornerRadius: 18)) + .background(Color.textMain.opacity(0.06), in: .rect(cornerRadius: 18)) + .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.textMain.opacity(0.1))) + .opacity(item.card.isEnded ? 0.4 : 1) + } +} + +/// A card peeking beneath the pill's front card. +private struct PaymentRequestPillLayer: View { + + let index: Int + + var body: some View { + RoundedRectangle(cornerRadius: 18) + .fill(Color.textMain.opacity(index == 1 ? 0.06 : 0.08)) + .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.textMain.opacity(0.1))) + .frame(height: 36) + .padding(.trailing, CGFloat(index) * 14) + } +} + +// MARK: - Expanded - + +/// The expanded deck: every request as a full-width card beneath the app bar, over the dimmed chat. +/// The front card moves in from the pill and back on collapse. +struct PaymentRequestDeckOverlay: View { + + let items: [PaymentRequestDeckItem] + @Bindable var state: PaymentRequestDeckState + let open: (PaymentRequestID) -> Void + + @Namespace private var namespace + @State private var origin: CGPoint = .zero + + private var isExpanded: Bool { state.phase == .expanded } + + var body: some View { + ZStack(alignment: .top) { + Color.backgroundMain + .opacity(isExpanded ? 0.85 : 0) + .ignoresSafeArea() + .onTapGesture { state.collapse() } + .accessibilityLabel("Collapse requests") + .accessibilityAddTraits(.isButton) + + if let front = items.first { + if isExpanded { + list + } else { + PaymentRequestPillFace(item: front) + .matchedGeometryEffect(id: front.id, in: namespace) + .frame(width: state.pillFrame.width, height: state.pillFrame.height) + .position(x: state.pillFrame.midX - origin.x, y: state.pillFrame.midY - origin.y) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .allowsHitTesting(false) + } + } + } + .onGeometryChange(for: CGPoint.self) { $0.frame(in: .global).origin } action: { origin = $0 } + .gesture(DragGesture(minimumDistance: 12).onEnded { value in + if value.translation.height < -40 { state.collapse() } + }) + } + + private var list: some View { + ScrollView { + VStack(spacing: 10) { + ForEach(items) { item in + Button { open(item.id) } label: { + PaymentRequestCard(content: item.card) + .padding(14) + .background(Color.textMain.opacity(0.1), in: .rect(cornerRadius: 12)) + .opacity(item.card.isEnded ? 0.4 : 1) + } + .buttonStyle(.plain) + .matchedGeometryEffect(id: item.id, in: namespace, isSource: item.id == items.first?.id) + .transition(.offset(y: -24).combined(with: .opacity)) + } + + Capsule() + .fill(Color.textSecondary) + .frame(width: 36, height: 5) + .padding(.top, 2) + .accessibilityHidden(true) + } + .padding(.horizontal, 20) + .padding(.top, 8) + } + .scrollBounceBehavior(.basedOnSize) + } +} + +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, + order: PaymentRequestDeckOrder, + copy: PaymentRequestCopy, + now: Date + ) -> [PaymentRequestDeckItem] { + order.apply(to: deck(for: conversationID, at: now)).compactMap { request in + guard let text = text(of: request) else { return nil } + return PaymentRequestDeckItem( + request: request, + card: PaymentRequestCardContent(request: request, title: text.title, copy: copy, now: now), + amounts: copy.deckAmounts(request) + ) + } + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckOrder.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckOrder.swift new file mode 100644 index 000000000..415c76d92 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckOrder.swift @@ -0,0 +1,43 @@ +// +// 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 + } + + /// 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/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..8f2808be0 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -0,0 +1,388 @@ +// +// 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 + + let requestID: PaymentRequestID + + @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 payment: PaymentRequestPayment? + @State private var isPreparing = false + @State private var isPaying = false + @State private var isUpdating = false + + var body: some View { + NavigationStack { + // 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") + } + CloseButton(action: { dismiss() }) + } + } + } + .task(id: requestID) { await load() } + .dialog(item: $dialogItem) + .sheet(isPresented: $isPaying) { + if let payment, let creator = payment.request?.creator { + PaymentRequestPaySheet( + payment: payment, + payee: member(creator), + payeeID: creator, + amount: payAmount(payment) + ) + } + } + } + + // 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) + } + if let sendTitle = content.sendTitle { + CodeButton(isLoading: 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) + } + + private func shareRow(_ row: PaymentRequestDetailsContent.Row) -> some View { + Button { + switch row.action { + case .markPaid: presentMarkPaidOptions(row) + case .none: break + } + } label: { + 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) + } + .buttonStyle(.plain) + .disabled(row.action == .none || isUpdating) + .accessibilityElement(children: .combine) + } + + /// 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 } + } + + private func payAmount(_ payment: PaymentRequestPayment) -> String { + guard let request = payment.request, let share = payment.share else { return "" } + return PaymentRequestCopy(session.displayCurrency).shareText(share, of: request) + } + + // MARK: - Actions - + + private func load() async { + let requests = sessionContainer.paymentRequests + do { + hasPaymentIntent = try await requests.refresh(requestID) != nil + } catch { + hasPaymentIntent = false + } + guard let chatID = requests.request(requestID)?.chatID else { return } + await requests.loadSeal(for: chatID) + await sessionContainer.groupRosters.refresh(chatID) + } + + /// Refetches for the intent ID right before the swipe sheet, as the spec requires. + private func pay() { + Task { + isPreparing = true + defer { isPreparing = false } + let payment = self.payment ?? PaymentRequestPayment(requestID: requestID, sessionContainer: sessionContainer) + self.payment = payment + switch await payment.prepare() { + case .ready: + isPaying = true + 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 {} + +/// 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..f5f525322 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift @@ -0,0 +1,152 @@ +// +// 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 + + /// `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 formatted = date.formatted( + Date.FormatStyle(date: .abbreviated, time: .shortened, locale: locale) + .year(.omitted) + ) + return "Until \(formatted)" + } + } +} + +/// 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 isValid 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? { + isValid && 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..af0144548 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -0,0 +1,640 @@ +// +// 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. +struct MoneyActionsSheet: View { + + @Environment(\.dismiss) private var dismiss + + let sendMoney: () -> Void + let requestMoney: () -> Void + + var body: some View { + PartialSheet { + VStack(alignment: .leading, spacing: 0) { + Text("Money") + .font(.appTitle) + .foregroundStyle(Color.textMain) + .padding(.bottom, 16) + + row(symbol: "arrow.up", title: "Send Money", subtitle: "Send money to someone in the group, or multiple people") { + dismiss() + sendMoney() + } + row(symbol: "arrow.down", title: "Request Money", subtitle: "Collect money from people in the group") { + dismiss() + requestMoney() + } + + CodeButton(style: .subtle, title: "Dismiss") { dismiss() } + .padding(.top, 8) + } + .padding(20) + } + } + + private func row(symbol: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(alignment: .top, spacing: 20) { + Image(systemName: symbol) + .font(.system(size: 20, weight: .regular)) + .frame(width: 24) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + Text(subtitle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.leading) + } + Spacer(minLength: 0) + } + .foregroundStyle(Color.textMain) + .padding(.vertical, 16) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Flow - + +private enum PaymentRequestStep: Hashable { + case amount + case summary + case selectPeople +} + +/// 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) { + viewModel.startNewRequest() + path.append(.amount) + } + .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, selectPeople: { + path.append(.selectPeople) + }, sent: { dismiss() }) + case .selectPeople: + PaymentRequestSelectPeopleScreen(viewModel: viewModel) { + path.removeLast() + } + } + } + } + } +} + +// 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 + + @State private var selection: PaymentRequestSelection? + + 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() } + .sheet(item: $selection) { selection in + PaymentRequestDetailsScreen(requestID: selection.id) + } + } + + 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 { + selection = PaymentRequestSelection(id: 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 selectPeople: () -> Void + let sent: () -> Void + + @State private var isEditingName = false + @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) { + isEditingName = true + } + 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: $isEditingName) { + PaymentRequestNameEditor(title: viewModel.title) { viewModel.title = $0 } + } + .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 .messageFailed: + // The request exists and the deck shows it; only its chat message failed. + dialogItem = .error(title: "Message Not Sent", subtitle: "Your request was created, but its message couldn't be sent to the chat.") { + DialogAction.okay(kind: .standard) { sent() } + } + case .failed(let copy): + dialogItem = .alert(title: copy.title, subtitle: copy.subtitle) { + DialogAction.okay(kind: .standard) + } + } + } +} + +// MARK: - Name - + +private struct PaymentRequestNameEditor: View { + + @Environment(\.dismiss) private var dismiss + + @State private var text: String + @FocusState private var isFocused: Bool + + let save: (String) -> Void + + init(title: String, save: @escaping (String) -> Void) { + _text = State(initialValue: title) + self.save = save + } + + private var validated: String? { PaymentRequestTitleValidator().validate(text) } + + var body: some View { + NavigationStack { + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 8) { + TextField("Name", text: $text) + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .focused($isFocused) + .submitLabel(.done) + .onSubmit(done) + .padding(14) + .background(Color.white.opacity(0.06), in: .rect(cornerRadius: 12)) + Spacer() + } + .padding(20) + } + .navigationTitle("Name") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done", action: done) + .disabled(validated == nil) + } + } + } + .presentationDetents([.medium]) + .onAppear { isFocused = true } + } + + private func done() { + guard let validated else { return } + save(validated) + dismiss() + } +} + +// MARK: - Deadline - + +private struct PaymentRequestDeadlineSheet: View { + + @Environment(\.dismiss) private var dismiss + + @Binding var deadline: PaymentRequestDeadline + + @State private var customDate: Date = PaymentRequestDeadline.oneWeek.endsAt(sentAt: .now) + @State private var isPickingCustom = false + + var body: some View { + NavigationStack { + List { + ForEach(PaymentRequestDeadline.presets, id: \.rowTitle) { preset in + row(preset.rowTitle, isChecked: preset == deadline) { + deadline = preset + dismiss() + } + } + row("Custom", isChecked: isCustom) { + if case .custom(let date) = deadline { customDate = date } + isPickingCustom = true + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.backgroundMain) + .navigationTitle("Time To Pay") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $isPickingCustom) { + customPicker + } + } + .presentationDetents([.medium, .large]) + } + + private var isCustom: Bool { + switch deadline { + case .custom: true + case .oneDay, .threeDays, .oneWeek, .twoWeeks, .fourWeeks: false + } + } + + private var customPicker: some View { + let now = Date.now + return VStack { + DatePicker( + "Until", + selection: $customDate, + in: now.addingTimeInterval(PaymentRequestDeadline.minimumCustomLead)...now.addingTimeInterval(PaymentRequestDeadline.maximumLead), + displayedComponents: [.date, .hourAndMinute] + ) + .datePickerStyle(.graphical) + .padding(20) + Spacer() + } + .background(Color.backgroundMain) + .navigationTitle("Custom") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + deadline = .custom(customDate) + dismiss() + } + } + } + } + + private func row(_ title: String, isChecked: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack { + Text(title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Spacer() + if isChecked { + Image(systemName: "checkmark") + .foregroundStyle(Color.textMain) + } + } + .contentShape(.rect) + } + .listRowBackground(Color.clear) + .accessibilityAddTraits(isChecked ? .isSelected : []) + } +} + +// 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/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift new file mode 100644 index 000000000..1e5455fbd --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -0,0 +1,226 @@ +// +// 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) + + 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> = [] + + 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 yet, so encrypted titles can open. + func loadSeal(for conversationID: ConversationID) async { + guard seals[conversationID] == nil, 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 as of `now`, in deck order. + func deck(for conversationID: ConversationID, at now: Date) -> [GroupPaymentRequest] { + GroupPaymentRequest.deckOrder(requests.values.filter { $0.chatID == conversationID }, 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. A failed read keeps what was held. + func refreshChat(_ conversationID: ConversationID) async { + 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 + } catch { + logger.warning("Failed to read a chat's payment requests", metadata: [ + "conversationID": "\(conversationID)", + "error": "\(error)", + ]) + } + } + + /// 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 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]) + 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) + } catch { + logger.warning("Failed to read a payment request", metadata: ["error": "\(error)"]) + } + } +} + +/// 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 +} diff --git a/Flipcash/Core/Screens/Send/GroupSendFlow.swift b/Flipcash/Core/Screens/Send/GroupSendFlow.swift index 8d5f6ebe1..bb647bce9 100644 --- a/Flipcash/Core/Screens/Send/GroupSendFlow.swift +++ b/Flipcash/Core/Screens/Send/GroupSendFlow.swift @@ -130,8 +130,6 @@ private struct GroupSendAmountScreen: View { private struct GroupSendSelectPeopleScreen: View { - @Environment(SessionContainer.self) private var sessionContainer - let viewModel: GroupSendViewModel @State private var isConfirming = false @@ -139,20 +137,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, @@ -175,9 +165,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, @@ -185,14 +205,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/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..f66ecb0af 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift @@ -97,6 +97,13 @@ public struct ChatSeal: @unchecked Sendable { try seal(.plaintext(media: photo.media, caption: caption, repliedTo: repliedTo)) } + /// 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 } } + }) + } + private func seal(_ plaintext: Flipcash_Messaging_V1_Content) throws -> Flipcash_Messaging_V1_Content { let bytes = SharedBytes.shared let content = bytes.byteArray(data: try plaintext.serializedData()) @@ -209,12 +216,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. 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..0995f3d5e --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift @@ -0,0 +1,149 @@ +// +// 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 { + + /// The requests a chat's deck shows as of `now`, in deck order: open requests by nearest + /// deadline, then those that ended within ``deckRetention``, most recently ended first. + public static func deckOrder(_ requests: [GroupPaymentRequest], 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 { ($0.split?.endsAt ?? .distantFuture) < ($1.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 99fd53989..d2414aeb6 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 = 56 + public static let schemaVersion = 57 /// Removes the store and the write-ahead log files beside it. /// 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/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift new file mode 100644 index 000000000..b32ccd44f --- /dev/null +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -0,0 +1,274 @@ +// +// 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 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 : 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("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]) + } + + // 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) + } + + @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 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) + } +} diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index 4ca4f8915..a1fc922f5 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -445,6 +445,10 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con nil } + func sendPaymentRequestMessage(owner: KeyPair, conversationID: ConversationID, paymentRequestID id: PaymentRequestID, clientMessageID: UUID) async throws -> ConversationMessage { + throw ErrorSendMessage.unknown + } + func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? { nil } 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..1dbbc5caf 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 } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 3c7a5128a..26747c655 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 @@ -384,6 +388,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 +760,12 @@ 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.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 +839,34 @@ 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. + 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 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? { @@ -1635,7 +1659,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..db670f7bc --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift @@ -0,0 +1,44 @@ +// +// PaymentRequestRing.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// A ring around a "$" glyph, filled clockwise from 12 o'clock with a request's paid fraction. +public struct PaymentRequestRing: View { + + let fraction: Double + let isEnded: Bool + let diameter: CGFloat + + public init(fraction: Double, isEnded: Bool, diameter: CGFloat = 26) { + self.fraction = fraction + self.isEnded = isEnded + self.diameter = diameter + } + + private var lineWidth: CGFloat { diameter * 0.12 } + + public var body: some View { + ZStack { + Circle() + .stroke(Color.textMain.opacity(0.15), lineWidth: lineWidth) + Circle() + .trim(from: 0, to: max(0, min(1, fraction))) + .stroke(isEnded ? Color.textSecondary : Color.success, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Circle() + .fill(Color.textMain) + .frame(width: diameter * 0.7, height: diameter * 0.7) + Text(verbatim: "$") + .font(.appTextCaption) + .foregroundStyle(Color.textAction) + } + .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) From 316c042972ffb3be8f0d63e7085df65d71108d17 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 10:30:02 -0400 Subject: [PATCH 02/45] chore(shared-core): bump to 0.16.0 for the payment request text cipher --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- CrossPlatformVectors/Package.swift | 2 +- FlipcashCore/Package.swift | 2 +- FlipcashUI/Package.swift | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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/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/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( From 59f0d7e5c996558bac085f1a9df080264a4f991d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 11:10:05 -0400 Subject: [PATCH 03/45] fix(group-requests): match Android and the spec on deck, details and send failure - Back collapses an expanded deck instead of leaving the chat; the rear cards rise from beneath the front card. - Request details refetch every 45 s while on screen. - A request message that fails to send stays in the chat as a failed row with retry, like any message, instead of an alert that pops the flow. A successful send now refreshes the open transcript. - "Choose People" only for an empty selection; custom timing shows the year; the custom picker's bounds are taken when it opens. - Chat list preview reads "<first name> requested <amount>" or "You requested <amount>", fetching the request when the list needs it. - Test for spec case 22: ends_at = now + 24 h reads "24 hours left to pay". --- .../Controllers/ConversationController.swift | 64 ++++++++++----- .../Conversation/ConversationScreen.swift | 14 +++- .../Main/Tips/TipConversationsScreen.swift | 10 +++ .../PaymentRequestCreateViewModel.swift | 11 +-- .../PaymentRequest/PaymentRequestDeck.swift | 64 ++++++++++----- .../PaymentRequestDetailsScreen.swift | 24 ++++-- .../PaymentRequest/PaymentRequestDraft.swift | 17 ++-- .../PaymentRequest/PaymentRequestFlow.swift | 13 ++-- .../PaymentRequest/PaymentRequests.swift | 6 ++ .../ConversationControllerTests.swift | 78 +++++++++++++++++++ .../Database+ConversationsTests.swift | 8 +- .../PaymentRequestSpecTests.swift | 29 +++++++ .../TestSupport/MockConversations.swift | 7 +- .../FlipcashUI/Chat/ChatViewController.swift | 7 +- 14 files changed, 278 insertions(+), 74 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 41ad076bd..0ef7fd512 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<String> = [ "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", ] @@ -1709,9 +1709,11 @@ final class ConversationController { return "Shared a profile" case .widget(.paymentRequest(let id)): - let who = isFromSelf ? "You" : (senderName ?? "Someone") - guard let amount = requestAmount(id) else { return "\(who) sent a request" } - return "\(who) requested \(amount.formatted())" + // Until the request loads, or while the sender can't be named, the line stays blank. + guard let amount = requestAmount(id)?.formatted() else { return nil } + if isFromSelf { return "You requested \(amount)" } + guard let senderName else { return nil } + return "\(senderName) requested \(amount)" case .widget(.unrecognized): return nil @@ -2236,6 +2238,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: [ @@ -2302,19 +2307,28 @@ final class ConversationController { } } - /// 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. - /// - /// Every pending bubble appears at once, even for a chip still uploading. Each message is then - /// posted only once its own chip's upload settles, so a later photo never lands before an - /// earlier one. A chip that fails marks only its own bubble `.failed`, and the next chip still - /// gets its turn. - /// The seal a photo staged for `conversationID` is encrypted with, nil when the chat takes - /// plaintext photos; throws when the chat encrypts but its seal can't be built. - /// Sends the widget for the payment request `id` into `conversationID`, retrying a failed send - /// with the same request. Throws the last send's error; the request itself already exists. - func sendPaymentRequest(_ id: PaymentRequestID, in conversationID: ConversationID, attempts: Int = 3) async throws { + /// 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..<attempts { @@ -2329,7 +2343,9 @@ final class ConversationController { clientMessageID: clientMessageID ) confirmed.clientMessageID = clientMessageID - if !persist(operation: "send-payment-request", { try database.upsertConversationMessages([confirmed], conversationID: conversationID) }) { + 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) @@ -2339,7 +2355,7 @@ final class ConversationController { refreshFeedPreview(for: conversationID) persistConversation(conversationID) Analytics.sentMessage(chatType: chatType) - return + return true } catch { lastError = error logger.warning("Failed to send payment request message", metadata: [ @@ -2349,11 +2365,21 @@ final class ConversationController { ]) } } + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) ErrorReporting.captureError(lastError, reason: "Failed to send payment request message") Analytics.sentMessage(chatType: chatType, error: lastError) - throw 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. + /// + /// Every pending bubble appears at once, even for a chip still uploading. Each message is then + /// posted only once its own chip's upload settles, so a later photo never lands before an + /// earlier one. A chip that fails marks only its own bubble `.failed`, and the next chip still + /// gets its turn. + /// The seal a photo staged for `conversationID` is encrypted with, nil when the chat takes + /// plaintext photos; throws when the chat encrypts but its seal can't be built. func photoSeal(for conversationID: ConversationID) async throws -> ChatSeal? { try await messaging.photoSeal(owner: owner, conversationID: conversationID) } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index b5a22629a..77dd451fd 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -728,7 +728,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.phase != .collapsed) .toolbar { if composer.isEditing { ToolbarItem(placement: .topBarLeading) { @@ -741,6 +742,17 @@ struct ConversationScreen: View { .accessibilityLabel("Stop editing") } } else { + if paymentRequestDeck.phase != .collapsed { + ToolbarItem(placement: .topBarLeading) { + Button { + paymentRequestDeck.collapse() + } label: { + Image(systemName: "chevron.backward") + .foregroundStyle(Color.textMain) + } + .accessibilityLabel("Collapse requests") + } + } ToolbarItem(placement: .principal) { ConversationTitleItem( title: title, diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 3a2eb5be1..171fe9ea7 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -393,6 +393,16 @@ struct TipConversationRow: View { .task(id: UnreadCountSubject(conversation: conversation, selfUserID: conversationController.selfUserID)) { await conversationController.resolveUnreadCount(for: conversation) } + // A request's preview names its amount, which only the request itself carries. + .task(id: lastRequestID) { + guard let lastRequestID else { return } + await sessionContainer.paymentRequests.fetchIfMissing(lastRequestID) + } + } + + 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 diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift index a11afeddf..dfe6604ff 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift @@ -18,9 +18,9 @@ private let logger = Logger(label: "flipcash.payment-request-create") 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 - /// The request exists but its chat message never went out; the deck still shows it. - case messageFailed case failed(DialogCopy) } @@ -158,12 +158,7 @@ final class PaymentRequestCreateViewModel { } } - do { - try await conversationController.sendPaymentRequest(request.id, in: conversationID) - } catch { - finish() - return .messageFailed - } + await conversationController.sendPaymentRequest(request.id, in: conversationID) finish() return .sent } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 62a38d22b..ca49fd479 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -177,6 +177,7 @@ struct PaymentRequestDeckOverlay: View { @Namespace private var namespace @State private var origin: CGPoint = .zero + @State private var cardHeight: CGFloat = 0 private var isExpanded: Bool { state.phase == .expanded } @@ -189,42 +190,52 @@ struct PaymentRequestDeckOverlay: View { .accessibilityLabel("Collapse requests") .accessibilityAddTraits(.isButton) - if let front = items.first { - if isExpanded { - list - } else { - PaymentRequestPillFace(item: front) - .matchedGeometryEffect(id: front.id, in: namespace) - .frame(width: state.pillFrame.width, height: state.pillFrame.height) - .position(x: state.pillFrame.midX - origin.x, y: state.pillFrame.midY - origin.y) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .allowsHitTesting(false) - } + list + if let front = items.first, !isExpanded { + PaymentRequestPillFace(item: front) + .matchedGeometryEffect(id: front.id, in: namespace) + .frame(width: state.pillFrame.width, height: state.pillFrame.height) + .position(x: state.pillFrame.midX - origin.x, y: state.pillFrame.midY - origin.y) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .allowsHitTesting(false) } } .onGeometryChange(for: CGPoint.self) { $0.frame(in: .global).origin } action: { origin = $0 } .gesture(DragGesture(minimumDistance: 12).onEnded { value in - if value.translation.height < -40 { state.collapse() } + // 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(items) { item in - Button { open(item.id) } label: { - PaymentRequestCard(content: item.card) - .padding(14) - .background(Color.textMain.opacity(0.1), in: .rect(cornerRadius: 12)) - .opacity(item.card.isEnded ? 0.4 : 1) + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + if index == 0 { + // While the front card is still the pill, an invisible copy holds its slot so + // the rest know where to start from. + if isExpanded { + card(item) + .matchedGeometryEffect(id: item.id, in: namespace) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { cardHeight = $0 } + } else { + card(item) + .hidden() + .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: isExpanded ? 0 : -CGFloat(index) * (cardHeight + 10)) + .opacity(isExpanded ? 1 : 0) + .zIndex(Double(-index)) } - .buttonStyle(.plain) - .matchedGeometryEffect(id: item.id, in: namespace, isSource: item.id == items.first?.id) - .transition(.offset(y: -24).combined(with: .opacity)) } Capsule() .fill(Color.textSecondary) + .opacity(isExpanded ? 1 : 0) .frame(width: 36, height: 5) .padding(.top, 2) .accessibilityHidden(true) @@ -233,6 +244,17 @@ struct PaymentRequestDeckOverlay: View { .padding(.top, 8) } .scrollBounceBehavior(.basedOnSize) + .allowsHitTesting(isExpanded) + } + + private func card(_ item: PaymentRequestDeckItem) -> some View { + Button { open(item.id) } label: { + PaymentRequestCard(content: item.card) + .padding(14) + .background(Color.textMain.opacity(0.1), in: .rect(cornerRadius: 12)) + .opacity(item.card.isEnded ? 0.4 : 1) + } + .buttonStyle(.plain) } } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index 8f2808be0..3da9dd96d 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -16,6 +16,7 @@ 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 @@ -59,7 +60,16 @@ struct PaymentRequestDetailsScreen: View { } } } - .task(id: requestID) { await load() } + // 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() + while !Task.isCancelled { + try? await Task.sleep(for: PaymentRequests.pollInterval) + guard !Task.isCancelled else { return } + await refresh() + } + } .dialog(item: $dialogItem) .sheet(isPresented: $isPaying) { if let payment, let creator = payment.request?.creator { @@ -190,14 +200,18 @@ struct PaymentRequestDetailsScreen: View { private func load() async { let requests = sessionContainer.paymentRequests + await refresh() + guard let chatID = requests.request(requestID)?.chatID else { return } + await requests.loadSeal(for: chatID) + await sessionContainer.groupRosters.refresh(chatID) + } + + private func refresh() async { do { - hasPaymentIntent = try await requests.refresh(requestID) != nil + hasPaymentIntent = try await sessionContainer.paymentRequests.refresh(requestID) != nil } catch { hasPaymentIntent = false } - guard let chatID = requests.request(requestID)?.chatID else { return } - await requests.loadSeal(for: chatID) - await sessionContainer.groupRosters.refresh(chatID) } /// Refetches for the intent ID right before the swipe sheet, as the spec requires. diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift index f5f525322..1af23778a 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift @@ -25,6 +25,11 @@ enum PaymentRequestDeadline: Hashable, Sendable { /// 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 @@ -59,11 +64,9 @@ enum PaymentRequestDeadline: Hashable, Sendable { case .twoWeeks: return "14 Days To Pay" case .fourWeeks: return "28 Days To Pay" case .custom(let date): - let formatted = date.formatted( - Date.FormatStyle(date: .abbreviated, time: .shortened, locale: locale) - .year(.omitted) - ) - return "Until \(formatted)" + 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)" } } } @@ -109,7 +112,7 @@ struct PaymentRequestPeople: Hashable, Sendable { /// The summary's People value. var summaryValue: String { - guard isValid else { return "Choose People" } + guard !selected.isEmpty else { return "Choose People" } let othersCount = members.filter { $0 != creator }.count let everyoneElse = askedCount == othersCount switch (everyoneElse, includesCreator) { @@ -122,7 +125,7 @@ struct PaymentRequestPeople: Hashable, Sendable { /// The summary's second People line, shown when the creator is included. var summarySecondLine: String? { - isValid && includesCreator ? "You'll be marked as paid" : nil + includesCreator ? "You'll be marked as paid" : nil } /// The selected members in roster order, the creator first when included. diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index af0144548..ff2533a19 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -445,11 +445,6 @@ private struct PaymentRequestSummaryScreen: View { switch await viewModel.send() { case .sent: sent() - case .messageFailed: - // The request exists and the deck shows it; only its chat message failed. - dialogItem = .error(title: "Message Not Sent", subtitle: "Your request was created, but its message couldn't be sent to the chat.") { - DialogAction.okay(kind: .standard) { sent() } - } case .failed(let copy): dialogItem = .alert(title: copy.title, subtitle: copy.subtitle) { DialogAction.okay(kind: .standard) @@ -522,6 +517,7 @@ private struct PaymentRequestDeadlineSheet: View { @State private var customDate: Date = PaymentRequestDeadline.oneWeek.endsAt(sentAt: .now) @State private var isPickingCustom = false + @State private var customRange = PaymentRequestDeadline.customRange(openedAt: .now) var body: some View { NavigationStack { @@ -533,7 +529,9 @@ private struct PaymentRequestDeadlineSheet: View { } } row("Custom", isChecked: isCustom) { + customRange = PaymentRequestDeadline.customRange(openedAt: .now) if case .custom(let date) = deadline { customDate = date } + customDate = min(max(customDate, customRange.lowerBound), customRange.upperBound) isPickingCustom = true } } @@ -557,12 +555,11 @@ private struct PaymentRequestDeadlineSheet: View { } private var customPicker: some View { - let now = Date.now - return VStack { + VStack { DatePicker( "Until", selection: $customDate, - in: now.addingTimeInterval(PaymentRequestDeadline.minimumCustomLead)...now.addingTimeInterval(PaymentRequestDeadline.maximumLead), + in: customRange, displayedComponents: [.date, .hourAndMinute] ) .datePickerStyle(.graphical) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 1e5455fbd..6cea27a6f 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -224,3 +224,9 @@ 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/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 0cb191812..569e8765a 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 \"<sender> requested <share>\", or \"You requested <share>\"") + func requestPreviewNamesSenderAndShare() { + let me = UUID() + let them = UUID() + let controller = makeController(MockConversations(), selfUserID: me) + let share = FiatAmount(value: 20, currency: .usd) + + let fromThem = requestConversation(from: them, members: [member(them, named: "Alice")]) + let fromMe = requestConversation(from: me, members: []) + #expect(controller.lastMessagePreview(for: fromThem, requestAmount: { _ in share }) { _ in nil } == "Alice requested $20.00") + #expect(controller.lastMessagePreview(for: fromMe, requestAmount: { _ in share }) { _ in nil } == "You requested $20.00") + } + + @Test("a request previews as nothing until it loads, or while its sender can't be named") + func requestPreviewIsBlankWithoutAmountOrSender() { + let me = UUID() + let controller = makeController(MockConversations(), selfUserID: me) + let share = FiatAmount(value: 20, currency: .usd) + + #expect(controller.lastMessagePreview(for: requestConversation(from: me, members: [])) { _ in nil } == nil) + #expect(controller.lastMessagePreview(for: requestConversation(from: UUID(), members: []), requestAmount: { _ in share }) { _ 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 index b32ccd44f..4611af309 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -90,6 +90,7 @@ struct PaymentRequestSpecTests { @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") } @@ -184,6 +185,17 @@ struct PaymentRequestSpecTests { 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") @@ -256,6 +268,23 @@ struct PaymentRequestCreateCopyTests { #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. diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index a1fc922f5..c92d5971c 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -446,7 +446,12 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con } func sendPaymentRequestMessage(owner: KeyPair, conversationID: ConversationID, paymentRequestID id: PaymentRequestID, clientMessageID: UUID) async throws -> ConversationMessage { - throw ErrorSendMessage.unknown + 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? { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 26747c655..8e6dbcc80 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -762,6 +762,7 @@ public final class ChatViewController: UICollectionViewController { 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) } @@ -859,11 +860,13 @@ public final class ChatViewController: UICollectionViewController { onCashCardTap?(id) } - /// The id of the payment request whose card is at `indexPath`, or nil if that row isn't one. + /// 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 else { return nil } + case .paymentRequest(let request) = message.content, + !message.isFailed else { return nil } return request.requestID } From 18c4b6fcad8070333417279f92ca3956657c9644 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 14:13:18 -0400 Subject: [PATCH 04/45] fix(group-requests): present request flow and details full screen, like Share Request Money, request history's details, and the chat's request details now open with fullScreenCover, the presentation ShareToChatsSheet uses. Swipe-down no longer dismisses them; the close X in each toolbar does. --- Flipcash/Core/Screens/Conversation/ConversationScreen.swift | 4 ++-- Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 77dd451fd..3b41bd9d3 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -641,7 +641,7 @@ struct ConversationScreen: View { if let conversationID { await sessionContainer.groupRosters.refresh(conversationID) } } } - .sheet(item: $paymentRequestDetails) { selection in + .fullScreenCover(item: $paymentRequestDetails) { selection in PaymentRequestDetailsScreen(requestID: selection.id) } .sheet(isPresented: $isShowingMoneyActions, onDismiss: runPendingMoneyAction) { @@ -650,7 +650,7 @@ struct ConversationScreen: View { requestMoney: { pendingMoneyAction = .request } ) } - .sheet(isPresented: $isShowingRequestMoney) { + .fullScreenCover(isPresented: $isShowingRequestMoney) { if let conversationID { PaymentRequestFlow(conversationID: conversationID) } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index ff2533a19..80aeb8789 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -257,7 +257,7 @@ private struct PaymentRequestHistoryScreen: View { .navigationTitle("Request Money") .navigationBarTitleDisplayMode(.inline) .task { await history.reload() } - .sheet(item: $selection) { selection in + .fullScreenCover(item: $selection) { selection in PaymentRequestDetailsScreen(requestID: selection.id) } } From 4f6e0bc108f0e34c8323f1cbdb9e9ec443239804 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 18:35:29 -0400 Subject: [PATCH 05/45] fix(group-requests): full-screen group send, concept B deck, pushed history details - Group send in a private group opens full screen with its close button and no swipe-down, through a new `coversFullScreen` flag on the router's sheet presentations. - The app-bar pill follows concept B: the front card with up to two more peeking beneath it. - Expanded deck cards sit on the opaque card surface instead of glass. - The container, ring, title and amounts each move between the pill and the front expanded card; the text moves without resizing so it does not truncate mid-flight. - Tapping the dimmed area below the expanded cards collapses the deck. - The custom deadline picker uses the wheel style. - Tapping a request in Request Money's history pushes its details onto the flow's stack instead of presenting them. --- .../Navigation/AppRouter+NestedSheet.swift | 34 ++-- .../AppRouter+SheetPresentation.swift | 11 ++ Flipcash/Core/Navigation/RootSheetHost.swift | 13 +- .../PaymentRequest/PaymentRequestDeck.swift | 169 ++++++++++++++---- .../PaymentRequestDetailsScreen.swift | 67 ++++--- .../PaymentRequest/PaymentRequestFlow.swift | 18 +- 6 files changed, 226 insertions(+), 86 deletions(-) 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<AppRouter.SheetPresentation?> { // 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<AppRouter.SheetPresentation?>( + 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..8eee7842d 100644 --- a/Flipcash/Core/Navigation/RootSheetHost.swift +++ b/Flipcash/Core/Navigation/RootSheetHost.swift @@ -109,7 +109,18 @@ struct RootSheetHostModifier: ViewModifier { // stack) — nested sheets mount inside this root sheet's content // via `.appRouterNestedSheet`. .sheet(item: Binding( - get: { router.rootSheet }, + get: { router.rootSheet.flatMap { $0.coversFullScreen ? nil : $0 } }, + set: { newValue in + if newValue == nil { + router.dismissSheet() + } + } + ), onDismiss: router.sheetDidFinishDismissing) { sheet in + RoutedSheet(sheet: sheet) + .appRouterNestedSheet() + } + .fullScreenCover(item: Binding( + get: { router.rootSheet.flatMap { $0.coversFullScreen ? $0 : nil } }, set: { newValue in if newValue == nil { router.dismissSheet() diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index ca49fd479..ab3105349 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -33,7 +33,7 @@ final class PaymentRequestDeckState { var order = PaymentRequestDeckOrder() private(set) var phase: Phase = .collapsed - /// The pill's frame in global coordinates, where the shared front card starts and ends. + /// The pill's front card frame in global coordinates, where the shared front card starts and ends. var pillFrame: CGRect = .zero /// Lifts the front card into the overlay, then grows it into the expanded list. @@ -58,8 +58,8 @@ final class PaymentRequestDeckState { // MARK: - Pill - -/// The app-bar pill: the front request with up to two more peeking beneath it. Swipe down cycles; -/// tap expands. +/// The app-bar pill (concept B): the front request with up to two more peeking beneath it. Swipe +/// down cycles; tap expands. struct PaymentRequestPill: View { let items: [PaymentRequestDeckItem] @@ -67,26 +67,33 @@ struct PaymentRequestPill: View { @Bindable var state: PaymentRequestDeckState @State private var drag: CGFloat = 0 - @State private var height: CGFloat = 43 + @State private var frontSize = CGSize(width: 131, height: 43) - private static let peekOffset: CGFloat = 5 + private static let peekStep: CGFloat = 5 + private static let peekInset: CGFloat = 7 var body: some View { - ZStack(alignment: .topLeading) { - ForEach(Array(items.prefix(3).enumerated().reversed()), id: \.element.id) { index, item in - if index == 0 { - PaymentRequestPillFace(item: item) - .offset(y: drag) - .opacity(1 - min(0.45, drag / max(height, 1) * 0.45)) - .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height = $0 } - } else { - PaymentRequestPillLayer(index: index) - .offset(x: CGFloat(index) * 7, y: CGFloat(index) * Self.peekOffset) - } + ZStack(alignment: .top) { + // The peeking cards, back first, each as tall as the front card and stepped in at both + // edges so only their bottoms show. + ForEach(Array(items.dropFirst().prefix(2).enumerated().reversed()), id: \.element.id) { offset, _ in + let depth = CGFloat(offset + 1) + PaymentRequestPillLayer(isBack: offset == 1) + .frame(width: max(frontSize.width - 2 * Self.peekInset * depth, 0), height: frontSize.height) + .offset(y: Self.peekStep * depth) + } + if let front = items.first { + PaymentRequestPillFace(item: front) + .offset(y: drag) + .opacity(1 - min(0.45, drag / max(frontSize.height, 1) * 0.45)) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { + frontSize = $0.size + state.pillFrame = $0 + } } } + .padding(.bottom, Self.peekStep * CGFloat(min(max(items.count - 1, 0), 2))) .opacity(state.phase == .collapsed ? 1 : 0) - .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { state.pillFrame = $0 } .contentShape(Rectangle()) .onTapGesture { state.expand() } .gesture(cycleGesture) @@ -111,7 +118,7 @@ struct PaymentRequestPill: View { let finishes = items.count > 1 && PaymentRequestDeckOrder.finishesCycle( translation: value.translation.height, predictedTranslation: value.predictedEndTranslation.height, - height: height + height: frontSize.height ) withAnimation(.spring(duration: 0.3, bounce: 0.2)) { if finishes { state.order.cycle() } @@ -121,21 +128,55 @@ struct PaymentRequestPill: View { } } +/// The parts of the front card that each move on their own between the pill and the expanded list. +private enum DeckPart: Hashable { + case container + case ring + case title + case amounts +} + +private struct DeckPartID: Hashable { + let request: PaymentRequestID + let part: DeckPart +} + +private extension View { + /// Ties this view to the same part of the other state of the front card, when `namespace` is set. + @ViewBuilder + func deckPart(_ part: DeckPart, of request: PaymentRequestID, in namespace: Namespace.ID?) -> some View { + if let namespace { + // Text keeps its own size and only moves, so it never truncates mid-flight. + matchedGeometryEffect( + id: DeckPartID(request: request, part: part), + in: namespace, + properties: part == .title || part == .amounts ? .position : .frame + ) + } else { + self + } + } +} + /// The front card of the pill: the ring, the title and "<paid> of <requested>". struct PaymentRequestPillFace: View { let item: PaymentRequestDeckItem + var namespace: Namespace.ID? = nil var body: some View { HStack(spacing: 8) { PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded) - VStack(alignment: .leading, spacing: 0) { + .deckPart(.ring, of: item.id, in: namespace) + VStack(alignment: .leading, spacing: 2) { Text(item.card.title) - .font(.appTextSmall) + .font(.appTextCaption) .foregroundStyle(Color.textMain) + .deckPart(.title, of: item.id, in: namespace) Text(item.amounts) .font(.appTextCaption) .foregroundStyle(Color.textSecondary) + .deckPart(.amounts, of: item.id, in: namespace) } .lineLimit(1) .truncationMode(.tail) @@ -144,31 +185,81 @@ struct PaymentRequestPillFace: View { .padding(.leading, 5) .padding(.trailing, 12) .padding(.vertical, 5) - .background(Color.backgroundMain, in: .rect(cornerRadius: 18)) - .background(Color.textMain.opacity(0.06), in: .rect(cornerRadius: 18)) - .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.textMain.opacity(0.1))) .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, of: item.id, in: namespace) + } } } /// A card peeking beneath the pill's front card. private struct PaymentRequestPillLayer: View { - let index: Int + let isBack: Bool var body: some View { RoundedRectangle(cornerRadius: 18) - .fill(Color.textMain.opacity(index == 1 ? 0.06 : 0.08)) - .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.textMain.opacity(0.1))) - .frame(height: 36) - .padding(.trailing, CGFloat(index) * 14) + .fill(isBack ? Color.textMain.opacity(0.08) : Color.backgroundSecondary) + .background(Color.backgroundMain, in: .rect(cornerRadius: 18)) + .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.rowSeparator)) + } +} + +/// 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 namespace: Namespace.ID? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded, diameter: 36) + .deckPart(.ring, of: item.id, in: namespace) + VStack(alignment: .leading, spacing: 2) { + Text("Request") + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + Text(item.card.title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(2) + .deckPart(.title, of: item.id, in: namespace) + } + } + Text(item.card.progressLine) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .deckPart(.amounts, of: item.id, in: namespace) + PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: item.card.isEnded) + Text(item.card.statusLine) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } + .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, of: item.id, in: namespace) + } + .accessibilityElement(children: .combine) } } // MARK: - Expanded - /// The expanded deck: every request as a full-width card beneath the app bar, over the dimmed chat. -/// The front card moves in from the pill and back on collapse. +/// The front card's container, ring, title and amounts move in from the pill and back on collapse. struct PaymentRequestDeckOverlay: View { let items: [PaymentRequestDeckItem] @@ -178,6 +269,7 @@ struct PaymentRequestDeckOverlay: View { @Namespace private var namespace @State private var origin: CGPoint = .zero @State private var cardHeight: CGFloat = 0 + @State private var viewportHeight: CGFloat = 0 private var isExpanded: Bool { state.phase == .expanded } @@ -192,8 +284,7 @@ struct PaymentRequestDeckOverlay: View { list if let front = items.first, !isExpanded { - PaymentRequestPillFace(item: front) - .matchedGeometryEffect(id: front.id, in: namespace) + PaymentRequestPillFace(item: front, namespace: namespace) .frame(width: state.pillFrame.width, height: state.pillFrame.height) .position(x: state.pillFrame.midX - origin.x, y: state.pillFrame.midY - origin.y) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -216,8 +307,7 @@ struct PaymentRequestDeckOverlay: View { // While the front card is still the pill, an invisible copy holds its slot so // the rest know where to start from. if isExpanded { - card(item) - .matchedGeometryEffect(id: item.id, in: namespace) + card(item, namespace: namespace) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { cardHeight = $0 } } else { card(item) @@ -242,17 +332,20 @@ struct PaymentRequestDeckOverlay: View { } .padding(.horizontal, 20) .padding(.top, 8) + // The scroll view covers the dimmed chat, so the space below the cards takes the + // scrim'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(isExpanded) } - private func card(_ item: PaymentRequestDeckItem) -> some View { + private func card(_ item: PaymentRequestDeckItem, namespace: Namespace.ID? = nil) -> some View { Button { open(item.id) } label: { - PaymentRequestCard(content: item.card) - .padding(14) - .background(Color.textMain.opacity(0.1), in: .rect(cornerRadius: 12)) - .opacity(item.card.isEnded ? 0.4 : 1) + PaymentRequestDeckCard(item: item, namespace: namespace) } .buttonStyle(.plain) } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index 3da9dd96d..ad8d25232 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -19,6 +19,9 @@ struct PaymentRequestDetailsScreen: View { @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. @@ -31,33 +34,11 @@ struct PaymentRequestDetailsScreen: View { @State private var isUpdating = false var body: some View { - NavigationStack { - // 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") - } - CloseButton(action: { dismiss() }) - } + Group { + if isPushed { + screen + } else { + NavigationStack { screen } } } // Like the chat, details refetch on a 45 s poll while on screen in the foreground. @@ -83,6 +64,38 @@ struct PaymentRequestDetailsScreen: View { } } + 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 { diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index 80aeb8789..b94af3315 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -76,6 +76,7 @@ private enum PaymentRequestStep: Hashable { case amount case summary case selectPeople + case details(PaymentRequestID) } /// Request Money in a private group: the history, then New Request's amount, summary and confirm. @@ -111,10 +112,10 @@ private struct PaymentRequestFlowContent: View { var body: some View { NavigationStack(path: $path) { - PaymentRequestHistoryScreen(history: history) { + PaymentRequestHistoryScreen(history: history, newRequest: { viewModel.startNewRequest() path.append(.amount) - } + }, open: { path.append(.details($0)) }) .toolbar { ToolbarItem(placement: .topBarTrailing) { CloseButton(action: { dismiss() }) @@ -134,6 +135,8 @@ private struct PaymentRequestFlowContent: View { PaymentRequestSelectPeopleScreen(viewModel: viewModel) { path.removeLast() } + case .details(let id): + PaymentRequestDetailsScreen(requestID: id, isPushed: true) } } } @@ -225,8 +228,7 @@ private struct PaymentRequestHistoryScreen: View { let history: PaymentRequestHistory let newRequest: () -> Void - - @State private var selection: PaymentRequestSelection? + let open: (PaymentRequestID) -> Void var body: some View { Background(color: .backgroundMain) { @@ -257,9 +259,6 @@ private struct PaymentRequestHistoryScreen: View { .navigationTitle("Request Money") .navigationBarTitleDisplayMode(.inline) .task { await history.reload() } - .fullScreenCover(item: $selection) { selection in - PaymentRequestDetailsScreen(requestID: selection.id) - } } private func header(_ title: String) -> some View { @@ -271,7 +270,7 @@ private struct PaymentRequestHistoryScreen: View { private func row(_ request: GroupPaymentRequest) -> some View { let copy = PaymentRequestCopy(session.displayCurrency) return Button { - selection = PaymentRequestSelection(id: request.id) + open(request.id) } label: { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { @@ -562,7 +561,8 @@ private struct PaymentRequestDeadlineSheet: View { in: customRange, displayedComponents: [.date, .hourAndMinute] ) - .datePickerStyle(.graphical) + .datePickerStyle(.wheel) + .labelsHidden() .padding(20) Spacer() } From 449fa728a079c3ec6cca7d212961388039383a0d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 18:41:37 -0400 Subject: [PATCH 06/45] fix(group-requests): push a Name screen from New Request Tapping Name now pushes a screen laid out like the group-name and profile-name screens: a heading, one large field, a countdown near the 100-scalar cap, and a filled Done button. It replaces the half-height sheet. --- .../PaymentRequest/PaymentRequestFlow.swift | 88 +++++++++++-------- 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index b94af3315..fe634cdfc 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -75,6 +75,7 @@ struct MoneyActionsSheet: View { private enum PaymentRequestStep: Hashable { case amount case summary + case name case selectPeople case details(PaymentRequestID) } @@ -128,9 +129,16 @@ private struct PaymentRequestFlowContent: View { path.append(.summary) } case .summary: - PaymentRequestSummaryScreen(viewModel: viewModel, selectPeople: { + 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() @@ -336,10 +344,10 @@ private struct PaymentRequestAmountScreen: View { private struct PaymentRequestSummaryScreen: View { @Bindable var viewModel: PaymentRequestCreateViewModel + let editName: () -> Void let selectPeople: () -> Void let sent: () -> Void - @State private var isEditingName = false @State private var isPickingDeadline = false @State private var dialogItem: DialogItem? @@ -358,9 +366,7 @@ private struct PaymentRequestSummaryScreen: View { } .padding(.bottom, 8) - summaryRow(label: "Name", value: viewModel.title, secondLine: nil) { - isEditingName = true - } + summaryRow(label: "Name", value: viewModel.title, secondLine: nil, action: editName) summaryRow(label: "People", value: viewModel.peopleValue, secondLine: viewModel.peopleSecondLine, action: selectPeople) summaryRow( label: "Timing", @@ -390,9 +396,6 @@ private struct PaymentRequestSummaryScreen: View { .task(id: viewModel.conversationID) { await viewModel.refreshMembers() } - .sheet(isPresented: $isEditingName) { - PaymentRequestNameEditor(title: viewModel.title) { viewModel.title = $0 } - } .sheet(isPresented: $isPickingDeadline) { PaymentRequestDeadlineSheet(deadline: $viewModel.deadline) } @@ -454,15 +457,18 @@ private struct PaymentRequestSummaryScreen: View { // MARK: - Name - -private struct PaymentRequestNameEditor: View { - - @Environment(\.dismiss) private var dismiss +/// 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 @@ -470,39 +476,51 @@ private struct PaymentRequestNameEditor: View { private var validated: String? { PaymentRequestTitleValidator().validate(text) } + private var remainingScalars: Int { + PaymentRequestTitleValidator.maxScalars - text.unicodeScalars.count + } + var body: some View { - NavigationStack { - Background(color: .backgroundMain) { - VStack(alignment: .leading, spacing: 8) { - TextField("Name", text: $text) - .font(.appTextLarge) - .foregroundStyle(Color.textMain) - .focused($isFocused) - .submitLabel(.done) - .onSubmit(done) - .padding(14) - .background(Color.white.opacity(0.06), in: .rect(cornerRadius: 12)) - Spacer() - } - .padding(20) - } - .navigationTitle("Name") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done", action: done) - .disabled(validated == nil) + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 0) { + Text("What's this 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) } - .presentationDetents([.medium]) - .onAppear { isFocused = true } + .navigationBarTitleDisplayMode(.inline) + .focusAfterPush($isFocused) } private func done() { guard let validated else { return } save(validated) - dismiss() } } From 4e13577c290522c351007f795a1073bd180c0012 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 18:48:18 -0400 Subject: [PATCH 07/45] fix(group-requests): green $ disc inside the request ring The disc behind the "$" now takes the ring's accent, success green while the request is open and secondary grey once it ends, with a black glyph. Android's RequestRing draws it the same way. --- .../Sources/FlipcashUI/Chat/PaymentRequestRing.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift index db670f7bc..80561a89e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift @@ -7,7 +7,7 @@ import SwiftUI -/// A ring around a "$" glyph, filled clockwise from 12 o'clock with a request's paid fraction. +/// A ring around a green "$" disc, filled clockwise from 12 o'clock with a request's paid fraction. public struct PaymentRequestRing: View { let fraction: Double @@ -22,20 +22,23 @@ public struct PaymentRequestRing: View { private var lineWidth: CGFloat { diameter * 0.12 } + /// The arc and the disc behind the "$" 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() .stroke(Color.textMain.opacity(0.15), lineWidth: lineWidth) Circle() .trim(from: 0, to: max(0, min(1, fraction))) - .stroke(isEnded ? Color.textSecondary : Color.success, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .stroke(accent, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) Circle() - .fill(Color.textMain) + .fill(accent) .frame(width: diameter * 0.7, height: diameter * 0.7) Text(verbatim: "$") .font(.appTextCaption) - .foregroundStyle(Color.textAction) + .foregroundStyle(Color.black) } .frame(width: diameter, height: diameter) .animation(.easeOut(duration: 0.3), value: fraction) From 30bc1b58f96af4d43d2e975f1436c92d059b657e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 18:52:35 -0400 Subject: [PATCH 08/45] fix(group-requests): money sheet as Add Money cards, ring drawn to the spec The private-group money sheet is now two Add Money-style cards with a trailing withdraw/deposit glyph and no title or Dismiss, as Android draws it (node 12243:1009). The request ring is a 2 pt stroke on the row-separator track, the disc sits 4 pt inside at 26 pt (scaled for larger rings), and a closed request shows a check in place of the dollar sign, matching Android's RequestRing. --- .../PaymentRequest/PaymentRequestDeck.swift | 4 +- .../PaymentRequest/PaymentRequestFlow.swift | 50 ++++++++++--------- .../FlipcashUI/Chat/PaymentRequestRing.swift | 26 ++++++---- 3 files changed, 45 insertions(+), 35 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index ab3105349..64702f547 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -166,7 +166,7 @@ struct PaymentRequestPillFace: View { var body: some View { HStack(spacing: 8) { - PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded) + PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded, isClosed: item.request.status == .closed) .deckPart(.ring, of: item.id, in: namespace) VStack(alignment: .leading, spacing: 2) { Text(item.card.title) @@ -221,7 +221,7 @@ private struct PaymentRequestDeckCard: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 10) { - PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded, diameter: 36) + PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded, isClosed: item.request.status == .closed, diameter: 36) .deckPart(.ring, of: item.id, in: namespace) VStack(alignment: .leading, spacing: 2) { Text("Request") diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index fe634cdfc..00ce519ed 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -12,6 +12,8 @@ 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 @@ -21,52 +23,52 @@ struct MoneyActionsSheet: View { var body: some View { PartialSheet { - VStack(alignment: .leading, spacing: 0) { - Text("Money") - .font(.appTitle) - .foregroundStyle(Color.textMain) - .padding(.bottom, 16) - - row(symbol: "arrow.up", title: "Send Money", subtitle: "Send money to someone in the group, or multiple people") { + 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(symbol: "arrow.down", title: "Request Money", subtitle: "Collect money from people in the group") { + row(icon: .deposit, title: "Request Money", subtitle: "Collect money from people in the group", identifier: "money-action-request") { dismiss() requestMoney() } - - CodeButton(style: .subtle, title: "Dismiss") { dismiss() } - .padding(.top, 8) } - .padding(20) + .padding(.horizontal) + .padding(.vertical, 20) } } - private func row(symbol: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View { + private func row(icon: Asset, title: String, subtitle: String, identifier: String, action: @escaping () -> Void) -> some View { Button(action: action) { - HStack(alignment: .top, spacing: 20) { - Image(systemName: symbol) - .font(.system(size: 20, weight: .regular)) - .frame(width: 24) - .padding(.top, 2) + HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(title) - .font(.appTextLarge) + .font(.appTextMedium) .foregroundStyle(Color.textMain) Text(subtitle) .font(.appTextSmall) .foregroundStyle(Color.textSecondary) - .multilineTextAlignment(.leading) } - Spacer(minLength: 0) + .multilineTextAlignment(.leading) + + Spacer(minLength: 12) + + Image.asset(icon) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(Color.textMain) } - .foregroundStyle(Color.textMain) - .padding(.vertical, 16) - .contentShape(.rect) + .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) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift index 80561a89e..58375df8d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift @@ -7,37 +7,45 @@ import SwiftUI -/// A ring around a green "$" disc, filled clockwise from 12 o'clock with a request's paid fraction. +/// 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 - public init(fraction: Double, isEnded: Bool, diameter: CGFloat = 26) { + /// `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 var lineWidth: CGFloat { diameter * 0.12 } + private static let lineWidth: CGFloat = 2 - /// The arc and the disc behind the "$" share one color, green while the request is open. + /// 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() - .stroke(Color.textMain.opacity(0.15), lineWidth: lineWidth) + .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, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .stroke(accent, lineWidth: Self.lineWidth) .rotationEffect(.degrees(-90)) Circle() .fill(accent) - .frame(width: diameter * 0.7, height: diameter * 0.7) - Text(verbatim: "$") - .font(.appTextCaption) + .frame(width: discDiameter, height: discDiameter) + Text(verbatim: isClosed ? "✓" : "$") + .font(diameter > 30 ? .appTextMedium : .appTextCaption) .foregroundStyle(Color.black) } .frame(width: diameter, height: diameter) From 773f64af7332cdfd425229d07804847790597769 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 18:55:36 -0400 Subject: [PATCH 09/45] fix(group-requests): member rows in request details no longer look disabled A row with no mark-paid action was a Button with .disabled(true), which dims the names and amounts with the rest of the row. Those rows are now a plain view; only rows you can act on stay buttons. --- .../PaymentRequestDetailsScreen.swift | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index ad8d25232..c97d6445d 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -149,37 +149,46 @@ struct PaymentRequestDetailsScreen: View { .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 { - Button { - switch row.action { - case .markPaid: presentMarkPaidOptions(row) - case .none: break + switch row.action { + case .markPaid: + Button { + presentMarkPaidOptions(row) + } label: { + shareRowContent(row) } - } label: { - 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) + .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) } - .padding(.horizontal, 12) - .frame(minHeight: 64) - .background(Color.white.opacity(0.06), in: .rect(cornerRadius: 12)) - .contentShape(.rect) + Spacer(minLength: 8) + Text(row.amount) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) } - .buttonStyle(.plain) - .disabled(row.action == .none || isUpdating) - .accessibilityElement(children: .combine) + .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. From 1369392207830f22f608fd30dd90e24aecf2f9f9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 18:57:45 -0400 Subject: [PATCH 10/45] fix(group-requests): soft scroll edge on request details The root's softScrollEdge doesn't reach a full-screen cover, so request details sets it on its own scroll view, as ShareToChatsSheet does. --- .../Screens/PaymentRequest/PaymentRequestDetailsScreen.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index c97d6445d..88468db80 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -112,6 +112,7 @@ struct PaymentRequestDetailsScreen: View { .padding(.horizontal, 20) .padding(.vertical, 24) } + .softScrollEdge(for: [.top, .bottom]) if let sendTitle = content.sendTitle { CodeButton(isLoading: isPreparing, style: .filled, title: sendTitle) { pay() From 47d10d6b539523a9ecb264d42de66c6887d0af21 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 19:00:19 -0400 Subject: [PATCH 11/45] fix(group-requests): Name screen title reads "What is the request for?" --- Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index 00ce519ed..50de9fcb6 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -485,7 +485,7 @@ private struct PaymentRequestNameScreen: View { var body: some View { Background(color: .backgroundMain) { VStack(alignment: .leading, spacing: 0) { - Text("What's this request for?") + Text("What is the request for?") .font(.appTextLarge) .foregroundStyle(Color.textMain) .padding(.top, 20) From 6822473299652c745714efa72c10577c483c2d17 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 19:26:18 -0400 Subject: [PATCH 12/45] fix(group-requests): deck expand and collapse stay fluid Card-only lines (progress bar, deadline, Request label) belonged to no container while expanding, so they showed at full opacity over the chat before the card formed. One animated progress value now drives the front card from the pill's measured frame to the card's, and those lines fade in from 60% of the move and fade out first on collapse. The pill keeps its final layout throughout, so it lands without re-measuring, and any tap mid-move reverses from where it is. --- .../Conversation/ConversationScreen.swift | 5 +- .../PaymentRequest/PaymentRequestDeck.swift | 334 ++++++++++++++---- 2 files changed, 265 insertions(+), 74 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 3b41bd9d3..12c16e6ee 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -729,7 +729,7 @@ struct ConversationScreen: View { // 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. // An expanded request deck takes the back button the same way: back collapses it first. - .navigationBarBackButtonHidden(composer.isEditing || paymentRequestDeck.phase != .collapsed) + .navigationBarBackButtonHidden(composer.isEditing || paymentRequestDeck.isOpen) .toolbar { if composer.isEditing { ToolbarItem(placement: .topBarLeading) { @@ -742,7 +742,8 @@ struct ConversationScreen: View { .accessibilityLabel("Stop editing") } } else { - if paymentRequestDeck.phase != .collapsed { + // 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() diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 64702f547..3b09babb9 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -26,32 +26,68 @@ final class PaymentRequestDeckState { enum Phase { case collapsed - /// The overlay is up with the front card still drawn at the pill's frame. + /// 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() - private(set) var phase: Phase = .collapsed - /// The pill's front card frame in global coordinates, where the shared front card starts and ends. - var pillFrame: CGRect = .zero + /// 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 the pill and in the expanded list. + var pillFrames: [DeckPart: CGRect] = [:] + var cardFrames: [DeckPart: CGRect] = [:] + 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 + } - /// Lifts the front card into the overlay, then grows it into the expanded list. + /// Grows the front card from the pill into the expanded list, from wherever it is. func expand() { - guard phase == .collapsed else { return } - phase = .presenting - Task { @MainActor in - withAnimation(.spring(duration: 0.4, bounce: 0.15)) { phase = .expanded } + 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) } } } - /// Plays the expansion in reverse, back into the pill. + /// Shrinks the front card back into the pill, from wherever it is. func collapse() { - guard phase == .expanded else { return } - withAnimation(.spring(duration: 0.35, bounce: 0.1)) { - phase = .presenting + guard isOpen else { return } + isOpen = false + isSettled = false + generation += 1 + move(to: 0, generation: generation) + } + + 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: { - self.phase = .collapsed + guard generation == self.generation else { return } + if target == 1 { + self.isSettled = true + } else { + self.isOverlayPresented = false + } } } } @@ -83,17 +119,15 @@ struct PaymentRequestPill: View { .offset(y: Self.peekStep * depth) } if let front = items.first { - PaymentRequestPillFace(item: front) + PaymentRequestPillFace(item: front) { state.pillFrames[$0] = $1 } .offset(y: drag) .opacity(1 - min(0.45, drag / max(frontSize.height, 1) * 0.45)) - .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { - frontSize = $0.size - state.pillFrame = $0 - } + .onGeometryChange(for: CGSize.self) { $0.size } action: { frontSize = $0 } + // The moving copy stands in for it until it lands back here. + .opacity(state.phase == .collapsed ? 1 : 0) } } .padding(.bottom, Self.peekStep * CGFloat(min(max(items.count - 1, 0), 2))) - .opacity(state.phase == .collapsed ? 1 : 0) .contentShape(Rectangle()) .onTapGesture { state.expand() } .gesture(cycleGesture) @@ -128,30 +162,28 @@ struct PaymentRequestPill: View { } } -/// The parts of the front card that each move on their own between the pill and the expanded list. -private enum DeckPart: Hashable { +/// 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 } -private struct DeckPartID: Hashable { - let request: PaymentRequestID - let part: DeckPart -} +/// Reports a part's global frame. +typealias DeckPartMeasure = (DeckPart, CGRect) -> Void private extension View { - /// Ties this view to the same part of the other state of the front card, when `namespace` is set. + /// Reports this view's global frame as `part`, when `measure` is set. @ViewBuilder - func deckPart(_ part: DeckPart, of request: PaymentRequestID, in namespace: Namespace.ID?) -> some View { - if let namespace { - // Text keeps its own size and only moves, so it never truncates mid-flight. - matchedGeometryEffect( - id: DeckPartID(request: request, part: part), - in: namespace, - properties: part == .title || part == .amounts ? .position : .frame - ) + 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 } @@ -162,21 +194,21 @@ private extension View { struct PaymentRequestPillFace: View { let item: PaymentRequestDeckItem - var namespace: Namespace.ID? = nil + var measure: DeckPartMeasure? = nil var body: some View { HStack(spacing: 8) { PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded, isClosed: item.request.status == .closed) - .deckPart(.ring, of: item.id, in: namespace) + .deckPart(.ring, measure) VStack(alignment: .leading, spacing: 2) { Text(item.card.title) .font(.appTextCaption) .foregroundStyle(Color.textMain) - .deckPart(.title, of: item.id, in: namespace) + .deckPart(.title, measure) Text(item.amounts) .font(.appTextCaption) .foregroundStyle(Color.textSecondary) - .deckPart(.amounts, of: item.id, in: namespace) + .deckPart(.amounts, measure) } .lineLimit(1) .truncationMode(.tail) @@ -193,7 +225,7 @@ struct PaymentRequestPillFace: View { .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, of: item.id, in: namespace) + .deckPart(.container, measure) } } } @@ -216,32 +248,35 @@ private struct PaymentRequestPillLayer: View { private struct PaymentRequestDeckCard: View { let item: PaymentRequestDeckItem - var namespace: Namespace.ID? = nil + 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) - .deckPart(.ring, of: item.id, in: namespace) + .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, of: item.id, in: namespace) + .deckPart(.title, measure) } } Text(item.card.progressLine) .font(.appTextSmall) .foregroundStyle(Color.textMain) - .deckPart(.amounts, of: item.id, in: namespace) + .deckPart(.amounts, measure) PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: item.card.isEnded) + .deckPart(.bar, measure) Text(item.card.statusLine) .font(.appTextCaption) .foregroundStyle(Color.textSecondary) + .deckPart(.status, measure) } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) @@ -250,7 +285,7 @@ private struct PaymentRequestDeckCard: View { RoundedRectangle(cornerRadius: 12) .fill(Color.backgroundSecondary) .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.rowSeparator)) - .deckPart(.container, of: item.id, in: namespace) + .deckPart(.container, measure) } .accessibilityElement(children: .combine) } @@ -259,36 +294,37 @@ private struct PaymentRequestDeckCard: View { // MARK: - Expanded - /// The expanded deck: every request as a full-width card beneath the app bar, over the dimmed chat. -/// The front card's container, ring, title and amounts move in from the pill and back on collapse. +/// 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 - @Namespace private var namespace @State private var origin: CGPoint = .zero @State private var cardHeight: CGFloat = 0 @State private var viewportHeight: CGFloat = 0 - private var isExpanded: Bool { state.phase == .expanded } - var body: some View { ZStack(alignment: .top) { Color.backgroundMain - .opacity(isExpanded ? 0.85 : 0) + .opacity(0.85 * state.progress) .ignoresSafeArea() .onTapGesture { state.collapse() } .accessibilityLabel("Collapse requests") .accessibilityAddTraits(.isButton) list - if let front = items.first, !isExpanded { - PaymentRequestPillFace(item: front, namespace: namespace) - .frame(width: state.pillFrame.width, height: state.pillFrame.height) - .position(x: state.pillFrame.midX - origin.x, y: state.pillFrame.midY - origin.y) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .allowsHitTesting(false) + if let front = items.first, !state.isSettled { + PaymentRequestDeckMorph( + item: front, + progress: state.progress, + pill: state.pillFrames, + card: state.cardFrames, + origin: origin + ) + .allowsHitTesting(false) } } .onGeometryChange(for: CGPoint.self) { $0.frame(in: .global).origin } action: { origin = $0 } @@ -304,28 +340,23 @@ struct PaymentRequestDeckOverlay: View { VStack(spacing: 10) { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in if index == 0 { - // While the front card is still the pill, an invisible copy holds its slot so - // the rest know where to start from. - if isExpanded { - card(item, namespace: namespace) - .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { cardHeight = $0 } - } else { - card(item) - .hidden() - .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { cardHeight = $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: isExpanded ? 0 : -CGFloat(index) * (cardHeight + 10)) - .opacity(isExpanded ? 1 : 0) + .offset(y: -CGFloat(index) * (cardHeight + 10) * (1 - state.progress)) + .opacity(state.progress) .zIndex(Double(-index)) } } Capsule() .fill(Color.textSecondary) - .opacity(isExpanded ? 1 : 0) + .opacity(state.isOpen ? 1 : 0) .frame(width: 36, height: 5) .padding(.top, 2) .accessibilityHidden(true) @@ -340,17 +371,176 @@ struct PaymentRequestDeckOverlay: View { } .scrollBounceBehavior(.basedOnSize) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { viewportHeight = $0 } - .allowsHitTesting(isExpanded) + .allowsHitTesting(state.isOpen) } - private func card(_ item: PaymentRequestDeckItem, namespace: Namespace.ID? = nil) -> some View { + private func card(_ item: PaymentRequestDeckItem, measure: DeckPartMeasure? = nil) -> some View { Button { open(item.id) } label: { - PaymentRequestDeckCard(item: item, namespace: namespace) + 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 origin: CGPoint + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + private static let pillRing: CGFloat = 26 + private static let cardRing: CGFloat = 36 + /// appTextMedium over appTextCaption, the title's two sizes. + private static let titleScale: CGFloat = 16 / 12 + /// 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) + + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 18 + (12 - 18) * p) + .fill(Color.backgroundSecondary) + .opacity(item.card.isEnded ? 0.4 + 0.6 * p : 1) + .background(Color.backgroundMain, in: .rect(cornerRadius: 18 + (12 - 18) * p)) + .overlay(RoundedRectangle(cornerRadius: 18 + (12 - 18) * p).strokeBorder(Color.rowSeparator)) + .place(box, origin) + + Group { + ring(p) + shared(.title, scale: Self.titleScale, p: p, outgoing: outgoing, incoming: incoming) { isCard in + Text(item.card.title) + .font(isCard ? .appTextMedium : .appTextCaption) + .foregroundStyle(Color.textMain) + .lineLimit(isCard ? 2 : 1) + } + shared(.amounts, scale: Self.amountsScale, p: p, outgoing: amountsOut, incoming: amountsIn) { isCard in + Text(isCard ? item.card.progressLine : item.amounts) + .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) { + Text(item.card.statusLine).font(.appTextCaption).foregroundStyle(Color.textSecondary) + } + } + .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 ? Self.pillRing : Self.cardRing + PaymentRequestRing( + fraction: item.card.fraction, + isEnded: item.card.isEnded, + isClosed: item.request.status == .closed, + diameter: base + ) + .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) + .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. From fb564e6e3b91e78b2de0be6f2cc2d6672357506b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 19:39:17 -0400 Subject: [PATCH 13/45] fix(group-requests): pill text no longer jumps when the deck settles The toolbar keeps the pill's text at one size while the overlay follows the Dynamic Type setting, so at a smaller text size the moving copy drew the title and amounts about 9% smaller than the pill. When the overlay left, the pill's own text replaced it at its larger size. The moving copy now draws the pill's text at the pill's type size. --- .../Core/Screens/PaymentRequest/PaymentRequestDeck.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 3b09babb9..c43f0995e 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -43,6 +43,9 @@ final class PaymentRequestDeckState { /// The front card's parts in global coordinates, in the pill and in the expanded list. var pillFrames: [DeckPart: CGRect] = [:] var cardFrames: [DeckPart: CGRect] = [:] + /// The pill's Dynamic Type size. The toolbar can lay its text out at a different size than the + /// overlay, so the moving copy draws the pill's text at this one to land on it exactly. + var pillTypeSize: DynamicTypeSize = .large private var generation = 0 private static let motion = Animation.spring(duration: 0.4, bounce: 0.12) @@ -102,6 +105,7 @@ struct PaymentRequestPill: View { let accessibilityLabel: String @Bindable var state: PaymentRequestDeckState + @Environment(\.dynamicTypeSize) private var typeSize @State private var drag: CGFloat = 0 @State private var frontSize = CGSize(width: 131, height: 43) @@ -128,6 +132,7 @@ struct PaymentRequestPill: View { } } .padding(.bottom, Self.peekStep * CGFloat(min(max(items.count - 1, 0), 2))) + .onChange(of: typeSize, initial: true) { state.pillTypeSize = typeSize } .contentShape(Rectangle()) .onTapGesture { state.expand() } .gesture(cycleGesture) @@ -322,6 +327,7 @@ struct PaymentRequestDeckOverlay: View { progress: state.progress, pill: state.pillFrames, card: state.cardFrames, + pillTypeSize: state.pillTypeSize, origin: origin ) .allowsHitTesting(false) @@ -391,6 +397,7 @@ private struct PaymentRequestDeckMorph: View, Animatable { var progress: CGFloat let pill: [DeckPart: CGRect] let card: [DeckPart: CGRect] + let pillTypeSize: DynamicTypeSize let origin: CGPoint var animatableData: CGFloat { @@ -493,6 +500,7 @@ private struct PaymentRequestDeckMorph: View, Animatable { 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) From f686fd4bd32df53bab6d5aa29c8ad44a8f217af7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 19:51:35 -0400 Subject: [PATCH 14/45] feat(group-requests): fade the request pill in and out of the app bar When the first fetch or a new request lands after the chat is on screen, the pill fades in over 250 ms from 95% scale while the title widens its margin in the same animation. It fades out the same way when nothing is left. A chat that opens with requests already known shows the pill from the first frame, and a refetch that keeps it showing doesn't replay the fade. --- .../Conversation/ConversationScreen.swift | 11 ++- .../PaymentRequest/PaymentRequestDeck.swift | 77 +++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 12c16e6ee..9ea3ec0d9 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -92,6 +92,7 @@ struct ConversationScreen: View { /// The payment request whose details sheet is open. @State private var paymentRequestDetails: PaymentRequestSelection? @State private var paymentRequestDeck = PaymentRequestDeckState() + @State private var requestPillPresence = PaymentRequestPillPresence() /// 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. @@ -611,6 +612,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() } @@ -776,7 +780,7 @@ struct ConversationScreen: View { opensProfile: profileTapAction != nil ) } - if !requestDeckItems.isEmpty { + 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 } @@ -814,10 +818,11 @@ struct ConversationScreen: View { private var requestPill: some View { PaymentRequestPill( - items: requestDeckItems, + items: requestPillPresence.items(requestDeckItems), accessibilityLabel: requestDeckAccessibilityLabel, state: paymentRequestDeck ) + .modifier(PaymentRequestPillAppearance(presence: requestPillPresence)) } private var requestDeckAccessibilityLabel: String { @@ -828,7 +833,7 @@ struct ConversationScreen: View { /// The title's right margin, widened to clear the request pill while one shows. private var titleTrailingInset: CGFloat { - Self.titleTrailingInset + (requestDeckItems.isEmpty ? 0 : Self.requestPillWidth) + Self.titleTrailingInset + (requestPillPresence.isVisible ? Self.requestPillWidth : 0) } /// The fetches the transcript needs: gate token names, sender names, and avatars. diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index c43f0995e..d0d696d98 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -95,6 +95,83 @@ final class PaymentRequestDeckState { } } +// 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: - Pill - /// The app-bar pill (concept B): the front request with up to two more peeking beneath it. Swipe From 4270231a356c870543bb29a659f2104b5b014cfa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 20:02:46 -0400 Subject: [PATCH 15/45] feat(group-requests): blur the chat behind the expanded deck like the message menu The expanded deck dimmed the chat with its own fade and left the app bar's back button, avatar and title showing. It now draws the long-press menu's ChatBackdrop (same blur fraction and dim) through a SwiftUI wrapper, in a window above the navigation bar, so the backdrop covers the whole screen and only the moving and expanded cards sit above it. The backdrop's opacity follows the deck's progress, so it comes and goes at the expand/collapse pace, and a tap on it anywhere outside the cards collapses. The moving card stays mounted and is hidden at rest. Inserted in the same update that starts a collapse, it had no starting value and jumped straight to the pill. --- .../Conversation/ConversationScreen.swift | 14 +++- .../PaymentRequest/PaymentRequestDeck.swift | 19 +++-- .../PaymentRequestDeckWindow.swift | 71 +++++++++++++++++++ .../FlipcashUI/Chat/ChatBackdropView.swift | 49 +++++++++++++ 4 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdropView.swift diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 9ea3ec0d9..4186c2c2a 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -92,6 +92,7 @@ struct ConversationScreen: View { /// 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() /// Whether a private group's Money sheet (Send or Request) is open. @State private var isShowingMoneyActions = false @@ -791,14 +792,21 @@ struct ConversationScreen: View { } } } - .overlay { - if paymentRequestDeck.phase != .collapsed { - PaymentRequestDeckOverlay(items: requestDeckItems, state: paymentRequestDeck) { id in + // 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 paymentRequestDeck.collapse() paymentRequestDetails = PaymentRequestSelection(id: id) } + } else { + deckWindow.dismiss() } } + .onChange(of: requestDeckItems.map(\.id)) { deckWindow.items = requestDeckItems } + .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() diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index d0d696d98..2b0500df5 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -375,7 +375,8 @@ private struct PaymentRequestDeckCard: View { // MARK: - Expanded - -/// The expanded deck: every request as a full-width card beneath the app bar, over the dimmed chat. +/// 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 { @@ -390,15 +391,10 @@ struct PaymentRequestDeckOverlay: View { var body: some View { ZStack(alignment: .top) { - Color.backgroundMain - .opacity(0.85 * state.progress) - .ignoresSafeArea() - .onTapGesture { state.collapse() } - .accessibilityLabel("Collapse requests") - .accessibilityAddTraits(.isButton) - list - if let front = items.first, !state.isSettled { + 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, @@ -407,6 +403,7 @@ struct PaymentRequestDeckOverlay: View { pillTypeSize: state.pillTypeSize, origin: origin ) + .opacity(state.isSettled ? 0 : 1) .allowsHitTesting(false) } } @@ -446,8 +443,8 @@ struct PaymentRequestDeckOverlay: View { } .padding(.horizontal, 20) .padding(.top, 8) - // The scroll view covers the dimmed chat, so the space below the cards takes the - // scrim's tap; the cards' own buttons win over it. + // 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() } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift new file mode 100644 index 000000000..cc65c32f0 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift @@ -0,0 +1,71 @@ +// +// 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 + + private var window: UIWindow? + + /// Puts the window up over the app. Presenting twice is a no-op. + func present(state: PaymentRequestDeckState, open: @escaping (PaymentRequestID) -> Void) { + let scene = UIApplication.shared.firstWindowScene + guard window == nil, let scene else { return } + let host = UIHostingController(rootView: PaymentRequestDeckWindowContent(window: self, state: state, open: open)) + 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() { + window?.isHidden = true + window = nil + } +} + +private struct PaymentRequestDeckWindowContent: View { + + let window: PaymentRequestDeckWindow + @Bindable var state: PaymentRequestDeckState + let open: (PaymentRequestID) -> Void + + 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, open: open) + .frame(width: window.listFrame.width, height: window.listFrame.height) + .padding(.leading, window.listFrame.minX) + .padding(.top, window.listFrame.minY) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .ignoresSafeArea() + } +} 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 From 925cd198f87fe3afe08ab7ebd7ccae82082aa5e8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 20:29:38 -0400 Subject: [PATCH 16/45] fix(group-requests): keep the deck expanded under request details, and close it from where the pill sits The expanded deck drops its grab handle and shows a glass close button where the pill sits. Opening a card's details now presents them from the deck's window, so the deck no longer collapses underneath and going back finds it as it was. --- .../Conversation/ConversationScreen.swift | 5 +-- .../PaymentRequest/PaymentRequestDeck.swift | 6 ---- .../PaymentRequestDeckWindow.swift | 35 ++++++++++++++++--- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 4186c2c2a..4002761b4 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -798,8 +798,9 @@ struct ConversationScreen: View { if isUp { deckWindow.items = requestDeckItems deckWindow.present(state: paymentRequestDeck) { id in - paymentRequestDeck.collapse() - paymentRequestDetails = PaymentRequestSelection(id: id) + PaymentRequestDetailsScreen(requestID: id) + .injectingEnvironment(from: sessionContainer) + .injectingEnvironment(from: container) } } else { deckWindow.dismiss() diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 2b0500df5..ee6883465 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -434,12 +434,6 @@ struct PaymentRequestDeckOverlay: View { } } - Capsule() - .fill(Color.textSecondary) - .opacity(state.isOpen ? 1 : 0) - .frame(width: 36, height: 5) - .padding(.top, 2) - .accessibilityHidden(true) } .padding(.horizontal, 20) .padding(.top, 8) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift index cc65c32f0..9e2f9d209 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift @@ -20,14 +20,20 @@ final class PaymentRequestDeckWindow { 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 the + /// deck stays expanded under them, and going back finds it as it was. + var details: PaymentRequestSelection? private var window: UIWindow? /// Puts the window up over the app. Presenting twice is a no-op. - func present(state: PaymentRequestDeckState, open: @escaping (PaymentRequestID) -> Void) { + /// `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 host = UIHostingController(rootView: PaymentRequestDeckWindowContent(window: self, state: state, open: open)) + 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`. @@ -40,6 +46,7 @@ final class PaymentRequestDeckWindow { /// Takes the window down. func dismiss() { + details = nil window?.isHidden = true window = nil } @@ -47,9 +54,9 @@ final class PaymentRequestDeckWindow { private struct PaymentRequestDeckWindowContent: View { - let window: PaymentRequestDeckWindow + @Bindable var window: PaymentRequestDeckWindow @Bindable var state: PaymentRequestDeckState - let open: (PaymentRequestID) -> Void + let details: (PaymentRequestID) -> AnyView var body: some View { ZStack(alignment: .topLeading) { @@ -60,12 +67,30 @@ private struct PaymentRequestDeckWindowContent: View { .accessibilityLabel("Collapse requests") .accessibilityAddTraits(.isButton) - PaymentRequestDeckOverlay(items: window.items, state: state, open: open) + 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) { details($0.id) } + } + + /// Sits where the 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.pillFrames[.container] { + 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) + } } } From 164b86b53fba9bd3b3574ccef92b6c1bdc41bf0a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 21:30:15 -0400 Subject: [PATCH 17/45] fix(group-requests): minimize the deck behind request details opened from it Once details finish covering the chat, the deck snaps back to the pill with no animation, so closing details shows the chat with the deck minimized and no backdrop. Deck order is unchanged. --- .../PaymentRequest/PaymentRequestDeck.swift | 18 +++++++++ .../PaymentRequestDeckWindow.swift | 40 +++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index ee6883465..57f228b6e 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -79,6 +79,24 @@ final class PaymentRequestDeckState { 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. diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift index 9e2f9d209..490712cf3 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift @@ -20,8 +20,9 @@ final class PaymentRequestDeckWindow { 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 the - /// deck stays expanded under them, and going back finds it as it was. + /// The request whose details show over the expanded deck. They open from this window so nothing + /// of the deck draws over them; once they cover the screen the deck snaps back to the pill, so + /// going back finds it minimized. var details: PaymentRequestSelection? private var window: UIWindow? @@ -76,7 +77,10 @@ private struct PaymentRequestDeckWindowContent: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .ignoresSafeArea() - .fullScreenCover(item: $window.details) { details($0.id) } + .fullScreenCover(item: $window.details, onDismiss: state.endPresentation) { + details($0.id) + .background(AppearanceReader(didAppear: state.snapToPill)) + } } /// Sits where the pill does, so it reads as the pill turned into a close. It fades in with the @@ -94,3 +98,33 @@ private struct PaymentRequestDeckWindowContent: View { } } } + +/// Calls `didAppear` once the screen it sits in has finished appearing, after any presentation +/// transition, which SwiftUI's `onAppear` doesn't wait for. +private struct AppearanceReader: UIViewControllerRepresentable { + + let didAppear: () -> Void + + func makeUIViewController(context: Context) -> Controller { + Controller(didAppear: didAppear) + } + + func updateUIViewController(_ controller: Controller, context: Context) {} + + final class Controller: UIViewController { + + let didAppear: () -> Void + + init(didAppear: @escaping () -> Void) { + self.didAppear = didAppear + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + didAppear() + } + } +} From f7fdf04b201afc88ccbb99887460aa0184175011 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 22:10:08 -0400 Subject: [PATCH 18/45] fix(group-requests): stop the pill re-measuring itself forever in the app bar Opening a chat with an open request hung at 100% CPU: the peeking cards took their width from the front card's measured size inside the same stack, so a peek could outgrow a front card the bar had just narrowed and the bar re-measured the pill every frame. The peeks now sit in a background that takes the front card's size and adds none of its own. --- .../PaymentRequest/PaymentRequestDeck.swift | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 57f228b6e..2c422b246 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -202,30 +202,36 @@ struct PaymentRequestPill: View { @Environment(\.dynamicTypeSize) private var typeSize @State private var drag: CGFloat = 0 - @State private var frontSize = CGSize(width: 131, height: 43) + @State private var frontHeight: CGFloat = 43 private static let peekStep: CGFloat = 5 private static let peekInset: CGFloat = 7 var body: some View { ZStack(alignment: .top) { - // The peeking cards, back first, each as tall as the front card and stepped in at both - // edges so only their bottoms show. - ForEach(Array(items.dropFirst().prefix(2).enumerated().reversed()), id: \.element.id) { offset, _ in - let depth = CGFloat(offset + 1) - PaymentRequestPillLayer(isBack: offset == 1) - .frame(width: max(frontSize.width - 2 * Self.peekInset * depth, 0), height: frontSize.height) - .offset(y: Self.peekStep * depth) - } if let front = items.first { PaymentRequestPillFace(item: front) { state.pillFrames[$0] = $1 } .offset(y: drag) - .opacity(1 - min(0.45, drag / max(frontSize.height, 1) * 0.45)) - .onGeometryChange(for: CGSize.self) { $0.size } action: { frontSize = $0 } + .opacity(1 - min(0.45, drag / max(frontHeight, 1) * 0.45)) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { frontHeight = $0 } // The moving copy stands in for it until it lands back here. .opacity(state.phase == .collapsed ? 1 : 0) } } + // The peeking cards, back first, each as tall as the front card and stepped in at both + // edges so only their bottoms show. A background takes the front card's size and adds none + // of its own: sized from a measurement in the same stack, a peek could outgrow a front card + // the bar had just narrowed and the bar would re-measure the pill forever. + .background(alignment: .top) { + ZStack(alignment: .top) { + ForEach(Array(items.dropFirst().prefix(2).enumerated().reversed()), id: \.element.id) { offset, _ in + let depth = CGFloat(offset + 1) + PaymentRequestPillLayer(isBack: offset == 1) + .padding(.horizontal, Self.peekInset * depth) + .offset(y: Self.peekStep * depth) + } + } + } .padding(.bottom, Self.peekStep * CGFloat(min(max(items.count - 1, 0), 2))) .onChange(of: typeSize, initial: true) { state.pillTypeSize = typeSize } .contentShape(Rectangle()) @@ -252,7 +258,7 @@ struct PaymentRequestPill: View { let finishes = items.count > 1 && PaymentRequestDeckOrder.finishesCycle( translation: value.translation.height, predictedTranslation: value.predictedEndTranslation.height, - height: frontSize.height + height: frontHeight ) withAnimation(.spring(duration: 0.3, bounce: 0.2)) { if finishes { state.order.cycle() } From 7b292cf22cdcb2db83196ec67ae16b0367f463ff Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 22:23:17 -0400 Subject: [PATCH 19/45] fix(group-requests): open sealed request messages so the chat list can preview them A request message is sealed, but ChatSeal.readable returned nil for every widget, so each one opened as unsupported and stayed ciphertext. The chat list row had nothing to preview ("You requested $1.00" never showed), and a request from another member drew as "This Message Needs a Newer Version of Flipcash" in the transcript. A sealed widget now opens when it is a payment request with an id. A sealed profile share still opens as unsupported, since only the server sends one. schemaVersion goes to 58 so stores that already hold these rows as ciphertext rebuild from the server. --- .../Models/Conversation/ChatSeal.swift | 13 ++++++++++-- .../Sources/FlipcashStore/Database.swift | 2 +- .../GroupChatSealTests.swift | 20 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift index f66ecb0af..27f0afca6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift @@ -104,7 +104,8 @@ public struct ChatSeal: @unchecked Sendable { }) } - private func seal(_ plaintext: Flipcash_Messaging_V1_Content) throws -> Flipcash_Messaging_V1_Content { + /// 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 @@ -391,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/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index d2414aeb6..bf1619905 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 = 58 /// 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) From bcd58c716762a42b9b5107a26aae3462ecae9729 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 22:29:49 -0400 Subject: [PATCH 20/45] fix(group-requests): preview a request in the chat list by its title The row now reads "<first name> sent request for <title>" or "You sent request for <title>". The title is opened with the chat's seal, so the row loads the seal alongside the request. A title that hasn't loaded or won't open drops the "for <title>" part instead of blanking the line. --- .../Controllers/ConversationController.swift | 15 +++++++------ .../Main/Tips/TipConversationsScreen.swift | 7 +++--- .../ConversationControllerTests.swift | 22 +++++++++---------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 0ef7fd512..49d02f27e 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1638,13 +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. `requestAmount` is a payment - /// request's per-person share as the viewer reads it, nil while the request isn't loaded. + /// `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 }, - requestAmount: (PaymentRequestID) -> FiatAmount? = { _ in nil }, + requestTitle: (PaymentRequestID) -> String? = { _ in nil }, currencyName: (PublicKey) -> String? ) -> String? { if isCounterpartTyping(in: conversation.id) { @@ -1709,11 +1709,12 @@ final class ConversationController { return "Shared a profile" case .widget(.paymentRequest(let id)): - // Until the request loads, or while the sender can't be named, the line stays blank. - guard let amount = requestAmount(id)?.formatted() else { return nil } - if isFromSelf { return "You requested \(amount)" } + // 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 } - return "\(senderName) requested \(amount)" + let firstName = senderName.split(separator: " ").first.map(String.init) ?? senderName + return "\(firstName) sent request\(subject)" case .widget(.unrecognized): return nil diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 171fe9ea7..252165032 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -393,10 +393,11 @@ struct TipConversationRow: View { .task(id: UnreadCountSubject(conversation: conversation, selfUserID: conversationController.selfUserID)) { await conversationController.resolveUnreadCount(for: conversation) } - // A request's preview names its amount, which only the request itself carries. + // 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) } } @@ -414,8 +415,8 @@ struct TipConversationRow: View { for: conversation, knownAuthors: sessionContainer.knownAuthors.snapshot, viewerAmount: session.displayCurrency.amount, - requestAmount: { id in - sessionContainer.paymentRequests.request(id).flatMap(PaymentRequestCopy(session.displayCurrency).perPersonAmount) + requestTitle: { id in + sessionContainer.paymentRequests.request(id).flatMap(sessionContainer.paymentRequests.text)?.title }, currencyName: { session.balance(for: $0)?.name } ) { diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 569e8765a..a97dc305b 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -1799,27 +1799,27 @@ struct ConversationControllerTests { #expect(controller.lastMessagePreview(for: conversation, knownAuthors: knownAuthors) { _ in nil } == "Alice: gm") } - @Test("a request previews as \"<sender> requested <share>\", or \"You requested <share>\"") - func requestPreviewNamesSenderAndShare() { + @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 share = FiatAmount(value: 20, currency: .usd) - let fromThem = requestConversation(from: them, members: [member(them, named: "Alice")]) + let fromThem = requestConversation(from: them, members: [member(them, named: "Alice Smith")]) let fromMe = requestConversation(from: me, members: []) - #expect(controller.lastMessagePreview(for: fromThem, requestAmount: { _ in share }) { _ in nil } == "Alice requested $20.00") - #expect(controller.lastMessagePreview(for: fromMe, requestAmount: { _ in share }) { _ in nil } == "You requested $20.00") + #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 previews as nothing until it loads, or while its sender can't be named") - func requestPreviewIsBlankWithoutAmountOrSender() { + @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) - let share = FiatAmount(value: 20, currency: .usd) - #expect(controller.lastMessagePreview(for: requestConversation(from: me, members: [])) { _ in nil } == nil) - #expect(controller.lastMessagePreview(for: requestConversation(from: UUID(), members: []), requestAmount: { _ in share }) { _ in nil } == nil) + #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 { From 8995d5e68e18da98adc5120fa48b6e6a89d149e3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 22:48:04 -0400 Subject: [PATCH 21/45] fix(group-requests): swipe the minimized deck like a stack of cards Dragging the pill down moved one reused face into the chat, past the first message, and left the pill empty; the next request only appeared after release, and the ring animated apart from its card as the face swapped contents. Each request is now its own card, keyed by id. The front card follows the finger and rubber-bands at one pill height while the card behind rises and grows into the front slot. Past the threshold the front card drops behind into the back slot; short of it, everything springs back. The pill's size still comes from the front request at rest, so the moving cards never feed back into the bar's layout. --- .../PaymentRequest/PaymentRequestDeck.swift | 92 ++++++++++++------- 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 2c422b246..4852d6194 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -192,8 +192,8 @@ struct PaymentRequestPillAppearance: ViewModifier { // MARK: - Pill - -/// The app-bar pill (concept B): the front request with up to two more peeking beneath it. Swipe -/// down cycles; tap expands. +/// The app-bar pill (concept B): the front request with up to two more peeking beneath it, as a +/// stack of cards. Swipe down cycles; tap expands. struct PaymentRequestPill: View { let items: [PaymentRequestDeckItem] @@ -201,34 +201,30 @@ struct PaymentRequestPill: View { @Bindable var state: PaymentRequestDeckState @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 - private static let peekInset: CGFloat = 7 + /// 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) { state.pillFrames[$0] = $1 } - .offset(y: drag) - .opacity(1 - min(0.45, drag / max(frontHeight, 1) * 0.45)) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { frontHeight = $0 } - // The moving copy stands in for it until it lands back here. - .opacity(state.phase == .collapsed ? 1 : 0) + .opacity(0) } } - // The peeking cards, back first, each as tall as the front card and stepped in at both - // edges so only their bottoms show. A background takes the front card's size and adds none - // of its own: sized from a measurement in the same stack, a peek could outgrow a front card - // the bar had just narrowed and the bar would re-measure the pill forever. - .background(alignment: .top) { + .overlay(alignment: .top) { ZStack(alignment: .top) { - ForEach(Array(items.dropFirst().prefix(2).enumerated().reversed()), id: \.element.id) { offset, _ in - let depth = CGFloat(offset + 1) - PaymentRequestPillLayer(isBack: offset == 1) - .padding(.horizontal, Self.peekInset * depth) - .offset(y: Self.peekStep * depth) + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + card(item, at: index) } } } @@ -243,16 +239,50 @@ struct PaymentRequestPill: View { .accessibilityAction { state.expand() } .accessibilityAction(named: "Next request") { guard items.count > 1 else { return } - state.order.cycle() + withAnimation(Self.settle) { state.order.cycle() } } } + /// `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. + 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, fillsWidth: true) + .overlay { + RoundedRectangle(cornerRadius: 18) + .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.phase != .collapsed ? 0 : 1) + .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 - let down = max(0, value.translation.height) - // One request only pulls elastically. - drag = items.count > 1 ? down : sqrt(down) * 2 + // 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( @@ -260,7 +290,9 @@ struct PaymentRequestPill: View { predictedTranslation: value.predictedEndTranslation.height, height: frontHeight ) - withAnimation(.spring(duration: 0.3, bounce: 0.2)) { + // 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 } @@ -300,6 +332,8 @@ private extension View { struct PaymentRequestPillFace: View { let item: PaymentRequestDeckItem + /// 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 body: some View { @@ -320,6 +354,7 @@ struct PaymentRequestPillFace: View { .truncationMode(.tail) .frame(maxWidth: 90, alignment: .leading) } + .frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading) .padding(.leading, 5) .padding(.trailing, 12) .padding(.vertical, 5) @@ -336,19 +371,6 @@ struct PaymentRequestPillFace: View { } } -/// A card peeking beneath the pill's front card. -private struct PaymentRequestPillLayer: View { - - let isBack: Bool - - var body: some View { - RoundedRectangle(cornerRadius: 18) - .fill(isBack ? Color.textMain.opacity(0.08) : Color.backgroundSecondary) - .background(Color.backgroundMain, in: .rect(cornerRadius: 18)) - .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.rowSeparator)) - } -} - /// 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 { From 7d54ba71368fa69017aae9225566341b594ec258 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 07:57:01 -0400 Subject: [PATCH 22/45] feat(group-requests): show the deck as a strip while the viewer owes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the viewer has an unpaid share in an open request, the deck leaves the app bar and sits under it as a full-width strip: "You owe <share> · <time left>" with a Pay button that starts the existing pay flow. Swipe down cycles as in the pill, and tap expands from the strip. - Deck order puts open requests the viewer owes on first, by nearest deadline, then other open requests, then ended ones. The creator never counts as owing. - Scrolled away from the newest messages, the strip shrinks into a green app-bar pill and grows back on return. - The deck opens itself once per owed request per device, newest first with a NEW chip, and collapses after 2.5 s or on tap. It skips this under VoiceOver or Reduce Motion. - Each collapsed face records its own frames, so a face fading out can't overwrite the one the expand morph lands on. - The pay sheet moved out of request details so the strip can open it. Spec: group-requests.md "When the viewer owes", tests 23-26. --- .../ChatScreenRepresentable.swift | 6 +- .../Conversation/ConversationScreen.swift | 94 +++++- .../PaymentRequestAutoExpand.swift | 36 +++ .../PaymentRequest/PaymentRequestCopy.swift | 20 ++ .../PaymentRequest/PaymentRequestDeck.swift | 306 +++++++++++++++--- .../PaymentRequestDeckOrder.swift | 5 + .../PaymentRequestDeckWindow.swift | 8 +- .../PaymentRequestDetailsScreen.swift | 90 ++++-- .../PaymentRequest/PaymentRequests.swift | 6 +- .../GroupPaymentRequest+Lifecycle.swift | 22 +- .../PaymentRequestSpecTests.swift | 50 ++- .../Chat/ChatScreenViewController.swift | 7 + .../FlipcashUI/Chat/ChatViewController.swift | 9 + 13 files changed, 580 insertions(+), 79 deletions(-) create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestAutoExpand.swift diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index ee2e9fecc..c9247b6eb 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -142,8 +142,10 @@ 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 when the reader leaves or comes back to the newest messages. + var onScrolledAwayChange: (Bool) -> Void = { _ in } /// The latest request to land on a message, from a message link; each new token scrolls once. var messageJump: MessageJump? = nil @@ -171,6 +173,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.barRestingDrop = BarMetrics.compactDrop screen.authorAvatars = authorAvatars screen.transcriptTopInset = transcriptTopInset + screen.onScrolledAwayChange = onScrolledAwayChange screen.onReachTop = onReachTop screen.onRetry = onRetry screen.onCashCardTap = onCashCardTap @@ -235,6 +238,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.showsGatePlaceholder = showsGatePlaceholder screen.barRestingDrop = BarMetrics.compactDrop screen.transcriptTopInset = transcriptTopInset + screen.onScrolledAwayChange = onScrolledAwayChange screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 4002761b4..2d91d2dd6 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -94,6 +94,14 @@ struct ConversationScreen: View { @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() + @State private var requestAutoExpand = PaymentRequestAutoExpand() + /// Whether the reader has scrolled away from the newest messages, which shrinks the owed strip + /// into the green app-bar pill. + @State private var isScrolledAway = false + /// 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. @@ -126,6 +134,9 @@ struct ConversationScreen: View { /// Whether the keyboard is up, which holds the update toast back. @State private var isKeyboardUp = false + @Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverOn + @Environment(\.accessibilityReduceMotion) private var reducesMotion + /// 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, @@ -480,7 +491,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 }, + onScrolledAwayChange: { away in + withAnimation(Self.deckStyleMotion) { isScrolledAway = away } + }, messageJump: messageJump ) } @@ -718,6 +733,20 @@ struct ConversationScreen: View { .onDisappear { anchorHeight = 0 } .transition(.opacity.combined(with: .move(edge: .top))) } + if requestDeckStyle == .strip { + PaymentRequestPill( + items: requestDeckItems, + style: .strip, + accessibilityLabel: requestDeckAccessibilityLabel, + state: paymentRequestDeck, + pay: payRequest + ) + .padding(.horizontal, BarMetrics.edgeInset) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { stripHeight = $0 } + .onDisappear { stripHeight = 0 } + // Shrinks toward the pill's slot at the bar's trailing edge, and grows out of it. + .transition(.scale(scale: 0.3, anchor: .topTrailing).combined(with: .opacity)) + } if let reactionToastText { ToastLabel(reactionToastText) .allowsHitTesting(false) @@ -725,6 +754,12 @@ struct ConversationScreen: View { } } .padding(.top, 8) + .animation(Self.deckStyleMotion, value: requestDeckStyle) + } + .paymentRequestPaySheet(requestPayFlow) + .task(id: owedRequestIDs) { await autoExpandForNewOwedRequests() } + .onChange(of: requestDeckStyle, initial: true) { _, style in + paymentRequestDeck.source = style } .background(Color.backgroundMain) .navigationTitle("") @@ -819,30 +854,81 @@ struct ConversationScreen: View { 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, shrunk into the + /// green app-bar pill while they read older messages, otherwise the grey pill. + private var requestDeckStyle: PaymentRequestDeckStyle { + guard requestDeckItems.contains(where: { $0.owedShare != nil }) else { return .pill } + return isScrolledAway ? .compact : .strip + } + + /// The requests the viewer owes on, which the deck opens itself for once each. + private var owedRequestIDs: [PaymentRequestID] { + 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 { - PaymentRequestPill( + let style = requestDeckStyle + return PaymentRequestPill( items: requestPillPresence.items(requestDeckItems), + style: style == .strip ? .pill : style, accessibilityLabel: requestDeckAccessibilityLabel, state: paymentRequestDeck ) + .opacity(style == .strip ? 0 : 1) + .allowsHitTesting(style != .strip) + .accessibilityHidden(style == .strip) + .animation(Self.deckStyleMotion, value: style) .modifier(PaymentRequestPillAppearance(presence: requestPillPresence)) } private var requestDeckAccessibilityLabel: String { guard let front = requestDeckItems.first else { return "" } + if 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, count: requestDeckItems.count, now: .now) } - /// The title's right margin, widened to clear the request pill while one shows. + /// The title's right margin, widened to clear the request pill while one shows in the bar. private var titleTrailingInset: CGFloat { - Self.titleTrailingInset + (requestPillPresence.isVisible ? Self.requestPillWidth : 0) + let showsPill = requestPillPresence.isVisible && requestDeckStyle != .strip + return Self.titleTrailingInset + (showsPill ? Self.requestPillWidth : 0) + } + + private func payRequest(_ id: PaymentRequestID) { + Task { await requestPayFlow.start(id, sessionContainer: sessionContainer) } + } + + /// Opens the deck by itself once the chat has settled, for owed requests this device hasn't + /// opened it for, with the nearest of them in front. + private func autoExpandForNewOwedRequests() async { + guard !owedRequestIDs.isEmpty, !isVoiceOverOn, !reducesMotion else { return } + try? await Task.sleep(for: UpdatePolicy.chatSettle) + guard !Task.isCancelled, paymentRequestDeck.phase == .collapsed, !composer.isEditing else { return } + let fresh = requestAutoExpand.claim(owedRequestIDs) + guard !fresh.isEmpty, let group = groupConversation else { return } + let deck = sessionContainer.paymentRequests.deck(for: group.id, viewer: session.userID, at: .now) + if let index = deck.firstIndex(where: { fresh.contains($0.id) }) { + paymentRequestDeck.order.bringToFront(index) + } + paymentRequestDeck.autoExpand(showing: Set(fresh), for: PaymentRequestAutoExpand.duration) } /// The fetches the transcript needs: gate token names, sender names, and avatars. diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestAutoExpand.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestAutoExpand.swift new file mode 100644 index 000000000..2bbc67d54 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestAutoExpand.swift @@ -0,0 +1,36 @@ +// +// PaymentRequestAutoExpand.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// The owed requests this device has already opened the deck for, so it opens itself once per +/// request. +@MainActor +struct PaymentRequestAutoExpand { + + /// How long the deck stays open on its own before it collapses into the strip. + static let duration: Duration = .seconds(2.5) + + private static let key = "PaymentRequestAutoExpand.shown" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// The requests among `owed` the deck hasn't opened for yet, now remembered as shown. + mutating func claim(_ owed: [PaymentRequestID]) -> [PaymentRequestID] { + var shown = Set(defaults.stringArray(forKey: Self.key) ?? []) + let fresh = owed.filter { !shown.contains($0.data.base64EncodedString()) } + guard !fresh.isEmpty else { return [] } + shown.formUnion(fresh.map { $0.data.base64EncodedString() }) + defaults.set(Array(shown), forKey: Self.key) + return fresh + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift index 835dd1068..6db68ef0b 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift @@ -93,6 +93,20 @@ struct PaymentRequestCopy { } } + /// 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 { @@ -105,4 +119,10 @@ struct PaymentRequestCopy { let summary = "\(title), \(deckAmounts(front)) paid, \(Self.statusLine(front.deadline(at: now)))" 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/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 4852d6194..abb82ad46 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -14,6 +14,10 @@ struct PaymentRequestDeckItem: Identifiable, Hashable { let request: GroupPaymentRequest let card: PaymentRequestCardContent let amounts: String + /// 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 } } @@ -40,12 +44,22 @@ final class PaymentRequestDeckState { 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 the pill and in the expanded list. - var pillFrames: [DeckPart: CGRect] = [:] + /// 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 pill's Dynamic Type size. The toolbar can lay its text out at a different size than the - /// overlay, so the moving copy draws the pill's text at this one to land on it exactly. - var pillTypeSize: DynamicTypeSize = .large + /// 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[source == .compact ? .compact : .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 + /// The requests the deck opened itself to show, each with a "NEW" chip until it collapses. + var newIDs: Set<PaymentRequestID> = [] private var generation = 0 private static let motion = Animation.spring(duration: 0.4, bounce: 0.12) @@ -70,6 +84,20 @@ final class PaymentRequestDeckState { } } + /// Opens the deck on its own with a "NEW" chip on each of `ids`, then collapses it after + /// `duration` unless a tap or a swipe moved it first. + func autoExpand(showing ids: Set<PaymentRequestID>, for duration: Duration) { + guard !isOpen else { return } + newIDs = ids + expand() + let generation = generation + Task { @MainActor in + try? await Task.sleep(for: duration) + guard generation == self.generation else { return } + self.collapse() + } + } + /// Shrinks the front card back into the pill, from wherever it is. func collapse() { guard isOpen else { return } @@ -85,6 +113,7 @@ final class PaymentRequestDeckState { guard isOpen else { return } isOpen = false isSettled = false + newIDs = [] generation += 1 var transaction = Transaction() transaction.disablesAnimations = true @@ -108,6 +137,7 @@ final class PaymentRequestDeckState { self.isSettled = true } else { self.isOverlayPresented = false + self.newIDs = [] } } } @@ -190,15 +220,88 @@ struct PaymentRequestPillAppearance: ViewModifier { } } +// MARK: - Style - + +/// Where the collapsed deck sits: the grey app-bar pill; the strip under the bar while the viewer +/// owes; or the green app-bar pill the strip shrinks into while they read older messages. +enum PaymentRequestDeckStyle: Hashable { + case pill + case compact + case strip + + /// The face `item` shows in this style: only a card the viewer owes on turns the pill green. + func face(for item: PaymentRequestDeckItem) -> PaymentRequestDeckStyle { + self == .compact && item.owedShare == nil ? .pill : self + } + + var ringDiameter: CGFloat { + switch self { + case .pill, .compact: 26 + case .strip: 32 + } + } + + var cornerRadius: CGFloat { + switch self { + case .pill, .compact: 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, .compact: .appTextCaption + case .strip: .appTextSmall + } + } + + var titleSize: CGFloat { + switch self { + case .pill, .compact: 12 + case .strip: 14 + } + } + + /// The face's title line: the green pill folds the whole owed summary into it. + func title(_ item: PaymentRequestDeckItem) -> String { + switch self { + case .pill, .strip: + item.card.title + case .compact: + item.owedShare.map { "You owe \($0) · \(item.card.title) · \(item.timeLeft)" } ?? item.card.title + } + } + + /// The face's second line: "You owe $5 · 2 days left" on the strip while owed, or + /// "<paid> of <requested>". The green pill has none. + func amounts(_ item: PaymentRequestDeckItem) -> AttributedString { + switch self { + case .pill: + return AttributedString(item.amounts) + case .compact: + return AttributedString() + case .strip: + guard let share = item.owedShare else { return AttributedString(item.amounts) } + var owe = AttributedString("You owe \(share)") + owe.foregroundColor = Color.textSuccess + return owe + AttributedString(" · \(item.timeLeft)") + } + } +} + // MARK: - Pill - -/// The app-bar pill (concept B): the front request with up to two more peeking beneath it, as a -/// stack of cards. Swipe down cycles; tap expands. +/// 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. @@ -216,7 +319,7 @@ struct PaymentRequestPill: View { // drawn over it move freely without feeding back into the bar's layout. ZStack(alignment: .top) { if let front = items.first { - PaymentRequestPillFace(item: front) { state.pillFrames[$0] = $1 } + PaymentRequestPillFace(item: front, style: style, measure: report) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { frontHeight = $0 } .opacity(0) } @@ -229,23 +332,32 @@ struct PaymentRequestPill: View { } } .padding(.bottom, Self.peekStep * CGFloat(min(max(items.count - 1, 0), 2))) - .onChange(of: typeSize, initial: true) { state.pillTypeSize = typeSize } + .onChange(of: typeSize, initial: true) { state.faceTypeSizes[style] = typeSize } + .onChange(of: style) { state.faceTypeSizes[style] = typeSize } .contentShape(Rectangle()) .onTapGesture { state.expand() } .gesture(cycleGesture) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) - .accessibilityAddTraits(.isButton) - .accessibilityAction { state.expand() } - .accessibilityAction(named: "Next request") { - guard items.count > 1 else { return } - withAnimation(Self.settle) { state.order.cycle() } + .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 @@ -253,16 +365,18 @@ struct PaymentRequestPill: View { 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, fillsWidth: true) + return PaymentRequestPillFace(item: item, style: style, fillsWidth: true, pay: pay) .overlay { - RoundedRectangle(cornerRadius: 18) + 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.phase != .collapsed ? 0 : 1) + .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)) } @@ -328,18 +442,38 @@ private extension View { } } -/// The front card of the pill: the ring, the title and "<paid> of <requested>". +/// A card in the collapsed deck: the ring, the title and "<paid> of <requested>" in the grey pill; +/// the owed summary in the green one; 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 var body: some View { + switch style.face(for: item) { + case .pill: pill + case .compact: compact + 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 + ) + .deckPart(.ring, measure) + } + + private var pill: some View { HStack(spacing: 8) { - PaymentRequestRing(fraction: item.card.fraction, isEnded: item.card.isEnded, isClosed: item.request.status == .closed) - .deckPart(.ring, measure) + ring VStack(alignment: .leading, spacing: 2) { Text(item.card.title) .font(.appTextCaption) @@ -369,6 +503,71 @@ struct PaymentRequestPillFace: View { .deckPart(.container, measure) } } + + private var compact: some View { + HStack(spacing: 8) { + ring + Text(style.title(item)) + .font(.appTextCaption) + .foregroundStyle(Color.backgroundMain) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 100, alignment: .leading) + // One line stands for both; the expanded card's amounts grow out of it. + .deckPart(.title, measure) + .deckPart(.amounts, measure) + } + .frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading) + .padding(.leading, 5) + .padding(.trailing, 12) + .padding(.vertical, 5) + .background { + RoundedRectangle(cornerRadius: 18) + .fill(Color.success) + .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) + Text(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) } + .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 @@ -376,6 +575,7 @@ struct PaymentRequestPillFace: View { private struct PaymentRequestDeckCard: View { let item: PaymentRequestDeckItem + var isNew = false var measure: DeckPartMeasure? = nil var body: some View { @@ -409,6 +609,17 @@ private struct PaymentRequestDeckCard: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .opacity(item.card.isEnded ? 0.4 : 1) + .overlay(alignment: .topTrailing) { + if isNew { + Text("NEW") + .font(.appTextCaption) + .foregroundStyle(Color.backgroundMain) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.success, in: .capsule) + .padding(14) + } + } .background { RoundedRectangle(cornerRadius: 12) .fill(Color.backgroundSecondary) @@ -447,6 +658,7 @@ struct PaymentRequestDeckOverlay: View { pill: state.pillFrames, card: state.cardFrames, pillTypeSize: state.pillTypeSize, + source: state.source.face(for: front), origin: origin ) .opacity(state.isSettled ? 0 : 1) @@ -496,7 +708,7 @@ struct PaymentRequestDeckOverlay: View { private func card(_ item: PaymentRequestDeckItem, measure: DeckPartMeasure? = nil) -> some View { Button { open(item.id) } label: { - PaymentRequestDeckCard(item: item, measure: measure) + PaymentRequestDeckCard(item: item, isNew: state.newIDs.contains(item.id), measure: measure) } .buttonStyle(.plain) } @@ -512,6 +724,8 @@ private struct PaymentRequestDeckMorph: View, Animatable { 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 { @@ -519,10 +733,9 @@ private struct PaymentRequestDeckMorph: View, Animatable { set { progress = newValue } } - private static let pillRing: CGFloat = 26 private static let cardRing: CGFloat = 36 - /// appTextMedium over appTextCaption, the title's two sizes. - private static let titleScale: CGFloat = 16 / 12 + /// 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 @@ -539,24 +752,36 @@ private struct PaymentRequestDeckMorph: View, Animatable { 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: 18 + (12 - 18) * p) + RoundedRectangle(cornerRadius: radius) .fill(Color.backgroundSecondary) + .overlay { + // The green pill's fill drains as it grows into the grey card. + if source == .compact { + RoundedRectangle(cornerRadius: radius).fill(Color.success).opacity(1 - p) + } + } .opacity(item.card.isEnded ? 0.4 + 0.6 * p : 1) - .background(Color.backgroundMain, in: .rect(cornerRadius: 18 + (12 - 18) * p)) - .overlay(RoundedRectangle(cornerRadius: 18 + (12 - 18) * p).strokeBorder(Color.rowSeparator)) + .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: Self.titleScale, p: p, outgoing: outgoing, incoming: incoming) { isCard in - Text(item.card.title) - .font(isCard ? .appTextMedium : .appTextCaption) - .foregroundStyle(Color.textMain) + shared(.title, scale: titleScale, p: p, outgoing: outgoing, incoming: incoming) { isCard in + Text(isCard ? item.card.title : source.title(item)) + .font(isCard ? .appTextMedium : source.titleFont) + .foregroundStyle(isCard || source != .compact ? Color.textMain : Color.backgroundMain) .lineLimit(isCard ? 2 : 1) } shared(.amounts, scale: Self.amountsScale, p: p, outgoing: amountsOut, incoming: amountsIn) { isCard in - Text(isCard ? item.card.progressLine : item.amounts) + Text(isCard ? AttributedString(item.card.progressLine) : source.amounts(item)) .font(isCard ? .appTextSmall : .appTextCaption) .foregroundStyle(isCard ? Color.textMain : Color.textSecondary) .lineLimit(1) @@ -586,7 +811,7 @@ private struct PaymentRequestDeckMorph: View, Animatable { 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 ? Self.pillRing : Self.cardRing + let base = p < 0.5 ? source.ringDiameter : Self.cardRing PaymentRequestRing( fraction: item.card.fraction, isEnded: item.card.isEnded, @@ -668,16 +893,23 @@ extension PaymentRequests { /// 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, at: now)).compactMap { request in + 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) + amounts: copy.deckAmounts(request), + 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 index 415c76d92..32140f630 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckOrder.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckOrder.swift @@ -34,6 +34,11 @@ struct PaymentRequestDeckOrder: Hashable { 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 { diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift index 490712cf3..eda02f288 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift @@ -83,12 +83,12 @@ private struct PaymentRequestDeckWindowContent: View { } } - /// Sits where the 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. + /// 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.pillFrames[.container] { + 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") diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index 88468db80..6e583cc5c 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -28,9 +28,7 @@ struct PaymentRequestDetailsScreen: View { @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 payment: PaymentRequestPayment? - @State private var isPreparing = false - @State private var isPaying = false + @State private var payFlow = PaymentRequestPayFlow() @State private var isUpdating = false var body: some View { @@ -52,16 +50,7 @@ struct PaymentRequestDetailsScreen: View { } } .dialog(item: $dialogItem) - .sheet(isPresented: $isPaying) { - if let payment, let creator = payment.request?.creator { - PaymentRequestPaySheet( - payment: payment, - payee: member(creator), - payeeID: creator, - amount: payAmount(payment) - ) - } - } + .paymentRequestPaySheet(payFlow) } private var screen: some View { @@ -114,7 +103,7 @@ struct PaymentRequestDetailsScreen: View { } .softScrollEdge(for: [.top, .bottom]) if let sendTitle = content.sendTitle { - CodeButton(isLoading: isPreparing, style: .filled, title: sendTitle) { + CodeButton(isLoading: payFlow.isPreparing, style: .filled, title: sendTitle) { pay() } .padding(.horizontal, 20) @@ -214,11 +203,6 @@ struct PaymentRequestDetailsScreen: View { .first { $0.userID == userID } } - private func payAmount(_ payment: PaymentRequestPayment) -> String { - guard let request = payment.request, let share = payment.share else { return "" } - return PaymentRequestCopy(session.displayCurrency).shareText(share, of: request) - } - // MARK: - Actions - private func load() async { @@ -240,13 +224,9 @@ struct PaymentRequestDetailsScreen: View { /// Refetches for the intent ID right before the swipe sheet, as the spec requires. private func pay() { Task { - isPreparing = true - defer { isPreparing = false } - let payment = self.payment ?? PaymentRequestPayment(requestID: requestID, sessionContainer: sessionContainer) - self.payment = payment - switch await payment.prepare() { + switch await payFlow.start(requestID, sessionContainer: sessionContainer) { case .ready: - isPaying = true + break case .nothingToPay, .closed, .failed: hasPaymentIntent = false } @@ -348,6 +328,66 @@ private struct PaymentRequestAvatar: View { 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) } ?? "" + ) + } + } + } +} + /// The swipe-to-send sheet for the viewer's share: the creator, the amount, the token picker. private struct PaymentRequestPaySheet: View { diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 6cea27a6f..7099ad40f 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -128,9 +128,9 @@ final class PaymentRequests { requests[id] } - /// `conversationID`'s requests the deck shows as of `now`, in deck order. - func deck(for conversationID: ConversationID, at now: Date) -> [GroupPaymentRequest] { - GroupPaymentRequest.deckOrder(requests.values.filter { $0.chatID == conversationID }, at: now) + /// `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. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift index 0995f3d5e..3f4c14488 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift @@ -123,9 +123,17 @@ extension GroupPaymentRequest { extension GroupPaymentRequest { - /// The requests a chat's deck shows as of `now`, in deck order: open requests by nearest - /// deadline, then those that ended within ``deckRetention``, most recently ended first. - public static func deckOrder(_ requests: [GroupPaymentRequest], at now: Date) -> [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)] = [] @@ -142,7 +150,13 @@ extension GroupPaymentRequest { } } - open.sort { ($0.split?.endsAt ?? .distantFuture) < ($1.split?.endsAt ?? .distantFuture) } + 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/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index 4611af309..69d70d58d 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -18,6 +18,7 @@ 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( @@ -32,7 +33,7 @@ struct PaymentRequestSpecTests { ) -> GroupPaymentRequest { let list = (0..<shares).map { index in GroupPaymentRequest.Share( - userID: index == 0 ? creator : UUID(), + userID: index == 0 ? creator : (index == 1 ? viewer : UUID()), nativeAmount: amount, settlement: index < paid ? (index == 0 ? .creator : .manual) : nil ) @@ -144,6 +145,53 @@ struct PaymentRequestSpecTests { #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: the deck opens itself for a new owed request once per device") + func autoExpandOnce() throws { + let defaults = try #require(UserDefaults(suiteName: "PaymentRequestSpecTests.autoExpand")) + defaults.removePersistentDomain(forName: "PaymentRequestSpecTests.autoExpand") + let first = request(id: 1).id + let second = request(id: 2).id + + var firstOpen = PaymentRequestAutoExpand(defaults: defaults) + #expect(firstOpen.claim([first]) == [first]) + var secondOpen = PaymentRequestAutoExpand(defaults: defaults) + #expect(secondOpen.claim([first]).isEmpty) + #expect(secondOpen.claim([first, second]) == [second]) + } + // MARK: - People - private func people(count: Int) -> PaymentRequestPeople { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 1dbbc5caf..375b506e6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -318,6 +318,13 @@ 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 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 8e6dbcc80..6a23ddf05 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -242,10 +242,14 @@ 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 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 @@ -1168,6 +1172,11 @@ public final class ChatViewController: UICollectionViewController { } 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 From 74dfffe43c231b438b3ab4c246bf940da1e263d2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 08:20:45 -0400 Subject: [PATCH 23/45] fix(group-requests): keep the chat title clear of the back button behind the strip Behind the owed strip the app-bar pill is transparent but still takes its slot, so a title sized without it ran under the back button. --- Flipcash/Core/Screens/Conversation/ConversationScreen.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 2d91d2dd6..7162f1d43 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -908,8 +908,8 @@ struct ConversationScreen: View { /// The title's right margin, widened to clear the request pill while one shows in the bar. private var titleTrailingInset: CGFloat { - let showsPill = requestPillPresence.isVisible && requestDeckStyle != .strip - return Self.titleTrailingInset + (showsPill ? Self.requestPillWidth : 0) + // 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) { From aabb8123151086e04076df532316c6e410b234d0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 08:32:46 -0400 Subject: [PATCH 24/45] fix(group-requests): shrink the owed strip into the grey pill, and drop auto-expand While scrolled away from the newest messages, the owed strip now shrinks into the normal app-bar pill instead of a green one, and grows back at the newest. The deck no longer opens itself for a new owed request, so the NEW chip and the per-device record of which requests it opened for are gone. Only the strip reads the owed accessibility label. --- .../Conversation/ConversationScreen.swift | 37 +----- .../PaymentRequestAutoExpand.swift | 36 ------ .../PaymentRequest/PaymentRequestDeck.swift | 112 +++--------------- .../PaymentRequestSpecTests.swift | 17 +-- 4 files changed, 30 insertions(+), 172 deletions(-) delete mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestAutoExpand.swift diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 7162f1d43..04d06478c 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -96,9 +96,8 @@ struct ConversationScreen: View { @State private var requestPillPresence = PaymentRequestPillPresence() /// Paying a share from the owed strip's Pay button. @State private var requestPayFlow = PaymentRequestPayFlow() - @State private var requestAutoExpand = PaymentRequestAutoExpand() /// Whether the reader has scrolled away from the newest messages, which shrinks the owed strip - /// into the green app-bar pill. + /// into the app-bar pill. @State private var isScrolledAway = false /// The owed strip's measured height, reserved at the transcript's top; 0 while hidden. @State private var stripHeight: CGFloat = 0 @@ -134,8 +133,6 @@ struct ConversationScreen: View { /// Whether the keyboard is up, which holds the update toast back. @State private var isKeyboardUp = false - @Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverOn - @Environment(\.accessibilityReduceMotion) private var reducesMotion /// This conversation's id when the viewer hosts it as a private group; the anchor is host-only. private var hostedConversationID: ConversationID? { @@ -757,7 +754,6 @@ struct ConversationScreen: View { .animation(Self.deckStyleMotion, value: requestDeckStyle) } .paymentRequestPaySheet(requestPayFlow) - .task(id: owedRequestIDs) { await autoExpandForNewOwedRequests() } .onChange(of: requestDeckStyle, initial: true) { _, style in paymentRequestDeck.source = style } @@ -863,16 +859,10 @@ struct ConversationScreen: View { 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, shrunk into the - /// green app-bar pill while they read older messages, otherwise the grey pill. + /// 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 { - guard requestDeckItems.contains(where: { $0.owedShare != nil }) else { return .pill } - return isScrolledAway ? .compact : .strip - } - - /// The requests the viewer owes on, which the deck opens itself for once each. - private var owedRequestIDs: [PaymentRequestID] { - requestDeckItems.filter { $0.owedShare != nil }.map(\.id) + .collapsed(owes: requestDeckItems.contains { $0.owedShare != nil }, isScrolledAway: isScrolledAway) } /// Kept in the bar under the strip, hidden, so the bar never rebuilds its item as the strip comes @@ -881,7 +871,7 @@ struct ConversationScreen: View { let style = requestDeckStyle return PaymentRequestPill( items: requestPillPresence.items(requestDeckItems), - style: style == .strip ? .pill : style, + style: .pill, accessibilityLabel: requestDeckAccessibilityLabel, state: paymentRequestDeck ) @@ -894,7 +884,7 @@ struct ConversationScreen: View { private var requestDeckAccessibilityLabel: String { guard let front = requestDeckItems.first else { return "" } - if let share = front.owedShare { + if requestDeckStyle == .strip, let share = front.owedShare { return PaymentRequestCopy.owedAccessibilityLabel( share: share, title: front.card.title, @@ -916,21 +906,6 @@ struct ConversationScreen: View { Task { await requestPayFlow.start(id, sessionContainer: sessionContainer) } } - /// Opens the deck by itself once the chat has settled, for owed requests this device hasn't - /// opened it for, with the nearest of them in front. - private func autoExpandForNewOwedRequests() async { - guard !owedRequestIDs.isEmpty, !isVoiceOverOn, !reducesMotion else { return } - try? await Task.sleep(for: UpdatePolicy.chatSettle) - guard !Task.isCancelled, paymentRequestDeck.phase == .collapsed, !composer.isEditing else { return } - let fresh = requestAutoExpand.claim(owedRequestIDs) - guard !fresh.isEmpty, let group = groupConversation else { return } - let deck = sessionContainer.paymentRequests.deck(for: group.id, viewer: session.userID, at: .now) - if let index = deck.firstIndex(where: { fresh.contains($0.id) }) { - paymentRequestDeck.order.bringToFront(index) - } - paymentRequestDeck.autoExpand(showing: Set(fresh), for: PaymentRequestAutoExpand.duration) - } - /// The fetches the transcript needs: gate token names, sender names, and avatars. private func loads(_ content: some View) -> some View { content diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestAutoExpand.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestAutoExpand.swift deleted file mode 100644 index 2bbc67d54..000000000 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestAutoExpand.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// PaymentRequestAutoExpand.swift -// Flipcash -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import Foundation -import FlipcashCore - -/// The owed requests this device has already opened the deck for, so it opens itself once per -/// request. -@MainActor -struct PaymentRequestAutoExpand { - - /// How long the deck stays open on its own before it collapses into the strip. - static let duration: Duration = .seconds(2.5) - - private static let key = "PaymentRequestAutoExpand.shown" - - private let defaults: UserDefaults - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - /// The requests among `owed` the deck hasn't opened for yet, now remembered as shown. - mutating func claim(_ owed: [PaymentRequestID]) -> [PaymentRequestID] { - var shown = Set(defaults.stringArray(forKey: Self.key) ?? []) - let fresh = owed.filter { !shown.contains($0.data.base64EncodedString()) } - guard !fresh.isEmpty else { return [] } - shown.formUnion(fresh.map { $0.data.base64EncodedString() }) - defaults.set(Array(shown), forKey: Self.key) - return fresh - } -} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index abb82ad46..4ac9505c2 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -50,7 +50,7 @@ final class PaymentRequestDeckState { /// 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[source == .compact ? .compact : .pill]?[.container] } + 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] = [:] @@ -58,8 +58,6 @@ final class PaymentRequestDeckState { 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 - /// The requests the deck opened itself to show, each with a "NEW" chip until it collapses. - var newIDs: Set<PaymentRequestID> = [] private var generation = 0 private static let motion = Animation.spring(duration: 0.4, bounce: 0.12) @@ -84,20 +82,6 @@ final class PaymentRequestDeckState { } } - /// Opens the deck on its own with a "NEW" chip on each of `ids`, then collapses it after - /// `duration` unless a tap or a swipe moved it first. - func autoExpand(showing ids: Set<PaymentRequestID>, for duration: Duration) { - guard !isOpen else { return } - newIDs = ids - expand() - let generation = generation - Task { @MainActor in - try? await Task.sleep(for: duration) - guard generation == self.generation else { return } - self.collapse() - } - } - /// Shrinks the front card back into the pill, from wherever it is. func collapse() { guard isOpen else { return } @@ -113,7 +97,6 @@ final class PaymentRequestDeckState { guard isOpen else { return } isOpen = false isSettled = false - newIDs = [] generation += 1 var transaction = Transaction() transaction.disablesAnimations = true @@ -137,7 +120,6 @@ final class PaymentRequestDeckState { self.isSettled = true } else { self.isOverlayPresented = false - self.newIDs = [] } } } @@ -222,28 +204,28 @@ struct PaymentRequestPillAppearance: ViewModifier { // MARK: - Style - -/// Where the collapsed deck sits: the grey app-bar pill; the strip under the bar while the viewer -/// owes; or the green app-bar pill the strip shrinks into while they read older messages. +/// 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 compact case strip - /// The face `item` shows in this style: only a card the viewer owes on turns the pill green. - func face(for item: PaymentRequestDeckItem) -> PaymentRequestDeckStyle { - self == .compact && item.owedShare == nil ? .pill : self + /// 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, .compact: 26 + case .pill: 26 case .strip: 32 } } var cornerRadius: CGFloat { switch self { - case .pill, .compact: 18 + case .pill: 18 case .strip: 14 } } @@ -251,36 +233,24 @@ enum PaymentRequestDeckStyle: Hashable { /// The title's font and its point size, which the expand morph scales from. var titleFont: Font { switch self { - case .pill, .compact: .appTextCaption + case .pill: .appTextCaption case .strip: .appTextSmall } } var titleSize: CGFloat { switch self { - case .pill, .compact: 12 + case .pill: 12 case .strip: 14 } } - /// The face's title line: the green pill folds the whole owed summary into it. - func title(_ item: PaymentRequestDeckItem) -> String { - switch self { - case .pill, .strip: - item.card.title - case .compact: - item.owedShare.map { "You owe \($0) · \(item.card.title) · \(item.timeLeft)" } ?? item.card.title - } - } - /// The face's second line: "You owe $5 · 2 days left" on the strip while owed, or - /// "<paid> of <requested>". The green pill has none. + /// "<paid> of <requested>". func amounts(_ item: PaymentRequestDeckItem) -> AttributedString { switch self { case .pill: return AttributedString(item.amounts) - case .compact: - return AttributedString() case .strip: guard let share = item.owedShare else { return AttributedString(item.amounts) } var owe = AttributedString("You owe \(share)") @@ -442,8 +412,8 @@ private extension View { } } -/// A card in the collapsed deck: the ring, the title and "<paid> of <requested>" in the grey pill; -/// the owed summary in the green one; and on the strip, the owed line with a Pay button. +/// 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 @@ -454,9 +424,8 @@ struct PaymentRequestPillFace: View { var pay: ((PaymentRequestID) -> Void)? = nil var body: some View { - switch style.face(for: item) { + switch style { case .pill: pill - case .compact: compact case .strip: strip } } @@ -504,31 +473,6 @@ struct PaymentRequestPillFace: View { } } - private var compact: some View { - HStack(spacing: 8) { - ring - Text(style.title(item)) - .font(.appTextCaption) - .foregroundStyle(Color.backgroundMain) - .lineLimit(1) - .truncationMode(.tail) - .frame(maxWidth: 100, alignment: .leading) - // One line stands for both; the expanded card's amounts grow out of it. - .deckPart(.title, measure) - .deckPart(.amounts, measure) - } - .frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading) - .padding(.leading, 5) - .padding(.trailing, 12) - .padding(.vertical, 5) - .background { - RoundedRectangle(cornerRadius: 18) - .fill(Color.success) - .background(Color.backgroundMain, in: .rect(cornerRadius: 18)) - .deckPart(.container, measure) - } - } - private var strip: some View { HStack(spacing: 12) { ring @@ -575,7 +519,6 @@ struct PaymentRequestPillFace: View { private struct PaymentRequestDeckCard: View { let item: PaymentRequestDeckItem - var isNew = false var measure: DeckPartMeasure? = nil var body: some View { @@ -609,17 +552,6 @@ private struct PaymentRequestDeckCard: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .opacity(item.card.isEnded ? 0.4 : 1) - .overlay(alignment: .topTrailing) { - if isNew { - Text("NEW") - .font(.appTextCaption) - .foregroundStyle(Color.backgroundMain) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color.success, in: .capsule) - .padding(14) - } - } .background { RoundedRectangle(cornerRadius: 12) .fill(Color.backgroundSecondary) @@ -658,7 +590,7 @@ struct PaymentRequestDeckOverlay: View { pill: state.pillFrames, card: state.cardFrames, pillTypeSize: state.pillTypeSize, - source: state.source.face(for: front), + source: state.source, origin: origin ) .opacity(state.isSettled ? 0 : 1) @@ -708,7 +640,7 @@ struct PaymentRequestDeckOverlay: View { private func card(_ item: PaymentRequestDeckItem, measure: DeckPartMeasure? = nil) -> some View { Button { open(item.id) } label: { - PaymentRequestDeckCard(item: item, isNew: state.newIDs.contains(item.id), measure: measure) + PaymentRequestDeckCard(item: item, measure: measure) } .buttonStyle(.plain) } @@ -756,12 +688,6 @@ private struct PaymentRequestDeckMorph: View, Animatable { ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: radius) .fill(Color.backgroundSecondary) - .overlay { - // The green pill's fill drains as it grows into the grey card. - if source == .compact { - RoundedRectangle(cornerRadius: radius).fill(Color.success).opacity(1 - p) - } - } .opacity(item.card.isEnded ? 0.4 + 0.6 * p : 1) .background(Color.backgroundMain, in: .rect(cornerRadius: radius)) .overlay(RoundedRectangle(cornerRadius: radius).strokeBorder(Color.rowSeparator)) @@ -775,9 +701,9 @@ private struct PaymentRequestDeckMorph: View, Animatable { Group { ring(p) shared(.title, scale: titleScale, p: p, outgoing: outgoing, incoming: incoming) { isCard in - Text(isCard ? item.card.title : source.title(item)) + Text(item.card.title) .font(isCard ? .appTextMedium : source.titleFont) - .foregroundStyle(isCard || source != .compact ? Color.textMain : Color.backgroundMain) + .foregroundStyle(Color.textMain) .lineLimit(isCard ? 2 : 1) } shared(.amounts, scale: Self.amountsScale, p: p, outgoing: amountsOut, incoming: amountsIn) { isCard in diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index 69d70d58d..31560ff7e 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -178,18 +178,11 @@ struct PaymentRequestSpecTests { #expect(!paid.isOwed(by: viewer, at: now)) } - @Test("26: the deck opens itself for a new owed request once per device") - func autoExpandOnce() throws { - let defaults = try #require(UserDefaults(suiteName: "PaymentRequestSpecTests.autoExpand")) - defaults.removePersistentDomain(forName: "PaymentRequestSpecTests.autoExpand") - let first = request(id: 1).id - let second = request(id: 2).id - - var firstOpen = PaymentRequestAutoExpand(defaults: defaults) - #expect(firstOpen.claim([first]) == [first]) - var secondOpen = PaymentRequestAutoExpand(defaults: defaults) - #expect(secondOpen.claim([first]).isEmpty) - #expect(secondOpen.claim([first, second]) == [second]) + @Test("26: viewer owes and scrolls away, so the strip shrinks into the pill and grows back") + 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) } // MARK: - People - From 8e00a067b3069672360e6bd844c7ec4860835b9e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 09:29:54 -0400 Subject: [PATCH 25/45] fix(group-requests): show Send on request details opened from the deck Details opened from the expanded deck live in the deck's own window, which doesn't receive the app's scene phase, so it read as background. The details fetch only runs while active, so the pay intent never loaded and Send never showed. The deck window now passes the app's scene phase to the details. --- Flipcash/Core/Screens/Conversation/ConversationScreen.swift | 1 + .../Screens/PaymentRequest/PaymentRequestDeckWindow.swift | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 04d06478c..d6fc13385 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -838,6 +838,7 @@ struct ConversationScreen: View { } } .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] }) { diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift index eda02f288..388622d19 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift @@ -24,6 +24,8 @@ final class PaymentRequestDeckWindow { /// of the deck draws over them; once they 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? @@ -79,6 +81,8 @@ private struct PaymentRequestDeckWindowContent: View { .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(AppearanceReader(didAppear: state.snapToPill)) } } From 10bf983240775d9bde10a0893720e8ade60e0e52 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 09:55:19 -0400 Subject: [PATCH 26/45] fix(group-requests): retry the chat seal so encrypted requests stop showing loading A request card, the deck and Request details stay in their loading state until the chat's seal opens the encrypted title. The chat and details read the seal once on open and never again, so one failed read (a slow getChat, a key not yet synced) left them loading until the app came back to the foreground. The store now reads a missing seal on every chat refresh, alongside the request pages instead of before them, and after each single-request read, so the 45 s poll retries it. Concurrent reads for one chat collapse into one. --- .../Conversation/ConversationScreen.swift | 1 - .../PaymentRequestDetailsScreen.swift | 1 - .../PaymentRequest/PaymentRequests.swift | 18 +++-- .../PaymentRequestsSealTests.swift | 72 +++++++++++++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index d6fc13385..a44058649 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -957,7 +957,6 @@ struct ConversationScreen: View { .task(id: PaymentRequestPoll(conversationID: groupConversation?.id, isActive: scenePhase == .active)) { guard let groupID = groupConversation?.id, scenePhase == .active else { return } let requests = sessionContainer.paymentRequests - await requests.loadSeal(for: groupID) while !Task.isCancelled { await requests.refreshChat(groupID) try? await Task.sleep(for: PaymentRequests.pollInterval) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index 6e583cc5c..eab722e18 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -209,7 +209,6 @@ struct PaymentRequestDetailsScreen: View { let requests = sessionContainer.paymentRequests await refresh() guard let chatID = requests.request(requestID)?.chatID else { return } - await requests.loadSeal(for: chatID) await sessionContainer.groupRosters.refresh(chatID) } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 7099ad40f..0f1f3c12c 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -55,6 +55,7 @@ final class PaymentRequests { @ObservationIgnored private let markPaidShare: MarkPaid @ObservationIgnored private let getPage: GetPage @ObservationIgnored private var inFlight: Set<PaymentRequestID> = [] + @ObservationIgnored private var sealReads: Set<ConversationID> = [] init( getChatPage: @escaping GetChatPage, @@ -96,9 +97,12 @@ final class PaymentRequests { ingest([try await markPaidShare(id, userID)]) } - /// Loads `conversationID`'s opening seal if it isn't held yet, so encrypted titles can open. + /// 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, let seal = await openingSeal(conversationID) else { return } + 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 } @@ -146,8 +150,10 @@ final class PaymentRequests { } } - /// Reads `conversationID`'s OPEN and RESOLVED first pages. A failed read keeps what was held. + /// 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) @@ -159,6 +165,7 @@ final class PaymentRequests { "error": "\(error)", ]) } + await sealed } /// Reads one page of a chat's `section` for Request Money history and merges it. Returns the page's @@ -174,13 +181,14 @@ final class PaymentRequests { return (page.requests.map(\.id), page.nextPagingToken) } - /// Reads one request and merges it. Returns the intent id the viewer pays under, `nil` when the - /// viewer has no unpaid share. Throws what the read threw. + /// 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 { diff --git a/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift new file mode 100644 index 000000000..600799af5 --- /dev/null +++ b/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift @@ -0,0 +1,72 @@ +// +// 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) + } +} From 17e7481aa65ccb631055be2d92bd020a92333d5a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 10:01:57 -0400 Subject: [PATCH 27/45] fix(group-requests): keep the expanded deck still while request details open over it Opening a back card's details snapped the deck to the pill as soon as the details finished appearing, which showed through the sheet as it slid up. The snap now happens as the details start to close, while they still cover the screen, so the deck underneath never moves on open and is already the pill on the way back. --- .../PaymentRequestDeckWindow.swift | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift index 388622d19..3a1b01b83 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeckWindow.swift @@ -21,8 +21,8 @@ final class PaymentRequestDeckWindow { /// 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; once they cover the screen the deck snaps back to the pill, so - /// going back finds it minimized. + /// 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 @@ -83,7 +83,7 @@ private struct PaymentRequestDeckWindowContent: View { details($0.id) // Details poll and fetch the pay intent only while active. .environment(\.scenePhase, window.scenePhase) - .background(AppearanceReader(didAppear: state.snapToPill)) + .background(DisappearanceReader(willDisappear: state.snapToPill)) } } @@ -103,32 +103,33 @@ private struct PaymentRequestDeckWindowContent: View { } } -/// Calls `didAppear` once the screen it sits in has finished appearing, after any presentation -/// transition, which SwiftUI's `onAppear` doesn't wait for. -private struct AppearanceReader: UIViewControllerRepresentable { +/// 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 didAppear: () -> Void + let willDisappear: () -> Void func makeUIViewController(context: Context) -> Controller { - Controller(didAppear: didAppear) + Controller(willDisappear: willDisappear) } func updateUIViewController(_ controller: Controller, context: Context) {} final class Controller: UIViewController { - let didAppear: () -> Void + let willDisappear: () -> Void - init(didAppear: @escaping () -> Void) { - self.didAppear = didAppear + 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 viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - didAppear() + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + willDisappear() } } } From 3c76f9eb302abf80cfbaff9c86adaa078c71d060 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 10:08:06 -0400 Subject: [PATCH 28/45] fix(group-requests): retry a failed request read within seconds, not at the next 45 s poll A chat card or Request details left loading by a failed section read, a failed GetPaymentRequest, or a chat key that wasn't ready waited for the next 45 s poll, and a failed single-request read outside the first pages was never read again. The store now remembers failed reads, retries them on the next chat refetch, and reports whether a chat or request is still settling; both polls then retry after 2 s, doubling up to 45 s. --- .../Conversation/ConversationScreen.swift | 10 ++- .../PaymentRequestDetailsScreen.swift | 10 ++- .../PaymentRequest/PaymentRequests.swift | 44 ++++++++++++ .../PaymentRequestsSealTests.swift | 67 +++++++++++++++++++ 4 files changed, 129 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index a44058649..a903b06f0 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -957,9 +957,17 @@ struct ConversationScreen: View { .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) - try? await Task.sleep(for: PaymentRequests.pollInterval) + // 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. diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index eab722e18..025351d29 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -43,8 +43,16 @@ struct PaymentRequestDetailsScreen: View { .task(id: PaymentRequestDetailsPoll(requestID: requestID, isActive: scenePhase == .active)) { guard scenePhase == .active else { return } await load() + var retries = 0 while !Task.isCancelled { - try? await Task.sleep(for: PaymentRequests.pollInterval) + // 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() } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 0f1f3c12c..0340feb13 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -38,6 +38,12 @@ final class PaymentRequests { /// 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> = [] @@ -56,6 +62,10 @@ final class PaymentRequests { @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, @@ -159,13 +169,34 @@ final class PaymentRequests { 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 @@ -207,12 +238,25 @@ final class PaymentRequests { 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. diff --git a/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift index 600799af5..4c09fa62d 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestsSealTests.swift @@ -69,4 +69,71 @@ struct PaymentRequestsSealTests { 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) + } } From d1c1f71be6bd4e2d5d1bdfd54816a3a9f19e50cf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 10:13:55 -0400 Subject: [PATCH 29/45] fix(group-requests): shrink the owed strip into the pill in step with the scroll The strip swapped for the pill on the jump button's at-bottom flag, so it popped instead of tracking the scroll, flapped on small scrolls around the threshold, and inserting or removing it moved the transcript's top inset by its height. The transcript now reports how far the reader sits above the newest messages on every scroll. The strip stays mounted while the viewer owes, so the inset holds, and scales, lifts and fades toward the pill's slot over the first 140 points while the pill fades in. The style flips at 65% going away and 35% coming back. --- .../ChatScreenRepresentable.swift | 8 +-- .../Conversation/ConversationScreen.swift | 28 +++++---- .../PaymentRequest/PaymentRequestDeck.swift | 62 +++++++++++++++++++ .../PaymentRequestSpecTests.swift | 30 +++++++++ .../Chat/ChatScreenViewController.swift | 7 +++ .../FlipcashUI/Chat/ChatViewController.swift | 13 +++- 6 files changed, 131 insertions(+), 17 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index c9247b6eb..3d5e03992 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -144,8 +144,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { var onMediaTap: (ChatMediaViewerRequest) -> Void = { _ in } /// Room the transcript reserves under the navigation bar for the anchors above it. var transcriptTopInset: CGFloat = 0 - /// Fired when the reader leaves or comes back to the newest messages. - var onScrolledAwayChange: (Bool) -> Void = { _ in } + /// Fired as the reader scrolls, with how far in points they sit above the newest messages. + var onDistanceFromNewestChange: (CGFloat) -> Void = { _ in } /// The latest request to land on a message, from a message link; each new token scrolls once. var messageJump: MessageJump? = nil @@ -173,7 +173,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.barRestingDrop = BarMetrics.compactDrop screen.authorAvatars = authorAvatars screen.transcriptTopInset = transcriptTopInset - screen.onScrolledAwayChange = onScrolledAwayChange + screen.onDistanceFromNewestChange = onDistanceFromNewestChange screen.onReachTop = onReachTop screen.onRetry = onRetry screen.onCashCardTap = onCashCardTap @@ -238,7 +238,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.showsGatePlaceholder = showsGatePlaceholder screen.barRestingDrop = BarMetrics.compactDrop screen.transcriptTopInset = transcriptTopInset - screen.onScrolledAwayChange = onScrolledAwayChange + screen.onDistanceFromNewestChange = onDistanceFromNewestChange screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index a903b06f0..0e14479d2 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -96,9 +96,9 @@ struct ConversationScreen: View { @State private var requestPillPresence = PaymentRequestPillPresence() /// Paying a share from the owed strip's Pay button. @State private var requestPayFlow = PaymentRequestPayFlow() - /// Whether the reader has scrolled away from the newest messages, which shrinks the owed strip - /// into the app-bar pill. - @State private var isScrolledAway = false + /// 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. @@ -490,9 +490,7 @@ struct ConversationScreen: View { onMediaTap: openMediaViewer, // Each anchor shown takes its height and the stack's 8-point gap. transcriptTopInset: [anchorHeight, stripHeight].filter { $0 > 0 }.reduce(0) { $0 + $1 + 8 }, - onScrolledAwayChange: { away in - withAnimation(Self.deckStyleMotion) { isScrolledAway = away } - }, + onDistanceFromNewestChange: { [stripCollapse] in stripCollapse.update(distanceFromNewest: $0) }, messageJump: messageJump ) } @@ -730,7 +728,9 @@ struct ConversationScreen: View { .onDisappear { anchorHeight = 0 } .transition(.opacity.combined(with: .move(edge: .top))) } - if requestDeckStyle == .strip { + // 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, @@ -738,10 +738,10 @@ struct ConversationScreen: View { state: paymentRequestDeck, pay: payRequest ) + .modifier(PaymentRequestStripCollapseEffect(collapse: stripCollapse)) .padding(.horizontal, BarMetrics.edgeInset) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { stripHeight = $0 } .onDisappear { stripHeight = 0 } - // Shrinks toward the pill's slot at the bar's trailing edge, and grows out of it. .transition(.scale(scale: 0.3, anchor: .topTrailing).combined(with: .opacity)) } if let reactionToastText { @@ -751,7 +751,7 @@ struct ConversationScreen: View { } } .padding(.top, 8) - .animation(Self.deckStyleMotion, value: requestDeckStyle) + .animation(Self.deckStyleMotion, value: viewerOwes) } .paymentRequestPaySheet(requestPayFlow) .onChange(of: requestDeckStyle, initial: true) { _, style in @@ -863,7 +863,11 @@ struct ConversationScreen: View { /// 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: requestDeckItems.contains { $0.owedShare != nil }, isScrolledAway: isScrolledAway) + .collapsed(owes: viewerOwes, isScrolledAway: stripCollapse.isScrolledAway) + } + + private var viewerOwes: Bool { + requestDeckItems.contains { $0.owedShare != nil } } /// Kept in the bar under the strip, hidden, so the bar never rebuilds its item as the strip comes @@ -876,10 +880,10 @@ struct ConversationScreen: View { accessibilityLabel: requestDeckAccessibilityLabel, state: paymentRequestDeck ) - .opacity(style == .strip ? 0 : 1) + .modifier(PaymentRequestPillRevealEffect(collapse: stripCollapse, isBehindStrip: viewerOwes)) .allowsHitTesting(style != .strip) .accessibilityHidden(style == .strip) - .animation(Self.deckStyleMotion, value: style) + .animation(Self.deckStyleMotion, value: viewerOwes) .modifier(PaymentRequestPillAppearance(presence: requestPillPresence)) } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 4ac9505c2..b691fe71e 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -202,6 +202,68 @@ struct PaymentRequestPillAppearance: ViewModifier { } } +// 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. +@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 + + /// Points scrolled away from the newest messages over which the strip shrinks into the pill. + static let distance: CGFloat = 140 + 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) { + 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 + } + } +} + +/// Shrinks the owed strip toward the pill's slot at the bar's trailing edge as `collapse` runs, +/// fading it out on the way; 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 + content + .scaleEffect(1 - 0.65 * progress, anchor: .topTrailing) + .offset(y: -Self.lift * progress) + .opacity(max(0, 1 - 1.4 * progress)) + .allowsHitTesting(progress < 0.5) + .accessibilityHidden(progress >= 0.5) + } + + /// How far the strip rises into the bar by the end of the collapse. + private static let lift: CGFloat = 36 +} + +/// Fades the app-bar pill in over the second half of `collapse` while the strip stands in for it, +/// so the two hand over in the bar's trailing slot. +struct PaymentRequestPillRevealEffect: ViewModifier { + let collapse: PaymentRequestStripCollapse + let isBehindStrip: Bool + + func body(content: Content) -> some View { + content.opacity(isBehindStrip ? min(max((collapse.progress - 0.35) / 0.65, 0), 1) : 1) + } +} + // MARK: - Style - /// Where the collapsed deck sits: the app-bar pill, or the strip under the bar while the viewer diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index 31560ff7e..7a96002d5 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -185,6 +185,36 @@ struct PaymentRequestSpecTests { #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 * 3) + #expect(collapse.progress == 1) + collapse.update(distanceFromNewest: -20) + #expect(collapse.progress == 0) + } + + @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: - People - private func people(count: Int) -> PaymentRequestPeople { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 375b506e6..8b28b732f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -325,6 +325,13 @@ public final class ChatScreenViewController: UIViewController { 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 } + } + /// 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 6a23ddf05..a136a76dc 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -245,6 +245,9 @@ public final class ChatViewController: UICollectionViewController { /// 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)? /// 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)? @@ -1167,8 +1170,13 @@ 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() { @@ -1268,6 +1276,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. From dfff01adf938008f119fd7a1aed8cc2e9558fc2b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 10:32:04 -0400 Subject: [PATCH 30/45] fix(group-requests): move the whole strip stack into the pill, and settle a half scroll Scrolling away from the newest messages faded the strip out while the grey pill faded in, so the back cards, glyph and text moved as two layers. The strip's stack now interpolates from the strip slot to the pill's frame as one piece and the pill takes over only at the end. Releasing a scroll partway through the collapse left the strip half shrunk. The chat now settles the scroll to whichever end is nearer: back to the newest messages with the strip open, or past the collapse distance with the pill pinned. --- .../ChatScreenRepresentable.swift | 4 + .../Conversation/ConversationScreen.swift | 5 ++ .../PaymentRequest/PaymentRequestDeck.swift | 84 ++++++++++++++++--- .../Chat/ChatScreenViewController.swift | 7 ++ .../FlipcashUI/Chat/ChatViewController.swift | 35 +++++++- 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 3d5e03992..05f9433d9 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -146,6 +146,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { 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 @@ -174,6 +176,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.authorAvatars = authorAvatars screen.transcriptTopInset = transcriptTopInset screen.onDistanceFromNewestChange = onDistanceFromNewestChange + screen.snapDistanceFromNewest = snapDistanceFromNewest screen.onReachTop = onReachTop screen.onRetry = onRetry screen.onCashCardTap = onCashCardTap @@ -239,6 +242,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.barRestingDrop = BarMetrics.compactDrop screen.transcriptTopInset = transcriptTopInset screen.onDistanceFromNewestChange = onDistanceFromNewestChange + screen.snapDistanceFromNewest = snapDistanceFromNewest screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 0e14479d2..246cefeea 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -491,6 +491,8 @@ struct ConversationScreen: View { // 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 ? PaymentRequestStripCollapse.distance : nil, messageJump: messageJump ) } @@ -739,6 +741,8 @@ struct ConversationScreen: View { 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 } @@ -880,6 +884,7 @@ struct ConversationScreen: View { 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) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index b691fe71e..fc6dbbf4c 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -215,8 +215,19 @@ final class PaymentRequestStripCollapse { /// band either side so a small scroll back and forth never flaps it. private(set) var isScrolledAway = 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 @@ -234,36 +245,86 @@ final class PaymentRequestStripCollapse { } } -/// Shrinks the owed strip toward the pill's slot at the bar's trailing edge as `collapse` runs, -/// fading it out on the way; it stops taking touches once it is mostly the pill's. +/// 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 - .scaleEffect(1 - 0.65 * progress, anchor: .topTrailing) - .offset(y: -Self.lift * progress) - .opacity(max(0, 1 - 1.4 * progress)) + // 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) } - /// How far the strip rises into the bar by the end of the collapse. - private static let lift: CGFloat = 36 + /// 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 } + } } -/// Fades the app-bar pill in over the second half of `collapse` while the strip stands in for it, -/// so the two hand over in the bar's trailing slot. +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.opacity(isBehindStrip ? min(max((collapse.progress - 0.35) / 0.65, 0), 1) : 1) + 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 @@ -485,6 +546,8 @@ struct PaymentRequestPillFace: View { var measure: DeckPartMeasure? = nil var pay: ((PaymentRequestID) -> Void)? = nil + @Environment(\.paymentRequestStripCollapse) private var stripCollapse + var body: some View { switch style { case .pill: pill @@ -553,6 +616,7 @@ struct PaymentRequestPillFace: View { Spacer(minLength: 0) if item.owedShare != nil { Button("Pay") { pay?(item.id) } + .opacity(max(0, 1 - 2 * stripCollapse)) .font(.appTextSmall) .foregroundStyle(Color.backgroundMain) .padding(.horizontal, 18) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 8b28b732f..6091f128c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -332,6 +332,13 @@ public final class ChatScreenViewController: UIViewController { 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 a136a76dc..de6692419 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -248,6 +248,10 @@ public final class ChatViewController: UICollectionViewController { /// 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)? @@ -1266,8 +1270,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) { From 419dd581c542f2c98cfc35490b3213da1ff23a1d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 10:45:32 -0400 Subject: [PATCH 31/45] fix(group-requests): show the payee's handle once in the pay sheet Username.handle already starts with "@", and the sheet prefixed another. --- .../Screens/PaymentRequest/PaymentRequestDetailsScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index 025351d29..fd7d57c6a 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -418,7 +418,7 @@ private struct PaymentRequestPaySheet: View { .foregroundStyle(Color.textMain) .lineLimit(1) if let handle = payee?.username?.handle { - Text("@\(handle)") + Text(handle) .font(.appTextSmall) .foregroundStyle(Color.textSecondary) } From 977cc988b05f44ba9d9d6c94b391b41bb1b3f006 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 10:48:28 -0400 Subject: [PATCH 32/45] fix(group-requests): load the group roster for the pay sheet opened from the strip --- .../PaymentRequest/PaymentRequestDetailsScreen.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index fd7d57c6a..5fb2b1c7a 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -390,6 +390,12 @@ private struct PaymentRequestPaySheetPresenter: ViewModifier { 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) + } + } } } } From de256026fa77cc4a2807cc83b5f2fefa266e0626 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 10:54:45 -0400 Subject: [PATCH 33/45] fix(group-requests): keep the owed strip pinned in the pill once it has fully collapsed Scrolling back to the newest messages no longer grows it back. A new owed request arriving while the chat is open brings the strip back, and a reopened chat starts with it out. --- .../Conversation/ConversationScreen.swift | 9 +++- .../PaymentRequest/PaymentRequestDeck.swift | 17 ++++++- .../PaymentRequestSpecTests.swift | 44 ++++++++++++++++++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 246cefeea..0c24364dd 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -492,7 +492,7 @@ struct ConversationScreen: View { 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 ? PaymentRequestStripCollapse.distance : nil, + snapDistanceFromNewest: viewerOwes && !stripCollapse.isPinned ? PaymentRequestStripCollapse.distance : nil, messageJump: messageJump ) } @@ -761,6 +761,9 @@ struct ConversationScreen: View { .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("") .toolbarTitleDisplayMode(.inline) @@ -874,6 +877,10 @@ struct ConversationScreen: View { requestDeckItems.contains { $0.owedShare != nil } } + private var owedRequestIDs: Set<PaymentRequestID> { + 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 { diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index fc6dbbf4c..79b3fd52e 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -205,7 +205,7 @@ struct PaymentRequestPillAppearance: ViewModifier { // 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. +/// from the newest messages until it has shrunk all the way, then pinned there for the visit. @MainActor @Observable final class PaymentRequestStripCollapse { @@ -214,6 +214,8 @@ final class PaymentRequestStripCollapse { /// 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. @@ -233,6 +235,7 @@ final class PaymentRequestStripCollapse { /// 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 @@ -242,6 +245,18 @@ final class PaymentRequestStripCollapse { } 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 } } diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index 7a96002d5..9be513a5b 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -178,7 +178,7 @@ struct PaymentRequestSpecTests { #expect(!paid.isOwed(by: viewer, at: now)) } - @Test("26: viewer owes and scrolls away, so the strip shrinks into the pill and grows back") + @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) @@ -193,10 +193,50 @@ struct PaymentRequestSpecTests { 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) - collapse.update(distanceFromNewest: -20) + #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") From 2c28ab03280399da7398eb34ac52be6dba416868 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 11:37:46 -0400 Subject: [PATCH 34/45] feat(group-requests): show the viewer's paid status on every deck card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paid badge on the glyph and a status line ("✓ You paid $5 · $15 of $45", "You owe $5 · …", "Your request · …") replace the bare amounts in the pill, the strip's other cards and the expanded list. The totals drop first when the line doesn't fit. --- .../Conversation/ConversationScreen.swift | 2 +- .../PaymentRequest/PaymentRequestCopy.swift | 7 +- .../PaymentRequest/PaymentRequestDeck.swift | 45 ++++--- .../PaymentRequestViewerStatus.swift | 113 ++++++++++++++++++ .../PaymentRequestSpecTests.swift | 84 +++++++++++++ 5 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestViewerStatus.swift diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 0c24364dd..1a4dc4d42 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -910,7 +910,7 @@ struct ConversationScreen: View { ) } return PaymentRequestCopy(session.displayCurrency) - .deckAccessibilityLabel(front: front.request, title: front.card.title, count: requestDeckItems.count, now: .now) + .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. diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift index 6db68ef0b..bb455f20d 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift @@ -114,9 +114,10 @@ struct PaymentRequestCopy { return requestIsOpen ? "Hasn't paid yet" : "Didn't pay" } - /// The deck's VoiceOver label: "<N> requests, <title>, <paid> of <requested> paid, <status>". - func deckAccessibilityLabel(front: GroupPaymentRequest, title: String, count: Int, now: Date) -> String { - let summary = "\(title), \(deckAmounts(front)) paid, \(Self.statusLine(front.deadline(at: now)))" + /// The deck's VoiceOver label: "<N> requests, <title>, <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 } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 79b3fd52e..e4bc2d652 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -13,7 +13,9 @@ import FlipcashCore 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". @@ -383,17 +385,14 @@ enum PaymentRequestDeckStyle: Hashable { } } - /// The face's second line: "You owe $5 · 2 days left" on the strip while owed, or - /// "<paid> of <requested>". - func amounts(_ item: PaymentRequestDeckItem) -> AttributedString { - switch self { - case .pill: - return AttributedString(item.amounts) - case .strip: - guard let share = item.owedShare else { return AttributedString(item.amounts) } - var owe = AttributedString("You owe \(share)") - owe.foregroundColor = Color.textSuccess - return owe + AttributedString(" · \(item.timeLeft)") + /// 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) } } } @@ -577,6 +576,7 @@ struct PaymentRequestPillFace: View { isClosed: item.request.status == .closed, diameter: style.ringDiameter ) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: style.ringDiameter, isEnded: item.card.isEnded) .deckPart(.ring, measure) } @@ -588,7 +588,7 @@ struct PaymentRequestPillFace: View { .font(.appTextCaption) .foregroundStyle(Color.textMain) .deckPart(.title, measure) - Text(item.amounts) + style.amounts(item) .font(.appTextCaption) .foregroundStyle(Color.textSecondary) .deckPart(.amounts, measure) @@ -621,7 +621,7 @@ struct PaymentRequestPillFace: View { .font(.appTextSmall) .foregroundStyle(Color.textMain) .deckPart(.title, measure) - Text(style.amounts(item)) + style.amounts(item) .font(.appTextCaption) .foregroundStyle(Color.textSecondary) .deckPart(.amounts, measure) @@ -666,6 +666,7 @@ private struct PaymentRequestDeckCard: 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") @@ -679,7 +680,7 @@ private struct PaymentRequestDeckCard: View { .deckPart(.title, measure) } } - Text(item.card.progressLine) + PaymentRequestViewerStatusLine(status: item.viewerStatus, amounts: item.amounts) .font(.appTextSmall) .foregroundStyle(Color.textMain) .deckPart(.amounts, measure) @@ -848,10 +849,16 @@ private struct PaymentRequestDeckMorph: View, Animatable { .lineLimit(isCard ? 2 : 1) } shared(.amounts, scale: Self.amountsScale, p: p, outgoing: amountsOut, incoming: amountsIn) { isCard in - Text(isCard ? AttributedString(item.card.progressLine) : source.amounts(item)) - .font(isCard ? .appTextSmall : .appTextCaption) - .foregroundStyle(isCard ? Color.textMain : Color.textSecondary) - .lineLimit(1) + Group { + if isCard { + PaymentRequestViewerStatusLine(status: item.viewerStatus, amounts: item.amounts) + } else { + source.amounts(item) + } + } + .font(isCard ? .appTextSmall : .appTextCaption) + .foregroundStyle(isCard ? Color.textMain : Color.textSecondary) + .lineLimit(1) } Group { extra(.label, in: box, from: cardBox) { @@ -885,6 +892,7 @@ private struct PaymentRequestDeckMorph: View, Animatable { 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) @@ -975,6 +983,7 @@ extension PaymentRequests { 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/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/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index 9be513a5b..84152cf6c 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -255,6 +255,90 @@ struct PaymentRequestSpecTests { #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 { From caeee0cbcdc5bed02b602d747450dcc5dc51cc79 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 11:44:53 -0400 Subject: [PATCH 35/45] fix(group-requests): put the viewer status in the expanded card's footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanded cards keep their paid/requested line, since it already shows the totals, and show only the viewer part ("You owe $5", "✓ You paid $5", "Your request") at the footer's trailing end, as in the design. --- .../PaymentRequest/PaymentRequestDeck.swift | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index e4bc2d652..4c730fcc0 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -680,15 +680,13 @@ private struct PaymentRequestDeckCard: View { .deckPart(.title, measure) } } - PaymentRequestViewerStatusLine(status: item.viewerStatus, amounts: item.amounts) + Text(item.card.progressLine) .font(.appTextSmall) .foregroundStyle(Color.textMain) .deckPart(.amounts, measure) PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: item.card.isEnded) .deckPart(.bar, measure) - Text(item.card.statusLine) - .font(.appTextCaption) - .foregroundStyle(Color.textSecondary) + PaymentRequestDeckCardFooter(item: item) .deckPart(.status, measure) } .frame(maxWidth: .infinity, alignment: .leading) @@ -704,6 +702,27 @@ private struct PaymentRequestDeckCard: View { } } +/// 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` @@ -851,7 +870,7 @@ private struct PaymentRequestDeckMorph: View, Animatable { shared(.amounts, scale: Self.amountsScale, p: p, outgoing: amountsOut, incoming: amountsIn) { isCard in Group { if isCard { - PaymentRequestViewerStatusLine(status: item.viewerStatus, amounts: item.amounts) + Text(item.card.progressLine) } else { source.amounts(item) } @@ -867,8 +886,8 @@ private struct PaymentRequestDeckMorph: View, Animatable { extra(.bar, in: box, from: cardBox, stretches: true) { PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: item.card.isEnded) } - extra(.status, in: box, from: cardBox) { - Text(item.card.statusLine).font(.appTextCaption).foregroundStyle(Color.textSecondary) + extra(.status, in: box, from: cardBox, stretches: true) { + PaymentRequestDeckCardFooter(item: item) } } .opacity(extras) From e073659c69c0e406ff7029bd1c60c6c25119b998 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 12:24:41 -0400 Subject: [PATCH 36/45] feat(group-requests): pick the deadline from one Time To Pay sheet Replace the six-row list and the separate Custom wheel with one sheet: a readout, preset chips from 1 Day to 4 Weeks, a five-row grid of today through today + 28 days, and a Time row for a custom day that defaults to 6:00 PM, clamped to now + 1 h through now + 28 d - 5 min. Spec tests 34-39. --- .../PaymentRequestDeadlinePicker.swift | 213 ++++++++++++++++++ .../PaymentRequest/PaymentRequestFlow.swift | 211 ++++++++++++----- .../PaymentRequestSpecTests.swift | 87 +++++++ 3 files changed, 450 insertions(+), 61 deletions(-) create mode 100644 Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift new file mode 100644 index 000000000..c3477e9a7 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift @@ -0,0 +1,213 @@ +// +// 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)) + } + + /// Whether `day` sits between today and the chosen day, so it carries the selection band. + func isInBand(_ day: Date) -> Bool { + let start = calendar.startOfDay(for: day) + return start > today && start < selectedDay + } + + /// 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 + } + } + + /// Sets the custom time from the Time row and keeps it for later days. + func setCustomDate(_ date: Date) { + guard customDate != nil else { return } + let clamped = clamped(date) + customTime = calendar.dateComponents([.hour, .minute], from: clamped) + choice = .custom(clamped) + } + + /// The times the Time row offers on the chosen custom day. + var customTimeRange: ClosedRange<Date> { + let bounds = allowedRange + guard let day = customDate.map({ calendar.startOfDay(for: $0) }), + let dayEnd = calendar.date(byAdding: DateComponents(day: 1, minute: -1), to: day) else { + return bounds + } + let lower = max(day, 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: - Readout - + + /// The readout's large line: "1 Week", or "Until Nov 5, 6:00 PM". + var readoutTitle: String { + guard let date = customDate else { return choice.rowTitle } + return "Until \(dayText(date)), \(timeText(date))" + } + + /// The readout's small line: "Closes 7 days after you send · around Mon, Nov 2", or + /// "Custom · 10 days from now". + var readoutSubtitle: String { + guard let date = customDate else { + let days = presetDays + let around = choice.endsAt(sentAt: openedAt).formatted( + Date.FormatStyle(locale: locale, calendar: calendar, timeZone: calendar.timeZone) + .weekday(.abbreviated).month(.abbreviated).day() + ) + return "Closes \(Self.days(days)) after you send · around \(around)" + } + let days = calendar.dateComponents([.day], from: today, to: calendar.startOfDay(for: date)).day ?? 0 + return days == 0 ? "Custom · Today" : "Custom · \(Self.days(days)) from now" + } + + /// 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) + .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" + } +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index 50de9fcb6..c652a6929 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -534,87 +534,176 @@ private struct PaymentRequestDeadlineSheet: View { @Binding var deadline: PaymentRequestDeadline - @State private var customDate: Date = PaymentRequestDeadline.oneWeek.endsAt(sentAt: .now) - @State private var isPickingCustom = false - @State private var customRange = PaymentRequestDeadline.customRange(openedAt: .now) + @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 { - NavigationStack { - List { - ForEach(PaymentRequestDeadline.presets, id: \.rowTitle) { preset in - row(preset.rowTitle, isChecked: preset == deadline) { - deadline = preset - dismiss() - } - } - row("Custom", isChecked: isCustom) { - customRange = PaymentRequestDeadline.customRange(openedAt: .now) - if case .custom(let date) = deadline { customDate = date } - customDate = min(max(customDate, customRange.lowerBound), customRange.upperBound) - isPickingCustom = true + VStack(spacing: 16) { + Text("Time To Pay") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .padding(.top, 24) + + VStack(spacing: 6) { + Text(picker.readoutTitle) + .font(.appDisplayCompact) + .foregroundStyle(Color.textMain) + Text(picker.readoutSubtitle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .lineLimit(1) + .minimumScaleFactor(0.7) + .accessibilityElement(children: .combine) + .onChange(of: picker.readoutTitle) { _, title in + AccessibilityNotification.Announcement(title).post() + } + + chips + + PaymentRequestDeadlineGrid(picker: picker) + + if let date = picker.customDate { + HStack { + Text("Time") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Spacer() + DatePicker( + "Time", + selection: Binding(get: { date }, set: { picker.setCustomDate($0) }), + in: picker.customTimeRange, + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.compact) + .labelsHidden() } + .padding(.leading, 16) + .padding(.trailing, 8) + .padding(.vertical, 6) + .background(Color.backgroundSecondary, in: .rect(cornerRadius: 12)) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.backgroundMain) - .navigationTitle("Time To Pay") - .navigationBarTitleDisplayMode(.inline) - .navigationDestination(isPresented: $isPickingCustom) { - customPicker + + Button { + deadline = picker.choice + dismiss() + } label: { + Text("Done") + .font(.appTextMedium) + .foregroundStyle(Color.black) + .frame(maxWidth: .infinity, minHeight: 52) + .background(Color.white, in: .capsule) } + .padding(.bottom, 8) } - .presentationDetents([.medium, .large]) + .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) + .background(Color.backgroundMain) + .presentationDetents([height > 0 ? .height(height) : .large]) + .presentationDragIndicator(.visible) } - private var isCustom: Bool { - switch deadline { - case .custom: true - case .oneDay, .threeDays, .oneWeek, .twoWeeks, .fourWeeks: false + 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) + .background(isSelected ? Color.white : Color.backgroundSecondary, in: .capsule) + } + .accessibilityAddTraits(isSelected ? .isSelected : []) + } } + .frame(maxWidth: .infinity) } +} - private var customPicker: some View { - VStack { - DatePicker( - "Until", - selection: $customDate, - in: customRange, - displayedComponents: [.date, .hourAndMinute] - ) - .datePickerStyle(.wheel) - .labelsHidden() - .padding(20) - Spacer() - } - .background(Color.backgroundMain) - .navigationTitle("Custom") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { - deadline = .custom(customDate) - dismiss() - } +/// The Time To Pay grid: five weeks from today's, with only today through today + 28 days tappable. +private struct PaymentRequestDeadlineGrid: View { + + 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) } } + .padding(12) + .background(Color.backgroundSecondary, in: .rect(cornerRadius: 16)) } - private func row(_ title: String, isChecked: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack { - Text(title) + 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 isToday = calendar.isDate(day, inSameDayAs: picker.today) + let number = calendar.component(.day, from: day) + Button { + picker.select(day: day) + } label: { + ZStack { + if picker.isInBand(day) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.white.opacity(0.1)) + } + if isSelected { + Circle().fill(Color.white).frame(width: 34, height: 34) + } else if isToday { + Circle().strokeBorder(Color.textSecondary, lineWidth: 1).frame(width: 34, height: 34) + } + Text("\(number)") .font(.appTextMedium) - .foregroundStyle(Color.textMain) - Spacer() - if isChecked { - Image(systemName: "checkmark") - .foregroundStyle(Color.textMain) + .foregroundStyle(isSelected ? Color.black : Color.textMain) + .opacity(isEnabled ? 1 : 0.3) + if number == 1 { + Text(day.formatted(.dateTime.month(.abbreviated).locale(picker.locale)).uppercased()) + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(Color.textSecondary) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, 1) } } + .frame(height: 38) .contentShape(.rect) } - .listRowBackground(Color.clear) - .accessibilityAddTraits(isChecked ? .isSelected : []) + .buttonStyle(.plain) + .disabled(!isEnabled) + .accessibilityHidden(!isEnabled) + .accessibilityLabel(picker.accessibilityDay(day)) + .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index 84152cf6c..2d352f309 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -496,3 +496,90 @@ struct PaymentRequestCreateCopyTests { #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.readoutTitle == "1 Week") + #expect(picker.readoutSubtitle == "Closes 7 days after you send · around Mon, Nov 2") + } + + @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.readoutTitle == "Until Nov 5, 6:00\u{202F}PM") + #expect(picker.readoutSubtitle == "Custom · 10 days from now") + } + + @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 hides the time 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.readoutTitle == "2 Weeks") + } + + @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)) + } +} From 6ecb2ffac2d93bee9ba6291b57a942a154792680 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 12:32:57 -0400 Subject: [PATCH 37/45] fix(group-requests): quote a payment request in the reaction alert The reaction alert added on main switches over every message widget, and this branch adds the payment request widget. Use the snippet the reply quote already shows for it. --- Flipcash/Core/Controllers/ReactionAlertController.swift | 1 + 1 file changed, 1 insertion(+) 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 } } From adb6deb2bb689fa5d7984d6c08352260366245be Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 12:43:30 -0400 Subject: [PATCH 38/45] feat(group-requests): show the deadline as a range with Starts and Closes Replace the Time To Pay readout with a Starts field ("Today, on send") and an outlined Closes field over a summary line. The grid draws today as a grey circle and the closing day as a white one, joined by a band that wraps rounded at the row edges; a deadline later today shows only the white circle. Spec tests 34, 35 and 38 updated, 40 added. --- .../PaymentRequestDeadlinePicker.swift | 61 +++++++++----- .../PaymentRequest/PaymentRequestFlow.swift | 79 ++++++++++++++----- .../PaymentRequestSpecTests.swift | 31 ++++++-- 3 files changed, 128 insertions(+), 43 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift index c3477e9a7..ea0d8b467 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift @@ -75,10 +75,25 @@ final class PaymentRequestDeadlinePicker { calendar.startOfDay(for: choice.endsAt(sentAt: openedAt)) } - /// Whether `day` sits between today and the chosen day, so it carries the selection band. - func isInBand(_ day: Date) -> Bool { - let start = calendar.startOfDay(for: day) - return start > today && start < selectedDay + /// 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. @@ -153,27 +168,21 @@ final class PaymentRequestDeadlinePicker { return minute.addingTimeInterval(60) } - // MARK: - Readout - + // MARK: - Starts / Closes - - /// The readout's large line: "1 Week", or "Until Nov 5, 6:00 PM". - var readoutTitle: String { - guard let date = customDate else { return choice.rowTitle } - return "Until \(dayText(date)), \(timeText(date))" + /// 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 readout's small line: "Closes 7 days after you send · around Mon, Nov 2", or - /// "Custom · 10 days from now". - var readoutSubtitle: String { + /// 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 { - let days = presetDays - let around = choice.endsAt(sentAt: openedAt).formatted( - Date.FormatStyle(locale: locale, calendar: calendar, timeZone: calendar.timeZone) - .weekday(.abbreviated).month(.abbreviated).day() - ) - return "Closes \(Self.days(days)) after you send · around \(around)" + 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)) from now" + return days == 0 ? "Custom · Today" : "Custom · \(Self.days(days))" } /// The Time row's value: "6:00 PM". @@ -192,7 +201,7 @@ final class PaymentRequestDeadlinePicker { private func dayText(_ date: Date) -> String { date.formatted( Date.FormatStyle(locale: locale, calendar: calendar, timeZone: calendar.timeZone) - .month(.abbreviated).day() + .weekday(.abbreviated).month(.abbreviated).day() ) } @@ -211,3 +220,15 @@ final class PaymentRequestDeadlinePicker { 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/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index c652a6929..1a695f77f 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -549,19 +549,19 @@ private struct PaymentRequestDeadlineSheet: View { .foregroundStyle(Color.textMain) .padding(.top, 24) - VStack(spacing: 6) { - Text(picker.readoutTitle) - .font(.appDisplayCompact) - .foregroundStyle(Color.textMain) - Text(picker.readoutSubtitle) + 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.7) - .accessibilityElement(children: .combine) - .onChange(of: picker.readoutTitle) { _, title in - AccessibilityNotification.Announcement(title).post() + .lineLimit(1) + .minimumScaleFactor(0.8) } chips @@ -610,6 +610,29 @@ private struct PaymentRequestDeadlineSheet: View { .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 @@ -669,20 +692,19 @@ private struct PaymentRequestDeadlineGrid: View { let calendar = picker.calendar let isEnabled = picker.isEnabled(day) let isSelected = calendar.isDate(day, inSameDayAs: picker.selectedDay) - let isToday = calendar.isDate(day, inSameDayAs: picker.today) + 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 picker.isInBand(day) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.white.opacity(0.1)) + if let band = picker.band(for: day) { + bandPiece(band) } if isSelected { - Circle().fill(Color.white).frame(width: 34, height: 34) - } else if isToday { - Circle().strokeBorder(Color.textSecondary, lineWidth: 1).frame(width: 34, height: 34) + Circle().fill(Color.white).frame(width: Self.circle, height: Self.circle) + } else if isStart { + Circle().fill(Self.startGrey).frame(width: Self.circle, height: Self.circle) } Text("\(number)") .font(.appTextMedium) @@ -705,6 +727,27 @@ private struct PaymentRequestDeadlineGrid: View { .accessibilityLabel(picker.accessibilityDay(day)) .accessibilityAddTraits(isSelected ? .isSelected : []) } + + private static let circle: CGFloat = 34 + private static let startGrey = Color(red: 0x6B / 255, green: 0x6B / 255, blue: 0x6E / 255) + + /// One cell's slice of the range band, flat where a circle caps it and rounded where the week wraps. + private func bandPiece(_ band: RangeBand) -> some View { + let radius = Self.circle / 2 + return HStack(spacing: 0) { + Color.clear.frame(maxWidth: band.fromCenter ? .infinity : 0) + UnevenRoundedRectangle( + topLeadingRadius: band.roundedLeading ? radius : 0, + bottomLeadingRadius: band.roundedLeading ? radius : 0, + bottomTrailingRadius: band.roundedTrailing ? radius : 0, + topTrailingRadius: band.roundedTrailing ? radius : 0 + ) + .fill(Color.white.opacity(0.12)) + .frame(maxWidth: .infinity) + Color.clear.frame(maxWidth: band.toCenter ? .infinity : 0) + } + .frame(height: Self.circle) + } } // MARK: - Select People - diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index 2d352f309..b79166f1a 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -530,8 +530,16 @@ struct PaymentRequestDeadlinePickerTests { #expect(picker.selectedPreset == .oneWeek) #expect(picker.selectedDay == date(11, 2)) #expect(picker.customDate == nil) - #expect(picker.readoutTitle == "1 Week") - #expect(picker.readoutSubtitle == "Closes 7 days after you send · around Mon, Nov 2") + #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") } @Test("35: tapping a day makes it custom at 6:00 PM") @@ -542,8 +550,10 @@ struct PaymentRequestDeadlinePickerTests { let custom = try #require(picker.customDate) #expect(custom == date(11, 5, 18)) #expect(picker.timeText(custom) == "6:00\u{202F}PM") - #expect(picker.readoutTitle == "Until Nov 5, 6:00\u{202F}PM") - #expect(picker.readoutSubtitle == "Custom · 10 days from now") + #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") @@ -567,7 +577,8 @@ struct PaymentRequestDeadlinePickerTests { picker.select(.twoWeeks) #expect(picker.customDate == nil) #expect(picker.selectedDay == date(11, 9)) - #expect(picker.readoutTitle == "2 Weeks") + #expect(picker.band(for: date(11, 9))?.toCenter == true) + #expect(picker.summaryLine == "2 Weeks · closes 14 days after you send") } @Test("39: the summary keeps its choice until Done, and reopening shows the current one") @@ -582,4 +593,14 @@ struct PaymentRequestDeadlinePickerTests { 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") + } } From 0b63296ee6d3fc4678625d9c79877a2a18738569 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 13:49:50 -0400 Subject: [PATCH 39/45] fix(group-requests): steady the Time To Pay sheet and animate its selection The sheet kept the system glass background, which flashed across the whole sheet on every tap; give it the app background like the other sheets. The white day circle and chip highlight now slide to the new pick and the range band grows or shrinks with it. The Time row always shows: for a preset it reads the time the preset closes if sent now, and changing it makes that day custom. --- .../PaymentRequestDeadlinePicker.swift | 26 ++++++--- .../PaymentRequest/PaymentRequestFlow.swift | 58 ++++++++++++------- .../PaymentRequestSpecTests.swift | 13 ++++- 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift index ea0d8b467..360a1ab47 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeadlinePicker.swift @@ -129,22 +129,30 @@ final class PaymentRequestDeadlinePicker { } } - /// Sets the custom time from the Time row and keeps it for later days. - func setCustomDate(_ date: Date) { - guard customDate != nil else { return } - let clamped = clamped(date) + /// 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 chosen custom day. - var customTimeRange: ClosedRange<Date> { + /// The times the Time row offers on the selected day. + var timeRange: ClosedRange<Date> { let bounds = allowedRange - guard let day = customDate.map({ calendar.startOfDay(for: $0) }), - let dayEnd = calendar.date(byAdding: DateComponents(day: 1, minute: -1), to: day) else { + guard let dayEnd = calendar.date(byAdding: DateComponents(day: 1, minute: -1), to: selectedDay) else { return bounds } - let lower = max(day, bounds.lowerBound) + let lower = max(selectedDay, bounds.lowerBound) let upper = min(dayEnd, bounds.upperBound) return lower <= upper ? lower...upper : bounds } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index 1a695f77f..17d445478 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -537,6 +537,8 @@ private struct PaymentRequestDeadlineSheet: View { @State private var picker: PaymentRequestDeadlinePicker @State private var height: CGFloat = 0 + @Namespace private var chipSelection + init(deadline: Binding<PaymentRequestDeadline>) { _deadline = deadline _picker = State(initialValue: PaymentRequestDeadlinePicker(deadline: deadline.wrappedValue)) @@ -568,26 +570,24 @@ private struct PaymentRequestDeadlineSheet: View { PaymentRequestDeadlineGrid(picker: picker) - if let date = picker.customDate { - HStack { - Text("Time") - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - Spacer() - DatePicker( - "Time", - selection: Binding(get: { date }, set: { picker.setCustomDate($0) }), - in: picker.customTimeRange, - displayedComponents: .hourAndMinute - ) - .datePickerStyle(.compact) - .labelsHidden() - } - .padding(.leading, 16) - .padding(.trailing, 8) - .padding(.vertical, 6) - .background(Color.backgroundSecondary, in: .rect(cornerRadius: 12)) + 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 { deadline = picker.choice @@ -599,13 +599,14 @@ private struct PaymentRequestDeadlineSheet: View { .frame(maxWidth: .infinity, minHeight: 52) .background(Color.white, in: .capsule) } + .buttonStyle(.plain) .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) - .background(Color.backgroundMain) + .presentationBackground(Color.backgroundMain) .presentationDetents([height > 0 ? .height(height) : .large]) .presentationDragIndicator(.visible) } @@ -648,12 +649,22 @@ private struct PaymentRequestDeadlineSheet: View { .padding(.horizontal, 6) .padding(.vertical, 8) .frame(maxWidth: .infinity) - .background(isSelected ? Color.white : Color.backgroundSecondary, in: .capsule) + .background { + ZStack { + Capsule().fill(Color.backgroundSecondary) + if isSelected { + Capsule().fill(Color.white) + .matchedGeometryEffect(id: "chip", in: chipSelection) + } + } + } } + .buttonStyle(.plain) .accessibilityAddTraits(isSelected ? .isSelected : []) } } .frame(maxWidth: .infinity) + .animation(.snappy(duration: 0.3), value: picker.selectedPreset) } } @@ -662,6 +673,8 @@ private struct PaymentRequestDeadlineGrid: View { let picker: PaymentRequestDeadlinePicker + @Namespace private var selection + private let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7) var body: some View { @@ -678,6 +691,7 @@ private struct PaymentRequestDeadlineGrid: View { } .padding(12) .background(Color.backgroundSecondary, in: .rect(cornerRadius: 16)) + .animation(.snappy(duration: 0.3), value: picker.selectedDay) } private var weekdaySymbols: [String] { @@ -700,9 +714,11 @@ private struct PaymentRequestDeadlineGrid: View { ZStack { if let band = picker.band(for: day) { bandPiece(band) + .transition(.scale(scale: 0, anchor: .leading).combined(with: .opacity)) } if isSelected { Circle().fill(Color.white).frame(width: Self.circle, height: Self.circle) + .matchedGeometryEffect(id: "selected", in: selection) } else if isStart { Circle().fill(Self.startGrey).frame(width: Self.circle, height: Self.circle) } diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index b79166f1a..de4acd164 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -570,7 +570,7 @@ struct PaymentRequestDeadlinePickerTests { #expect(picker.customDate == date(11, 23, 11, 34)) } - @Test("38: a chip after a custom day hides the time and highlights its day") + @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)) @@ -581,6 +581,17 @@ struct PaymentRequestDeadlinePickerTests { #expect(picker.summaryLine == "2 Weeks · closes 14 days after you send") } + @Test("A preset shows its closing time, and changing it makes that day custom") + func presetTime() { + let picker = picker(openedAt: date(10, 26, 11, 39)) + #expect(picker.timeRowDate == date(11, 2, 11, 39)) + picker.setTime(date(10, 26, 20, 15)) + #expect(picker.selectedPreset == nil) + #expect(picker.customDate == date(11, 2, 20, 15)) + picker.select(day: date(11, 5)) + #expect(picker.customDate == date(11, 5, 20, 15)) + } + @Test("39: the summary keeps its choice until Done, and reopening shows the current one") func dismissKeepsChoice() { let deadline = PaymentRequestDeadline.oneWeek From 65244634e3aca360743f1c44f6f29fb80db768e7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 13:59:06 -0400 Subject: [PATCH 40/45] fix(group-requests): slide one solid selection in the Time To Pay sheet The closing-day circle and the chip highlight were drawn per item and matched between them, so SwiftUI cross-faded them and they turned grey mid-move. Each is now one shape drawn under the items at the selected item's bounds, so it slides solid white. Done uses the filled button style the other request screens use. --- .../PaymentRequest/PaymentRequestFlow.swift | 69 ++++++++++++------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index 17d445478..db194e98a 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -537,8 +537,6 @@ private struct PaymentRequestDeadlineSheet: View { @State private var picker: PaymentRequestDeadlinePicker @State private var height: CGFloat = 0 - @Namespace private var chipSelection - init(deadline: Binding<PaymentRequestDeadline>) { _deadline = deadline _picker = State(initialValue: PaymentRequestDeadlinePicker(deadline: deadline.wrappedValue)) @@ -589,17 +587,11 @@ private struct PaymentRequestDeadlineSheet: View { .padding(.vertical, 6) .background(Color.backgroundSecondary, in: .rect(cornerRadius: 12)) - Button { + Button("Done") { deadline = picker.choice dismiss() - } label: { - Text("Done") - .font(.appTextMedium) - .foregroundStyle(Color.black) - .frame(maxWidth: .infinity, minHeight: 52) - .background(Color.white, in: .capsule) } - .buttonStyle(.plain) + .buttonStyle(.filled) .padding(.bottom, 8) } .padding(.horizontal, 20) @@ -649,21 +641,32 @@ private struct PaymentRequestDeadlineSheet: View { .padding(.horizontal, 6) .padding(.vertical, 8) .frame(maxWidth: .infinity) - .background { - ZStack { - Capsule().fill(Color.backgroundSecondary) - if isSelected { - Capsule().fill(Color.white) - .matchedGeometryEffect(id: "chip", in: chipSelection) - } - } - } + .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(.snappy(duration: 0.3), value: picker.selectedPreset) } } @@ -673,8 +676,6 @@ private struct PaymentRequestDeadlineGrid: View { let picker: PaymentRequestDeadlinePicker - @Namespace private var selection - private let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7) var body: some View { @@ -689,6 +690,16 @@ private struct PaymentRequestDeadlineGrid: View { cell(day) } } + .backgroundPreferenceValue(SelectionAnchors<Date>.self) { anchors in + GeometryReader { proxy in + // 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(.snappy(duration: 0.3), value: picker.selectedDay) @@ -716,10 +727,7 @@ private struct PaymentRequestDeadlineGrid: View { bandPiece(band) .transition(.scale(scale: 0, anchor: .leading).combined(with: .opacity)) } - if isSelected { - Circle().fill(Color.white).frame(width: Self.circle, height: Self.circle) - .matchedGeometryEffect(id: "selected", in: selection) - } else if isStart { + if isStart { Circle().fill(Self.startGrey).frame(width: Self.circle, height: Self.circle) } Text("\(number)") @@ -734,8 +742,10 @@ private struct PaymentRequestDeadlineGrid: View { .padding(.top, 1) } } + .frame(maxWidth: .infinity) .frame(height: 38) .contentShape(.rect) + .anchorPreference(key: SelectionAnchors<Date>.self, value: .bounds) { [calendar.startOfDay(for: day): $0] } } .buttonStyle(.plain) .disabled(!isEnabled) @@ -766,6 +776,15 @@ private struct PaymentRequestDeadlineGrid: View { } } +/// 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 { From 26069c19ad8cfe5b30a55c10450d6604c930297c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 14:04:33 -0400 Subject: [PATCH 41/45] fix(group-requests): draw the Time To Pay band as one shape per week Each day drew its own slice of the band and animated it on its own, so a change that crossed weeks showed a vertical seam where a growing slice met a finished one. Each week row now draws a single shape that grows from the start side. --- .../PaymentRequest/PaymentRequestFlow.swift | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index db194e98a..f2a30652b 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -692,6 +692,10 @@ private struct PaymentRequestDeadlineGrid: View { } .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] @@ -723,10 +727,6 @@ private struct PaymentRequestDeadlineGrid: View { picker.select(day: day) } label: { ZStack { - if let band = picker.band(for: day) { - bandPiece(band) - .transition(.scale(scale: 0, anchor: .leading).combined(with: .opacity)) - } if isStart { Circle().fill(Self.startGrey).frame(width: Self.circle, height: Self.circle) } @@ -757,22 +757,37 @@ private struct PaymentRequestDeadlineGrid: View { private static let circle: CGFloat = 34 private static let startGrey = Color(red: 0x6B / 255, green: 0x6B / 255, blue: 0x6E / 255) - /// One cell's slice of the range band, flat where a circle caps it and rounded where the week wraps. - private func bandPiece(_ band: RangeBand) -> some View { - let radius = Self.circle / 2 - return HStack(spacing: 0) { - Color.clear.frame(maxWidth: band.fromCenter ? .infinity : 0) + 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 + let roundedLeading = inRange.first?.1.roundedLeading ?? true + let roundedTrailing = inRange.last?.1.roundedTrailing ?? true UnevenRoundedRectangle( - topLeadingRadius: band.roundedLeading ? radius : 0, - bottomLeadingRadius: band.roundedLeading ? radius : 0, - bottomTrailingRadius: band.roundedTrailing ? radius : 0, - topTrailingRadius: band.roundedTrailing ? radius : 0 + topLeadingRadius: roundedLeading ? radius : 0, + bottomLeadingRadius: roundedLeading ? radius : 0, + bottomTrailingRadius: roundedTrailing ? radius : 0, + topTrailingRadius: roundedTrailing ? radius : 0 ) .fill(Color.white.opacity(0.12)) - .frame(maxWidth: .infinity) - Color.clear.frame(maxWidth: band.toCenter ? .infinity : 0) + .frame(width: max(trailing - leading, 0), height: Self.circle) + .position(x: (leading + trailing) / 2, y: rowEnd.midY) + .opacity(inRange.isEmpty ? 0 : 1) } - .frame(height: Self.circle) } } From fbcc49e3f98927e6adf098143ee92ce382eb93a9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 14:15:00 -0400 Subject: [PATCH 42/45] fix(group-requests): move the Time To Pay selection without animating under Reduce Motion Spec 3bc80ce test 44. Also covers tests 41, 42 and 45, and the Time row in tests 34 and 38. --- .../PaymentRequest/PaymentRequestFlow.swift | 7 +++-- .../PaymentRequestSpecTests.swift | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index f2a30652b..444204008 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -531,6 +531,7 @@ private struct PaymentRequestNameScreen: View { private struct PaymentRequestDeadlineSheet: View { @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion @Binding var deadline: PaymentRequestDeadline @@ -667,13 +668,15 @@ private struct PaymentRequestDeadlineSheet: View { } } } - .animation(.snappy(duration: 0.3), value: picker.selectedPreset) + .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) @@ -706,7 +709,7 @@ private struct PaymentRequestDeadlineGrid: View { } .padding(12) .background(Color.backgroundSecondary, in: .rect(cornerRadius: 16)) - .animation(.snappy(duration: 0.3), value: picker.selectedDay) + .animation(reduceMotion ? nil : .snappy(duration: 0.3), value: picker.selectedDay) } private var weekdaySymbols: [String] { diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index de4acd164..5ce65e408 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -540,6 +540,7 @@ struct PaymentRequestDeadlinePickerTests { #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") @@ -579,17 +580,37 @@ struct PaymentRequestDeadlinePickerTests { #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("A preset shows its closing time, and changing it makes that day custom") + @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)) - #expect(picker.timeRowDate == date(11, 2, 11, 39)) - picker.setTime(date(10, 26, 20, 15)) + picker.setTime(date(10, 26, 15)) #expect(picker.selectedPreset == nil) - #expect(picker.customDate == date(11, 2, 20, 15)) + #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, 20, 15)) + #expect(picker.customDate == date(11, 5, 15)) + #expect(picker.closesValue == "Thu, Nov 5, 3:00\u{202F}PM") } @Test("39: the summary keeps its choice until Done, and reopening shows the current one") From 5303de587557090cd319f1698483824f7941387f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 14:35:26 -0400 Subject: [PATCH 43/45] fix(group-requests): centre the Time To Pay month label with its day in the circle Spec 243abdb tests 46 and 47. The label sat pinned to the top of the 38 pt cell, on the circle's edge; it now stacks above the number as one centred group, and its grey follows the cell's state. --- .../PaymentRequest/PaymentRequestFlow.swift | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index 444204008..c1ec4d761 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -733,16 +733,17 @@ private struct PaymentRequestDeadlineGrid: View { if isStart { Circle().fill(Self.startGrey).frame(width: Self.circle, height: Self.circle) } - Text("\(number)") - .font(.appTextMedium) - .foregroundStyle(isSelected ? Color.black : Color.textMain) - .opacity(isEnabled ? 1 : 0.3) - if number == 1 { - Text(day.formatted(.dateTime.month(.abbreviated).locale(picker.locale)).uppercased()) - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(Color.textSecondary) - .frame(maxHeight: .infinity, alignment: .top) - .padding(.top, 1) + // 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) @@ -757,6 +758,17 @@ private struct PaymentRequestDeadlineGrid: View { .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) From cf374868a7ee0a16df24c4b73a57b8c43159947a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 14:59:44 -0400 Subject: [PATCH 44/45] fix(group-requests): keep the band's ends square under the circles while it slides The band animated its corner radius, so an end moving with the circle across a week rounded for a few frames. It is now a square-ended rectangle masked by a capsule spanning the row, so only the row's edges round. Adds spec test 48. --- .../PaymentRequest/PaymentRequestFlow.swift | 24 +++++++++---------- .../PaymentRequestSpecTests.swift | 11 +++++++++ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index c1ec4d761..2ee3d4c6d 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -790,18 +790,18 @@ private struct PaymentRequestDeadlineGrid: View { let trailing: CGFloat = inRange.last.flatMap { day, band in anchors[day].map { band.toCenter ? proxy[$0].midX : proxy[$0].maxX } } ?? rowStart.minX - let roundedLeading = inRange.first?.1.roundedLeading ?? true - let roundedTrailing = inRange.last?.1.roundedTrailing ?? true - UnevenRoundedRectangle( - topLeadingRadius: roundedLeading ? radius : 0, - bottomLeadingRadius: roundedLeading ? radius : 0, - bottomTrailingRadius: roundedTrailing ? radius : 0, - topTrailingRadius: roundedTrailing ? radius : 0 - ) - .fill(Color.white.opacity(0.12)) - .frame(width: max(trailing - leading, 0), height: Self.circle) - .position(x: (leading + trailing) / 2, y: rowEnd.midY) - .opacity(inRange.isEmpty ? 0 : 1) + // 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) } } } diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index 5ce65e408..31c63a8c9 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -613,6 +613,17 @@ struct PaymentRequestDeadlinePickerTests { #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 From a86f264130f18853b139b69f3248d500c49895af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 15:41:56 -0400 Subject: [PATCH 45/45] fix(group-requests): build on Xcode 26.3 CI's Xcode 26.3 timed out type-checking RootSheetHost's body once it gained the full-screen cover, and found 1 - 2 * stripCollapse ambiguous. The two root-sheet bindings now come from one helper, and the opacity converts the CGFloat to Double explicitly. --- Flipcash/Core/Navigation/RootSheetHost.swift | 30 +++++++++---------- .../PaymentRequest/PaymentRequestDeck.swift | 2 +- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/Flipcash/Core/Navigation/RootSheetHost.swift b/Flipcash/Core/Navigation/RootSheetHost.swift index 8eee7842d..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<AppRouter.SheetPresentation?> { + 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,25 +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.flatMap { $0.coversFullScreen ? nil : $0 } }, - 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: Binding( - get: { router.rootSheet.flatMap { $0.coversFullScreen ? $0 : nil } }, - set: { newValue in - if newValue == nil { - router.dismissSheet() - } - } - ), onDismiss: router.sheetDidFinishDismissing) { sheet in + .fullScreenCover(item: presentedRootSheet(fullScreen: true), onDismiss: router.sheetDidFinishDismissing) { sheet in RoutedSheet(sheet: sheet) .appRouterNestedSheet() } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 4c730fcc0..475c899c9 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -631,7 +631,7 @@ struct PaymentRequestPillFace: View { Spacer(minLength: 0) if item.owedShare != nil { Button("Pay") { pay?(item.id) } - .opacity(max(0, 1 - 2 * stripCollapse)) + .opacity(max(0, 1 - 2 * Double(stripCollapse))) .font(.appTextSmall) .foregroundStyle(Color.backgroundMain) .padding(.horizontal, 18)