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..23454eb5b 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, diameter: Self.ringDiameter) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: Self.ringDiameter) .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, diameter: 36) + .paidBadge(item.viewerStatus.showsPaidBadge, diameter: 36) .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, 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) @@ -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/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/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() 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))