From b473bc1634cbd16b4f77c9c37c1b80cdc04ed4ac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 07:12:50 -0400 Subject: [PATCH 1/5] feat(requests): apply live payment request updates from the chat stream Updates apply by version against the held copy: created merges like a fetched copy, a batch at L+1 applies in order, a gap or an update the copy can't apply refetches the request. The polls stay as reconciliation. --- .../Controllers/ConversationController.swift | 13 +- .../PaymentRequest/PaymentRequests.swift | 47 +++- .../Core/Session/SessionAuthenticator.swift | 1 + .../Conversation/ConversationStore.swift | 5 +- .../GroupPaymentRequest+Lifecycle.swift | 61 +++++ .../PaymentRequestsLiveUpdateTests.swift | 209 ++++++++++++++++++ 6 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 899034cfe..cf5094065 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -418,6 +418,9 @@ final class ConversationController { @ObservationIgnored var hostedLobbies: HostedLobbies? /// Keeps the private-group rosters the user has opened live. Set by the session after init. @ObservationIgnored var groupRosters: GroupRosters? + /// The session's payment request store, which live request updates apply to. Weak: the store reads + /// this controller for seals. Set by the session after init. + @ObservationIgnored weak var paymentRequests: PaymentRequests? /// Keeps a photo send running for the time iOS allows after the app leaves the screen. private let backgroundTasks: any BackgroundTaskAsserting @@ -576,6 +579,7 @@ final class ConversationController { self.mentionSearch?.apply(event) self.hostedLobbies?.apply(event) self.groupRosters?.apply(event) + self.applyPaymentRequests(event) if case .needsCatchUp(let conversationID, _) = gap { self.scheduleGapCatchUp(conversationID) } @@ -879,6 +883,13 @@ final class ConversationController { typing.apply(notifications, in: conversationID) } + private func applyPaymentRequests(_ event: ConversationStreamEvent) { + guard case .paymentRequestsChanged(_, let updates) = event, let paymentRequests else { return } + let stale = paymentRequests.apply(updates) + guard !stale.isEmpty else { return } + Task { await paymentRequests.refetch(stale) } + } + private func applyReactions(_ event: ConversationStreamEvent) { guard case .reactionsChanged(let conversationID, let updates) = event else { return } reactions.apply(updates, in: conversationID) @@ -1523,7 +1534,7 @@ final class ConversationController { // edited rules rather than the stale ones. persistConversation(conversationID) case .paymentRequestsChanged: - // TODO: apply to payment request state once a consumer holds it; nothing to persist yet. + // Request state is session-only and refetched; nothing to persist. break case .descriptionChanged: // Description isn't persisted, so there is nothing to write. Fold this into the diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 0340feb13..6db6dea94 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -13,9 +13,11 @@ 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. +/// A copy arrives from a fetch (a chat's sections, one request, or the response to the viewer's own +/// create, close or mark-paid) or from a live chat-stream update. Fetched copies go through +/// ``ingest(_:)``, which keeps the greater version and, at an equal version, EXPIRED over OPEN; live +/// updates go through ``apply(_:)``, which follows the same version rule. Updates are best effort, so +/// the polls stay as reconciliation. @MainActor @Observable final class PaymentRequests { @@ -160,6 +162,45 @@ final class PaymentRequests { } } + /// Applies live updates from the chat stream to the copies held, and returns the requests that need + /// a refetch (see ``refetch(_:)``). + /// + /// `.created` merges like a fetched copy. `.updates` apply by version against the held copy; with no + /// held copy they are dropped, since the request is fetched when shown. + @discardableResult + func apply(_ updates: [DecodedPaymentRequestUpdate]) -> [PaymentRequestID] { + var refetch: [PaymentRequestID] = [] + for update in updates { + switch update.change { + case .created(let request): + ingest([request]) + case .updates(let version, let changes): + guard let held = requests[update.id] else { continue } + switch held.applying(version: version, updates: changes) { + case .ignored: + break + case .applied(let applied): + requests[update.id] = applied + case .refetch: + if !refetch.contains(update.id) { refetch.append(update.id) } + } + } + } + return refetch + } + + /// Reads each of `ids` to reconcile a live update that couldn't be applied. A failed read keeps the + /// held copy; the next poll retries. + func refetch(_ ids: [PaymentRequestID]) async { + for id in ids { + do { + try await refresh(id) + } catch { + logger.warning("Failed to refetch a payment request after a live update", metadata: ["error": "\(error)"]) + } + } + } + /// 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 { diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index f53429e13..585a8e8e0 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -1032,6 +1032,7 @@ final class SessionContainer { ) } ) + conversationController.paymentRequests = self.paymentRequests self.rosterSearch = mentionSearch // The blocklist drives which conversations the feed hides: it supplies the diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index bede89f01..11706d0df 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -397,9 +397,8 @@ public struct ConversationStore: Sendable { applyRulesChanged(rules, in: conversationID) return .none case .paymentRequestsChanged: - // TODO: apply to payment request state (created: keep greater version; updates at V vs - // local L: V<=L ignore, V==L+1 apply in order then set version, V>L+1 or an unknown - // update refetch). No consumer holds request state in the conversation store yet. + // Request state lives in the app's `PaymentRequests` store, not here; the controller + // hands the event to it. return .none } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift index 3f4c14488..5acd2e8f6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift @@ -82,6 +82,67 @@ extension GroupPaymentRequest { } } +// MARK: - Live updates - + +extension GroupPaymentRequest { + + /// What a batch of live updates did to a held copy. + public enum UpdateOutcome: Hashable, Sendable { + /// The batch is at or below the held version; the copy is unchanged. + case ignored + /// The batch applied; this is the new copy, at the batch's version. + case applied(GroupPaymentRequest) + /// The batch can't be trusted against this copy (a missed version, or an update it can't apply); + /// nothing was applied and the request needs a refetch. + case refetch + } + + /// Applies one `updates` batch at `version` to this copy, by `PaymentRequestUpdate`'s rules. + /// + /// V <= L is ignored; V == L + 1 applies every update in order and sets the version to V; V > L + 1 + /// asks for a refetch. A batch holding an update this copy can't apply (an unknown kind, a share + /// without a user or that the copy doesn't have, a status other than COMPLETED or CLOSED) applies + /// none of it and asks for a refetch. + public func applying(version: UInt64, updates: [DecodedPaymentRequestUpdate.Change.Update]) -> UpdateOutcome { + guard version > self.version else { return .ignored } + guard version == self.version + 1, let split else { return .refetch } + + var shares = split.shares + var status = self.status + var closedAt = self.closedAt + for update in updates { + switch update { + case .sharePaid(let share): + guard let userID = share.userID, let index = shares.firstIndex(where: { $0.userID == userID }) else { + return .refetch + } + shares[index] = share + case .statusChanged(let newStatus, let newClosedAt): + switch newStatus { + case .completed, .closed: + status = newStatus + closedAt = newClosedAt + case .open, .expired, .unknown: + return .refetch + } + case .unknown: + return .refetch + } + } + + return .applied(GroupPaymentRequest( + id: id, + creator: creator, + createdAt: createdAt, + status: status, + closedAt: closedAt, + version: version, + chatID: chatID, + split: Split(text: split.text, currency: split.currency, endsAt: split.endsAt, shares: shares) + )) + } +} + // MARK: - Deadline - extension GroupPaymentRequest { diff --git a/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift new file mode 100644 index 000000000..e2dfaae10 --- /dev/null +++ b/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift @@ -0,0 +1,209 @@ +// +// PaymentRequestsLiveUpdateTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("PaymentRequests live updates") +struct PaymentRequestsLiveUpdateTests { + + private let chat = ConversationID.test(1) + private let creator = UUID() + private let payer = UUID() + private let other = UUID() + private let id = PaymentRequestID(data: Data([1])) + private let closedDate = Date(timeIntervalSince1970: 1_800_000_000) + + private func request( + version: UInt64 = 1, + status: GroupPaymentRequest.Status = .open, + paid: Bool = false + ) -> GroupPaymentRequest { + GroupPaymentRequest( + id: id, + creator: creator, + createdAt: closedDate.addingTimeInterval(-60), + status: status, + closedAt: nil, + version: version, + chatID: chat, + split: .init( + text: .plaintext(title: "Dinner", description: ""), + currency: "usd", + endsAt: closedDate.addingTimeInterval(86_400), + shares: [ + .init(userID: creator, nativeAmount: 10, settlement: .creator), + .init(userID: payer, nativeAmount: 10, settlement: paid ? .manual : nil), + .init(userID: other, nativeAmount: 10), + ] + ) + ) + } + + private func paidShare(_ user: UUID) -> GroupPaymentRequest.Share { + .init(userID: user, nativeAmount: 10, paidAt: closedDate, settlement: .intent(Data([9]))) + } + + private func updates(_ version: UInt64, _ list: [DecodedPaymentRequestUpdate.Change.Update]) -> DecodedPaymentRequestUpdate { + .init(id: id, change: .updates(version: version, list)) + } + + private func store(holding held: GroupPaymentRequest? = nil) -> PaymentRequests { + let store = PaymentRequests(getChatPage: { _, _, _ in [] }, getRequest: { _ in throw ErrorGetPaymentRequest.unknown }) + if let held { store.ingest([held]) } + return store + } + + // MARK: created + + @Test("created with a greater version replaces the held copy") + func createdGreater() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([.init(id: id, change: .created(request(version: 3, status: .closed)))]) + #expect(refetch.isEmpty) + #expect(store.request(id)?.version == 3) + #expect(store.request(id)?.status == .closed) + } + + @Test("created with a lesser version keeps the held copy") + func createdLesser() { + let store = store(holding: request(version: 3, status: .closed)) + store.apply([.init(id: id, change: .created(request(version: 1)))]) + #expect(store.request(id)?.version == 3) + } + + @Test("created at an equal version keeps EXPIRED over OPEN") + func createdEqualExpired() { + let store = store(holding: request(version: 2, status: .expired)) + store.apply([.init(id: id, change: .created(request(version: 2, status: .open)))]) + #expect(store.request(id)?.status == .expired) + } + + @Test("created with no held copy stores it") + func createdNoCopy() { + let store = store() + store.apply([.init(id: id, change: .created(request(version: 1)))]) + #expect(store.request(id) != nil) + } + + // MARK: updates + + @Test("V <= L is ignored, at equal and lesser versions") + func staleIgnored() { + let store = store(holding: request(version: 3)) + let refetch = store.apply([ + updates(3, [.sharePaid(paidShare(payer))]), + updates(2, [.sharePaid(paidShare(payer))]), + ]) + #expect(refetch.isEmpty) + #expect(store.request(id)?.version == 3) + #expect(store.request(id)?.share(for: payer)?.isPaid == false) + } + + @Test("V == L + 1 applies every change in order and sets the version") + func nextVersionApplies() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(2, [ + .sharePaid(paidShare(payer)), + .sharePaid(paidShare(other)), + .statusChanged(.completed, closedAt: closedDate), + ])]) + #expect(refetch.isEmpty) + let applied = store.request(id) + #expect(applied?.version == 2) + #expect(applied?.share(for: payer)?.settlement == .intent(Data([9]))) + #expect(applied?.share(for: other)?.isPaid == true) + #expect(applied?.status == .completed) + #expect(applied?.closedAt == closedDate) + } + + @Test("sharePaid replaces the share with the same user and leaves the others") + func sharePaidReplaces() { + let store = store(holding: request(version: 1)) + store.apply([updates(2, [.sharePaid(paidShare(payer))])]) + let applied = store.request(id) + #expect(applied?.share(for: payer)?.paidAt == closedDate) + #expect(applied?.share(for: other)?.isPaid == false) + #expect(applied?.split?.shares.count == 3) + #expect(applied?.status == .open) + } + + @Test("a close sets CLOSED and closedAt") + func closeApplies() { + let store = store(holding: request(version: 1)) + store.apply([updates(2, [.statusChanged(.closed, closedAt: closedDate)])]) + #expect(store.request(id)?.status == .closed) + #expect(store.request(id)?.closedAt == closedDate) + } + + @Test("V > L + 1 refetches and leaves the copy as held") + func gapRefetches() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(3, [.sharePaid(paidShare(payer))])]) + #expect(refetch == [id]) + #expect(store.request(id)?.version == 1) + #expect(store.request(id)?.share(for: payer)?.isPaid == false) + } + + @Test("an unknown update applies none of the batch and refetches") + func unknownRefetches() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(2, [.sharePaid(paidShare(payer)), .unknown])]) + #expect(refetch == [id]) + #expect(store.request(id)?.version == 1) + #expect(store.request(id)?.share(for: payer)?.isPaid == false) + } + + @Test("a share the copy does not have refetches without applying the batch") + func unknownShareRefetches() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(2, [.sharePaid(paidShare(UUID()))])]) + #expect(refetch == [id]) + #expect(store.request(id)?.version == 1) + } + + @Test("a status other than COMPLETED or CLOSED refetches") + func unsupportedStatusRefetches() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(2, [.statusChanged(.expired, closedAt: closedDate)])]) + #expect(refetch == [id]) + #expect(store.request(id)?.status == .open) + } + + @Test("updates with no held copy are ignored and nothing is fetched") + func noCopyIgnored() { + let store = store() + let refetch = store.apply([updates(2, [.sharePaid(paidShare(payer))])]) + #expect(refetch.isEmpty) + #expect(store.request(id) == nil) + } + + @Test("the same batch twice, or out of order, converges") + func duplicateAndOutOfOrder() { + let store = store(holding: request(version: 1)) + let first = updates(2, [.sharePaid(paidShare(payer))]) + let second = updates(3, [.statusChanged(.closed, closedAt: closedDate)]) + // v3 first is a gap; then v2 applies; then v3 applies; then v2 again is stale. + #expect(store.apply([second]) == [id]) + #expect(store.apply([first, second, first]).isEmpty) + #expect(store.request(id)?.version == 3) + #expect(store.request(id)?.status == .closed) + #expect(store.request(id)?.share(for: payer)?.isPaid == true) + } + + @Test("a refetch reads the request and merges the fetched copy") + func refetchMerges() async { + let fetched = request(version: 5, status: .closed) + let store = PaymentRequests(getChatPage: { _, _, _ in [] }, getRequest: { _ in (fetched, nil) }) + store.ingest([request(version: 1)]) + await store.refetch(store.apply([updates(4, [.unknown])])) + #expect(store.request(id)?.version == 5) + } +} From e93256427f16c3e971041a820097e7d0c4c08577 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 07:12:51 -0400 Subject: [PATCH 2/5] feat(activity): persist the request a payment paid Stores Activity.requestedPayment in a joined requestedPayment table, the way boostedReaction is, so it survives a read from the database. Bumps Database.schemaVersion to 61. --- .../FlipcashCore/Models/Activity.swift | 4 +- .../FlipcashStore/Database+Activities.swift | 46 ++++++++++++- .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 26 ++++++++ .../Database+RequestedPaymentTests.swift | 64 +++++++++++++++++++ 5 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 FlipcashTests/Database/Database+RequestedPaymentTests.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift index 0c5058a32..5c37ba122 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift @@ -31,8 +31,8 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { /// The reaction this payment boosted, when it was a boost. public let boostedReaction: BoostedReaction? - /// The payment request this payment paid a share of, when it was one. Not persisted by the - /// activity store yet, so it is `nil` on activities read back from the database. + /// The payment request this payment paid a share of, when it was one. A snapshot from payment + /// time: the request may since have closed, or the viewer may no longer be able to read it. public let requestedPayment: RequestedPayment? public var cancellableCashLinkMetadata: CashLinkMetadata? { diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift index 6cd378c0a..8b4e2ce76 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift @@ -100,13 +100,17 @@ nonisolated extension Database { b.conversationID AS boostConversationID, b.messageID AS boostMessageID, - b.emoji AS boostEmoji + b.emoji AS boostEmoji, + + r.conversationID AS requestConversationID, + r.paymentRequestID AS requestPaymentRequestID FROM activity a LEFT JOIN cashLinkMetadata c ON c.id = a.id LEFT JOIN swapMetadata s ON s.id = a.id LEFT JOIN boostedReaction b ON b.id = a.id + LEFT JOIN requestedPayment r ON r.id = a.id WHERE a.mint = ? @@ -152,13 +156,17 @@ nonisolated extension Database { b.conversationID AS boostConversationID, b.messageID AS boostMessageID, - b.emoji AS boostEmoji + b.emoji AS boostEmoji, + + r.conversationID AS requestConversationID, + r.paymentRequestID AS requestPaymentRequestID FROM activity a LEFT JOIN cashLinkMetadata c ON c.id = a.id LEFT JOIN swapMetadata s ON s.id = a.id LEFT JOIN boostedReaction b ON b.id = a.id + LEFT JOIN requestedPayment r ON r.id = a.id ORDER BY a.date DESC LIMIT ?; @@ -199,7 +207,24 @@ nonisolated extension Database { date: row[a.date], metadata: metadata(for: kind, row: row), counterparty: Activity.Counterparty(userID: row[a.counterpartyUserID], phone: row[a.counterpartyPhone]), - boostedReaction: boostedReaction(from: row) + boostedReaction: boostedReaction(from: row), + requestedPayment: requestedPayment(from: row) + ) + } + + /// Reads the joined `requestedPayment` columns, aliased in the SELECT. Both are NULL for a payment + /// that paid no request. + private func requestedPayment(from row: RowIterator.Element) -> Activity.RequestedPayment? { + guard + let conversationID = row[Expression("requestConversationID")], + let paymentRequestID = row[Expression("requestPaymentRequestID")] + else { + return nil + } + + return Activity.RequestedPayment( + conversationID: ConversationID(data: conversationID), + paymentRequestID: PaymentRequestID(data: paymentRequestID) ) } @@ -336,6 +361,21 @@ nonisolated extension Database { } else { try writer.run(boostTable.table.filter(boostTable.id == activity.id).delete()) } + + let requestTable = RequestedPaymentTable() + if let payment = activity.requestedPayment { + try writer.run( + requestTable.table.upsert( + requestTable.id <- activity.id, + requestTable.conversationID <- payment.conversationID.data, + requestTable.paymentRequestID <- payment.paymentRequestID.data, + + onConflictOf: requestTable.id, + ) + ) + } else { + try writer.run(requestTable.table.filter(requestTable.id == activity.id).delete()) + } } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 74ad6307c..b1f2f1593 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 = 60 + public static let schemaVersion = 61 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 830941386..49b67f702 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -128,6 +128,20 @@ nonisolated public struct BoostedReactionTable: Sendable { public let emoji = Expression ("emoji") } +// Side table for payments that paid a share of a payment request: the chat and request paid. Joined +// 1:1 to `activity` by id; a row exists only for such a payment. A snapshot from payment time, so the +// request may since have closed and the viewer may no longer be able to read it. +nonisolated public struct RequestedPaymentTable: Sendable { + public static let name = "requestedPayment" + + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let conversationID = Expression ("conversationID") + public let paymentRequestID = Expression ("paymentRequestID") +} + nonisolated public struct LimitsTable: Sendable { public static let name = "limits" @@ -525,6 +539,18 @@ nonisolated extension Database { }) } + let requestedPaymentTable = RequestedPaymentTable() + + try writer.transaction(.immediate) { + try writer.run(requestedPaymentTable.table.create(ifNotExists: true, withoutRowid: true) { t in + t.column(requestedPaymentTable.id, primaryKey: true) + t.column(requestedPaymentTable.conversationID) + t.column(requestedPaymentTable.paymentRequestID) + + t.foreignKey(requestedPaymentTable.id, references: activityTable.table, activityTable.id, delete: .cascade) + }) + } + let limitsTable = LimitsTable() try writer.transaction(.immediate) { diff --git a/FlipcashTests/Database/Database+RequestedPaymentTests.swift b/FlipcashTests/Database/Database+RequestedPaymentTests.swift new file mode 100644 index 000000000..fd5b4299f --- /dev/null +++ b/FlipcashTests/Database/Database+RequestedPaymentTests.swift @@ -0,0 +1,64 @@ +// +// Database+RequestedPaymentTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Database+RequestedPayment") +struct DatabaseRequestedPaymentTests { + + private static let payment = Activity.RequestedPayment( + conversationID: ConversationID(data: Data((0..<16).map(UInt8.init))), + paymentRequestID: PaymentRequestID(data: Data((16..<32).map(UInt8.init))) + ) + + private static func activity(payment: Activity.RequestedPayment?) -> Activity { + Activity( + id: .jeffy, + state: .completed, + kind: .gave, + title: "Sent", + exchangedFiat: .mockOne, + date: .now, + metadata: nil, + requestedPayment: payment + ) + } + + @Test("A payment for a request reads back with the same chat and request, per mint and recent") + func roundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(payment: Self.payment)]) + + #expect(try database.getRecentActivities(limit: 10).first?.requestedPayment == Self.payment) + #expect(try database.getActivities(mint: ExchangedFiat.mockOne.mint).first?.requestedPayment == Self.payment) + } + + @Test("A payment that paid no request reads back nil") + func noRequest() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(payment: nil)]) + + #expect(try database.getRecentActivities(limit: 10).first?.requestedPayment == nil) + } + + @Test("Re-inserting an activity without its request removes the stored link") + func resyncDropsLink() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(payment: Self.payment)]) + try database.insertActivities(activities: [Self.activity(payment: nil)]) + + #expect(try database.getRecentActivities(limit: 10).first?.requestedPayment == nil) + } +} From 6e167231c11ebca0be759d40041c349f5b69123a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 08:17:11 -0400 Subject: [PATCH 3/5] feat(activity): link a request payment to its Request details Transaction details shows View Request for a payment that paid a share, ahead of the counterparty button and behind a boost. Request details shows a message instead of a spinner when the request is DENIED or NOT_FOUND. A status update with no closedAt keeps the held closedAt. --- .../Main/Home/TransactionDetailsScreen.swift | 9 +++++ .../PaymentRequestDetailsScreen.swift | 8 +++++ .../GroupPaymentRequest+Lifecycle.swift | 3 +- .../Models/TransactionDetails.swift | 7 +++- .../TransactionDetailsTests.swift | 35 +++++++++++++++++++ .../PaymentRequestsLiveUpdateTests.swift | 12 +++++++ 6 files changed, 72 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index 4d00fd2b6..f90810f7f 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -36,6 +36,7 @@ struct TransactionDetailsScreen: View { @State private var resolution = ActivityResolution() @State private var dialogItem: DialogItem? @State private var didCopyID = false + @State private var requestDetails: PaymentRequestSelection? /// How long the copy control stays on the checkmark before reverting. private static let copyConfirmationDuration: Duration = .seconds(1.5) @@ -88,6 +89,11 @@ struct TransactionDetailsScreen: View { viewMessage(boost) } .buttonStyle(.filled05) + case .viewRequest(let requestID): + Button("View Request") { + requestDetails = PaymentRequestSelection(id: requestID) + } + .buttonStyle(.filled05) case .viewCounterparty(let userID): let dmID = sessionContainer.conversationController.tipDMID(withUserID: userID) Button(dmID == nil ? "View Profile" : "View in Chat") { @@ -122,6 +128,9 @@ struct TransactionDetailsScreen: View { } } .dialog(item: $dialogItem) + .fullScreenCover(item: $requestDetails) { selection in + PaymentRequestDetailsScreen(requestID: selection.id) + } .task(id: activity.id) { await resolution.resolve(activity: activity, in: sessionContainer, resolvingEntryToken: true) } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index c01bf194d..c635912e7 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -66,6 +66,14 @@ struct PaymentRequestDetailsScreen: View { TimelineView(.periodic(from: .now, by: 60)) { context in if let content = content(at: context.date) { loaded(content) + } else if sessionContainer.paymentRequests.unavailable.contains(requestID) { + // DENIED or NOT_FOUND, for example after the viewer left the group. + Text("This request isn't available") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift index 5acd2e8f6..a977e2fc5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift @@ -121,7 +121,8 @@ extension GroupPaymentRequest { switch newStatus { case .completed, .closed: status = newStatus - closedAt = newClosedAt + // An update that leaves closedAt unset keeps the held copy's. + closedAt = newClosedAt ?? closedAt case .open, .expired, .unknown: return .refetch } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift index 8f06c4df3..b21e01229 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift @@ -139,6 +139,8 @@ public struct TransactionDetails: Sendable, Equatable, Hashable { public enum ChatAction: Sendable, Equatable, Hashable { /// Opens the message a boost was for. case viewMessage(Activity.BoostedReaction) + /// Opens the request this payment paid a share of. + case viewRequest(PaymentRequestID) /// Opens the counterparty's DM, or their profile until the DM exists. case viewCounterparty(UserID) } @@ -212,9 +214,12 @@ extension TransactionDetails { // identifies one — a phone-number counterparty has no chat or profile. // The display name is not required: the profile fetches its own. self.canViewInChat = activity.counterparty?.userID != nil - // A boost wins over the counterparty: the message it was for is the more specific place. + // A boost wins over a request, and both win over the counterparty: the message or request it was for is the more specific place. if let boost = activity.boostedReaction { self.chatAction = .viewMessage(boost) + } else if let payment = activity.requestedPayment { + // The request is the more specific place, ahead of the counterparty. + self.chatAction = .viewRequest(payment.paymentRequestID) } else if let userID = activity.counterparty?.userID { self.chatAction = .viewCounterparty(userID) } else { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift index 94584c273..1e1c7a97a 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift @@ -179,6 +179,41 @@ struct TransactionDetailsTests { #expect(alone.chatAction == nil) } + @Test("D4: a requested payment opens the request ahead of the counterparty") + func requestedPaymentViewsRequest() { + let payment = Activity.RequestedPayment( + conversationID: ConversationID(data: Data(repeating: 1, count: 16)), + paymentRequestID: PaymentRequestID(data: Data([7])) + ) + let details = TransactionDetails( + activity: Activity( + id: .jeffy, state: .completed, kind: .gave, title: "Sent", + exchangedFiat: Self.usd(1), date: .now, metadata: nil, + counterparty: counterparty, requestedPayment: payment + ) + ) + + #expect(details.chatAction == .viewRequest(payment.paymentRequestID)) + } + + @Test("D5: a boost wins over a requested payment") + func boostBeatsRequest() { + let boost = boost() + let payment = Activity.RequestedPayment( + conversationID: ConversationID(data: Data(repeating: 1, count: 16)), + paymentRequestID: PaymentRequestID(data: Data([7])) + ) + let details = TransactionDetails( + activity: Activity( + id: .jeffy, state: .completed, kind: .gave, title: "Sent", + exchangedFiat: Self.usd(1), date: .now, metadata: nil, + counterparty: counterparty, boostedReaction: boost, requestedPayment: payment + ) + ) + + #expect(details.chatAction == .viewMessage(boost)) + } + // MARK: - Boost header private func boostDetails( diff --git a/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift index e2dfaae10..1bd6b5ab4 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift @@ -143,6 +143,18 @@ struct PaymentRequestsLiveUpdateTests { #expect(store.request(id)?.closedAt == closedDate) } + @Test("a status update with no closedAt keeps the held closedAt") + func statusKeepsClosedAt() { + let held = GroupPaymentRequest( + id: id, creator: creator, createdAt: nil, status: .open, closedAt: closedDate, + version: 1, chatID: chat, split: request().split + ) + let store = store(holding: held) + store.apply([updates(2, [.statusChanged(.closed, closedAt: nil)])]) + #expect(store.request(id)?.status == .closed) + #expect(store.request(id)?.closedAt == closedDate) + } + @Test("V > L + 1 refetches and leaves the copy as held") func gapRefetches() { let store = store(holding: request(version: 1)) From 643a30941b8483a9726dd9310e08eebd088e01d4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 09:43:19 -0400 Subject: [PATCH 4/5] fix(requests): open a requested payment's details over its conversation View Request in transaction details now opens the request's chat with Request details presented over it, as tapping the request in the chat does, instead of the details on their own. The unavailable message reads "This request is no longer available." --- .../Deep Links/DeepLinkController.swift | 10 ++++++++-- .../Navigation/AppRouter+Destination.swift | 7 ++++++- .../AppRouter+DestinationView.swift | 5 +++++ .../Conversation/ConversationScreen.swift | 10 ++++++++++ .../Main/Home/TransactionDetailsScreen.swift | 19 +++++++++++++------ .../PaymentRequestDetailsScreen.swift | 2 +- .../Models/TransactionDetails.swift | 6 +++--- .../TransactionDetailsTests.swift | 2 +- FlipcashTests/DeepLinkControllerTests.swift | 14 ++++++++++++++ 9 files changed, 61 insertions(+), 14 deletions(-) diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 3c3ed72c9..b589dbb1f 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -240,6 +240,7 @@ struct DeepLinkAction { static func routeChat( _ conversationID: ConversationID, landing: MessageLanding? = nil, + request: PaymentRequestID? = nil, origin: DeepLinkOrigin, in container: SessionContainer ) async { @@ -252,7 +253,8 @@ struct DeepLinkAction { for: conversation?.type, isPrivateOutsider: isPrivateOutsider, conversationID: conversationID, - landing: landing + landing: landing, + request: request ) else { logger.info("Ignoring non-tip chat deeplink", metadata: [ "conversationID": "\(conversationID)", @@ -287,7 +289,8 @@ struct DeepLinkAction { for type: ConversationType?, isPrivateOutsider: Bool = false, conversationID: ConversationID, - landing: MessageLanding? = nil + landing: MessageLanding? = nil, + request: PaymentRequestID? = nil ) -> AppRouter.Destination? { switch type { case .group where isPrivateOutsider: @@ -296,6 +299,9 @@ struct DeepLinkAction { if let landing { return .chatMessage(conversationID, landing) } + if let request { + return .chatRequest(conversationID, request) + } return .tipConversation(conversationID) case .contactDm, nil: return nil diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index ed419f5f7..d78f01a9e 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -127,6 +127,9 @@ extension AppRouter { /// Same screen as `tipConversation`, opened on one message from a message link. A sibling /// case for the same reason as `tipConversationWithKeyboard`: the ordinary opens stay untouched. case chatMessage(ConversationID, MessageLanding) + /// Same screen as `tipConversation`, opened with one payment request's details presented over + /// it, as when the request is tapped in the chat. + case chatRequest(ConversationID, PaymentRequestID) /// A person's Flipcash profile, pushed from a tip DM's title/card or a group member's face; /// hosts the Block action. `origin` decides whether it offers a way into the DM. case userProfile(UserID, origin: UserProfileOrigin) @@ -183,7 +186,7 @@ extension AppRouter { .settingsApplicationLogs, .blockedUsers, .trustedWebsites, .accessKey, .withdraw: return .you case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, - .tipConversation, .tipConversationWithKeyboard, .chatMessage, .userProfile, .chatProfile, + .tipConversation, .tipConversationWithKeyboard, .chatMessage, .chatRequest, .userProfile, .chatProfile, .editGroup, .editGroupName, .editGroupPicture, .editGroupCover, .editGroupDescription, .archivedChats, .chatSearch, .requests, .joinRequests, .inviteLinks, .groupMembers, .groupInvite: return .tips @@ -244,6 +247,7 @@ extension AppRouter { case .tipConversation: "tipConversation" case .tipConversationWithKeyboard: "tipConversationWithKeyboard" case .chatMessage: "chatMessage" + case .chatRequest: "chatRequest" case .userProfile: "userProfile" case .chatProfile: "chatProfile" case .editGroup: "editGroup" @@ -285,6 +289,7 @@ extension AppRouter { case .tipConversation(let conversationID), .tipConversationWithKeyboard(let conversationID), .chatMessage(let conversationID, _), + .chatRequest(let conversationID, _), .chatProfile(let conversationID, _), .editGroup(let conversationID), .editGroupName(let conversationID), diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 91f1000e5..ca077a746 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -274,6 +274,11 @@ struct DestinationView: View { ConversationScreen(context: .existing(conversationID), landing: landing) .id(conversationID) + case .chatRequest(let conversationID, let requestID): + // The chat with that request's details presented over it. + ConversationScreen(context: .existing(conversationID), openRequest: requestID) + .id(conversationID) + case .userProfile(let userID, let origin): UserProfileScreen(userID: userID, origin: origin) .id(userID) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index ada09f6f2..5a427a905 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -45,6 +45,9 @@ struct ConversationScreen: View { /// The message a message link opened this chat on, or nil for every other entry point. var landing: MessageLanding? = nil + /// The payment request whose details open over this chat on arrival, or nil. + var openRequest: PaymentRequestID? = nil + @Environment(ConversationController.self) private var conversationController @Environment(ContactSyncController.self) private var contactSyncController @Environment(AppRouter.self) private var router @@ -56,6 +59,7 @@ struct ConversationScreen: View { @Environment(\.scenePhase) private var scenePhase @State private var didInitialRead = false + @State private var didOpenRequest = false /// The chat whose opening load has landed, for a coordinator built after it. @State private var loadedConversationID: ConversationID? /// The chat whose stored draft has been put back, which is also what permits saving: an empty @@ -660,6 +664,12 @@ struct ConversationScreen: View { if let conversationID { await sessionContainer.groupRosters.refresh(conversationID) } } } + .task(id: didInitialRead) { + // Once, when the chat has loaded, so the details come up over the chat rather than the push. + guard didInitialRead, let openRequest, !didOpenRequest else { return } + didOpenRequest = true + paymentRequestDetails = PaymentRequestSelection(id: openRequest) + } .fullScreenCover(item: $paymentRequestDetails) { selection in PaymentRequestDetailsScreen(requestID: selection.id) } diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index f90810f7f..335b0bea6 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -36,7 +36,6 @@ struct TransactionDetailsScreen: View { @State private var resolution = ActivityResolution() @State private var dialogItem: DialogItem? @State private var didCopyID = false - @State private var requestDetails: PaymentRequestSelection? /// How long the copy control stays on the checkmark before reverting. private static let copyConfirmationDuration: Duration = .seconds(1.5) @@ -89,9 +88,9 @@ struct TransactionDetailsScreen: View { viewMessage(boost) } .buttonStyle(.filled05) - case .viewRequest(let requestID): + case .viewRequest(let payment): Button("View Request") { - requestDetails = PaymentRequestSelection(id: requestID) + viewRequest(payment) } .buttonStyle(.filled05) case .viewCounterparty(let userID): @@ -128,9 +127,6 @@ struct TransactionDetailsScreen: View { } } .dialog(item: $dialogItem) - .fullScreenCover(item: $requestDetails) { selection in - PaymentRequestDetailsScreen(requestID: selection.id) - } .task(id: activity.id) { await resolution.resolve(activity: activity, in: sessionContainer, resolvingEntryToken: true) } @@ -330,6 +326,17 @@ struct TransactionDetailsScreen: View { /// Opens the boosted message through the deep link's own hydration, so a chat the viewer has /// not synced is fetched first and a private group they are not in lands on its profile. + private func viewRequest(_ payment: Activity.RequestedPayment) { + Task { + await DeepLinkAction.routeChat( + payment.conversationID, + request: payment.paymentRequestID, + origin: .activity, + in: sessionContainer + ) + } + } + private func viewMessage(_ boost: Activity.BoostedReaction) { Task { await DeepLinkAction.routeChat( diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index c635912e7..c6dbaf466 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -68,7 +68,7 @@ struct PaymentRequestDetailsScreen: View { loaded(content) } else if sessionContainer.paymentRequests.unavailable.contains(requestID) { // DENIED or NOT_FOUND, for example after the viewer left the group. - Text("This request isn't available") + Text("This request is no longer available.") .font(.appTextMedium) .foregroundStyle(Color.textSecondary) .multilineTextAlignment(.center) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift index b21e01229..9632ba511 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift @@ -139,8 +139,8 @@ public struct TransactionDetails: Sendable, Equatable, Hashable { public enum ChatAction: Sendable, Equatable, Hashable { /// Opens the message a boost was for. case viewMessage(Activity.BoostedReaction) - /// Opens the request this payment paid a share of. - case viewRequest(PaymentRequestID) + /// Opens the chat the request was made in, with the request's details over it. + case viewRequest(Activity.RequestedPayment) /// Opens the counterparty's DM, or their profile until the DM exists. case viewCounterparty(UserID) } @@ -219,7 +219,7 @@ extension TransactionDetails { self.chatAction = .viewMessage(boost) } else if let payment = activity.requestedPayment { // The request is the more specific place, ahead of the counterparty. - self.chatAction = .viewRequest(payment.paymentRequestID) + self.chatAction = .viewRequest(payment) } else if let userID = activity.counterparty?.userID { self.chatAction = .viewCounterparty(userID) } else { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift index 1e1c7a97a..93de84943 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift @@ -193,7 +193,7 @@ struct TransactionDetailsTests { ) ) - #expect(details.chatAction == .viewRequest(payment.paymentRequestID)) + #expect(details.chatAction == .viewRequest(payment)) } @Test("D5: a boost wins over a requested payment") diff --git a/FlipcashTests/DeepLinkControllerTests.swift b/FlipcashTests/DeepLinkControllerTests.swift index 4d4bdec42..03edcadc2 100644 --- a/FlipcashTests/DeepLinkControllerTests.swift +++ b/FlipcashTests/DeepLinkControllerTests.swift @@ -247,4 +247,18 @@ struct DeepLinkControllerTests { == .chatProfile(Self.chatID, origin: .featuredGroup) ) } + + @Test("A requested payment opens its chat with the request, and a private outsider gets the profile") + func chatDestination_withRequest() { + let request = PaymentRequestID(data: Data([7])) + #expect( + DeepLinkAction.chatDestination(for: .group, conversationID: Self.chatID, request: request) + == .chatRequest(Self.chatID, request) + ) + #expect( + DeepLinkAction.chatDestination(for: .group, isPrivateOutsider: true, conversationID: Self.chatID, request: request) + == .chatProfile(Self.chatID, origin: .featuredGroup) + ) + #expect(DeepLinkAction.chatDestination(for: .contactDm, conversationID: Self.chatID, request: request) == nil) + } } From cf5d0848bf613ae0e43e23eaaf60001c42dbc3b5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 09:45:56 -0400 Subject: [PATCH 5/5] feat(requests): animate live request changes The ring sweep, the switch to the ended look and a share turning paid now animate (about 300 ms ease-out) when the value changes on a request already on screen, and not on first appearance. A re-sort caused by a request ending waits for that fade, then uses the deck's settle spring. Reduce Motion turns all of it off. --- .../PaymentRequest/PaymentRequestDeck.swift | 21 ++++++++++++--- .../PaymentRequestDetailsScreen.swift | 2 ++ .../Chat/PaymentRequestCardView.swift | 4 ++- .../FlipcashUI/Chat/PaymentRequestRing.swift | 26 ++++++++++++++++++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index a4731e002..307047622 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -411,6 +411,7 @@ struct PaymentRequestPill: View { var pay: ((PaymentRequestID) -> Void)? = nil @Environment(\.dynamicTypeSize) private var typeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion /// 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 @@ -420,6 +421,8 @@ struct PaymentRequestPill: View { 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) + /// How long the ended look takes to fade in, which a re-sort waits out. + private static let endedFadeDuration = 0.3 var body: some View { // The front request at rest sets the pill's size and is what the deck measures; the cards @@ -485,6 +488,13 @@ struct PaymentRequestPill: View { // Only the front card's Pay button takes a tap; the rest peek. .allowsHitTesting(index == 0) .zIndex(Double(items.count - index)) + // A move that no gesture drives, such as a request ending and sorting down, settles after + // the ended look has faded. Cycling sets its own animation and keeps it. + .transaction(value: index) { transaction in + if transaction.animation == nil, !reduceMotion { + transaction.animation = Self.settle.delay(Self.endedFadeDuration) + } + } } /// How far the front card has moved: with the finger at first, then rubber-banding so it never @@ -565,10 +575,15 @@ struct PaymentRequestPillFace: View { @Environment(\.paymentRequestStripCollapse) private var stripCollapse var body: some View { - switch style { - case .pill: pill - case .strip: strip + Group { + switch style { + case .pill: pill + case .strip: strip + } } + // The switch to the ended look fades, and so does the paid badge arriving. + .requestMotion(value: item.card.isEnded) + .requestMotion(value: item.viewerStatus.showsPaidBadge) } private var ring: some View { diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index c6dbaf466..63308d225 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -185,6 +185,7 @@ struct PaymentRequestDetailsScreen: View { Text(row.status) .font(.appTextSmall) .foregroundStyle(row.isPaid ? Color.textSuccess : Color.textSecondary) + .contentTransition(.opacity) } Spacer(minLength: 8) Text(row.amount) @@ -195,6 +196,7 @@ struct PaymentRequestDetailsScreen: View { .frame(minHeight: 64) .background(Color.white.opacity(0.06), in: .rect(cornerRadius: 12)) .contentShape(.rect) + .requestMotion(value: row.isPaid) } /// The viewer's reading of the request, or nil until it and its title are loaded. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift index b32332b5e..1b5311085 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift @@ -42,6 +42,7 @@ public struct PaymentRequestCard: View { .frame(maxWidth: .infinity, alignment: .leading) .redacted(reason: content == nil ? .placeholder : []) .opacity(content?.isEnded == true ? 0.5 : 1) + .requestMotion(value: content?.isEnded) .accessibilityElement(children: .combine) } } @@ -67,7 +68,8 @@ public struct PaymentRequestProgressBar: View { } } .frame(height: 6) - .animation(.easeOut(duration: 0.3), value: fraction) + .requestMotion(value: fraction) + .requestMotion(value: isEnded) .accessibilityHidden(true) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift index 58375df8d..044bb40b1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift @@ -49,7 +49,31 @@ public struct PaymentRequestRing: View { .foregroundStyle(Color.black) } .frame(width: diameter, height: diameter) - .animation(.easeOut(duration: 0.3), value: fraction) + .requestMotion(value: fraction) + .requestMotion(value: isEnded) + .requestMotion(value: isClosed) .accessibilityHidden(true) } } + +private struct RequestMotion: ViewModifier { + + let animation: Animation + let value: Value + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content.animation(reduceMotion ? nil : animation, value: value) + } +} + +public extension View { + + /// Animates a change to `value` on a payment request already on screen: ring sweeps, the switch + /// to the ended look, a share turning paid. `animation(_:value:)` skips the first appearance, so + /// nothing plays on load. With Reduce Motion on, the change is applied at once. + func requestMotion(_ animation: Animation = .easeOut(duration: 0.3), value: Value) -> some View { + modifier(RequestMotion(animation: animation, value: value)) + } +}