From 6ae5bdf32feb0cd8363576a9ca5fc984b9158f57 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 14:53:09 -0500 Subject: [PATCH 1/2] feat(payment-requests): Send Request button, empty Name field, ended requests leave the deck - The summary's create button reads "Send Request" for every amount and selection. - The Name screen opens empty with the weekday default as its placeholder while that default stands. Done stays disabled until something is typed, and going back keeps the default. A saved title still pre-fills. - The deck holds OPEN requests only. A completed request holds its place with the ring full for 300 ms, then the remaining cards close the gap; a closed or expired one leaves at once. The chat wakes at the nearest ends_at instead of the next minute, and an expanded list that loses its last card collapses. The greyed ended look and the 24-hour retention are gone; the timeline, details, and history still show ended requests. Spec: code-payments/flipcash-client-orchestrator#125 --- .../Conversation/ConversationScreen.swift | 51 ++++- .../PaymentRequestCreateViewModel.swift | 13 +- .../PaymentRequest/PaymentRequestDeck.swift | 183 ++++++++++++------ .../PaymentRequest/PaymentRequestDraft.swift | 39 +++- .../PaymentRequest/PaymentRequestFlow.swift | 12 +- .../PaymentRequest/PaymentRequests.swift | 3 +- .../GroupPaymentRequest+Lifecycle.swift | 41 ++-- .../PaymentRequestSpecTests.swift | 147 ++++++++++++-- 8 files changed, 356 insertions(+), 133 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 2ae3d86ea..a7baefc99 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -57,6 +57,7 @@ struct ConversationScreen: View { @Environment(Container.self) private var container @Environment(SessionContainer.self) private var sessionContainer @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var didInitialRead = false @State private var didOpenRequest = false @@ -110,6 +111,8 @@ struct ConversationScreen: View { @State private var paymentRequestDeck = PaymentRequestDeckState() @State private var deckWindow = PaymentRequestDeckWindow() @State private var requestDeckPresence = PaymentRequestDeckPresence() + /// The instant the request deck is worded at; it advances each minute and at every deadline it holds. + @State private var requestDeckClock = Date.now /// Paying a share from the request stack's Pay button. @State private var requestPayFlow = PaymentRequestPayFlow() /// The request stack's measured height, reserved at the transcript's top; 0 while hidden. @@ -682,7 +685,15 @@ struct ConversationScreen: View { Task { await sessionContainer.hostedLobbies.refresh(hostedConversationID) } } .onChange(of: requestDeckItems, initial: true) { _, items in - requestDeckPresence.update(items) + requestDeckPresence.update(items, completed: completedDeckItem, animates: !reduceMotion) + } + // Wakes at the deck's next deadline, so a request leaves at its `ends_at`, and at least once a + // minute so the time left stays current. + .task(id: requestDeckWake) { + let wake = requestDeckWake + try? await Task.sleep(for: .seconds(max(0, wake.timeIntervalSinceNow)) + .milliseconds(50)) + guard !Task.isCancelled else { return } + requestDeckClock = max(.now, wake) } .onChange(of: conversationController.reactions.error) { _, _ in scheduleReactionErrorDismissal() @@ -891,7 +902,7 @@ struct ConversationScreen: View { .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.items = requestDeckPresence.listItems(requestDeckItems) deckWindow.present(state: paymentRequestDeck) { id in PaymentRequestDetailsScreen(requestID: id) .injectingEnvironment(from: sessionContainer) @@ -901,7 +912,14 @@ struct ConversationScreen: View { deckWindow.dismiss() } } - .onChange(of: requestDeckItems.map(\.id)) { deckWindow.items = requestDeckItems } + .onChange(of: requestDeckPresence.listItems(requestDeckItems)) { _, items in + deckWindow.items = items + // An expanded list that loses its last card collapses as the stack fades out. Details + // opened from it stay up; closing them puts the deck back at rest. + if items.isEmpty, paymentRequestDeck.isOpen, deckWindow.details == nil { + paymentRequestDeck.collapse() + } + } .onChange(of: scenePhase, initial: true) { _, phase in deckWindow.scenePhase = phase } .onDisappear { deckWindow.dismiss() } // Each refetch puts the deck back in its default order. @@ -910,7 +928,7 @@ struct ConversationScreen: View { } } - /// The private group's open and recently ended requests, worded for the viewer, in deck order. + /// The private group's open requests, worded for the viewer, in deck order. private var requestDeckItems: [PaymentRequestDeckItem] { guard let group = groupConversation, group.isPrivate else { return [] } return sessionContainer.paymentRequests.deckItems( @@ -918,7 +936,26 @@ struct ConversationScreen: View { viewer: session.userID, order: paymentRequestDeck.order, copy: PaymentRequestCopy(session.displayCurrency), - now: .now + now: requestDeckClock + ) + } + + /// When the deck's clock next advances: the nearest deadline it holds, or a minute on. + private var requestDeckWake: Date { + let minute = requestDeckClock.addingTimeInterval(60) + guard let expiry = GroupPaymentRequest.nextExpiry(of: requestDeckItems.map(\.request), after: requestDeckClock) else { + return minute + } + return min(expiry, minute) + } + + /// A request that just left the deck, as its card reads now that it completed. + private func completedDeckItem(_ id: PaymentRequestID) -> PaymentRequestDeckItem? { + sessionContainer.paymentRequests.completedDeckItem( + id, + viewer: session.userID, + copy: PaymentRequestCopy(session.displayCurrency), + now: requestDeckClock ) } @@ -1087,12 +1124,12 @@ struct ConversationScreen: View { return PaymentRequestCopy.owedAccessibilityLabel( share: share, title: front.card.title, - deadline: front.request.deadline(at: .now), + deadline: front.request.deadline(at: requestDeckClock), count: requestDeckItems.count ) } return PaymentRequestCopy(session.displayCurrency) - .deckAccessibilityLabel(front: front.request, title: front.card.title, viewerStatus: front.viewerStatus, count: requestDeckItems.count, now: .now) + .deckAccessibilityLabel(front: front.request, title: front.card.title, viewerStatus: front.viewerStatus, count: requestDeckItems.count, now: requestDeckClock) } private func payRequest(_ id: PaymentRequestID) { diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift index 64ea75032..cf9b01302 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift @@ -31,7 +31,10 @@ final class PaymentRequestCreateViewModel { } var enteredAmount: String = "" - var title: String + /// The request's title, the weekday default until the creator saves one on the Name screen. + var name = PaymentRequestName() + /// The title the request is sent with. + var title: String { name.title } var deadline: PaymentRequestDeadline = .oneWeek private(set) var people: PaymentRequestPeople? /// Set when checking a member would make a 101st share, until the sheet is dismissed. @@ -59,13 +62,12 @@ final class PaymentRequestCreateViewModel { 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() + name = PaymentRequestName() deadline = .oneWeek people = nil idempotencyKey = nil @@ -129,10 +131,7 @@ final class PaymentRequestCreateViewModel { return people.shares(perPerson: amount) } - var sendButtonTitle: String { - guard let amount else { return "Request" } - return people?.buttonTitle(perPerson: amount) ?? "Request" - } + var sendButtonTitle: String { paymentRequestSendButtonTitle } // MARK: - Send - diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index d876d96d9..0967fae12 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -123,9 +123,12 @@ final class PaymentRequestDeckState { // MARK: - Presence - -/// Whether the stack is under 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. +/// Whether the stack is under the app bar, how far it has faded in, and which cards it draws while a +/// request leaves. 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. +/// +/// A request that ends leaves the deck. One that completed stays where it was, with its ring swept to +/// full, for ``sweep``; then the remaining cards close the gap with the deck's settle motion. @MainActor @Observable final class PaymentRequestDeckPresence { @@ -136,28 +139,98 @@ final class PaymentRequestDeckPresence { private(set) var isVisible = false /// The last non-empty deck, drawn while the stack fades out after the requests are gone. private(set) var lastItems: [PaymentRequestDeckItem] = [] + /// The cards drawn in place of the live deck while departing requests finish leaving. + private(set) var held: [PaymentRequestDeckItem]? /// The stack fades in when it first shows rather than being there from its first frame. private(set) var fadesIn = false private var hasSettled = false + /// The live deck as of the last update. + @ObservationIgnored private var live: [PaymentRequestDeckItem] = [] + @ObservationIgnored private var release: Task? static let fade = Animation.easeOut(duration: 0.25) + /// How the remaining cards close the gap a departed request leaves. + static let settle = Animation.spring(duration: 0.35, bounce: 0.15) + /// How long a completed request stays while its ring sweeps to full. + static let sweep: Duration = .milliseconds(300) - /// The items the stack draws: the live deck, or the last one while it fades out. + /// The items the stack draws: the held deck, the live one, or the last one while it fades out. func items(_ live: [PaymentRequestDeckItem]) -> [PaymentRequestDeckItem] { - live.isEmpty ? lastItems : live + if let held { return held } + return 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 + /// The items the expanded list draws: the held deck, or the live one. + func listItems(_ live: [PaymentRequestDeckItem]) -> [PaymentRequestDeckItem] { + held ?? live + } + + /// Follows the deck's contents; the first call sets the state without a fade. `completed` returns + /// a departed request's card as it reads now when the request completed, nil when it left any + /// other way. With `animates` false, as under Reduce Motion, departures apply at once. + func update( + _ items: [PaymentRequestDeckItem], + completed: (PaymentRequestID) -> PaymentRequestDeckItem?, + animates: Bool + ) { + let previous = held ?? live + live = items guard hasSettled else { hasSettled = true - isMounted = hasItems - isVisible = hasItems + if !items.isEmpty { lastItems = items } + isMounted = !items.isEmpty + isVisible = !items.isEmpty + return + } + release?.cancel() + let departing = animates ? Self.holding(previous: previous, live: items, completed: completed) : nil + guard let departing else { + held = nil + follow(items) return } + held = departing.items + lastItems = departing.items + let delay = departing.sweeps ? Self.sweep : .zero + release = Task { [weak self] in + try? await Task.sleep(for: delay) + guard let self, !Task.isCancelled else { return } + withAnimation(Self.settle) { self.held = nil } + self.follow(self.live) + } + } + + /// What the stack holds while requests leave: `previous` in its order, each still-live card as it + /// reads now, a completed one as `completed` returns it, and any other departed card as it was, + /// followed by cards that just arrived. Nil when nothing left. `sweeps` is whether any of them + /// completed and needs time for its ring. + static func holding( + previous: [PaymentRequestDeckItem], + live: [PaymentRequestDeckItem], + completed: (PaymentRequestID) -> PaymentRequestDeckItem? + ) -> (items: [PaymentRequestDeckItem], sweeps: Bool)? { + let liveByID = Dictionary(live.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let departed = previous.filter { liveByID[$0.id] == nil } + guard !departed.isEmpty else { return nil } + var sweeps = false + var items = previous.map { item in + if let current = liveByID[item.id] { return current } + if let done = completed(item.id) { + sweeps = true + return done + } + return item + } + let kept = Set(previous.map(\.id)) + items += live.filter { !kept.contains($0.id) } + return (items, sweeps) + } + + /// Shows or hides the stack for the live deck `items`. + private func follow(_ items: [PaymentRequestDeckItem]) { + if !items.isEmpty { lastItems = items } + let hasItems = !items.isEmpty guard hasItems != isVisible else { return } if hasItems { // The stack runs its own fade once it appears; this animation moves what sits below it. @@ -222,9 +295,9 @@ struct PaymentRequestStack: View { /// 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) - /// How long the ended look takes to fade in, which a re-sort waits out. - private static let endedFadeDuration = 0.3 + private static let settle = PaymentRequestDeckPresence.settle + /// How long a card's own change (a paid badge arriving) takes to fade in, which a re-sort waits out. + private static let fadeDuration = 0.3 /// The furthest an upward drag pulls the front card. static let maxUpwardPull: CGFloat = 12 @@ -290,11 +363,12 @@ struct PaymentRequestStack: 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. + // A move that no gesture drives, such as a paid request sorting behind an owed one, + // settles after the card's own change has faded. Cycling and a departure set their own + // animation and keep it. .transaction(value: index) { transaction in if transaction.animation == nil, !reduceMotion { - transaction.animation = Self.settle.delay(Self.endedFadeDuration) + transaction.animation = Self.settle.delay(Self.fadeDuration) } } } @@ -399,13 +473,8 @@ struct PaymentRequestStackFace: View { var body: some View { HStack(spacing: 12) { - PaymentRequestRing( - fraction: item.card.fraction, - isEnded: item.card.isEnded, - isClosed: item.request.status == .closed, - diameter: Self.ringDiameter - ) - .paidBadge(item.viewerStatus.showsPaidBadge, diameter: Self.ringDiameter, isEnded: item.card.isEnded) + PaymentRequestRing(fraction: item.card.fraction, isEnded: false, diameter: Self.ringDiameter) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: Self.ringDiameter, isEnded: false) .deckPart(.ring, measure) VStack(alignment: .leading, spacing: 2) { Text(item.card.title) @@ -429,18 +498,13 @@ struct PaymentRequestStackFace: View { .padding(.horizontal, 12) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) - .opacity(item.card.isEnded ? 0.4 : 1) .background { RoundedRectangle(cornerRadius: Self.cornerRadius) .fill(Color.backgroundSecondary) .overlay(RoundedRectangle(cornerRadius: Self.cornerRadius).strokeBorder(item.owedShare != nil ? Color.success : 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: Self.cornerRadius)) .deckPart(.container, measure) } - // The switch to the ended look fades, and so does the paid badge arriving. - .requestMotion(value: item.card.isEnded) + // The paid badge arriving fades in. .requestMotion(value: item.viewerStatus.showsPaidBadge) } } @@ -467,8 +531,8 @@ 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, isClosed: item.request.status == .closed, diameter: 36) - .paidBadge(item.viewerStatus.showsPaidBadge, diameter: 36, isEnded: item.card.isEnded) + PaymentRequestRing(fraction: item.card.fraction, isEnded: false, diameter: 36) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: 36, isEnded: false) .deckPart(.ring, measure) VStack(alignment: .leading, spacing: 2) { Text("Request") @@ -486,14 +550,13 @@ private struct PaymentRequestDeckCard: View { .font(.appTextSmall) .foregroundStyle(Color.textMain) .deckPart(.amounts, measure) - PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: item.card.isEnded) + PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: false) .deckPart(.bar, measure) PaymentRequestDeckCardFooter(item: item) .deckPart(.status, measure) } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) - .opacity(item.card.isEnded ? 0.4 : 1) .background { RoundedRectangle(cornerRadius: 12) .fill(Color.backgroundSecondary) @@ -635,7 +698,6 @@ private struct PaymentRequestDeckMorph: View, Animatable { let p = min(max(progress, 0), 1) if let stackBox = stack[.container], let cardBox = card[.container] { let box = lerp(stackBox, 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 @@ -648,8 +710,6 @@ private struct PaymentRequestDeckMorph: View, Animatable { ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: radius) .fill(Color.backgroundSecondary) - .opacity(item.card.isEnded ? 0.4 + 0.6 * p : 1) - .background(Color.backgroundMain, in: .rect(cornerRadius: radius)) .overlay(RoundedRectangle(cornerRadius: radius).strokeBorder(Color.rowSeparator)) .overlay { if item.owedShare != nil { @@ -684,7 +744,7 @@ private struct PaymentRequestDeckMorph: View, Animatable { Text("Request").font(.appTextCaption).foregroundStyle(Color.textSecondary) } extra(.bar, in: box, from: cardBox, stretches: true) { - PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: item.card.isEnded) + PaymentRequestProgressBar(fraction: item.card.fraction, isEnded: false) } extra(.status, in: box, from: cardBox, stretches: true) { PaymentRequestDeckCardFooter(item: item) @@ -692,7 +752,6 @@ private struct PaymentRequestDeckMorph: View, Animatable { } .opacity(extras) } - .opacity(content) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -705,13 +764,8 @@ private struct PaymentRequestDeckMorph: View, Animatable { if let from = stack[.ring], let to = card[.ring] { let frame = lerp(from, to, p) let base = p < 0.5 ? PaymentRequestStackFace.ringDiameter : Self.cardRing - PaymentRequestRing( - fraction: item.card.fraction, - isEnded: item.card.isEnded, - isClosed: item.request.status == .closed, - diameter: base - ) - .paidBadge(item.viewerStatus.showsPaidBadge, diameter: base, isEnded: item.card.isEnded) + PaymentRequestRing(fraction: item.card.fraction, isEnded: false, diameter: base) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: base, isEnded: false) .scaleEffect(frame.width / base) .frame(width: base, height: base) .position(x: frame.midX - origin.x, y: frame.midY - origin.y) @@ -808,19 +862,30 @@ extension PaymentRequests { now: Date ) -> [PaymentRequestDeckItem] { order.apply(to: deck(for: conversationID, viewer: viewer, at: now)).compactMap { request in - guard let text = text(of: request) else { return nil } - let owed = viewer.flatMap { viewer in - request.isOwed(by: viewer, at: now) ? request.share(for: viewer) : nil - } - let deadline = request.deadline(at: now) - return PaymentRequestDeckItem( - request: request, - card: PaymentRequestCardContent(request: request, title: text.title, copy: copy, now: now), - amounts: copy.deckAmounts(request), - viewerStatus: PaymentRequestViewerStatus(request: request, viewer: viewer, copy: copy), - owedShare: owed.map { copy.shareText($0, of: request) }, - timeLeft: PaymentRequestCopy.timeLeft(deadline) - ) + deckItem(request, viewer: viewer, copy: copy, now: now) } } + + /// The deck card for `id` once it has completed, for its ring's last sweep on the way out; nil + /// when it isn't held, didn't complete, or its title can't be read. + func completedDeckItem(_ id: PaymentRequestID, viewer: UserID?, copy: PaymentRequestCopy, now: Date) -> PaymentRequestDeckItem? { + guard let request = request(id), request.status == .completed else { return nil } + return deckItem(request, viewer: viewer, copy: copy, now: now) + } + + private func deckItem(_ request: GroupPaymentRequest, viewer: UserID?, copy: PaymentRequestCopy, now: Date) -> PaymentRequestDeckItem? { + guard let text = text(of: request) else { return nil } + let owed = viewer.flatMap { viewer in + request.isOwed(by: viewer, at: now) ? request.share(for: viewer) : nil + } + let deadline = request.deadline(at: now) + return PaymentRequestDeckItem( + request: request, + card: PaymentRequestCardContent(request: request, title: text.title, copy: copy, now: now), + amounts: copy.deckAmounts(request), + viewerStatus: PaymentRequestViewerStatus(request: request, viewer: viewer, copy: copy), + owedShare: owed.map { copy.shareText($0, of: request) }, + timeLeft: PaymentRequestCopy.timeLeft(deadline) + ) + } } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift index 1df931ecc..4351086cb 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDraft.swift @@ -139,20 +139,39 @@ struct PaymentRequestPeople: Hashable, Sendable { orderedSelection.map { GroupPaymentRequest.Share(userID: $0, nativeAmount: perPerson.doubleValue) } } - /// `perPerson` times the number of shares, or nil when nobody is selected. - func total(perPerson: FiatAmount) -> FiatAmount? { - let count = orderedSelection.count - guard count > 0 else { return nil } - return FiatAmount(value: perPerson.value * Decimal(count), currency: perPerson.currency) + private static func people(_ count: Int) -> String { + count == 1 ? "1 Person" : "\(count) People" } +} + +/// The summary's create button, whatever the amount and selection. +let paymentRequestSendButtonTitle = "Send Request" + +/// A new request's title: the weekday default until the creator saves their own. +struct PaymentRequestName: Equatable { + + /// The weekday default, "Monday Request". + let defaultTitle: String + /// The title the creator saved on the Name screen, nil while the default stands. + private(set) var customTitle: String? - /// "Request $520 Total", or nil when nobody is selected. - func buttonTitle(perPerson: FiatAmount) -> String? { - total(perPerson: perPerson).map { "Request \($0.formattedDroppingZeroFraction()) Total" } + init(defaultTitle: String = defaultPaymentRequestTitle()) { + self.defaultTitle = defaultTitle } - private static func people(_ count: Int) -> String { - count == 1 ? "1 Person" : "\(count) People" + /// The title the request is sent with and the summary's Name row shows. + var title: String { customTitle ?? defaultTitle } + + /// What the Name screen's field opens with: empty while the default stands, so the default + /// shows only as the placeholder. + var fieldText: String { customTitle ?? "" } + + /// The Name screen's placeholder. + var placeholder: String { defaultTitle } + + /// Keeps `title`, already validated, as the creator's own. + mutating func save(_ title: String) { + customTitle = title } } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift index f66d3f375..79a206f9f 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestFlow.swift @@ -154,8 +154,8 @@ private struct PaymentRequestFlowContent: View { path.append(.selectPeople) }, sent: { dismiss() }) case .name: - PaymentRequestNameScreen(title: viewModel.title) { - viewModel.title = $0 + PaymentRequestNameScreen(name: viewModel.name) { + viewModel.name.save($0) path.removeLast() } case .selectPeople: @@ -483,13 +483,15 @@ private struct PaymentRequestNameScreen: View { @State private var text: String @FocusState private var isFocused: Bool + let placeholder: String 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) + init(name: PaymentRequestName, save: @escaping (String) -> Void) { + _text = State(initialValue: name.fieldText) + self.placeholder = name.placeholder self.save = save } @@ -507,7 +509,7 @@ private struct PaymentRequestNameScreen: View { .foregroundStyle(Color.textMain) .padding(.top, 20) - TextField("Request Name", text: $text) + TextField(placeholder, text: $text) .font(.appDisplayMedium) .foregroundStyle(Color.textMain) .focused($isFocused) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 11fa63271..47bbfdab6 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -41,7 +41,8 @@ final class PaymentRequests { /// 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. + /// The first page of RESOLVED requests a chat refetch reads, which tells a held OPEN copy it ended + /// when a live update was missed. static let resolvedPageSize = 20 /// How often an on-screen chat or details sheet refetches. static let pollInterval: Duration = .seconds(45) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift index 1987c9b81..ff4c76c97 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift @@ -9,9 +9,6 @@ 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 { @@ -197,33 +194,21 @@ extension GroupPaymentRequest { } /// 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. + /// then the other open requests, each by nearest deadline. A request that has ended is never in it. public static func deckOrder(_ requests: [GroupPaymentRequest], viewer: UserID? = nil, at now: Date) -> [GroupPaymentRequest] { - var open: [GroupPaymentRequest] = [] - var ended: [(request: GroupPaymentRequest, closedAt: Date)] = [] - - for request in requests { - switch request.effectiveStatus(at: now) { - case .open: - open.append(request) - case .completed, .closed, .expired: - guard let closedAt = request.effectiveClosedAt(at: now), - now.timeIntervalSince(closedAt) < deckRetention else { continue } - ended.append((request, closedAt)) - case .unknown: - continue + requests + .filter { $0.effectiveStatus(at: now) == .open } + .sorted { 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) } - } + } - 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) + /// The earliest deadline after `now` among `requests`, when the deck next loses a request on the clock. + public static func nextExpiry(of requests: [GroupPaymentRequest], after now: Date) -> Date? { + requests.compactMap(\.split?.endsAt).filter { $0 > now }.min() } } diff --git a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift index 7bb224948..ea40eff54 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestSpecTests.swift @@ -88,6 +88,7 @@ struct PaymentRequestSpecTests { #expect(PaymentRequestCopy.statusLine(r.deadline(at: now)) == "Paid in full") #expect(r.paidFraction == 1) #expect(r.isEnded(at: now)) + #expect(GroupPaymentRequest.deckOrder([r], at: now).isEmpty) } // MARK: - Status line - @@ -114,6 +115,7 @@ struct PaymentRequestSpecTests { #expect(r.effectiveClosedAt(at: now) == endsAt) #expect(PaymentRequestCopy.statusLine(r.deadline(at: now)) == "Expired") #expect(r.isEnded(at: now)) + #expect(GroupPaymentRequest.deckOrder([r], at: now).isEmpty) } // MARK: - Merge - @@ -144,7 +146,7 @@ struct PaymentRequestSpecTests { // MARK: - Deck - - @Test("13: open by nearest deadline, then ended within 24 h") + @Test("13: open by nearest deadline; ended requests are not in the deck") func deckOrder() { let twoDays = request(id: 1, endsAt: now.addingTimeInterval(2 * 86_400)) let fiveDays = request(id: 2, endsAt: now.addingTimeInterval(5 * 86_400)) @@ -153,7 +155,103 @@ struct PaymentRequestSpecTests { let deck = GroupPaymentRequest.deckOrder([closed, expired, fiveDays, twoDays], at: now) - #expect(deck.map(\.id) == [twoDays.id, fiveDays.id, closed.id]) + #expect(deck.map(\.id) == [twoDays.id, fiveDays.id]) + } + + private func emptyStore() -> PaymentRequests { + PaymentRequests(getChatPage: { _, _, _ in [] }, getRequest: { _ in throw ErrorGetPaymentRequest.unknown }) + } + + private func deckItems(_ store: PaymentRequests, at now: Date) -> [PaymentRequestDeckItem] { + store.deckItems(for: chat, viewer: viewer, order: PaymentRequestDeckOrder(), copy: copy, now: now) + } + + private func timelineStatus(_ store: PaymentRequests, _ id: PaymentRequestID) -> String? { + guard case .card(let card) = store.cardState(for: id, in: chat, sentBy: creator, copy: copy, now: now) else { return nil } + return card.statusLine + } + + @Test("58: the last unpaid share is paid, so the request leaves the deck and the deck fades out") + func completingLeavesDeck() async throws { + let store = emptyStore() + store.ingest([request(id: 1, paid: 3)]) + let before = deckItems(store, at: now) + #expect(before.count == 1) + + let presence = PaymentRequestDeckPresence() + presence.update(before, completed: { _ in nil }, animates: true) + #expect(presence.isVisible) + + store.ingest([request(id: 1, paid: 4, status: .completed, closedAt: now, version: 2)]) + let after = deckItems(store, at: now) + #expect(after.isEmpty) + #expect(timelineStatus(store, before[0].id) == "Paid in full") + + // The completed card stays, its ring swept to full, before the deck fades out. + let completed = store.completedDeckItem(before[0].id, viewer: viewer, copy: copy, now: now) + #expect(completed?.card.fraction == 1) + presence.update(after, completed: { store.completedDeckItem($0, viewer: viewer, copy: copy, now: now) }, animates: true) + #expect(presence.held?.map(\.card.fraction) == [1]) + #expect(presence.isVisible) + + try await Task.sleep(for: PaymentRequestDeckPresence.sweep + .milliseconds(200)) + #expect(presence.held == nil) + #expect(!presence.isVisible) + } + + @Test("58: under Reduce Motion the completed request leaves at once") + func completingLeavesWithoutMotion() { + let store = emptyStore() + store.ingest([request(id: 1, paid: 3)]) + let presence = PaymentRequestDeckPresence() + presence.update(deckItems(store, at: now), completed: { _ in nil }, animates: true) + store.ingest([request(id: 1, paid: 4, status: .completed, closedAt: now, version: 2)]) + presence.update(deckItems(store, at: now), completed: { store.completedDeckItem($0, viewer: viewer, copy: copy, now: now) }, animates: false) + #expect(presence.held == nil) + #expect(!presence.isVisible) + } + + @Test("58: a closed or expired request leaves without holding its place") + func closedLeavesWithoutSweep() throws { + let store = emptyStore() + store.ingest([request(id: 1), request(id: 2, endsAt: now.addingTimeInterval(86_400))]) + let previous = deckItems(store, at: now) + let live = Array(previous.dropFirst()) + let held = try #require(PaymentRequestDeckPresence.holding(previous: previous, live: live, completed: { _ in nil })) + #expect(!held.sweeps) + #expect(held.items.map(\.id) == previous.map(\.id)) + #expect(PaymentRequestDeckPresence.holding(previous: live, live: live, completed: { _ in nil }) == nil) + } + + @Test("59: of OPEN, COMPLETED, CLOSED and EXPIRED, only the OPEN one is in the deck") + func onlyOpenInDeck() { + let store = emptyStore() + let open = request(id: 1) + let completed = request(id: 2, paid: 4, status: .completed, closedAt: now.addingTimeInterval(-3_600)) + let closed = request(id: 3, status: .closed, closedAt: now.addingTimeInterval(-3_600)) + let expired = request(id: 4, status: .expired, endsAt: now.addingTimeInterval(-3_600), closedAt: now.addingTimeInterval(-3_600)) + store.ingest([open, completed, closed, expired]) + + #expect(deckItems(store, at: now).map(\.id) == [open.id]) + #expect(timelineStatus(store, completed.id) == "Paid in full") + #expect(timelineStatus(store, closed.id) != nil) + #expect(timelineStatus(store, expired.id) == "Expired") + } + + @Test("60: the front card leaves at its ends_at and the other comes to the front") + func frontExpiresOnScreen() { + let store = emptyStore() + let endsAt = now.addingTimeInterval(90) + let front = request(id: 1, endsAt: endsAt) + let back = request(id: 2, endsAt: now.addingTimeInterval(86_400)) + store.ingest([front, back]) + + #expect(deckItems(store, at: now).map(\.id) == [front.id, back.id]) + // The deck wakes at ends_at itself, not at the next minute's refresh. + #expect(GroupPaymentRequest.nextExpiry(of: [front, back], after: now) == endsAt) + #expect(deckItems(store, at: endsAt.addingTimeInterval(-1)).map(\.id) == [front.id, back.id]) + #expect(deckItems(store, at: endsAt).map(\.id) == [back.id]) + #expect(GroupPaymentRequest.nextExpiry(of: [back], after: endsAt) == back.split?.endsAt) } @Test("23: an OPEN request the viewer owes on sorts before one they have paid") @@ -328,7 +426,7 @@ struct PaymentRequestSpecTests { let p = people(count: 26) #expect(p.summaryValue == "Everyone, Including You") #expect(p.summarySecondLine == "You'll be marked as paid") - #expect(p.buttonTitle(perPerson: .usd(20)) == "Request $520 Total") + #expect(paymentRequestSendButtonTitle == "Send Request") let shares = p.shares(perPerson: .usd(20)) #expect(shares.count == 26) #expect(shares.first?.userID == creator) @@ -340,7 +438,7 @@ struct PaymentRequestSpecTests { var p = people(count: 3) p.toggle(p.members[2]) #expect(p.summaryValue == "1 Person, Including You") - #expect(p.buttonTitle(perPerson: .usd(20)) == "Request $40 Total") + #expect(paymentRequestSendButtonTitle == "Send Request") #expect(p.shares(perPerson: .usd(20)).map(\.nativeAmount) == [20, 20]) } @@ -395,7 +493,7 @@ struct PaymentRequestSpecTests { let shares = p.shares(perPerson: amount) #expect(shares.map(\.nativeAmount) == [33.33, 33.33, 33.33]) #expect(shares.compactMap(\.userID) == p.members) - #expect(p.buttonTitle(perPerson: amount) == "Request $99.99 Total") + #expect(paymentRequestSendButtonTitle == "Send Request") } @Test("50: $20 per person, the creator deselected, one other") @@ -404,22 +502,18 @@ struct PaymentRequestSpecTests { p.toggle(creator) p.toggle(p.members[2]) #expect(p.shares(perPerson: .usd(20)).map(\.nativeAmount) == [20]) - #expect(p.buttonTitle(perPerson: .usd(20)) == "Request $20 Total") + #expect(paymentRequestSendButtonTitle == "Send Request") } - @Test("51: unchecking the creator in a 26-member group drops the total by one share") + @Test("51: unchecking the creator in a 26-member group keeps Send Request and drops a share") func uncheckingCreator() { var p = people(count: 26) - #expect(p.buttonTitle(perPerson: .usd(20)) == "Request $520 Total") + #expect(paymentRequestSendButtonTitle == "Send Request") p.toggle(creator) - #expect(p.buttonTitle(perPerson: .usd(20)) == "Request $500 Total") - } - - @Test("Nobody selected has no total") - func noTotalWithoutPeople() { - var p = people(count: 2) - for member in p.members { p.toggle(member) } - #expect(p.buttonTitle(perPerson: .usd(20)) == nil) + #expect(paymentRequestSendButtonTitle == "Send Request") + let shares = p.shares(perPerson: .usd(20)) + #expect(shares.count == 25) + #expect(shares.allSatisfy { $0.nativeAmount == 20 }) } @Test("Groups of 100 or fewer start with everyone selected") @@ -502,6 +596,27 @@ struct PaymentRequestCreateCopyTests { #expect(defaultPaymentRequestTitle(at: friday, locale: Locale(identifier: "en_US")) == "Friday Request") } + @Test("61: the Name screen opens empty over the weekday default, and back keeps it") + func nameScreenDefault() { + // 2027-01-18 08:00 UTC, a Monday from UTC−8 to UTC+15. + let monday = Date(timeIntervalSince1970: 1_800_000_000 + 3 * 86_400) + let name = PaymentRequestName(defaultTitle: defaultPaymentRequestTitle(at: monday, locale: Locale(identifier: "en_US"))) + #expect(name.fieldText.isEmpty) + #expect(name.placeholder == "Monday Request") + // Done is disabled while the field is empty. + #expect(PaymentRequestTitleValidator().validate(name.fieldText) == nil) + // Going back without Done saves nothing, so the default stands. + #expect(name.title == "Monday Request") + } + + @Test("62: the Name screen opened after saving Rent reads Rent") + func nameScreenSaved() { + var name = PaymentRequestName(defaultTitle: "Monday Request") + name.save("Rent") + #expect(name.fieldText == "Rent") + #expect(name.title == "Rent") + } + @Test("Titles are trimmed and limited to 100 scalars") func titleValidator() { let validator = PaymentRequestTitleValidator() From 393853a734a2f0582ef49d1ec0256a1b501e2781 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 15:03:33 -0500 Subject: [PATCH 2/2] refactor(payment-requests): drop the ended and closed looks from the deck ring and badge Only the deck draws PaymentRequestRing and paidBadge, and it holds OPEN requests only, so isEnded and isClosed were always false. --- .../PaymentRequest/PaymentRequestDeck.swift | 12 +++++----- .../PaymentRequestViewerStatus.swift | 6 ++--- .../FlipcashUI/Chat/PaymentRequestRing.swift | 22 +++++-------------- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index 0967fae12..23454eb5b 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -473,8 +473,8 @@ struct PaymentRequestStackFace: View { var body: some View { HStack(spacing: 12) { - PaymentRequestRing(fraction: item.card.fraction, isEnded: false, diameter: Self.ringDiameter) - .paidBadge(item.viewerStatus.showsPaidBadge, diameter: Self.ringDiameter, isEnded: false) + PaymentRequestRing(fraction: item.card.fraction, diameter: Self.ringDiameter) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: Self.ringDiameter) .deckPart(.ring, measure) VStack(alignment: .leading, spacing: 2) { Text(item.card.title) @@ -531,8 +531,8 @@ private struct PaymentRequestDeckCard: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 10) { - PaymentRequestRing(fraction: item.card.fraction, isEnded: false, diameter: 36) - .paidBadge(item.viewerStatus.showsPaidBadge, diameter: 36, isEnded: false) + PaymentRequestRing(fraction: item.card.fraction, diameter: 36) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: 36) .deckPart(.ring, measure) VStack(alignment: .leading, spacing: 2) { Text("Request") @@ -764,8 +764,8 @@ private struct PaymentRequestDeckMorph: View, Animatable { if let from = stack[.ring], let to = card[.ring] { let frame = lerp(from, to, p) let base = p < 0.5 ? PaymentRequestStackFace.ringDiameter : Self.cardRing - PaymentRequestRing(fraction: item.card.fraction, isEnded: false, diameter: base) - .paidBadge(item.viewerStatus.showsPaidBadge, diameter: base, isEnded: false) + PaymentRequestRing(fraction: item.card.fraction, diameter: base) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: base) .scaleEffect(frame.width / base) .frame(width: base, height: base) .position(x: frame.midX - origin.x, y: frame.midY - origin.y) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestViewerStatus.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestViewerStatus.swift index d290c9da7..cf2627c87 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestViewerStatus.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestViewerStatus.swift @@ -92,9 +92,9 @@ struct PaymentRequestViewerStatusLine: View { 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 + /// bottom-right, ringed in the card surface. 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 { + func paidBadge(_ isShown: Bool, diameter: CGFloat) -> some View { overlay(alignment: .bottomTrailing) { if isShown { let size = diameter * 10 / 26 @@ -102,7 +102,7 @@ extension View { .font(.system(size: size * 0.55, weight: .heavy)) .foregroundStyle(Color.black) .frame(width: size, height: size) - .background(isEnded ? Color.textSecondary : Color.success, in: .circle) + .background(Color.success, in: .circle) .padding(size * 0.12) .background(Color.backgroundSecondary, in: .circle) .offset(x: size * 0.3, y: size * 0.3) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift index 044bb40b1..ad3254126 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift @@ -11,15 +11,10 @@ import SwiftUI public struct PaymentRequestRing: View { let fraction: Double - let isEnded: Bool - let isClosed: Bool let diameter: CGFloat - /// `isClosed` draws "✓" in place of "$", for a request its creator closed. - public init(fraction: Double, isEnded: Bool, isClosed: Bool = false, diameter: CGFloat = 26) { + public init(fraction: Double, diameter: CGFloat = 26) { self.fraction = fraction - self.isEnded = isEnded - self.isClosed = isClosed self.diameter = diameter } @@ -28,9 +23,6 @@ public struct PaymentRequestRing: View { /// 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() @@ -39,19 +31,17 @@ public struct PaymentRequestRing: View { Circle() .inset(by: Self.lineWidth / 2) .trim(from: 0, to: max(0, min(1, fraction))) - .stroke(accent, lineWidth: Self.lineWidth) + .stroke(Color.success, lineWidth: Self.lineWidth) .rotationEffect(.degrees(-90)) Circle() - .fill(accent) + .fill(Color.success) .frame(width: discDiameter, height: discDiameter) - Text(verbatim: isClosed ? "✓" : "$") + Text(verbatim: "$") .font(diameter > 30 ? .appTextMedium : .appTextCaption) .foregroundStyle(Color.black) } .frame(width: diameter, height: diameter) .requestMotion(value: fraction) - .requestMotion(value: isEnded) - .requestMotion(value: isClosed) .accessibilityHidden(true) } } @@ -70,8 +60,8 @@ private struct RequestMotion: ViewModifier { 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 + /// Animates a change to `value` on a payment request already on screen: ring sweeps, a share + /// turning paid, the ended look in the timeline. `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))