From a62a931c31338a68a992efd15714de954709f1f0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 15:10:03 -0500 Subject: [PATCH 1/2] fix(send): style "each" on the group send sheet as the cash card does The confirm sheet drew "$1.00 each" as one `.appDisplayLarge` string, so "each" was as large as the amount. It now follows the chat cash card's amount line: "each" at half the amount's size, medium weight, in `textSecondary`, the total line's color. The line is one `Text`, so the amount and "each" shrink together on one line. `GroupSendViewModel` exposes the bare amount (`eachAmountLabel`) and `isSplitAcrossSelection` instead of a joined string. One recipient still shows only the amount. Refs code-payments/flipcash-client-orchestrator#126 --- .../Core/Screens/Send/GroupSendFlow.swift | 22 ++++++++++++++++--- .../Screens/Send/GroupSendViewModel.swift | 11 +++++++--- FlipcashTests/GroupSendViewModelTests.swift | 8 ++++--- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/Flipcash/Core/Screens/Send/GroupSendFlow.swift b/Flipcash/Core/Screens/Send/GroupSendFlow.swift index c07fdf4bb..009d40d7f 100644 --- a/Flipcash/Core/Screens/Send/GroupSendFlow.swift +++ b/Flipcash/Core/Screens/Send/GroupSendFlow.swift @@ -250,6 +250,24 @@ private struct GroupSendConfirmSheet: View { } } + /// Half of ``Font/appDisplayLarge``'s size, as "each" sits on the chat's cash card. + private static let eachFont: Font = .default(size: UIFont.appDisplayLarge.pointSize / 2, weight: .medium) + + /// The amount, followed by a smaller "each" for a group. One `Text`, so the runs share a + /// baseline and shrink together, and "each" never wraps. + private var amountLine: Text { + var line = AttributedString(viewModel.eachAmountLabel ?? "") + line.swiftUI.font = Font.appDisplayLarge + line.swiftUI.foregroundColor = Color.textMain + if viewModel.isSplitAcrossSelection { + var each = AttributedString(" each") + each.swiftUI.font = Self.eachFont + each.swiftUI.foregroundColor = Color.textSecondary + line.append(each) + } + return Text(line) + } + var body: some View { PartialSheet(background: .clear) { VStack(spacing: 25) { @@ -261,9 +279,7 @@ private struct GroupSendConfirmSheet: View { .lineLimit(1) } VStack(spacing: 8) { - Text(viewModel.eachLabel ?? "") - .font(.appDisplayLarge) - .foregroundStyle(Color.textMain) + amountLine .lineLimit(1) .minimumScaleFactor(0.4) if let total = viewModel.totalLabel { diff --git a/Flipcash/Core/Screens/Send/GroupSendViewModel.swift b/Flipcash/Core/Screens/Send/GroupSendViewModel.swift index b5fdcb840..b87c22aab 100644 --- a/Flipcash/Core/Screens/Send/GroupSendViewModel.swift +++ b/Flipcash/Core/Screens/Send/GroupSendViewModel.swift @@ -144,10 +144,15 @@ final class GroupSendViewModel { return FiatAmount(value: each.value * Decimal(selection.count), currency: each.currency) } - /// The confirm sheet's large line: "$1.00 each" for two or more people, just the amount for one. - var eachLabel: String? { + /// The confirm sheet's large amount, "$1.00", without the "each" that follows it for a group. + var eachAmountLabel: String? { guard let each = enteredAmountEach, !selection.isEmpty else { return nil } - return selection.count > 1 ? "\(each.formatted()) each" : each.formatted() + return each.formatted() + } + + /// Whether the confirm sheet's amount is one person's part, followed by "each": two or more people. + var isSplitAcrossSelection: Bool { + selection.count > 1 } /// The confirm sheet's smaller line, "$3.00 total"; nil for one person, who gets only the amount. diff --git a/FlipcashTests/GroupSendViewModelTests.swift b/FlipcashTests/GroupSendViewModelTests.swift index 38407fe1b..12852a48e 100644 --- a/FlipcashTests/GroupSendViewModelTests.swift +++ b/FlipcashTests/GroupSendViewModelTests.swift @@ -58,18 +58,20 @@ struct GroupSendViewModelTests { #expect(viewModel.sendTitle == "Send \(try #require(viewModel.enteredTotal).formatted())") } - @Test("The confirm sheet reads \"$1.00 each\" over \"$3.00 total\", or just the amount for one person") + @Test("The confirm sheet reads \"$1.00\" with \"each\" over \"$3.00 total\", or just the amount for one person") func confirmLabels() async throws { let (viewModel, _) = try await make(total: "1") let a = UUID() viewModel.toggle(a) - #expect(viewModel.eachLabel == "$1.00") + #expect(viewModel.eachAmountLabel == "$1.00") + #expect(viewModel.isSplitAcrossSelection == false) #expect(viewModel.totalLabel == nil) [UUID(), UUID()].forEach(viewModel.toggle) - #expect(viewModel.eachLabel == "$1.00 each") + #expect(viewModel.eachAmountLabel == "$1.00") + #expect(viewModel.isSplitAcrossSelection) #expect(viewModel.totalLabel == "$3.00 total") } From e4fd89aed92901fd902479415d9610725984f1f7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 15:10:03 -0500 Subject: [PATCH 2/2] fix(requests): keep the share amount on the pay sheet until it dismisses The Swipe to Pay sheet read its amount from `PaymentRequestPayment.share`, which is nil once the share reads paid. `submit()` refreshes the request before returning success, so the amount went blank during the checkmark hold and dismiss. `prepare()` now stores the request and share it found payable in `sheetSnapshot`, and the presenter renders the sheet from that snapshot, whatever the live request does afterwards. The chat strip opens the same presenter, so both entry points are covered. Refs code-payments/flipcash-client-orchestrator#126 --- .../PaymentRequestDetailsScreen.swift | 8 ++- .../PaymentRequestPayment.swift | 14 ++++- .../PaymentRequestPaymentCurrencyTests.swift | 58 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index fc748436b..0d0f221e6 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -437,8 +437,10 @@ private struct PaymentRequestPaySheetPresenter: ViewModifier { pendingDeposit = nil router.navigate(to: .currencyInfoForDeposit(mint)) }) { - // The share reads paid once the payment lands; the sheet stays up through its success state. - if let payment = flow.payment, let request = payment.request, let creator = request.creator { + // The share reads paid once the payment lands, so the sheet renders from what it opened + // with and stays up through its success state. + if let payment = flow.payment, let snapshot = payment.sheetSnapshot, let creator = snapshot.request.creator { + let request = snapshot.request PaymentRequestPaySheet( payment: payment, payee: request.chatID.flatMap { chatID in @@ -447,7 +449,7 @@ private struct PaymentRequestPaySheetPresenter: ViewModifier { .first { $0.userID == creator } }, payeeID: creator, - amount: payment.share.map { PaymentRequestCopy(session.displayCurrency).shareText($0, of: request) } ?? "" + amount: PaymentRequestCopy(session.displayCurrency).shareText(snapshot.share, of: request) ) .task { // Opened from the chat's strip, nothing else has loaded the roster that names the payee. diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift index 4c2c386ea..44a754176 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift @@ -35,8 +35,18 @@ final class PaymentRequestPayment { case noBalance } + /// The request and the viewer's unpaid share as they read when the swipe sheet opened. + struct SheetSnapshot: Equatable { + let request: GroupPaymentRequest + let share: GroupPaymentRequest.Share + } + let requestID: PaymentRequestID + /// What the swipe sheet shows, taken when ``prepare()`` finds the share payable; it outlives + /// the share reading paid, so the sheet keeps its amount through success and dismiss. + private(set) var sheetSnapshot: SheetSnapshot? + private(set) var selectedBalance: ExchangedBalance? /// Set when the insufficient-funds dialog's Add Money is tapped; the presenter routes to the deposit and clears it. var depositMint: PublicKey? @@ -104,7 +114,9 @@ final class PaymentRequestPayment { showRequestClosed() return .closed } - return intentID != nil && share != nil ? .ready : .nothingToPay + guard intentID != nil, let request, let share else { return .nothingToPay } + sheetSnapshot = SheetSnapshot(request: request, share: share) + return .ready } /// The share in the request's currency, nil when the request's currency or amount can't be read. diff --git a/FlipcashTests/PaymentRequest/PaymentRequestPaymentCurrencyTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestPaymentCurrencyTests.swift index e17e97c0b..76ad3c640 100644 --- a/FlipcashTests/PaymentRequest/PaymentRequestPaymentCurrencyTests.swift +++ b/FlipcashTests/PaymentRequest/PaymentRequestPaymentCurrencyTests.swift @@ -145,3 +145,61 @@ struct PaymentRequestPaymentNoBalanceTests { #expect(container.session.dialogItem?.title == "You Need More Cash") } } + +@MainActor +@Suite("PaymentRequestPayment sheet snapshot") +struct PaymentRequestPaymentSheetSnapshotTests { + + private let requestID = PaymentRequestID(data: Data([2])) + + /// What the server returns on the next refetch. + @MainActor + private final class Server { + var isPaid = false + } + + private func request(for userID: UserID, paid: Bool) -> GroupPaymentRequest { + GroupPaymentRequest( + id: requestID, + creator: UUID(), + createdAt: .now.addingTimeInterval(-60), + status: .open, + closedAt: nil, + version: paid ? 2 : 1, + chatID: ConversationID(data: Data(repeating: 0x0C, count: 32)), + split: .init( + text: .plaintext(title: "Dinner", description: ""), + currency: "usd", + endsAt: .now.addingTimeInterval(86_400), + shares: [.init( + userID: userID, + nativeAmount: 10, + paidAt: paid ? .now : nil, + settlement: paid ? .intent(Data(repeating: 9, count: 32)) : nil + )] + ) + ) + } + + @Test("The sheet keeps the share it opened with after a refetch reads it paid") + func sheetSnapshot_survivesShareReadingPaid() async throws { + let container = try SessionContainer.makeTest(holdings: []) + let userID = container.session.userID + let server = Server() + let requests = PaymentRequests(getChatPage: { _, _, _ in [] }, getRequest: { _ in + (request(for: userID, paid: server.isPaid), Data(repeating: 7, count: 32)) + }) + let payment = PaymentRequestPayment(requestID: requestID, sessionContainer: container, requests: requests) + + #expect(await payment.prepare() == .ready) + let opened = try #require(payment.sheetSnapshot) + + server.isPaid = true + _ = try await requests.refresh(requestID) + + #expect(payment.share == nil) + #expect(payment.sheetSnapshot == opened) + #expect(opened.share.nativeAmount == 10) + #expect(opened.share.isPaid == false) + } +}